AnsibleXXVI · Testing AutomationTesting automation
One role, three distributions
What you'll learn
- Extend a scenario to a platform matrix without duplicating the scenario
- Choose between one scenario with several hosts and several scenarios
- Estimate what a matrix costs in wall-clock time and maintenance
- Decide which platforms a role supports, and say so in the repository
- Recognise combinatorial growth before it makes the suite unrunnable
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
The single most valuable thing containers give you is cheap cross-distribution testing. A role written and tested only on Debian will contain assumptions about package names, service names and filesystem layout that its author never noticed making, and those assumptions surface on the day someone points it at a Rocky host.
The matrix is also the easiest part of a test suite to let grow until it stops being run. This lesson covers both halves.
Adding platforms
If the scenario declares its instances as inventory, as in the previous lesson, then extending it is an inventory edit:
---
all:
children:
molecule:
hosts:
ubuntu-2404:
container_image: docker.io/library/ubuntu:24.04
debian-12:
container_image: docker.io/library/debian:12
rocky-9:
container_image: quay.io/rockylinux/rockylinux:9
vars:
ansible_connection: containers.podman.podmancreate.yml and destroy.yml already loop over groups['molecule'],
so nothing else changes. molecule converge now applies the role to
three containers, and molecule idempotence re-runs it against all
three.
That is the whole mechanism. The interesting part is what it finds.
What the matrix actually catches
Run a Debian-developed role against Rocky and the failures come in a predictable order.
Package names. apache2 on Debian and Ubuntu, httpd on
RHEL-family. libssl-dev versus openssl-devel. python3-pip exists
on all three and installs different things. The fix is a variables file
per OS family, and the role loading the right one:
- name: Load the variables for this OS family
ansible.builtin.include_vars: "{{ ansible_facts['os_family'] }}.yml"
- name: Install the web server
ansible.builtin.package:
name: "{{ webserver_package }}"
state: presentService names. The same service is apache2 on Debian and httpd
on Rocky, and the unit names differ accordingly.
Configuration layout. /etc/apache2/sites-available/ with a2ensite
on Debian; /etc/httpd/conf.d/ with no equivalent tool on Rocky. This
is the difference that most often forces a role to be restructured
rather than parameterised, because the two distributions do not have the
same shape of configuration, only the same software.
Default users and groups. www-data versus apache.
What is installed by default. A minimal Rocky container has no
python3 until something installs it; a Debian container has no
sudo, no iproute2 and often no ca-certificates. These belong in
prepare.yml only when your real hosts have them and the image does
not — otherwise you are hiding a genuine role gap.
SELinux. Present and enforcing on RHEL-family hosts, absent on Debian. In a container it is usually neither, which is a fidelity gap worth writing down rather than a matrix result.
One scenario or several?
Both shapes work, and they optimise for different things.
One scenario, several hosts — the layout above. The role is applied to all three instances in one converge, and the whole thing passes or fails together.
- Fastest, because create, converge, idempotence and verify each run once against all hosts, with Ansible’s normal parallelism across them.
- Simplest to maintain: one set of playbooks.
- Coarse failure reporting: “the scenario failed” and you read the log to find out which host.
- Cannot vary the scenario per platform — if Rocky needs a different
prepare.yml, you are writing conditionals in a playbook.
One scenario per platform — molecule/ubuntu/, molecule/debian/,
molecule/rocky/.
- Independent:
molecule test -s rockyruns one platform, which is what you want when only that one is failing. - Can differ structurally — different
prepare.yml, different verify assertions, different images for a systemd-enabled variant. - More files to keep in step, and the duplication drifts.
molecule test --allruns them all, and-eexcludes one.
The pragmatic default is one scenario with several hosts for the fast
matrix, plus a separate scenario for anything structurally different —
typically a systemd scenario using a -ubi-init style image, which
needs different container options and therefore a different create.yml.
$ molecule test --help -s, --scenario-name TEXT Name of the scenario to target. May be
specified multiple times. (default: default)
-e, --exclude TEXT Name of the scenario to exclude from
targeting. May be specified multiple times.
--all / --no-all Target all scenarios. Overrides scenario-
name. For 'reset', this includes shared
state and inventory. (default: disabled)
--destroy [always|never] The destroy strategy used at the conclusion
of a Molecule run. (default: always)
--workers TEXT EXPERIMENTAL: Number of concurrent worker
processes. Accepts an integer, 'cpus', or
'cpus-1'. (default: 1)What it costs
The costs are not evenly distributed and it is worth being specific about which ones bite.
Wall-clock time grows with the slowest platform, not the sum, when
the hosts are in one scenario — Ansible runs them in parallel up to
forks. Separate scenarios are serial by default: three scenarios take
three times as long unless you use --workers, which is still marked
experimental.
Image pulls are the surprise. Three base images is three pulls on a cold CI runner, and a Rocky image is substantially larger than a Debian one. Caching the images in the runner is usually worth more than any other optimisation available here.
Maintenance is the cost that actually kills matrices. Every platform you add is a platform that can break for reasons unrelated to your change: a base image that stops shipping a package, a repository that moves, a distribution release that goes end-of-life and its mirrors disappear. A red build nobody caused is the fastest way to teach a team to ignore the build.
Cognitive cost on the role itself. Every distribution difference becomes a conditional, a variables file, or a restructure. Three is usually manageable. Six is a role whose logic is mostly about distributions and only incidentally about the service it configures.
The combinatorics, stated plainly
The matrix has more dimensions than distribution, and they multiply:
| Dimension | Realistic values | Running total |
|---|---|---|
| distribution | 3 | 3 |
| role variable combinations that change behaviour | 4 | 12 |
| container and systemd-enabled variants | 2 | 24 |
| VM as well as container | 2 | 48 |
Forty-eight scenario runs per commit is not a testing strategy, it is a queue. The way out is not to test fewer things — it is to test different things at different frequencies:
- Every commit: one scenario, three distributions, default variables, containers. Minutes.
- Nightly: the variable combinations that change behaviour, still in containers.
- Before a release: the VM scenario, on the platforms you actually ship to.
That schedule keeps the fast feedback fast and the expensive coverage real, and it makes the honest statement easy: this commit was tested against three distributions with default settings, in containers.
Knowledge check
Knowledge check · 4 questions
Q1. A role branches on ansible_facts["distribution"] to pick package names, and has separate cases for Ubuntu and Debian that are identical. What is the better key?
Q2. Your CI matrix is one Molecule scenario with three container hosts. What is the main thing you give up compared with three separate scenarios?
Q3. Which of these are real reasons a platform matrix stops being useful over time? Select all that apply.
Q4. Pinning test images by digest is the safest choice, because it guarantees the test environment never changes.
Passing score: 75%. Answers are checked in this browser.