Module 11 — Testing, Molecule & CI/CD

Updated 20 August 2026

Module 11 · Testing, Molecule & CI/CD

Proving your automation works before it touches a server. This is the module that separates "I write playbooks" from "I ship automation other people depend on".

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–10.


Part A · The testing pyramid

A1 · What can go wrong, and what catches it

The analogy. Think of the stages a piece of writing goes through before it is published. Spellcheck catches typos in a second. Reading it back aloud catches sentences that do not parse. A colleague catches an argument that does not follow. And handing it to a stranger catches the thing all three of you were too close to notice. You run them in that order because each one costs more than the last — there is no sense asking a colleague to read something still full of typos.

yamllint is spellcheck, ansible-lint is reading it back, and Molecule is the stranger. Only the stranger can tell you whether the thing actually works, and only spellcheck is cheap enough to run on every keystroke. The whole of this module is about putting each check at the point in the day where it is worth its cost.

Diagram source
flowchart TD
    A["yamllint<br>milliseconds"] --> A1["Is it valid YAML?<br>Tabs, indentation, line length"]
    B["--syntax-check<br>under a second"] --> B1["Does Ansible parse it?<br>Unknown keywords, bad structure"]
    C["ansible-lint<br>seconds"] --> C1["Is it GOOD Ansible?<br>Unnamed tasks, missing FQCN,<br>shell where a module exists"]
    D["--check --diff<br>seconds, needs hosts"] --> D1["What WOULD change<br>against a real host?"]
    E["Molecule<br>minutes"] --> E1["Does it actually work?<br>Real container, real run,<br>IDEMPOTENCE verified"]
    F["Staging run<br>minutes"] --> F1["Does it work on<br>real infrastructure?"]
    style A fill:#D1FAE5,stroke:#059669
    style E fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
    style F fill:#FEF3C7,stroke:#D97706
Run them cheapest-first, and gate the pipeline on each. yamllint and --syntax-check cost nothing and catch the whole class of parse errors. ansible-lint catches the design mistakes this course has warned about repeatedly — unnamed tasks, missing FQCN, unquoted file modes, shell where a module exists.

Only Molecule proves idempotency, and idempotency is the property nothing else can verify without actually running twice against something real.

A2 · The cheap layers

bash
# 1. YAML validity
yamllint .

# 2. Does Ansible parse it?
ansible-playbook site.yml --syntax-check
ansible-inventory -i inventory/ --graph      # inventory parses too

# 3. Is it good Ansible?
ansible-lint

# 4. What would change?
ansible-playbook site.yml --check --diff --limit staging01
yaml
# .yamllint
---
extends: default
rules:
  line-length:
    max: 160
    level: warning
  truthy:
    allowed-values: ["true", "false"]     # catches yes/no/on/off - the Norway problem
  comments:
    min-spaces-from-content: 1
  braces:
    max-spaces-inside: 1                   # allows {{ var }} spacing
ignore: |
  collections/
  galaxy_roles/
  .venv/
The truthy rule is worth enabling deliberately. It forces true/false and rejects yes/no/on/off — which is the Module 02 Part A2 Norway problem caught at lint time rather than in production. Similarly, braces: max-spaces-inside: 1 stops yamllint fighting with normal {{ var }} spacing.

🎯 Interview questions — Testing strategy

Q. How do you test Ansible code?

In layers, cheapest first. yamllint for YAML validity. --syntax-check so Ansible can parse it. ansible-lint for design quality — unnamed tasks, missing FQCN, shell where a module exists. --check --diff against a real host to see what would change. Molecule to run it for real in a container and verify idempotency. Then a staging run.

The important distinction: only Molecule proves idempotency, because that requires actually running twice against something real.

Q. Why is --syntax-check not enough?

It only proves the YAML parses and the structure is valid Ansible. It says nothing about whether the tasks are correct, idempotent, or safe — a playbook full of shell commands with no names passes --syntax-check perfectly.

It is the cheapest gate and belongs first in a pipeline, not the only one.


Part B · ansible-lint

B1 · Running it

The analogy. Think of a driving instructor who marks every fault at once. On your first lesson, a list of ninety faults is not feedback — it is noise, and you will simply stop listening to it. A good instructor starts with "don't hit anything", and only once that is reliable moves on to mirrors, and then to smoothness.

That is exactly what ansible-lint profiles are for. Start at min, gate CI on it so nothing gets worse, and raise the bar one profile at a time. Switching straight to production on an existing repository produces a report nobody reads and a tool everybody disables within a week — which leaves you worse off than having no linter at all.

bash
pip install ansible-lint

ansible-lint                          # ⭐ lint everything it can discover
ansible-lint site.yml roles/          # specific paths
ansible-lint --profile production     # ⭐ the strictest profile
ansible-lint --list-rules             # every rule
ansible-lint --list-tags              # rule tags for filtering
ansible-lint --fix                    # ⭐ auto-fix what it safely can
ansible-lint -f codeclimate           # machine-readable, for CI reports

B2 · Profiles — the feature that makes adoption possible

ProfileEnforces
minOnly errors that break execution
basicPlus obvious style problems
moderatePlus naming and structure conventions
safetyPlus rules that prevent dangerous behaviour
sharedPlus what is needed to publish to Galaxy
production⭐ Everything — the bar for Automation Platform content
This is how you introduce linting to an existing codebase without a thousand-issue wall. Start at min, gate CI on it, then raise the profile one step at a time as you clean up. Announcing --profile production on a legacy repository produces a report nobody reads and a rule everyone disables.

