Runbook: Test a playbook
1 · Prerequisites
Confirm every item is in place before any state change.
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · The playbook is committed on a branch, so what you test is what a reviewer can read
- · The inventory the playbook will use in production is known, and a staging equivalent exists
- · The hosts each play targets have been read from the hosts: lines, before any tool is run
- · A staging host and a production canary host are both identified
- · Nothing in the playbook is scheduled to run automatically during the test window
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Rung 1: ansible-lint and --syntax-check - does it parse and does it follow the estates rules
- 2Rung 2: --list-tasks and --list-tags - does it contain what you think it contains
- 3Rung 3: --list-hosts against the production inventory - what would it target, before it targets it
- 4Rung 4: --check --diff against staging - what would change
- 5Rung 5: a real run against staging - does it work, and does it work twice
- 6Rung 6: --check --diff against one production canary - does production differ from staging
- 7Rung 7: a real run against the canary, then verify the service, then hold
- 8Record which rungs were run, against what, and what each one showed
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓ansible-lint exits 0 at the profile this repository enforces
- ✓--syntax-check exits 0
- ✓--list-tasks output matches the tasks you expect, including tasks pulled in by roles
- ✓--list-hosts against the production inventory returns exactly the host set intended, and the count is read not assumed
- ✓The staging check-mode diff contains no change that surprises you
- ✓The real staging run exits 0, and an immediate second run reports changed=0
- ✓The canary check-mode diff is compared against the staging one, and any difference is explained
- ✓After the canary run the service answers a real request, not just a port check
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Rungs 1 to 4 and 6 change nothing - there is nothing to roll back
- ↶Rung 5 changed a staging host: re-run the previous known-good playbook version against it, or rebuild it
- ↶Rung 7 changed a production host: restore the files the playbook manages from their backups and restart the affected services
- ↶Revert the playbook branch so nothing picks it up, and confirm no scheduled job references the branch
- ↶If the canary was left in a partially converged state, finish or reverse it deliberately - do not leave it half-applied while you investigate
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate if the production check-mode diff differs materially from the staging one; staging is not representative and that is a bigger problem than this playbook
- · Escalate to the service owner before Rung 7 if the diff touches anything they own
- · Escalate if the playbook cannot be tested against staging at all - a play that only exists in production is a play that is tested in production
- · Escalate if a second staging run reports changes; a non-idempotent playbook should not proceed to a canary
Testing a playbook is a ladder, not a switch. Each rung costs more than the one below it and proves something the one below it could not, and the discipline is to climb in order - because a failure on rung 2 costs nothing and the same failure discovered on rung 7 costs a service.
The other half of the discipline is honesty about what each rung does not prove. Every rung here says so explicitly.
When to use this runbook
- A new playbook is going to production for the first time.
- An existing playbook has been changed materially.
- A playbook is being pointed at a new inventory or a new group.
- Before any change window, as the pre-flight for what you are about to run.
Blast radius
| Rung | What it touches |
|---|---|
| 1 - lint, syntax | Nothing. Local files only. |
| 2 - list-tasks, list-tags | Nothing. |
| 3 - list-hosts | Nothing. Reads inventory. |
| 4 - check against staging | Connects to staging. Changes nothing. |
| 5 - real run against staging | Changes staging hosts. |
| 6 - check against a production canary | Connects to one production host. Changes nothing. |
| 7 - real run against the canary | Changes one production host. |
Rungs 1 to 3 do not open a network connection at all. Rungs 4 and 6 connect but do not change. That is four rungs of useful signal before anything is at risk.
Rung 1: Does it parse, and does it follow the rules
ansible-lint site.yml
ansible-playbook site.yml --syntax-check
echo "exit=$?"--syntax-check parses the playbook and everything it statically
imports. It does not evaluate Jinja, does not resolve variables and does
not connect anywhere.
What it cannot prove: anything about values. A playbook full of
undefined variables passes --syntax-check cleanly, because the
templates are not rendered until a task runs.
Rung 2: Does it contain what you think
ansible-playbook site.yml --list-tasks
ansible-playbook site.yml --list-tagsRead the task list against the change you intended. The usual finding is a role that pulls in a dependency you forgot about, or a task list twice as long as expected because a role is applied by two plays.
--list-tags matters when the change window will use --tags. A tag
that does not appear here does not exist, and a mistyped --tags runs
nothing at all while reporting success.
Verified on 2.21.3, a playbook whose only task is tagged deploy, run
with --tags deploi:
PLAY [Tagged play] *************************************************************
PLAY RECAP *********************************************************************
An empty recap, no warning, exit code 0. There is no host line to
read, which is the tell - but only if you are looking at the recap
rather than at the exit code. --list-tags before a tagged change
window costs one second and removes the entire failure mode.
Rung 3: What would it target
ansible-playbook -i inventories/production site.yml --list-hosts
ansible-playbook -i inventories/production site.yml --limit web --list-hostsVerified output shape on 2.21.3:
play #1 (web): Front-end web tier TAGS: []
pattern: ['web']
hosts (2):
web02.example.com
web01.example.com
Read the count. Not the pattern - the count. hosts (2) when you
expected two is a pass; hosts (240) when you expected two is the
finding this rung exists for, and it is the cheapest place in the whole
ladder to find it.
Do this against the production inventory even though you are about to test against staging. The question “what would this do in production” has an answer now, for free, and it is the answer that matters.
Rung 4: What would change, against staging
ansible-playbook -i inventories/staging site.yml \
--check --diff | tee "check-staging.txt"
echo "exit=$?"--check asks each module to report what it would do. --diff shows
the content change for file-shaped modules. Together they are the most
information you can get without changing anything.
What check mode cannot prove, and this list is the whole reason rung 5 exists:
- Modules without check-mode support are skipped. Their changes are not predicted at all, and anything downstream of them is evaluated against a world where they did not run.
commandandshelldo not run, so a task withwhen: result.rc == 0is evaluated against aresultthat does not exist.validate:does not run, because there is no rendered file to validate. A broken template passes check mode and fails for real.- Handlers are reported as notified but the service is not restarted. Whether the new config is actually loadable is untested.
- Anything that depends on a file an earlier task would have created reports a change that will not happen, or fails.
A clean check-mode run is a necessary gate. It is not a rehearsal.
grep -nE 'ansible\.builtin\.(command|shell|raw|script)' site.yml roles/*/tasks/*.yml
grep -rn 'check_mode:' site.yml roles/*/tasks/*.ymlThat first grep is the list of tasks check mode did not evaluate. Read it alongside the diff so you know what the diff left out.
Rung 5: A real run against staging, twice
ansible-playbook -i inventories/staging site.yml --diff | tee "run-staging-1.txt"
echo "exit=$?"Exit codes, verified on 2.21.3:
| Outcome | Exit code |
|---|---|
| Success | 0 |
| Task failure only | 2 |
| Unreachable host only | 4 |
| Task failure and unreachable host | 4 |
Unreachable takes precedence. A CI gate that only checks for 2 treats
a run with both failures and unreachable hosts as a pass. Check for
non-zero, then read the recap.
Now the second run, which is the part that gets skipped:
ansible-playbook -i inventories/staging site.yml --diff | tee "run-staging-2.txt"
grep -E 'changed=[1-9]' "run-staging-2.txt"That grep must produce nothing. Any host still reporting changes on the second run contains a non-idempotent task, and in production that means a change - often a service restart - on every scheduled converge forever.
What a staging run cannot prove: that production is like staging. Different data volumes, different existing configuration, different kernel, different hand-edits accumulated over years. That is rung 6.
Rung 6: Check mode against one production canary
ansible-playbook -i inventories/production site.yml \
--limit web01.example.com --check --diff | tee "check-canary.txt"Then compare the two diffs directly:
diff <(grep -E '^[+-]' "check-staging.txt") \
<(grep -E '^[+-]' "check-canary.txt") | head -40Differences are expected - hostnames, addresses, sizes. What you are looking for is a structural difference: a file that changes in production and not in staging, a service that stops in one and not the other, a package version that only production is missing.
A large structural difference is not a reason to be more careful with this playbook. It is a reason to escalate, because it means staging does not represent production and every test anyone runs there is worth less than they think.
Rung 7: Real run on the canary, then hold
ansible-playbook -i inventories/production site.yml \
--limit web01.example.com --diff | tee "run-canary.txt"Point of no return. One production host has now changed.
# A real request, not a port check
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' \
http://192.0.2.11:8080/healthz
# Logs from the last few minutes
ansible web01.example.com -b -m command \
-a 'journalctl -u nginx --since "5 min ago" --no-pager -p warning' -o
# Idempotence on the real host
ansible-playbook -i inventories/production site.yml \
--limit web01.example.com --diffA port check proves a process is listening. It does not prove the application behind it can answer, and a config that loads but points at a dead upstream passes every check except the one that asks for a response.
Hold before proceeding to the rest of the fleet. How long depends on the traffic pattern - long enough for the service to have done its normal work at least once.
Record what you did
For the change record:
- Which rungs were run, and against what.
- The host count from rung 3.
- Anything the check-mode diff did not predict that the real run did.
- The idempotence result on staging and on the canary.
- Any structural difference between the staging and production diffs.
The most valuable line is the second-to-last one. A gap between what check mode predicted and what happened is the thing that tells the next person how much to trust rung 4 for this playbook.
Rollback
Rungs 1 to 4 and 6 have nothing to roll back.
# Files the playbook manages, from the backups its tasks took
ansible web01.example.com -b -m find \
-a 'paths=/etc/nginx/conf.d patterns="*.conf.*"' -o
# Restore the chosen backup, validate, reload
ansible web01.example.com -b -m copy \
-a 'src=/etc/nginx/conf.d/default.conf.REPLACE_ME dest=/etc/nginx/conf.d/default.conf remote_src=true'
ansible web01.example.com -b -m command -a 'nginx -t'
ansible web01.example.com -b -m systemd_service -a 'name=nginx state=reloaded'Do not leave the canary half-converged while you investigate. Either finish the change or reverse it. A host in an intermediate state that nobody is tracking is how a configuration incident gets discovered three weeks later by someone else.
Common patterns
| Symptom | Likely cause | Resolution |
|---|---|---|
--syntax-check passes, the run fails on an undefined variable | Syntax check does not render templates | That is what rungs 4 and 5 are for |
--tags deploy runs nothing and exits 0 | The tag is misspelled or does not exist | --list-tags before every tagged run |
A task in a role never appears in --list-tasks | It comes from a dynamic include_role | Expect less coverage from static checks; test on staging |
| Check mode clean, real run breaks the service | validate: and command tasks are not evaluated in check mode | Grep for them; treat those tasks as untested until rung 5 |
| Staging passes, canary diff is structurally different | Staging does not represent production | Escalate - this is a platform finding |
| Second staging run still reports changes | Non-idempotent task | Fix before the canary; do not carry it forward |
| Run “succeeded” but nothing happened | Limit or tags matched nothing, exit code was still 0 for tags | Read the recap host list and task count, not just the exit code |
Escalation
Escalate when:
- The production check diff differs structurally from staging.
- The diff touches something owned by another team.
- The playbook cannot be tested against staging at all.
- Idempotence fails and there is pressure to proceed anyway.