AnsibleXXXVI · Drift and ConvergenceDrift and convergence
What check mode cannot tell you
What you'll learn
- Predict which tasks are skipped under --check by reading a module check_mode attribute
- Explain why a gate on a registered return code opens in check mode even though the command never ran
- Use check_mode: false correctly, and recognise when it makes a dry run change production
- Write conditionals that refuse to decide rather than deciding on absent evidence
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
The previous lesson turned a playbook into a drift report with one flag.
This lesson is about the ways that report lies to you, all of which have
been reproduced on ansible-core 2.21.3 rather than inferred.
They matter more than the mechanics, because a drift report you trust and should not is worse than no drift report at all.
Support is per-module, and it has three values
Every module documents a check_mode attribute, and it takes one of
three values:
ansible-doc ansible.builtin.command | sed -n '/ATTRIBUTES/,/NOTES/p'
ansible-doc ansible.builtin.wait_for | sed -n '/ATTRIBUTES/,/NOTES/p'
ansible-doc ansible.builtin.template | sed -n '/ATTRIBUTES/,/NOTES/p'Read on 2.21.3:
| Module | check_mode support | Behaviour under --check |
|---|---|---|
template, copy, file, lineinfile, stat, setup, package_facts, service_facts | full | Compares and predicts correctly |
command | partial | Skipped unless creates or removes is given |
wait_for | none | Always skipped |
command being partial rather than none is a distinction worth
having. Its documentation states that the command itself cannot be
subject to check-mode semantics, so creates/removes are offered as a
workaround — and they work:
$ ansible-playbook -i inventory.ini creates.yml --checkTASK [command with creates, marker absent] *************************************
changed: [localhost]
TASK [command with creates, marker present] ************************************
ok: [localhost]Without creates or removes, the same module is skipped. Which brings
us to the trap.
The trap: a skipped command still registers a result
This is the most important verified fact in this part.
$ ansible-playbook -i inventory.ini probe.yml --checkTASK [dump] ********************************************************************
ok: [localhost] => {
"probe": {
"changed": false,
"cmd": ["/bin/echo", "probe"],
"delta": null,
"end": null,
"failed": false,
"msg": "Command would have run if not in check mode",
"rc": 0,
"skipped": true,
"start": null,
"stderr": "",
"stderr_lines": [],
"stdout": "",
"stdout_lines": []
}
}Look at rc. It is 0. The command never ran, and the registered
result says it returned success.
Now consider the two gates every playbook contains:
$ ansible-playbook -i inventory.ini gates.yml --checkTASK [probe] *******************************************************************
skipping: [localhost]
TASK [naive gate on rc] ********************************************************
ok: [localhost] => {
"msg": "NAIVE GATE OPENED"
}when: probe.rc == 0— opens. The play proceeds as though the probe had succeeded, on evidence that does not exist.when: "'running' in probe.stdout"— closes, silently, becausestdoutis"". Every task behind it is skipped.probe.stdout | default('unknown')— does not help.stdoutexists and is the empty string, sodefault()never fires.
Writing conditionals that refuse
The fix is not a cleverer expression. It is to make the play refuse to decide when the evidence is missing.
- name: probe the current version
ansible.builtin.command:
cmd: /usr/local/bin/appctl version
register: probe
changed_when: false
- name: refuse to continue if the probe did not actually run
ansible.builtin.fail:
msg: >-
The version probe was skipped, so this play cannot decide anything.
Re-run without --check, or give the probe check_mode: false.
when: probe is skipped
- name: act on the probe result
ansible.builtin.debug:
msg: "version is {{ probe.stdout }}"
when:
- probe is not skipped
- probe.rc == 0$ ansible-playbook -i inventory.ini guard.yml --checkTASK [probe] *******************************************************************
skipping: [localhost]
TASK [guarded gate] ************************************************************
skipping: [localhost]
TASK [explicit refusal when the probe did not run] *****************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "probe was skipped; this play cannot decide anything in check mode"}A failed check run is a vastly better outcome than a clean one that examined nothing. It is visible, it is actionable, and it cannot be mistaken for evidence.
check_mode: false: the trap in the other direction
The keyword exists for a real need: a read-only probe should run even during a dry run, because otherwise the dry run cannot compute anything.
$ ansible-playbook -i inventory.ini forced.yml --checkTASK [probe with command (no creates/removes)] **********************************
skipping: [localhost]
TASK [gate on the probe] *******************************************************
ok: [localhost] => {"msg": "probe stdout was: "}
TASK [probe forced to run even in check mode] **********************************
ok: [localhost]
TASK [show forced result] ******************************************************
ok: [localhost] => {"msg": "forced stdout was: forced"}That is the intended use, and it is correct here because the task only reads.
grep -rn -B 3 'check_mode:[[:space:]]*\(false\|no\|False\)' roles/ playbooks/wait_for and the tasks that vanish
wait_for carries check_mode: support: none and is skipped in every
check run — confirmed by execution.
That is usually harmless and occasionally not. A play that waits for a service port before verifying it, then checks the service, will in check mode skip the wait and immediately check — which is a different play from the one that runs in production, and it can pass when the real one would fail, or vice versa.
The point generalises: check mode does not run the play you wrote. It
runs a play with some tasks removed. The removed tasks are the ones
whose modules do not support it, and the recap does not distinguish them
from tasks skipped by a when.
ansible-playbook -i inventory/ site.yml --limit canary01.example.com \
| tail -3 > /tmp/real.recap
ansible-playbook -i inventory/ site.yml --limit canary01.example.com --check \
| tail -3 > /tmp/check.recap
diff /tmp/real.recap /tmp/check.recapIf the check run skips twenty more tasks than the real one, your drift report has twenty blind spots and you now know the number. Put it in the runbook next to the report.
Knowledge check
Knowledge check · 4 questions
Q1. A command task with no creates or removes is skipped under --check and its result is registered as probe. What is probe.rc?
Q2. A nightly --check job has reported changed=0 on every host for a month while the fleet drifted badly. The audit play starts with a command task whose stdout gates the other forty tasks. What happened?
Q3. Which statements about check_mode: false are correct? Select all that apply.
Q4. Comparing the skipped count between a real run and a check run against one canary host tells you how many tasks your drift report cannot examine.
Passing score: 75%. Answers are checked in this browser.