AnsibleXXXVI · Drift and ConvergenceDrift and convergence
Reading diff output properly
What you'll learn
- Read a --diff hunk correctly, including what the before and after labels point at
- Predict which modules produce a diff and which produce nothing, from their diff_mode attribute
- Weigh the disclosure risk of a diff against its value as a review artefact
- Recognise the diff patterns that indicate a defective declaration rather than real drift
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
changed=3 tells you that three declarations do not match. --diff
tells you what does not match, in the only form that lets a human
judge whether the difference is a problem.
It is the best review surface Ansible has, and it is incomplete in ways that are not obvious from looking at the output.
What a hunk actually says
$ ansible-playbook -i inventory.ini site.yml --check --diffTASK [converge the config] *****************************************************
--- before: /etc/app/live.conf
+++ after: /srv/automation/files/desired.conf
@@ -1,2 +1,2 @@
-MaxClients 150
+MaxClients 256
Timeout 60
changed: [localhost]Reading it precisely:
before:is the file on the managed host, as it is now.after:is the content that would be written. Foransible.builtin.copythat label is the source path on the controller, not the destination.-lines disappear,+lines appear, unprefixed lines are context.- The direction is always “current state on the left, declared state on the right”, which is the opposite of a Git diff reviewing a proposed change to the declaration itself. Both appear in a normal working day and it is worth being conscious of which one you are looking at.
For ansible.builtin.template the after: label is stranger still:
$ ansible-playbook -i inventory.ini site.yml --check --diffTASK [render config] ***********************************************************
--- before: /etc/app/live.conf
+++ after: /home/deploy/.ansible/tmp/ansible-local-282623jc626hgd/tmpe6rtyo04/app.conf.j2
@@ -1,2 +1,2 @@
-MaxClients 150
+MaxClients 512
Timeout 60
changed: [localhost]That path is real. The template is rendered on the controller, into
~/.ansible/tmp, and then compared. Which means a --check --diff
run writes the fully rendered configuration — including any secrets it
contains — to the controller’s local disk, even though it changes
nothing on the target.
Which modules produce a diff at all
diff_mode is a documented attribute, exactly like check_mode, and it
varies more than people expect. Read on 2.21.3:
| Module | diff_mode support |
|---|---|
template, copy, lineinfile, blockinfile, replace, assemble, cron, apt, dnf | full |
file, unarchive | partial |
command, shell, systemd_service, user, group, get_url, wait_for, stat, setup | none |
service, package | N/A (action plugins that dispatch to a concrete module) |
ansible-doc ansible.builtin.file | sed -n '/diff_mode/,+3p'
ansible-doc ansible.builtin.systemd_service | sed -n '/diff_mode/,+3p'The consequence for a drift report: a task can report changed with
no diff at all. systemd_service restarting a unit, user altering a
shell, get_url fetching a file — all report a change and show you
nothing about what the change is.
So a drift report is two different documents stapled together: the part with diffs, which a reviewer can judge, and the part without, which is just a list of task names. Knowing which findings fall in which half is the difference between reviewing a change and rubber-stamping it.
no_log deletes the diff
The interaction is complete rather than partial, and it is worth seeing:
$ ansible-playbook -i inventory.ini nolog.yml --check --diffTASK [secret-bearing config, no_log on] ****************************************
changed: [localhost]
TASK [same, no_log off] ********************************************************
--- before: /etc/app/live.conf
+++ after: /srv/automation/files/desired.conf
@@ -1,2 +1,2 @@
-MaxClients 150
+MaxClients 256
Timeout 60
changed: [localhost]no_log: true gives you changed and nothing else. That is exactly
what it is for, and it creates a genuine conflict:
- Without
no_log, a diff of a rendered template prints its contents — database passwords, API tokens, private keys — into the run log, and from there into CI output, chat notifications, and log aggregation, where they are retained and searchable. - With
no_log, the most sensitive files in your estate are exactly the ones nobody can review, and drift in them is reported as a count with no detail.
Diff patterns that are not drift
A useful skill is recognising the diffs that indicate a defective declaration rather than a changed host. Four common ones:
A timestamp in a managed header. A template whose header renders
Generated at {{ ansible_date_time.iso8601 }} differs on every single
run, on every host, forever. The diff is one line and it is always
there. This one also breaks handlers: the file is rewritten every run,
so the notify fires every run, and the service is restarted nightly for
no reason.
Whitespace and line-ending churn. A diff where every line is both
removed and added is usually a trailing-newline or CRLF difference, not
a content change. Look at the hunk header: @@ -1,40 +1,40 @@ on a file
nobody edited is the signature.
Reordered lines. A template iterating a dictionary can emit keys in a different order than the file on disk has them. The content is equivalent and the diff is large. Sort explicitly in the template.
The whole file as additions. @@ -0,0 +1,120 @@ means the file does
not exist on the host. That is not drift in the “someone edited it”
sense; it is a host that has never had this configuration, which is
usually either a new host or a when condition that has been excluding
it.
ansible-playbook -i inventory/ site.yml \
--limit wave1_canary \
--check --diff \
| tee "artifacts/diff-$(date +%Y%m%dT%H%M%S)-$(git rev-parse --short HEAD).txt"Naming the file after the commit matters for the same reason it did in the runtime-budget work: a diff is only interpretable against the version of the content that produced it.
Knowledge check
Knowledge check · 4 questions
Q1. In a --check --diff run of a template task, what does the "+++ after:" path point at?
Q2. A drift report shows changed on a systemd_service task, with no diff beneath it. What does that mean?
Q3. Which of these diffs suggest a defective declaration rather than a host that has drifted? Select all that apply.
Q4. Putting no_log: true on a task that manages a config file containing one password is the right way to balance reviewability against disclosure.
Passing score: 75%. Answers are checked in this browser.