Skip to main content
RunBook Academy

← All labs in Ansible

Lab · advanced · ~120 min

Lab: Your first Molecule scenario, including the idempotence gate

B · Nested virtualisation

Objectives

  • Scaffold a Molecule scenario and explain each file the scaffold produces
  • Implement create and destroy playbooks against a container driver
  • Write verify assertions that test observable state rather than restating the role
  • Make the idempotence step fail deliberately, then fix the role so it passes

Prerequisites

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-core 2.21.x, molecule 26.x and a container runtime — Podman or Docker — that the operator can drive without root, or with a documented sudo path.
  • B-nested only. 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.podman or community.docker collection installed, for the modules create.yml will 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
Configuration changecontroller
$ 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.yml

Read 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
Configuration changecontroller
$ molecule create && molecule list
INFO     default > create
...
INSTANCE NAME    DRIVER NAME    PROVISIONER    SCENARIO NAME    CREATED
motd-test        default        ansible        default          true

Illustrative 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
Read-only / Safecontroller
$ molecule idempotence
INFO     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 applied

Illustrative 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
Configuration changecontroller
$ molecule test
INFO     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 files

Illustrative 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 drivers reports default and nothing else.
  • molecule create produces a container visible in molecule list and in podman ps (or docker ps).
  • molecule converge succeeds and /etc/motd on the instance contains the organisation name.
  • molecule idempotence fails before the role is fixed, naming Record when the banner was applied.
  • After the fix, molecule idempotence passes.
  • molecule verify fails if you change motd_org in converge.yml without updating the assertion — test your test.
  • molecule test completes the full twelve-step sequence and leaves no container behind: podman ps -a | grep motd-test returns nothing.
  • scope.md lists 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 drivers reports one, default, and create.yml/destroy.yml are yours to implement. Every tutorial with a platforms: 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.
  • prepare is 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 shell writing a timestamp — that a machine found in ninety seconds.
  • A verify that 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.

Deliverables

  • · A working molecule/default scenario for a role, passing molecule test end to end
  • · A recorded failing idempotence run and the change that fixed it
  • · A note listing what this scenario proves and what it does not

Verification status

Last reviewed
2026-08-11
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.