AnsibleXII · Idempotency and Change ReportingIdempotency and change reporting
changed=0 as the cheapest signal an estate produces
What you'll learn
- Explain why an accurate changed=0 is evidence rather than an absence of output
- Design a scheduled no-op run that acts as a drift detector
- Implement the second-run test as a CI gate
- Recognise how an inaccurate changed raises the noise floor of the whole estate
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Here is the payoff for the previous six lessons.
When every task in a play reports change accurately, running that play
against a converged fleet produces a recap of changed=0 on every
host. And that line, which looks like the absence of information, is
the strongest routine statement anyone in your organisation can make
about the estate:
Every resource this play declares was checked on every one of these hosts, and every one of them matched.
Nothing else you run produces that. A monitoring check tests a handful of symptoms. A compliance scan tests a policy. This tests the whole declaration, on every host, and it costs one scheduled job.
Two lines, one of which is evidence
$ ansible-playbook -i inventory.ini site.ymlPLAY RECAP *********************************************************************
web01.example.com : ok=42 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02.example.com : ok=42 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web03.example.com : ok=42 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Illustrative output
ok=42 changed=0 per host: forty-two declarations verified, none needed
correcting.
Now the same recap from a play with three command tasks nobody has
fenced:
$ ansible-playbook -i inventory.ini site.ymlPLAY RECAP *********************************************************************
web01.example.com : ok=42 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02.example.com : ok=42 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web03.example.com : ok=42 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Illustrative output
The second fleet may be in exactly the same state as the first. The difference is that nobody can tell — and, crucially, nobody can tell on the night when the number is 4.
The noise floor
That is the mechanism by which inaccurate reporting destroys the signal, and it is worth stating plainly because it is gradual.
A play with a permanent changed=3 has a noise floor of three. Any
genuine drift has to push the number above three to be noticed, and
somebody has to be looking closely enough to notice that 4 is not 3.
Nobody is. Within a few weeks the number is background, and within a few
months the recap is not read at all.
The rot is cumulative across the estate. Ten plays each with a small
permanent changed produce a dashboard where every run is amber, and an
amber dashboard is an ignored dashboard. The individual defects were
each defensible; the aggregate is an estate with no drift detection.
The scheduled no-op run
The design is deliberately boring:
- Run the full playbook on a schedule against the whole fleet.
- Expect
changed=0,failed=0,unreachable=0everywhere. - Alert on anything else.
Two decisions to make, and both are real trade-offs.
Enforce, or only report? A scheduled run that applies changes
corrects drift automatically, which is what configuration management is
for — and it also means the fleet changes at 02:00 without anyone
deciding to change it. A scheduled --check run reports drift without
correcting it, at the cost of the check-mode gaps covered in lesson 4:
tasks without check-mode support are skipped rather than compared.
The honest position is that these are different tools. Report-only is where most estates should start, because the first few weeks of drift reports are usually surprising. The drift part covers moving to enforcement.
What to alert on. changed on a scheduled run is either drift or a
reporting bug, and both need a human. Alerting on “any host with
changed > 0” is the right first rule, and it is only tolerable because
the reporting is accurate. That is the dependency: the alert is only
worth having if the previous six lessons were applied.
The second-run test as a CI gate
The same property, asserted mechanically before the code merges. Run the play twice against a disposable host and fail the build if the second run reports any change:
set -euo pipefail
ansible-playbook -i inventory.test.ini site.yml
ansible-playbook -i inventory.test.ini site.yml | tee second-run.log
if grep -qE 'changed=[1-9]' second-run.log; then
echo "FAIL: second run reported changes; the play is not idempotent"
exit 1
fi
echo "OK: second run reported no changes"Three things about that script are deliberate.
set -euo pipefail means the first run failing fails the job, rather
than the script continuing to a second run that will fail confusingly.
The grep is against changed=[1-9], which matches changed=1 through
changed=9 and also the first digit of larger numbers — it does not
match changed=0. Matching the recap text is crude, and the robust
version parses the JSON output of a callback plugin instead; the
testing part covers that. The crude version is still worth having on
day one.
The test runs against a disposable host. A second-run test that converges production twice is not a test, it is two deployments.
What changed=0 still does not prove
Being precise about the limits keeps the signal honest.
- It does not prove the declaration is correct. A play that declares
the wrong TLS configuration will converge every host onto it and
report
changed=0forever. Idempotency is agreement between the host and the declaration; it says nothing about whether the declaration is right. - It does not cover what the play does not declare. Attributes
nobody mentioned are not compared.
ok=42is forty-two declarations, not the whole host. - It does not cover hosts absent from the recap. A host excluded by
--limit, or dropped by a pattern typo, contributes no lines and no alarm. - It does not survive an inaccurate
changed_when: false. A task that writes and reports nothing contributes a falseok, which is worse than contributing noise.
Counting recap lines against the expected host count, and reviewing what the play actually declares, are the two habits that keep those limits from becoming blind spots.
Knowledge check
Knowledge check · 4 questions
Q1. A scheduled run over a converged fleet reports ok=42 changed=0 on every host. What has been established?
Q2. A play permanently reports changed=3 because of three unfenced command tasks. What is the cost?
Q3. Which are genuine limits of a changed=0 result? Select all that apply.
Q4. A scheduled --check run is a weaker drift signal than a scheduled real run, because modules without check-mode support are skipped rather than compared.
Passing score: 75%. Answers are checked in this browser.