Objective
By the end of this lab you will have a Molecule scenario that creates a container, converges a role onto it, asserts the result, proves the role is idempotent, and destroys the container — and you will have watched the idempotence step fail first, because a gate you have never seen fail is a gate you are assuming.
Architecture
Molecule drives Ansible against a throwaway container. Everything in the scenario directory is itself an Ansible playbook, which is the design insight worth holding on to.
roles/motd/
├── defaults/main.yml
├── tasks/main.yml
└── molecule/default/
├── molecule.yml scenario config, test sequence, ansible settings
├── create.yml brings up the instance (you implement this)
├── destroy.yml tears it down (you implement this)
├── converge.yml applies the role under test
└── verify.yml asserts the resulting state
Requirements
- A controller with
ansible-core2.21.x,molecule26.x and a container runtime — Podman or Docker — that the operator can drive without root, or with a documentedsudopath. B-nestedonly. A container runtime needs a real kernel. This lab cannot be done in simulation, and the whole point of Molecule is that something real is created and destroyed.- The
containers.podmanorcommunity.dockercollection installed, for the modulescreate.ymlwill use. - Roughly 2 GB of disk for the base images, and outbound network access to pull them the first time.
Scenario
Your team has a motd role. It works. Nobody is confident changing it,
because the only way to find out whether a change broke anything is to
apply it to a staging host and look, and staging is shared.
You are going to give the role a test you can run in ninety seconds on your own machine.
Tasks
Task 1: Build the role under test
WORKDIR="$HOME/ansible-molecule-lab"
mkdir -p "$WORKDIR"/roles/motd/{tasks,defaults,templates}
cd "$WORKDIR"
roles/motd/defaults/main.yml:
---
motd_org: Example Organisation
motd_contact: platform@example.com
motd_classification: internal
roles/motd/templates/motd.j2:
{{ motd_org }} — {{ motd_classification }} system
Authorised access only. All activity is logged.
Contact: {{ motd_contact }}
roles/motd/tasks/main.yml — deliberately containing the idempotence
defect you will find later:
- name: Render the message of the day
ansible.builtin.template:
src: motd.j2
dest: /etc/motd
owner: root
group: root
mode: '0644'
- name: Record when the banner was applied
ansible.builtin.shell: |
date -Is > /etc/motd.applied
args:
executable: /bin/bash
Task 2: Scaffold the scenario
cd "$HOME/ansible-molecule-lab/roles/motd"
molecule init scenario default
find molecule -type f | sort
$ molecule init scenario default && find molecule -type f | sort./molecule/default/converge.yml
./molecule/default/create.yml
./molecule/default/destroy.yml
./molecule/default/molecule.yml
./molecule/default/verify.ymlRead each one before changing anything. The scaffolded molecule.yml
declares the test sequence, which is the contract:
scenario:
name: default
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy
Twelve steps. destroy appears at both ends deliberately: a scenario must
start from nothing regardless of what a previous interrupted run left
behind.
The scaffolded converge.yml names a placeholder role:
- name: Apply role under test
ansible.builtin.include_role:
name: yournamespace.yourcollection.yourrole
Change it to the role you are actually testing:
- name: Converge
hosts: all
gather_facts: true
tasks:
- name: Apply the role under test
ansible.builtin.include_role:
name: motd
Task 3: Implement create and destroy
This is the part the scaffold leaves to you. The stub in create.yml
carries the comment TODO: Developer must implement and populate 'server' variable and a block that writes Molecule’s instance config.
Replace it with something that creates a container and reports it back to Molecule:
# molecule/default/create.yml
- name: Create
hosts: localhost
connection: local
gather_facts: false
vars:
molecule_image: docker.io/library/debian:12
molecule_instance_name: motd-test
tasks:
- name: Start the test container
containers.podman.podman_container:
name: "{{ molecule_instance_name }}"
image: "{{ molecule_image }}"
command: sleep infinity
state: started
detach: true
register: server
- name: Tell Molecule how to reach the instance
ansible.builtin.copy:
content: |
# Molecule managed
{{ [{'instance': molecule_instance_name,
'connection': 'containers.podman.podman'}] | to_nice_yaml }}
dest: "{{ molecule_instance_config }}"
mode: '0600'
- name: Add the instance to the running inventory
ansible.builtin.add_host:
name: "{{ molecule_instance_name }}"
groups: molecule
ansible_connection: containers.podman.podman
# molecule/default/destroy.yml
- name: Destroy
hosts: localhost
connection: local
gather_facts: false
vars:
molecule_instance_name: motd-test
tasks:
- name: Remove the test container
containers.podman.podman_container:
name: "{{ molecule_instance_name }}"
state: absent
- name: Clear the instance config
ansible.builtin.copy:
content: "# Molecule managed\n{}\n"
dest: "{{ molecule_instance_config }}"
mode: '0600'
Run the two steps individually before running the whole sequence:
cd "$HOME/ansible-molecule-lab/roles/motd"
molecule create
molecule list
$ molecule create && molecule listINFO default > create
...
INSTANCE NAME DRIVER NAME PROVISIONER SCENARIO NAME CREATED
motd-test default ansible default trueIllustrative output
Task 4: Converge, and read the failure honestly
molecule converge
The role’s first task will very likely fail on a bare Debian image, because
python3 is not installed and Ansible needs it. This is not a Molecule
problem — it is the bootstrap problem every new managed node has, and the
scaffold gives you the place to solve it: prepare.yml.
# molecule/default/prepare.yml
- name: Prepare
hosts: all
gather_facts: false
tasks:
- name: Install a Python interpreter so Ansible modules can run
ansible.builtin.raw: |
test -e /usr/bin/python3 || (apt-get update && apt-get install -y python3)
changed_when: false
Add it to molecule.yml under ansible.playbooks if it is not already
listed, then converge again:
molecule converge
Task 5: Watch the idempotence gate fail
molecule idempotence
$ molecule idempotenceINFO default > idempotence
...
TASK [motd : Record when the banner was applied] *******************************
changed: [motd-test]
CRITICAL Idempotence test failed because of the following tasks:
* [motd-test] => motd : Record when the banner was appliedIllustrative output
It names the exact task. date -Is > /etc/motd.applied writes a new
timestamp every run, so the shell task reports changed every run.
This is the same defect as the timestamp in the second-run lab, and here a machine found it in ninety seconds rather than a human finding it in a recap nobody reads.
Fix it in the role:
- name: Record when the banner was applied, only when it changed
ansible.builtin.copy:
content: "{{ ansible_date_time.iso8601 }}\n"
dest: /etc/motd.applied
mode: '0644'
when: motd_render.changed
…with register: motd_render on the template task. Then:
molecule converge
molecule idempotence
Task 6: Write assertions that test state, not tasks
The scaffolded verify.yml asserts true. Replace it with assertions
about observable state on the instance:
# molecule/default/verify.yml
- name: Verify
hosts: all
gather_facts: true
tasks:
- name: Read the rendered banner
ansible.builtin.slurp:
src: /etc/motd
register: motd_file
- name: Read its metadata
ansible.builtin.stat:
path: /etc/motd
register: motd_stat
- name: The banner names the organisation and the contact
ansible.builtin.assert:
that:
- "'Example Organisation' in (motd_file.content | b64decode)"
- "'platform@example.com' in (motd_file.content | b64decode)"
fail_msg: "rendered banner is missing required content"
- name: The banner is world-readable and root-owned
ansible.builtin.assert:
that:
- motd_stat.stat.mode == '0644'
- motd_stat.stat.pw_name == 'root'
fail_msg: >-
/etc/motd has mode {{ motd_stat.stat.mode }} owned by
{{ motd_stat.stat.pw_name }}
- name: The banner does not leak the template source path
ansible.builtin.assert:
that:
- "'motd.j2' not in (motd_file.content | b64decode)"
fail_msg: "the rendered banner contains the template filename"
Task 7: Run the full sequence
cd "$HOME/ansible-molecule-lab/roles/motd"
molecule test
$ molecule testINFO default > destroy
INFO default > syntax
INFO default > create
INFO default > prepare
INFO default > converge
INFO default > idempotence
INFO default > verify
INFO default > cleanup
INFO default > destroy
INFO Pruned instance filesIllustrative output
molecule test destroys the instance at the end, which is what makes it
suitable for CI and infuriating for debugging. When something fails and you
want to look at the container:
molecule create && molecule converge # leave it running
molecule login # shell into the instance
# ... investigate ...
molecule destroy # when you are done
Task 8: Write down what this proves
In scope.md, record both halves honestly.
What the scenario proves. That the role renders the expected content
with the expected ownership and mode on a Debian 12 userspace; that it is
idempotent; that its templates parse; that it does not fail on a host where
/etc/motd does not already exist.
What it does not prove. That the role works on a distribution the
scenario does not include; that any service it manages actually starts;
that it survives a reboot; that it behaves correctly with SELinux
enforcing; that its systemd interactions work at all, since systemd is
not PID 1 in this container.
That second list is the subject of the next lab, and writing it down now is
what stops a green molecule test being mistaken for production readiness.
Validation
molecule driversreportsdefaultand nothing else.molecule createproduces a container visible inmolecule listand inpodman ps(ordocker ps).molecule convergesucceeds and/etc/motdon the instance contains the organisation name.molecule idempotencefails before the role is fixed, namingRecord when the banner was applied.- After the fix,
molecule idempotencepasses. molecule verifyfails if you changemotd_orginconverge.ymlwithout updating the assertion — test your test.molecule testcompletes the full twelve-step sequence and leaves no container behind:podman ps -a | grep motd-testreturns nothing.scope.mdlists at least four things the scenario does not prove.
Expected Outcome
ansible-molecule-lab/roles/motd/
├── defaults/main.yml
├── molecule/default/
│ ├── converge.yml
│ ├── create.yml
│ ├── destroy.yml
│ ├── molecule.yml
│ ├── prepare.yml
│ └── verify.yml
├── scope.md
├── tasks/main.yml
└── templates/motd.j2
molecule test passes end to end in under two minutes and leaves nothing
running. The role is idempotent and you watched the gate that proves it
fail first.
Troubleshooting
molecule init scenario writes a create.yml full of TODO comments.
Expected in 26.x. The scaffold cannot know what you want to create.
molecule.yml from a tutorial has driver: and platforms: keys.
That tutorial predates the driver removal. Those keys are not read; the
scenario will appear to be configured and will do nothing.
connection failure: Failed to create temporary directory. The
container has no Python, or no writable /tmp. prepare.yml with a raw
task is the fix, as in Task 4.
molecule idempotence fails on Gathering Facts. Fact gathering does
not report changed, so this is a different task being mis-attributed —
usually one inside an include_role whose name Molecule renders oddly.
molecule converge twice by hand and read the second recap directly.
The container is left running after a failed molecule test.
molecule destroy, and if that fails because the scenario is in a bad
state, remove it directly: podman rm -f motd-test. Then
molecule reset clears Molecule’s temporary state.
molecule --version reports a different ansible-core than
ansible --version. Molecule uses whatever ansible-playbook is first
on PATH, which may not be the interpreter you think. Check both, and
prefer running Molecule from the same virtualenv as the controller you are
targeting — a role tested against a different core version than production
runs is a weaker test than it looks.
Cleanup
Molecule’s own destroy step removes the instance, but a failed or
interrupted run does not get there. Verify rather than assume.
Step 1. Ask Molecule to clean up:
cd "$HOME/ansible-molecule-lab/roles/motd"
molecule destroy
molecule list
molecule list should show no instances.
Step 2. Verify at the container runtime, because Molecule’s view and the runtime’s view can disagree after an interrupted run:
podman ps -a --filter 'name=motd-test'
# or, on Docker:
# docker ps -a --filter 'name=motd-test'
Step 3. Clear Molecule’s cached scenario state, which lives outside the project directory:
molecule reset
ls -la "${MOLECULE_EPHEMERAL_DIRECTORY:-$HOME/.cache/molecule}" 2>/dev/null || true
Step 4. Keep the scenario — it is the deliverable — and remove the rest:
mkdir -p "$HOME/ansible-lab-deliverables/molecule"
cp -a "$HOME/ansible-molecule-lab/roles/motd/molecule" \
"$HOME/ansible-molecule-lab/roles/motd/scope.md" \
"$HOME/ansible-lab-deliverables/molecule/"
rm -rf "$HOME/ansible-molecule-lab"
Step 5. The base image is still in local storage. Leave it if you will use it again; remove it if disk matters and you know nothing else uses it:
podman images | grep 'debian.*12' || echo 'no debian:12 image cached'
# podman rmi docker.io/library/debian:12
What You Learned
- Molecule 26.x has no built-in drivers.
molecule driversreports one,default, andcreate.yml/destroy.ymlare yours to implement. Every tutorial with aplatforms:key is describing software that no longer exists. - A scenario is five playbooks and a sequence. There is no new language to learn, which is why the technique transfers to anything Ansible can create.
prepareis for making the instance manageable, not for setting up the test. Putting the role’s preconditions there produces a test that passes because of the scaffolding.- You made the idempotence gate fail before trusting it, on a real
defect — a
shellwriting a timestamp — that a machine found in ninety seconds. - A
verifythat restates the role is not a test. Assert on content, mode, ownership and behaviour; if the assertion would pass with an empty file, rewrite it. - Write down what the scenario does not prove. No systemd, one distribution, no reboot, no SELinux. That list is what stops green meaning more than it should.