You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
21
22
22
### Added
23
23
24
+
***testing**: Add a Molecule-based test framework that runs the playbooks (and through them the roles) against throwaway libvirt/KVM VMs or Podman containers. Scenarios live under `extensions/molecule`; see the Testing section in `CONTRIBUTING.md`.
24
25
***role:icinga2_master, role:icingadb, role:icingaweb2, role:icingaweb2_module_reporting, role:icingaweb2_module_x509**: Add explicit Ubuntu variable files, making Ubuntu support visible alongside Debian. The Icinga repository, GPG key and package names were verified on Debian 13 and Ubuntu 24.04.
25
26
***role:nextcloud**: Add `meta/argument_specs.yml` declaring the user-facing variables, so role-entry validation catches type mismatches and missing mandatory variables.
26
27
***role:clamav**: Add `meta/argument_specs.yml` declaring the user-facing variables, so role-entry validation catches type mismatches and unknown variables.
Copy file name to clipboardExpand all lines: CONTRIBUTING.md
+110Lines changed: 110 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -922,6 +922,116 @@ Unit tests are **mandatory** for every in-house plugin. Any pull request that ad
922
922
* The `Linuxfabrik: Unit Tests` workflow runs the controller matrix on every push and pull request.
923
923
924
924
925
+
### Testing
926
+
927
+
Molecule is used as the framework to test the LFOps playbooks (and therefore indirectly the roles). The test scenarios and configurations live in `extensions/molecule` and are structured as follows:
928
+
929
+
```
930
+
extensions
931
+
└── molecule
932
+
├── apps -- test scenario, named after the playbook name
933
+
│ ├── install -- if needed, sub-scenario
934
+
│ │ ├── converge.yml -- the actual test phase. this is where the playbook under test runs against the hosts
935
+
│ │ ├── inventory -- scenario-specific inventory with variables that are needed for the playbook under test and optionally additional hosts (e.g. for a cluster test setup). overwrites the shared inventory (extensions/molecule/inventory)
936
+
│ │ │ ├── group_vars
937
+
│ │ │ │ └── systems_under_test.yml -- by convention, the "systems_under_test" group contains all our hosts against which the tests are run
938
+
│ │ │ └── hosts.yml -- here we select against which hosts we want to run (most of the time the hosts come from the shared inventory) and put them into the correct group for the playbook, here "lfops_apps"
939
+
│ │ ├── molecule.yml -- scenario marker; the file is required even if empty. can also be used to overwrite settings from the extensions/molecule/config.yml, for example which playbooks are used by Molecule (e.g. to switch between VM and container provisioning playbooks)
940
+
│ │ └── verify.yml -- runs after the test phase and uses ansible to check if the result is as expected
941
+
│ └── remove -- additional sub-scenario
942
+
│ └── ...
943
+
├── config.yml -- valid for all scenarios, can be overwritten in each scenario's molecule.yml (content and structure are the same)
944
+
├── default -- we are not using the "default" scenario, but molecule needs this to run at all. could be used to share config (e.g. prepare.yml) across *all* scenarios
945
+
│ └── molecule.yml
946
+
├── example -- fully commented reference scenario (install + remove sub-scenarios); copy it when adding a new test, like the example role
947
+
│ ├── install
948
+
│ │ └── ...
949
+
│ └── remove
950
+
│ └── ...
951
+
├── inventory -- shared inventory across all scenarios and therefore available in all scenarios. contains a basic set of VMs/containers that are commonly used
952
+
│ ├── hosts.yml -- required, even if empty, that Ansible can detect this inventory
953
+
│ └── host_vars
954
+
│ ├── debian11-container.yml
955
+
│ ├── debian11-vm.yml
956
+
│ └── ...
957
+
├── monitoring_plugins -- a scenario with no sub-scenarios
958
+
│ ├── converge.yml
959
+
│ ├── inventory
960
+
│ │ └── ...
961
+
│ ├── molecule.yml
962
+
│ └── verify.yml
963
+
├── playbooks -- shared playbooks used by Molecule for running the scenarios
964
+
│ ├── container-create.yml
965
+
│ ├── container-destroy.yml
966
+
│ └── ...
967
+
└── requirements.yml
968
+
```
969
+
970
+
The `extensions/molecule/example` scenario mirrors the `example` role: it is a fully commented, non-functional reference that walks through every file of a scenario (a non-functional reference because the `example` playbook installs the fictional "Example" application). Copy it as the starting point when adding a test for a playbook.
971
+
972
+
Tests can be run against a subset of targets by providing them as a comma-separated list via the project-specific `LFOPS_TEST_TARGETS` environment variable. The variable is optional: unset, every target in the scenario runs. `localhost` (the hypervisor) is included automatically, so you only ever pass the targets themselves:
973
+
974
+
```shell
975
+
# all targets in the scenario
976
+
molecule test --scenario-name apps/install
977
+
978
+
# a subset
979
+
LFOPS_TEST_TARGETS='rocky*' molecule test --scenario-name apps/install
980
+
```
981
+
982
+
983
+
Known Limitations:
984
+
985
+
* VM-based testing requires passwordless sudo on the Ansible controller. The cloud image and per-VM disks are written and built directly in the root-owned libvirt pool directory (`get_url`, `qemu-img`, `virt-customize`), which is plain filesystem I/O and needs root. The read-only libvirt API calls already run unprivileged via the `libvirt` group; it is only the pool writes that require sudo. Trying to make the whole run rootless is not worth it: the only way to provision VMs without root-equivalent rights at all is the user session (`qemu:///session`), which the test cannot use because its address discovery reads the host's ARP/neighbour table for the libvirt-managed `default` network that only the system connection (`qemu:///system`) provides. Every other route still grants effective root: a member of the `libvirt` group (which the read-only calls already require) can define a domain backed by any host device and drive QEMU as root. Swapping the `sudo` for a user-owned `qemu:///system` pool therefore only trades an explicit, on-demand escalation for a standing root-equivalent privilege plus looser filesystem permissions, which is a worse posture, not a better one.
986
+
* Does not work inside an Ansible Execution Environment (Ansible Navigator). Provisioning runs as `localhost`, which inside an EE is the container, yet it has to act on the host's libvirt and podman. The disk-build tools (`qemu-img`, `virt-customize`, `virt-sysprep`) are filesystem-bound to the pool and have no libvirt-socket equivalent, so an EE would have to bind-mount the host libvirt/podman sockets and the pool directory and use host networking, which removes most of the isolation an EE exists to provide.
987
+
988
+
989
+
#### How a scenario runs
990
+
991
+
`molecule test --scenario-name <scenario>` runs the steps listed in the `test_sequence` of `config.yml`, in order:
992
+
993
+
* `dependency`: installs the collections from `requirements.yml`.
994
+
* `create`: provisions the instances (libvirt/KVM VMs or Podman containers).
995
+
* `prepare`: waits until the instances are reachable and gathers facts.
996
+
* `converge`: runs the playbook under test (`converge.yml`).
997
+
* `verify`: runs `verify.yml` against the converged instances.
998
+
* `idempotence`: runs the playbook a second time and fails if it reports any change.
999
+
* `verify`: runs `verify.yml` again, now against the idempotent state.
1000
+
* `destroy`: tears the instances down.
1001
+
1002
+
1003
+
#### What to verify
1004
+
1005
+
Verify the observable end result, not the steps the role took to get there. Ansible and the role already guarantee their own mechanics, so re-checking those only tests Ansible. The guiding question is "what can only be confirmed by looking at the running system?".
1006
+
1007
+
Two guarantees come for free, so do not rebuild them in `verify.yml`:
1008
+
1009
+
* If the playbook errors out, the `converge` step fails and `verify.yml` never runs. `verify.yml` is therefore only ever about the *result* of a successful run, not about whether the run crashed.
1010
+
* Idempotence is enforced by the dedicated `idempotence` step. Never add tasks that check "running it a second time changes nothing".
1011
+
1012
+
Do **not** assert:
1013
+
1014
+
* That a templated file exists or contains a given line. If the `template` task ran, the file is there with the rendered content; asserting it only exercises Jinja and the `template` module.
1015
+
* That a package was installed or a file was written, as the goal of the test. The module already reports `changed`/`ok` for that. A one-line "the package is installed" smoke check is fine as a floor, but it is not where the value of the test lies.
1016
+
1017
+
Do assert what only the running system can confirm, that is, that the pieces actually work together:
1018
+
1019
+
* The application is running and enabled (`ansible.builtin.service_facts`), and reachable on its port (`ansible.builtin.wait_for`, or a request that would fail if it were not). A service that starts proves the deployed config is at least valid, which the role's own tasks cannot tell you.
1020
+
* The application actually *uses* the configured values. Ask the running application (an API or status endpoint via `ansible.builtin.uri`, or a CLI that prints the effective configuration) and assert it reports the value the scenario set in `group_vars`. This is the important one: it proves the whole chain, `group_vars` to template to the service reading the file to its behaviour, which is exactly what grepping the config file does not.
1021
+
* End state managed outside the package and file layer (users, databases, API objects) is present, or absent in a removal scenario.
1022
+
1023
+
A useful rule of thumb: if an assertion would still pass while the service is dead or running with the wrong configuration, it is testing the wrong thing.
1024
+
1025
+
1026
+
#### Troubleshooting
1027
+
1028
+
**`molecule test` aborts with `ansible_compat.errors.InvalidPrerequisiteError: Command ansible-galaxy collection install -vvv --force /path/to/lfops` during prerun while installing the local collection**
1029
+
1030
+
* Before running a scenario, Molecule's prerun step tries to install the current repository as a collection with `ansible-galaxy collection install --force <repo>`. That build fails because `galaxy.yml` carries a non-semver `version` (`main`), which `ansible-galaxy` rejects.
1031
+
* Option 1: disable the prerun so Molecule stops trying to build and install the local collection, by setting `prerun: false` as a top-level key in the `config.yml`. If you do this, you have to make sure that LFOps is installed yourself.
1032
+
* Option 2: If you installed LFOps by symlinking it, make sure the link points to the **same** folder that you are running `molecule` in (`ln -sf "$(pwd)" ~/.ansible/collections/ansible_collections/linuxfabrik/lfops`).
0 commit comments