Module 11 — Testing, Molecule & CI/CD
Updated 20 August 2026
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
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:#D97706Only Molecule proves idempotency, and idempotency is the property nothing else can verify without actually running twice against something real.
A2 · The cheap layers
# 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# .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/🎯 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
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.
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 reportsB2 · Profiles — the feature that makes adoption possible
| Profile | Enforces |
|---|---|
| min | Only errors that break execution |
| basic | Plus obvious style problems |
| moderate | Plus naming and structure conventions |
| safety | Plus rules that prevent dangerous behaviour |
| shared | Plus what is needed to publish to Galaxy |
| production | ⭐ Everything — the bar for Automation Platform content |
Saying "I'd adopt it incrementally by profile" is a much better answer than "I'd turn on ansible-lint".
# .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.tenantB3 · The rules that matter most
| Rule | What 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-permissions | A file task with no mode — the permission is then unpredictable |
| risky-octal | Unquoted mode: 0644 — Module 01 Part D2 |
| command-instead-of-module | shell: systemctl restart x where service exists — Module 01 Part C3 |
| no-changed-when | A command task with no changed_when — Module 03 Part C1 |
| no-log-password | A task handling a password without no_log — Module 07 Part B3 |
| ignore-errors | Blanket ignore_errors: true — Module 03 Part C1 |
| jinja[spacing] | {{var}} instead of {{ var }} |
| var-naming | Invalid names — hyphens, leading digits — Module 02 Part A1 |
B4 · Skipping a rule — properly
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.
# 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# Whole-file, in the file itself
# ansible-lint skip_list: ['risky-file-permissions']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
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
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
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:2pxEvery 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
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.
pip install "molecule-plugins[docker]" molecule ansible-lint
cd roles/webserver
molecule init scenario default -d docker # scaffold into molecule/default/
tree molecule/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# 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: ansibleThe 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.
# 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# 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: 200The 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
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'Reach for molecule test only when you believe it works.
C4 · Multiple scenarios
roles/webserver/molecule/
default/ <- the standard case
tls/ <- with TLS enabled
upgrade/ <- converge an old version, then the new one🧪 Exercise C4.1 — Catch a real idempotency bug with Molecule
# 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.txtcd roles/demo
molecule init scenario default -d docker
molecule test✅ Expected result — and the fix — click to reveal
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 runMolecule 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:
# 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
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.
# .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| Stage | Gates on |
|---|---|
| yamllint | Seconds. Catches the whole parse-error class |
| ansible-lint | Seconds. Design and safety rules |
| syntax | Playbooks and inventory parse |
| molecule | ⭐ Roles actually work and are idempotent, in parallel across roles |
| check-staging | ⭐ What would change on real infrastructure |
| deploy-production | Manual gate, main only |
D2 · The GitHub Actions equivalent
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 }}D3 · What the pipeline should and should not do
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.
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
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
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.................................................FixedTwo 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
| Habit | Why |
|---|---|
| Run the layers cheapest-first and gate CI on each | A 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 rule | Rejects yes/no/on/off — the Norway problem caught at lint time |
| Inline # noqa: rule with a reason, never a global skip_list | A global skip quietly removes a safety rule forever |
| A Molecule scenario for every role you intend to keep | Only Molecule can prove idempotency |
| Use systemd-capable images for roles that manage services | A stock image has bash as PID 1 and your service task cannot work |
| verify.yml asserts observable state, not task results | A role can go entirely green and still not serve traffic |
| Develop with converge • login; save test for CI | test destroys the container, so it is a poor development loop |
| Molecule matrix with fail-fast: false | Get every role's result in one run instead of one bug per push |
| --check --diff against staging automatically, before any deploy job | The diff lands in the job log where a reviewer can read it |
| Manual approval gate on production, main only | A bad playbook does not fail a health check — it reconfigures 500 servers |
| detect-private-key in pre-commit regardless of anything else | A committed key is not fixable by deletion; the history keeps it |
E2 · Capstone exercise
Brief. Add a complete test and delivery pipeline to the repository from Module 06:
- YAML and lint gates that fail fast, with lint adopted at a level a legacy repo can actually pass
- A Molecule scenario for the webserver role, testing both Ubuntu and Rocky
- The scenario must prove the role is idempotent, and must be able to test a service
- verify.yml must assert the service is genuinely serving traffic, not just that tasks succeeded
- Roles must be tested in parallel, with all failures visible in one run
- Staging must get an automatic --check --diff; production must require a human
- The vault password must never appear in a process list or job log
✅ Model answer — attempt it first, then click
# 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# 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# .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]# .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/.vpassThe seven decisions:
- profile: moderate, not production — requirement 1 explicitly said a level a legacy repo can pass. Starting too strict is how linting gets disabled.
- systemd images with privileged and the cgroup mount — requirement 3. Without them service: state: started fails and you debug a role that is fine.
- Two platforms in one scenario — requirement 2. Molecule runs the converge and idempotence steps against both, so a Debian-only assumption surfaces immediately.
- 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.
- parallel: matrix in GitLab (or fail-fast: false in GitHub Actions) — requirement 5.
- 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.
- 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:
yamllint . && ansible-lint --profile moderate
cd roles/webserver && molecule test
cd - && ansible-playbook -i inventories/staging playbooks/site.yml --check --diffE3 · Command reference — everything from this module
Linting
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 logSyntax and dry runs
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 changeMolecule
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 mysteriouslyCollection testing
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 defaultPre-commit
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 gateConfig files worth having
.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.ymlyamllint .
ansible-lint --profile moderate
ansible-playbook playbooks/site.yml --syntax-check
cd roles/<changed-role> && molecule testIf those pass, CI will almost certainly pass, and you have not burned a pipeline slot discovering a missing quote.
E4 · Official documentation
| Link | Covers |
|---|---|
| ansible-lint documentation | Rules, profiles, configuration, # noqa |
| ansible-lint rules index | Every rule with an explanation and an example |
| ansible-lint profiles | min → basic → moderate → safety → shared → production |
| Molecule documentation | Scenarios, drivers, verifiers, the full lifecycle |
| Molecule getting started | First scenario, step by step |
| Testing strategies | The official view on testing Ansible content |
| Testing collections | ansible-test sanity, units, integration |
| yamllint documentation | Rules including truthy and line-length |
| pre-commit | Hook configuration and available hooks |
E5 · Self-assessment
1. Name the testing layers in order, cheapest first.
yamllint → --syntax-check → ansible-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.
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:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
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.