Saying "I'd adopt it incrementally by profile" is a much better answer than "I'd turn on ansible-lint".

yaml
# .ansible-lint
---
profile: moderate

exclude_paths:
  - collections/
  - galaxy_roles/
  - .venv/
  - molecule/*/molecule.yml

skip_list:
  - yaml[line-length]          # handled by yamllint with our own limit

warn_list:
  - experimental              # warn, do not fail
  - fqcn[action-core]         # while we migrate to FQCNs

enable_list:
  - no-log-password
  - no-same-owner

mock_roles:
  - mycompany.platform.webserver
mock_modules:
  - mycompany.platform.tenant

B3 · The rules that matter most

RuleWhat it catches, and which module warned you about it
name[missing]Unnamed tasks — Module 01 Part D2
fqcn[action-core]Short module names — Module 01 Part A4
risky-file-permissionsA file task with no mode — the permission is then unpredictable
risky-octalUnquoted mode: 0644 — Module 01 Part D2
command-instead-of-moduleshell: systemctl restart x where service exists — Module 01 Part C3
no-changed-whenA command task with no changed_when — Module 03 Part C1
no-log-passwordA task handling a password without no_log — Module 07 Part B3
ignore-errorsBlanket ignore_errors: true — Module 03 Part C1
jinja[spacing]{{var}} instead of {{ var }}
var-namingInvalid names — hyphens, leading digits — Module 02 Part A1
Notice that almost every rule corresponds to a trap taught earlier in this course. ansible-lint is essentially the accumulated production experience of the Ansible community, encoded. That is why enabling it early is worth more than any individual thing it catches.

B4 · Skipping a rule — properly

The analogy. Think of a road sign you genuinely need to ignore once — there are roadworks, there is a marked diversion. So you note it in the log: this junction, this day, this reason. What you do not do is take the sign down across the whole city, permanently, because it was inconvenient one afternoon. Nobody ever puts it back, and years later people are driving past a blind junction with no warning at all.

An inline # noqa with a reason written next to it is the note in the log. A global skip_list entry is taking the sign down. Both silence the rule; only one of them is still telling the truth to the next person who reads the file.

yaml
# Inline, on one task, with the reason - PREFERRED
- name: Restart the legacy appliance daemon
  ansible.builtin.command: /opt/legacy/restart.sh
  changed_when: false
  # noqa: command-instead-of-module
  # No service module support - this appliance has no init system
yaml
# Whole-file, in the file itself
# ansible-lint skip_list: ['risky-file-permissions']
A global skip_list entry disables a rule everywhere, forever, and nobody revisits it. An inline # noqa with a comment explaining why is reviewable, scoped to the one place it is justified, and shows up in a diff when someone copies the pattern.

The interview version: "I skip inline with a reason. A global skip is how a codebase quietly loses a safety rule."

🧪 Exercise B4.1 — Lint a deliberately bad playbook
bash
cat > bad.yml <<'EOF'
---
- hosts: all
  tasks:
    - shell: systemctl restart nginx

    - copy:
        src: app.conf
        dest: /etc/app.conf
        mode: 0644

    - name: Set the password
      user:
        name: deploy
        password: "{{ raw_password }}"

    - command: /usr/bin/check-status
      ignore_errors: true
EOF

ansible-lint bad.yml
ansible-lint bad.yml --profile production | head -40
Expected result — click to reveal
plain text
WARNING  Listing 9 violation(s) that are fatal

name[play]: All plays should be named.
bad.yml:2

fqcn[action-core]: Use FQCN for builtin module actions (shell).
bad.yml:4 Use `ansible.builtin.shell` instead.

name[missing]: All tasks should be named.
bad.yml:4 Task/Handler: shell systemctl restart nginx

command-instead-of-module: systemctl used in place of service module
bad.yml:4

no-changed-when: Commands should not change things if nothing needs doing.
bad.yml:4

risky-octal: Use quotes for octal permissions.
bad.yml:9 mode: 0644 -> mode: "0644"

no-log-password: Password should have no_log configured.
bad.yml:11

ignore-errors: Use failed_when and specify error conditions instead of using
ignore_errors.
bad.yml:17

Rule Violation Summary
 count tag                        profile   rule associated tags
     1 command-instead-of-module  basic     command-shell, idiom
     1 ignore-errors              basic     unpredictability
     1 no-changed-when            shared    command-shell, idempotency
     1 no-log-password            production security
     2 name[missing]              basic     idiom
     ...

Eight distinct problems in twelve lines of YAML — and every one of them was warned about earlier in this course. The shell: systemctl restart nginx line alone triggered four rules: no name, no FQCN, a command where a module exists, and no changed_when.

Read the summary table's profile column. It tells you which profile each rule belongs to, so you can see exactly what raising your profile one step would newly enforce. no-log-password is production — which is why a team on moderate would not catch a leaking password until they raise the bar.

Now run ansible-lint bad.yml --fix and diff the file. It safely fixes the mechanical ones — FQCNs, octal quoting — and correctly leaves the judgement calls (ignore_errors, no_log) to you.

🎯 Interview questions — Linting

Q. What does ansible-lint catch that --syntax-check does not?

Design and safety problems rather than parse errors: unnamed tasks and plays, short module names instead of FQCN, unquoted octal file modes, shell/command where a real module exists, command tasks with no changed_when, passwords without no_log, blanket ignore_errors, invalid variable names, Jinja spacing.

Essentially it encodes the community's accumulated production experience — a playbook can be perfectly valid YAML and perfectly parseable while being full of these.

Q. How would you introduce linting to a large legacy codebase?

Incrementally, using profiles. Start at min, gate CI on it so nothing gets worse, then raise one profile step at a time — basic, moderate, safety, production — cleaning up as you go.

Turning on --profile production immediately produces a thousand-issue report nobody reads and a rule everyone disables. The profile ladder exists precisely for this.

Q. How do you skip a rule you genuinely cannot satisfy?

Inline # noqa: rule-name on the specific task, with a comment explaining why — scoped, reviewable, and visible in a diff.

Avoid a global skip_list entry, which disables the rule everywhere permanently and quietly removes a safety check that nobody revisits.

Q. What is mock_roles / mock_modules in the config for?

They tell ansible-lint that a role or module exists even though it is not installed in the linting environment — so it does not report "couldn't resolve module/action" for your own collection's content or for roles installed only at deploy time.

Without them, CI linting fails on perfectly valid references to content that is installed later in the pipeline.


Part C · Molecule

C1 · What Molecule does

The analogy. Think of testing a recipe properly. You cook it once and it works — which proves very little, because you were standing there adjusting things by instinct and may not even remember doing it. The real test is cooking it a second time, following only what you actually wrote down, and getting the same dish. If the second attempt differs, something that lives in your head never made it onto the page.

That second cook is Molecule's idempotence step, and nothing else in the whole toolchain can substitute for it — because it requires genuinely doing the thing twice, for real, on a real machine. A linter can tell you a task looks risky; only a second run can tell you it changed something it should not have.

Diagram source
flowchart LR
    A["create<br>spin up a container/VM"] --> B["prepare<br>optional pre-setup"]
    B --> C["converge<br>RUN THE ROLE"]
    C --> D["idempotence<br>RUN IT AGAIN,<br>fail if anything changed"]
    D --> E["verify<br>assert the end state"]
    E --> F["destroy<br>tear it down"]
    style C fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
    style D fill:#FEF3C7,stroke:#D97706,stroke-width:2px
The idempotence step is the reason Molecule exists. It runs your role a second time and fails the build if anything reports changed. Nothing else in the toolchain can verify that — not linting, not --syntax-check, not --check.

Every idempotency trap in this course — password_hash without a salt, unsorted dictsort, a timestamp in ansible_managed, a bare shell task — is caught automatically by this one step, on every commit.

C2 · Setting up a scenario

The analogy. Think of rehearsing a play on a stage that has no lights. The cues go wrong, the actors stumble, and everyone walks away concluding the script is bad. It was not the script — you rehearsed in a room that could not do what the performance requires. A rehearsal is only worth having if the room resembles the theatre.

A plain container has no init system, so a role that starts a service fails there and works perfectly in production. The role was never broken; the stage had no lights. That is exactly what the systemd-capable test images exist for, and it is the first thing to check when a Molecule failure makes no sense against a role you know works.

bash
pip install "molecule-plugins[docker]" molecule ansible-lint

cd roles/webserver
molecule init scenario default -d docker      # scaffold into molecule/default/
tree molecule/
plain text
roles/webserver/molecule/default/
  molecule.yml       <- driver, platforms, provisioner config
  converge.yml       <- the playbook that applies your role
  verify.yml         <- assertions about the result
  prepare.yml        <- optional setup before converge
yaml
# molecule/default/molecule.yml
---
role_name_check: 1

dependency:
  name: galaxy
  options:
    requirements-file: requirements.yml

driver:
  name: docker

platforms:
  - name: ubuntu2204
    image: geerlingguy/docker-ubuntu2204-ansible:latest
    pre_build_image: true
    command: /lib/systemd/systemd
    cgroupns_mode: host
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw

  - name: rocky9
    image: geerlingguy/docker-rockylinux9-ansible:latest
    pre_build_image: true
    command: /usr/sbin/init
    cgroupns_mode: host
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw

provisioner:
  name: ansible
  config_options:
    defaults:
      interpreter_python: auto_silent
      callbacks_enabled: profile_tasks
  inventory:
    group_vars:
      all:
        webserver_port: 8080

verifier:
  name: ansible
Testing a role that manages services needs a container that runs systemd. A stock ubuntu:22.04 image has PID 1 as bash, so service: state: started fails and you conclude your role is broken when it is the test environment that is wrong.

The geerlingguy/docker-*-ansible images exist precisely for this — they run systemd as PID 1, which is why the command, privileged and cgroup volume settings above are there. Knowing this saves an afternoon and is a good detail to mention.

yaml
# molecule/default/converge.yml
---
- name: Converge
  hosts: all
  become: true
  tasks:
    - name: Apply the webserver role
      ansible.builtin.include_role:
        name: webserver
      vars:
        webserver_port: 8080
yaml
# molecule/default/verify.yml
---
- name: Verify
  hosts: all
  become: true
  gather_facts: true
  tasks:
    - name: Gather service facts
      ansible.builtin.service_facts:

    - name: nginx must be running and enabled
      ansible.builtin.assert:
        that:
          - ansible_facts.services['nginx.service'].state == 'running'
          - ansible_facts.services['nginx.service'].status == 'enabled'
        fail_msg: "nginx is not running or not enabled"

    - name: The config must exist with the right ownership
      ansible.builtin.stat:
        path: /etc/nginx/nginx.conf
      register: cfg

    - name: Assert config properties
      ansible.builtin.assert:
        that:
          - cfg.stat.exists
          - cfg.stat.mode == '0644'
          - cfg.stat.pw_name == 'root'

    - name: It must actually serve traffic on the configured port
      ansible.builtin.uri:
        url: "http://localhost:8080/"
        status_code: 200
Note what verify.yml asserts. Not "did the task report changed" — that is what converge already told you. It asserts the observable end state: the service is running, the file has the right mode and owner, and the port actually answers.

The last assertion is the valuable one. A role can complete successfully with every task green and still leave a service that does not serve traffic. Testing behaviour rather than task results is what makes the suite worth having.

C3 · Running it

bash
molecule test                    # ⭐ the full lifecycle - what CI runs
molecule converge                # ⭐ create + run the role, LEAVE IT UP
molecule login -h ubuntu2204     # ⭐ shell into the container and look around
molecule verify                  # run only the assertions
molecule idempotence             # ⭐ run the role again, fail on any change
molecule destroy
molecule list                    # what is currently running
molecule test -s hardened        # a named scenario other than 'default'
The development loop is convergelogin → fix → converge. molecule test destroys everything at the end, which is right for CI and painful while developing. converge leaves the container running so you can shell in with login and see what actually happened.

Reach for molecule test only when you believe it works.

C4 · Multiple scenarios

plain text
roles/webserver/molecule/
  default/          <- the standard case
  tls/              <- with TLS enabled
  upgrade/          <- converge an old version, then the new one
Scenarios are how you test the combinations your role supports. default proves the happy path; tls proves the feature flag actually changes behaviour; upgrade proves a version bump does not destroy existing data — which is the case real deployments break on and nobody tests.
🧪 Exercise C4.1 — Catch a real idempotency bug with Molecule
yaml
# roles/demo/tasks/main.yml  -- deliberately broken
---
- name: Ensure the marker directory exists
  ansible.builtin.file:
    path: /opt/demo
    state: directory
    mode: "0755"

- name: BROKEN - appends every run
  ansible.builtin.shell: echo "run at $(date)" >> /opt/demo/log.txt
bash
cd roles/demo
molecule init scenario default -d docker
molecule test
Expected result — and the fix — click to reveal
plain text
INFO     Running default > converge
TASK [Ensure the marker directory exists] ****   changed: [ubuntu2204]
TASK [BROKEN - appends every run] ************   changed: [ubuntu2204]

INFO     Running default > idempotence
TASK [Ensure the marker directory exists] ****   ok: [ubuntu2204]
TASK [BROKEN - appends every run] ************   changed: [ubuntu2204]

CRITICAL Idempotence test failed because of the following tasks:
* [ubuntu2204] => demo : BROKEN - appends every run

Molecule named the exact task. Not "something changed" — the specific task that was not idempotent, on the specific platform. That is the whole value: this bug is invisible to linting, invisible to --syntax-check, and invisible to a single run.

Note the first task passed. file: state: directory reported changed on run one and ok on run two, because it inspects state first — the Module 01 Part C3 lesson, now verified automatically.

Two ways to fix it:

yaml
# A - a marker, so it runs once
- name: Write the marker once
  ansible.builtin.shell: echo "installed at $(date)" > /opt/demo/log.txt
  args:
    creates: /opt/demo/log.txt

# B - declare the content, which is better
- name: Manage the marker file
  ansible.builtin.copy:
    content: "demo marker\n"
    dest: /opt/demo/log.txt
    mode: "0644"

Re-run molecule test and the idempotence step passes.

This is the exercise to remember for interviews. "How do you know your role is idempotent?""Molecule's idempotence step runs it twice and fails the build on any change, naming the offending task. It runs on every commit."

🎯 Interview questions — Molecule

Q. What is Molecule and what does it do that nothing else does?

A testing framework for roles and collections. It runs a lifecycle: create a container or VM, optionally prepare it, converge (apply the role), idempotence (apply it again and fail on any change), verify (assert the end state), destroy.

The idempotence step is unique — no linter, no --syntax-check and no --check run can prove idempotency, because it requires actually applying the role twice to something real. That single step catches every idempotency trap: unsalted password_hash, unsorted dictionary iteration, timestamps in generated files, bare shell tasks.

Q. What should verify.yml assert?

Observable end state, not task results. The service is running and enabled, the config file exists with the right mode and owner, and the port actually answers a request.

converge already told you whether tasks succeeded. A role can go entirely green and still leave a service that does not serve traffic — testing behaviour rather than task outcomes is what makes the suite worth having.

Q. Your role's service task fails in Molecule but works on a real host. Why?

The container almost certainly has no init system — a stock ubuntu:22.04 image runs bash as PID 1, so systemd is not there and service: state: started cannot work.

Use an image that runs systemd, such as the geerlingguy/docker-*-ansible family, with command: /lib/systemd/systemd, privileged: true, cgroupns_mode: host and the cgroup volume mount. The role is fine; the test environment was wrong.

Q. molecule test versus molecule converge — when do you use each?

converge creates the container and applies the role, then leaves it running — so you can molecule login and inspect what actually happened. That is the development loop: converge, login, fix, converge.

molecule test runs the whole lifecycle including destroy. It is what CI runs and what you use when you believe it already works. Using it as a development loop means recreating the container on every iteration for no reason.

Q. Why would a role have several Molecule scenarios?

To test the combinations it supports. default for the happy path, tls to prove a feature flag genuinely changes behaviour, upgrade to converge an old version then the new one and prove a version bump does not destroy existing data.

That last one is the case real deployments break on and almost nobody tests.


Part D · CI/CD

D1 · A complete pipeline

The analogy. Think of the checks between the airport door and your seat. The boarding pass scan takes a second. Security takes minutes. And after all of it there is still a human at the gate who can stop you. That order is not an accident — cheapest first, and the last one is deliberately a person, because some decisions should not be automatic.

A pipeline runs the same way: lint in seconds, Molecule in minutes, and a manual gate before production. The reason the last one stays human is worth saying plainly: a bad playbook does not fail a health check and quietly roll back like a bad container image. It reconfigures five hundred servers correctly, quickly, and exactly as instructed.

yaml
# .gitlab-ci.yml
---
stages: [lint, test, deploy]

variables:
  ANSIBLE_FORCE_COLOR: "1"
  PY_COLORS: "1"

.ansible_base: &ansible_base
  image: python:3.11
  before_script:
    - pip install -q ansible-core ansible-lint yamllint
    - ansible-galaxy install -r requirements.yml

yamllint:
  stage: lint
  <<: *ansible_base
  script: [yamllint .]

ansible-lint:
  stage: lint
  <<: *ansible_base
  script: [ansible-lint --profile moderate]

syntax:
  stage: lint
  <<: *ansible_base
  script:
    - ansible-playbook playbooks/site.yml --syntax-check
    - ansible-inventory -i inventories/staging --graph

molecule:
  stage: test
  image: python:3.11
  services: [docker:dind]
  variables:
    DOCKER_HOST: tcp://docker:2375
  before_script:
    - pip install -q "molecule-plugins[docker]" molecule ansible-lint ansible-core
  script:
    - cd roles/webserver && molecule test
  parallel:
    matrix:
      - ROLE: [webserver, monitoring, common]

check-staging:
  stage: test
  <<: *ansible_base
  script:
    - echo "$VAULT_PASSWORD" > /tmp/.vpass && chmod 600 /tmp/.vpass
    - ansible-playbook -i inventories/staging playbooks/site.yml
        --check --diff --vault-password-file /tmp/.vpass
  after_script:
    - shred -u /tmp/.vpass || rm -f /tmp/.vpass

deploy-production:
  stage: deploy
  <<: *ansible_base
  when: manual                          # a human must click
  only: [main]
  environment: production
  script:
    - echo "$VAULT_PASSWORD" > /tmp/.vpass && chmod 600 /tmp/.vpass
    - ansible-playbook -i inventories/production playbooks/site.yml
        --vault-password-file /tmp/.vpass
  after_script:
    - shred -u /tmp/.vpass || rm -f /tmp/.vpass
StageGates on
yamllintSeconds. Catches the whole parse-error class
ansible-lintSeconds. Design and safety rules
syntaxPlaybooks and inventory parse
molecule⭐ Roles actually work and are idempotent, in parallel across roles
check-staging⭐ What would change on real infrastructure
deploy-productionManual gate, main only

D2 · The GitHub Actions equivalent

yaml
name: ansible
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.11"}
      - run: pip install ansible-core ansible-lint yamllint
      - run: yamllint .
      - run: ansible-lint --profile moderate
      - run: ansible-playbook playbooks/site.yml --syntax-check

  molecule:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        role: [webserver, monitoring, common]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.11"}
      - run: pip install "molecule-plugins[docker]" molecule ansible-core ansible-lint
      - run: molecule test
        working-directory: roles/${{ matrix.role }}
fail-fast: false on the matrix matters. By default one role's failure cancels the others, so you fix one bug, push, and discover the next. With it off you get every role's result in one run — which turns a three-round debugging cycle into one.

D3 · What the pipeline should and should not do

The analogy. Think of patting your pockets before you leave the house — keys, wallet, phone. It takes two seconds and saves you the walk back. But it is not a security system: you can walk out without doing it, and on the days you are in a hurry you will.

Pre-commit hooks are the pocket check: fast, genuinely useful, and skippable with --no-verify. CI is the locked door. You want both, and it matters that you know which one is the actual gate — a team that enforces its standards only in pre-commit hooks has standards that anyone can bypass by being in a hurry.

Never let CI apply to production automatically on merge. Configuration management is not application deployment: a bad playbook does not fail a health check and roll back, it reconfigures 500 servers and keeps going.

Use a manual gate on production, restricted to main, with --check --diff having run automatically against staging first. Combine with the Module 03 rollout pattern — serial plus max_fail_percentage: 0 — so even an approved run halts on the first failure.

🧪 Exercise D3.1 — Build a pre-commit gate
bash
pip install pre-commit
cat > .pre-commit-config.yaml <<'EOF'
---
repos:
  - repo: https://github.com/adrienverge/yamllint
    rev: v1.35.1
    hooks:
      - id: yamllint
        args: [--strict]

  - repo: https://github.com/ansible/ansible-lint
    rev: v24.2.0
    hooks:
      - id: ansible-lint

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: check-merge-conflict
      - id: detect-private-key          # catches a committed SSH key
      - id: end-of-file-fixer
      - id: trailing-whitespace
EOF

pre-commit install
pre-commit run --all-files
Expected result — click to reveal
plain text
yamllint.................................................................Failed
- hook id: yamllint
- exit code: 1

site.yml
  12:81     warning  line too long (94 > 80 characters)  (line-length)
  18:1      error    trailing spaces  (trailing-spaces)

ansible-lint.............................................................Failed
- hook id: ansible-lint

name[missing]: All tasks should be named.
site.yml:8

Detect Private Key.......................................................Passed
Fix End of Files.........................................................Fixed
Trim Trailing Whitespace.................................................Fixed

Two of those hooks fixed the files in place and staged nothing — so git commit aborts, you review the changes, git add and commit again. That is intentional: the hook never silently rewrites what you are committing.

detect-private-key is the one worth having regardless of everything else. It catches an SSH private key or a PEM file heading into version control — which, per Module 07, is not fixable by deleting it later, because the history retains it and the credential must be rotated.

Why pre-commit and not just CI? Both. Pre-commit gives feedback in two seconds instead of two minutes and keeps trivially broken commits out of the branch. CI is the gate that cannot be skipped, since git commit --no-verify bypasses hooks. Pre-commit is convenience; CI is enforcement.

🎯 Interview questions — CI/CD

Q. Describe a CI pipeline for an Ansible repository.

Staged, cheapest first. Lint: yamllint, ansible-lint at an agreed profile, --syntax-check on playbooks and ansible-inventory --graph on inventories. Test: Molecule per role, in a parallel matrix, which proves the roles work and are idempotent. Pre-deploy: --check --diff against staging so the diff is visible in the job log. Deploy: a manual gate, main only, with the vault password from the platform's secret store written to a 0600 temp file and shredded afterwards.

Q. Should merging to main deploy to production automatically?

For configuration management, no. Application deployment can roll back on a failed health check; a bad playbook reconfigures 500 servers and keeps going.

Use a manual approval gate on production, with --check --diff against staging having run automatically, and combine it with serial plus max_fail_percentage: 0 so even an approved run halts on the first failure.

Q. Pre-commit hooks or CI?

Both, for different jobs. Pre-commit gives feedback in seconds and keeps trivially broken commits out of the branch — but git commit --no-verify bypasses it, so it cannot be the gate.

CI is enforcement. Pre-commit is convenience. The one hook worth having regardless is detect-private-key, because a committed credential is not fixable by deletion — the history keeps it and the key must be rotated.

Q. How do you handle the vault password in CI?

From the platform's secret store as a masked variable, written to a 0600 temporary file, passed with --vault-password-file, and shredded in an after_script that runs even on failure.

Never as a command-line argument — it appears in the process list and often in job logs. Better still, a vault password client script that fetches it from AWS Secrets Manager or HashiCorp Vault at run time, so nothing is written to disk at all.


Part E · Putting it together

E1 · Production practice

HabitWhy
Run the layers cheapest-first and gate CI on eachA YAML error should fail in two seconds, not after a five-minute Molecule run
Adopt ansible-lint by profile, starting at min--profile production on a legacy repo produces a report nobody reads
Enable yamllint's truthy ruleRejects yes/no/on/off — the Norway problem caught at lint time
Inline # noqa: rule with a reason, never a global skip_listA global skip quietly removes a safety rule forever
A Molecule scenario for every role you intend to keepOnly Molecule can prove idempotency
Use systemd-capable images for roles that manage servicesA stock image has bash as PID 1 and your service task cannot work
verify.yml asserts observable state, not task resultsA role can go entirely green and still not serve traffic
Develop with convergelogin; save test for CItest destroys the container, so it is a poor development loop
Molecule matrix with fail-fast: falseGet every role's result in one run instead of one bug per push
--check --diff against staging automatically, before any deploy jobThe diff lands in the job log where a reviewer can read it
Manual approval gate on production, main onlyA bad playbook does not fail a health check — it reconfigures 500 servers
detect-private-key in pre-commit regardless of anything elseA committed key is not fixable by deletion; the history keeps it

E2 · Capstone exercise

Attempt this without looking anything up. It exercises linting, Molecule, idempotence and pipeline design together.

Brief. Add a complete test and delivery pipeline to the repository from Module 06:

  1. YAML and lint gates that fail fast, with lint adopted at a level a legacy repo can actually pass
  2. A Molecule scenario for the webserver role, testing both Ubuntu and Rocky
  3. The scenario must prove the role is idempotent, and must be able to test a service
  4. verify.yml must assert the service is genuinely serving traffic, not just that tasks succeeded
  5. Roles must be tested in parallel, with all failures visible in one run
  6. Staging must get an automatic --check --diff; production must require a human
  7. The vault password must never appear in a process list or job log
Model answer — attempt it first, then click
yaml
# roles/webserver/molecule/default/molecule.yml   -- requirements 2, 3
---
dependency:
  name: galaxy
  options:
    requirements-file: ../../../../requirements.yml

driver:
  name: docker

platforms:
  - name: ubuntu2204
    image: geerlingguy/docker-ubuntu2204-ansible:latest
    pre_build_image: true
    command: /lib/systemd/systemd        # requirement 3 - real init system
    cgroupns_mode: host
    privileged: true
    volumes: ["/sys/fs/cgroup:/sys/fs/cgroup:rw"]

  - name: rocky9
    image: geerlingguy/docker-rockylinux9-ansible:latest
    pre_build_image: true
    command: /usr/sbin/init
    cgroupns_mode: host
    privileged: true
    volumes: ["/sys/fs/cgroup:/sys/fs/cgroup:rw"]

provisioner:
  name: ansible
  config_options:
    defaults:
      interpreter_python: auto_silent

verifier:
  name: ansible
yaml
# roles/webserver/molecule/default/verify.yml     -- requirement 4
---
- name: Verify
  hosts: all
  become: true
  gather_facts: true
  tasks:
    - ansible.builtin.service_facts:

    - name: Service must be running and enabled
      ansible.builtin.assert:
        that:
          - ansible_facts.services['nginx.service'].state == 'running'
          - ansible_facts.services['nginx.service'].status == 'enabled'

    - name: Config must exist with correct ownership
      ansible.builtin.stat:
        path: /etc/nginx/nginx.conf
      register: cfg

    - ansible.builtin.assert:
        that:
          - cfg.stat.exists
          - cfg.stat.mode == '0644'
          - cfg.stat.pw_name == 'root'

    - name: It must ACTUALLY answer on the port      # requirement 4
      ansible.builtin.uri:
        url: "http://localhost:{{ webserver_port | default(80) }}/"
        status_code: 200
yaml
# .ansible-lint   -- requirement 1
---
profile: moderate                 # not 'production' on a legacy repo
exclude_paths: [collections/, galaxy_roles/, .venv/]
skip_list: [yaml[line-length]]    # yamllint owns line length, with our limit
warn_list: [experimental]
yaml
# .gitlab-ci.yml   -- requirements 1, 5, 6, 7
---
stages: [lint, test, deploy]

.base: &base
  image: python:3.11
  before_script:
    - pip install -q ansible-core ansible-lint yamllint
    - ansible-galaxy install -r requirements.yml

lint:
  stage: lint
  <<: *base
  script:                                   # requirement 1 - fail fast, cheapest first
    - yamllint .
    - ansible-lint --profile moderate
    - ansible-playbook playbooks/site.yml --syntax-check
    - ansible-inventory -i inventories/staging --graph

molecule:
  stage: test
  image: python:3.11
  services: [docker:dind]
  variables: {DOCKER_HOST: "tcp://docker:2375"}
  before_script:
    - pip install -q "molecule-plugins[docker]" molecule ansible-core ansible-lint
  script: [cd roles/$ROLE && molecule test]
  parallel:                                 # requirement 5
    matrix:
      - ROLE: [webserver, monitoring, common]

check-staging:
  stage: test
  <<: *base
  script:                                   # requirements 6, 7
    - echo "$VAULT_PASSWORD" > /tmp/.vpass && chmod 600 /tmp/.vpass
    - ansible-playbook -i inventories/staging playbooks/site.yml
        --check --diff --vault-password-file /tmp/.vpass
  after_script:
    - shred -u /tmp/.vpass || rm -f /tmp/.vpass

deploy-production:
  stage: deploy
  <<: *base
  when: manual                              # requirement 6 - a human clicks
  only: [main]
  environment: production
  script:
    - echo "$VAULT_PASSWORD" > /tmp/.vpass && chmod 600 /tmp/.vpass
    - ansible-playbook -i inventories/production playbooks/site.yml
        --vault-password-file /tmp/.vpass
  after_script:
    - shred -u /tmp/.vpass || rm -f /tmp/.vpass

The seven decisions:

  1. profile: moderate, not production — requirement 1 explicitly said a level a legacy repo can pass. Starting too strict is how linting gets disabled.
  2. systemd images with privileged and the cgroup mount — requirement 3. Without them service: state: started fails and you debug a role that is fine.
  3. Two platforms in one scenario — requirement 2. Molecule runs the converge and idempotence steps against both, so a Debian-only assumption surfaces immediately.
  4. The uri assertion in verify — requirement 4, and the one that matters. Everything above it checks that Ansible did what it said; only this checks that the outcome is real.
  5. parallel: matrix in GitLab (or fail-fast: false in GitHub Actions) — requirement 5.
  6. when: manual plus only: [main] — requirement 6. Staging's --check --diff runs automatically in the same stage, so the diff is in the log before anyone approves.
  7. Password to a 0600 file, shred in after_script — requirement 7. after_script runs even when the job fails, which a trailing line in script: would not.

Verify locally before pushing:

bash
yamllint . && ansible-lint --profile moderate
cd roles/webserver && molecule test
cd - && ansible-playbook -i inventories/staging playbooks/site.yml --check --diff

E3 · Command reference — everything from this module

Commands from Module 11. ⭐ marks genuinely daily-use.

Linting

bash
yamllint .                                   # ⭐ YAML validity
yamllint -f parsable .                       # machine-readable for CI
ansible-lint                                 # ⭐ lint everything discoverable
ansible-lint site.yml roles/                 # specific paths
ansible-lint --profile production            # ⭐ the strictest profile
ansible-lint --fix                           # ⭐ auto-fix the mechanical rules
ansible-lint --list-rules                    # every rule
ansible-lint --list-tags                     # rule tags
ansible-lint -f codeclimate                  # CI-friendly output
ansible-lint --nocolor 2>&1 | tail -30       # readable in a log

Syntax and dry runs

bash
ansible-playbook site.yml --syntax-check     # ⭐ does Ansible parse it?
ansible-inventory -i inventories/staging --graph   # ⭐ does the inventory parse?
ansible-playbook site.yml --list-tasks       # ⭐ full ordered scope
ansible-playbook site.yml --check --diff --limit staging01   # ⭐ what would change

Molecule

bash
pip install "molecule-plugins[docker]" molecule ansible-lint

molecule init scenario default -d docker     # ⭐ scaffold into molecule/default/
molecule list                                # what is running
molecule create                              # just the container
molecule converge                            # ⭐ create + apply the role, LEAVE IT UP
molecule login -h ubuntu2204                 # ⭐ shell into the container
molecule idempotence                         # ⭐ apply again, fail on any change
molecule verify                              # run verify.yml only
molecule destroy
molecule test                                # ⭐ the full lifecycle - what CI runs
molecule test -s tls                         # a named scenario
molecule --debug test                        # verbose, when it fails mysteriously

Collection testing

bash
cd ~/collections/ansible_collections/mycompany/platform
ansible-test sanity --docker default                    # ⭐ docs, imports, pep8
ansible-test sanity --docker --test validate-modules    # ⭐ docs vs argument_spec
ansible-test units --docker default
ansible-test integration --docker default

Pre-commit

bash
pip install pre-commit
pre-commit install                           # ⭐ activate the hooks
pre-commit run --all-files                   # ⭐ run against the whole repo
pre-commit autoupdate                        # bump hook versions
git commit --no-verify                       # bypass - which is why CI is the real gate

Config files worth having

plain text
.yamllint              ⭐ truthy rule on, line-length raised
.ansible-lint          ⭐ profile, exclude_paths, mock_roles
.pre-commit-config.yaml  ⭐ yamllint, ansible-lint, detect-private-key
molecule/default/      ⭐ molecule.yml, converge.yml, verify.yml
The local pre-push check — four commands, under a minute:
bash
yamllint .
ansible-lint --profile moderate
ansible-playbook playbooks/site.yml --syntax-check
cd roles/<changed-role> && molecule test

If those pass, CI will almost certainly pass, and you have not burned a pipeline slot discovering a missing quote.


E4 · Official documentation

LinkCovers
ansible-lint documentationRules, profiles, configuration, # noqa
ansible-lint rules indexEvery rule with an explanation and an example
ansible-lint profilesmin → basic → moderate → safety → shared → production
Molecule documentationScenarios, drivers, verifiers, the full lifecycle
Molecule getting startedFirst scenario, step by step
Testing strategiesThe official view on testing Ansible content
Testing collectionsansible-test sanity, units, integration
yamllint documentationRules including truthy and line-length
pre-commitHook configuration and available hooks

E5 · Self-assessment

1. Name the testing layers in order, cheapest first.

yamllint--syntax-checkansible-lint--check --diff → Molecule → staging run.

Gate CI on each so a YAML error fails in seconds rather than after a five-minute Molecule run.

2. What can only Molecule prove?

Idempotency. Its idempotence step applies the role a second time and fails the build if any task reports changed, naming the offending task.

No linter, no --syntax-check and no --check run can establish that, because it requires actually applying the role twice to something real.

3. How do you introduce ansible-lint to a legacy codebase?

By profile. Start at min, gate CI so nothing gets worse, then raise one step at a time — basic, moderate, safety, production — cleaning up as you go.

Enabling production immediately produces a report nobody reads and a tool everyone disables.

4. Global skip_list or inline # noqa?

Inline # noqa: rule-name with a comment explaining why — scoped to the one justified place, reviewable, and visible in a diff.

A global skip_list entry disables the rule everywhere forever and quietly removes a safety check nobody revisits.

5. Your service task fails in Molecule but works on a real host. Why?

The container has no init system — a stock image runs bash as PID 1. Use a systemd-capable image such as geerlingguy/docker-*-ansible, with command: /lib/systemd/systemd, privileged: true, cgroupns_mode: host and the cgroup volume mount.

The role is fine; the test environment was wrong.

6. What should verify.yml assert, and what should it not?

Observable end state — the service is running and enabled, the file has the right mode and owner, and the port actually answers a request.

Not task results: converge already reported those, and a role can go entirely green while leaving a service that does not serve traffic.

7. molecule converge versus molecule test?

converge applies the role and leaves the container running, so you can molecule login and inspect — that is the development loop.

molecule test runs the whole lifecycle including destroy; it is what CI runs and what you use when you believe it already works.

8. Should a merge to main deploy to production automatically?

For configuration management, no. An application deploy can fail a health check and roll back; a bad playbook reconfigures 500 servers and keeps going.

Manual gate on production, main only, with --check --diff against staging having run automatically — combined with serial and max_fail_percentage: 0 so even an approved run halts on the first failure.

9. Pre-commit or CI, and which single hook is non-negotiable?

Both — pre-commit for two-second feedback, CI for enforcement, because git commit --no-verify bypasses hooks.

The non-negotiable hook is detect-private-key: a committed credential cannot be fixed by deletion, since the history retains it and the key must be rotated.

10. How is the vault password handled safely in a pipeline?

From the platform's masked secret store, written to a 0600 temp file, passed with --vault-password-file, and shredded in an after_script that runs even on failure.

Never as a command-line argument — it lands in the process list and often the job log. Better still, a client script fetching it at run time so nothing touches disk.

11. Why fail-fast: false on a Molecule matrix?

By default one role's failure cancels the others, so you fix one bug, push, and discover the next. With it disabled you get every role's result from a single run, turning a multi-round debugging cycle into one.


Next — Module 12 · AWX / Ansible Automation Platform.

Running Ansible as a service rather than from laptops and CI — RBAC, credentials, job templates, workflows, surveys and scheduling.

📚 Sources for the interview questions

Behaviour verified against the current ansible-lint and Molecule documentation.

Question selection cross-referenced against publicly published 2026 Ansible interview question sets:

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.