Skip to main content
RunBook Academy

← All labs in Ansible

Lab · advanced · ~75 min

Lab: The dry run that lied

C · SimulationB · Nested virtualisation

Objectives

  • Reproduce a clean check-mode run whose real execution fails
  • Identify every task in a play that check mode silently skips, before running it
  • Apply check_mode: false, creates: and a check-mode-aware assertion to close the gap
  • State the three classes of defect check mode structurally cannot catch

Prerequisites

Objective

By the end of this lab you will have a play whose --check --diff output is clean, reassuring and completely wrong, and you will have the real run’s failure beside it. You will then audit every task for check-mode support before running it, fix the gap, and write down the three things check mode still cannot tell you.

Architecture

A single play that deploys a small application. Everything runs against ansible_connection: local writing into a scratch directory, so no managed node is required to reproduce the core result.

scratch/
├── migrate.sql       <- rendered by a copy task
└── app.conf          <- rendered by a template task

/usr/bin/false        <- stands in for the migration tool that fails

Requirements

  • A controller with ansible-core 2.21.x. The exact output below was captured from 2.21.3.
  • No managed nodes needed. Every task runs against ansible_connection: local and writes only into scratch/. A real node is worth using if you want to extend the module audit to apt, dnf and a real systemd_service — hence B-nested as the higher-fidelity mode — but nothing in the lab as written requires one.
  • A writable scratch directory. Nothing outside it is touched.

Scenario

The change process at your organisation requires a --check run attached to every change ticket. Last week’s ticket had one: changed=3, failed=0, a clean diff of the config file, no warnings.

The real run failed on the third task, on all forty hosts, after the config file had been written. The change reviewer’s reasonable question — “what was the point of the dry run?” — is what this lab answers.

Tasks

Task 1: Build the play

WORKDIR="$HOME/ansible-checkmode-lab"
mkdir -p "$WORKDIR/scratch"
cd "$WORKDIR"

inventory.yml:

all:
  hosts:
    node1:
  vars:
    ansible_connection: local

deploy.yml:

- name: Deploy the reporting application
  hosts: all
  gather_facts: false
  vars:
    scratch: "{{ playbook_dir }}/scratch"

  tasks:
    - name: Render the migration script
      ansible.builtin.copy:
        content: |
          -- schema migration 004
          ALTER TABLE reports ADD COLUMN generated_at timestamptz;
        dest: "{{ scratch }}/migrate.sql"
        mode: '0644'

    - name: Run the migration
      ansible.builtin.command:
        cmd: "/usr/bin/false --apply {{ scratch }}/migrate.sql"

    - name: Record that the migration completed
      ansible.builtin.debug:
        msg: "migration complete"

/usr/bin/false stands in for a migration tool that exits non-zero. In the real ticket it was a psql invocation against a database that rejected the statement.

Task 2: Run the dry run and believe it

Read-only / Safecontroller
$ ansible-playbook -i inventory.yml deploy.yml --check --diff
PLAY [Deploy the reporting application] ****************************************

TASK [Render the migration script] *********************************************
--- before
+++ after: /home/operator/ansible-checkmode-lab/scratch/migrate.sql
@@ -0,0 +1,2 @@
+-- schema migration 004
+ALTER TABLE reports ADD COLUMN generated_at timestamptz;

changed: [node1]

TASK [Run the migration] *******************************************************
skipping: [node1]

TASK [Record that the migration completed] *************************************
ok: [node1] => {
  "msg": "migration complete"
}

PLAY RECAP *********************************************************************
node1                      : ok=2    changed=1    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

failed=0. A clean diff showing exactly the file that will be written. A final task confirming the migration completed.

Every word of that is true and the conclusion a reviewer draws from it is false.

Task 3: Run it for real

Configuration changecontroller
$ ansible-playbook -i inventory.yml deploy.yml
TASK [Render the migration script] *********************************************
changed: [node1]

TASK [Run the migration] *******************************************************
fatal: [node1]: FAILED! => {"changed": true, "cmd": ["/usr/bin/false", "--apply", "/home/operator/ansible-checkmode-lab/scratch/migrate.sql"], "delta": "0:00:00.002", "msg": "non-zero return code", "rc": 1, "stderr": "", "stdout": ""}

PLAY RECAP *********************************************************************
node1                      : ok=1    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

Illustrative output

The file exists. The migration did not run. The third task — the one that said “migration complete” in the dry run — never executed.

ls -la scratch/

Put the two recaps side by side in comparison.md. ok=2 changed=1 failed=0 skipped=1 against ok=1 changed=1 failed=1 skipped=0. Nothing in the first predicts the second.

Task 4: Audit the play before you run it

The fix begins with a habit: for every task in a play you are about to check-mode, ask what its module’s check_mode attribute says. ansible-doc answers this directly.

for m in copy command shell package systemd_service uri template lineinfile debug; do
  printf '%-18s ' "$m"
  ansible-doc "$m" 2>/dev/null \
    | sed -n '/ATTRIBUTES:/,/EXAMPLES:/p' \
    | grep -A4 'check_mode:' \
    | grep -E 'support:' \
    | head -1
done
Read-only / Safecontroller
$ ansible-doc command | sed -n '/ATTRIBUTES:/,/EXAMPLES:/p' | grep -A4 'check_mode:'
        check_mode:
      description: Can run in check_mode and return changed status prediction without modifying
        target, if not supported the action will be skipped.
      details: while the command itself is arbitrary and cannot be subject to the check
        mode semantics it adds `creates'/`removes' options as a workaround
      support: partial

Note the sentence in the description: “if not supported the action will be skipped”. That is the documented behaviour, stated plainly, and it is what produced the clean dry run.

The results, verified on 2.21.3:

Modulecheck_mode supportConsequence under --check
copy, template, lineinfile, blockinfilefullpredicts the change, shows a diff
systemd_service, aptfullpredicts service and package state
command, shellpartialskipped, unless creates:/removes: is set
packagefull, with a caveat“support depends on the underlying plugin invoked”
servicefull, with a caveat“support depends on the underlying plugin invoked”
urinoneskipped
debug, assertn/arun normally

Write the audit into check-support.md for every module your play uses.

Task 5: Make the play honest

Three techniques, applied to the three kinds of task.

A read-only command: run it in check mode. check_mode: false forces a task to execute even under --check. It is safe only if the task cannot change anything:

    - name: Confirm the migration tool is present and reports a version
      ansible.builtin.command: /usr/bin/false --version
      register: tool
      changed_when: false
      failed_when: false
      check_mode: false      # safe: --version reads and exits

Now the dry run tells you something real: whether the tool exists.

A command with a predictable side effect: give it creates:. With creates:, command becomes a stat comparison and gains real check-mode support:

    - name: Run the migration
      ansible.builtin.command:
        cmd: "/usr/bin/false --apply {{ scratch }}/migrate.sql"
        creates: "{{ scratch }}/.migration-004.done"

Under --check the module stats the marker file and reports changed or ok accordingly instead of skipping. It still cannot tell you the command will succeed — nothing can — but the dry run now says “this would run” rather than “skipped”.

The task that cannot be predicted: say so out loud. Where a task genuinely cannot be evaluated, make the play announce it rather than let skipped=1 carry the message:

    - name: State plainly what this dry run did not evaluate
      ansible.builtin.debug:
        msg: >-
          CHECK MODE: the migration itself was not executed and its success
          is not predicted by this run. Tasks not evaluated:
          'Run the migration', 'Verify the API responds'.
      when: ansible_check_mode

Re-run the dry run and compare:

ansible-playbook -i inventory.yml deploy.yml --check --diff | tail -20

Task 6: The second class of gap — order dependence

Check mode does not only skip tasks. It also evaluates every task against the current state, because no earlier task actually changed anything.

Add a task that reads back what a previous task wrote:

    - name: Render the base configuration
      ansible.builtin.copy:
        content: "listen = 8080\n"
        dest: "{{ scratch }}/app.conf"
        mode: '0644'

    - name: Read back the rendered configuration
      ansible.builtin.slurp:
        src: "{{ scratch }}/app.conf"
      register: rendered

    - name: Assert the port directive is present
      ansible.builtin.assert:
        that: "'listen' in (rendered.content | b64decode)"
        success_msg: "config verified"

Run it in check mode against a scratch directory where app.conf does not exist yet:

Read-only / Safecontroller
$ ansible-playbook -i inventory.yml verify-order.yml --check
TASK [Render the base configuration] *******************************************
changed: [node1]

TASK [Read back the rendered configuration] ************************************
fatal: [node1]: FAILED! => {"changed": false, "msg": "File not found: /home/operator/ansible-checkmode-lab/scratch/app.conf"}

Then run it for real — ok=3 changed=1 failed=0 — and run it in check mode a second time, now that the file exists: ok=3 changed=0 failed=0.

Task 7: Write the blind-spot list

In blind-spots.md, record the three classes of thing check mode structurally cannot catch. From this lab:

  1. Tasks it skips. command and shell without creates:/removes:, uri entirely, and anything whose module reports support: none. The only trace is skipped=N.
  2. Order-dependent effects. A task whose input is a previous task’s output, on a host that is not already converged — a slurp of a file copy would have written, a lineinfile with create: false. Fails in check mode, succeeds for real.
  3. Anything that depends on the command actually running. A migration that would deadlock, a package that would conflict, a service that would fail its post-start check. Check mode predicts state changes; it does not execute anything, so it cannot observe a runtime failure.

Validation

  • ansible-playbook -i inventory.yml deploy.yml --check --diff reports failed=0 with skipped=1, and ls scratch/ shows nothing was written.
  • The same play run for real reports failed=1 after writing scratch/migrate.sql.
  • ansible-doc command | grep -A4 check_mode reports support: partial and names creates/removes as the workaround.
  • ansible-doc uri | grep -A4 check_mode reports support: none.
  • With creates: added, the migration task under --check reports changed or ok rather than skipping.
  • With the ansible_check_mode task added, the dry-run output ends with an explicit statement of what was not evaluated.
  • verify-order.yml --check fails at the slurp task on a fresh scratch directory, succeeds for real (ok=3 changed=1), and then passes in check mode on the second attempt (ok=3 changed=0).
  • check-support.md covers every module the play uses.

Expected Outcome

ansible-checkmode-lab/
├── blind-spots.md
├── check-support.md
├── comparison.md
├── deploy.yml
├── inventory.yml
└── scratch/

You can predict, before running a dry run, which of its tasks will be skipped. Your plays end with a statement of what check mode did not evaluate. And you can say why a clean --check on a change ticket is evidence of something narrower than the reviewer assumes.

Troubleshooting

--check reports changed on every task, every time. Check mode against a host that is not converged reports every difference between current and declared state — which is correct and is what makes it a drift detector. Run it against a converged host to get a meaningful answer.

check_mode: false on a task that writes. This is the dangerous inversion: the task now executes during a --check run, so your “dry run” changes the host. Only ever set it on a task you can state, in one sentence, cannot change anything.

check_mode: true — the reverse — is also available. It forces a task into check mode even in a real run, which is how you make a specific destructive task never execute while the rest of the play does. Useful and easy to forget you set.

ansible_check_mode is undefined. It is defined on every run, true or false. If a template reports it undefined, the template is being rendered by something outside a task context — a lookup('template', ...) on the controller, for example.

A package task behaves differently to the audit. package delegates to the platform’s manager, and its documentation says so explicitly: support depends on the underlying plugin invoked. Audit the concrete module — apt, dnf, zypper — not the abstract one.

--diff shows nothing for a template task in check mode. The task is being skipped for a different reason — usually a when: — or the file is already identical. -v distinguishes the two.

Cleanup

Everything this lab wrote is inside scratch/ in the working directory. No managed node was contacted for Tasks 1–5, and nothing outside the working directory was modified.

Step 1. Confirm that, rather than assuming it:

cd "$HOME/ansible-checkmode-lab"

# Nothing should exist outside scratch/ that the lab created
find . -newer inventory.yml -type f | sort

# And nothing at all outside the working directory
ls -la /usr/bin/false      # untouched, it is a system binary the lab only ran

Step 2. If you extended the module audit against a real managed node and installed anything to do it, remove it only if it was not already present.

Step 3. Keep the audit and remove the directory:

mkdir -p "$HOME/ansible-lab-deliverables"
cp -a "$HOME/ansible-checkmode-lab/check-support.md" \
      "$HOME/ansible-checkmode-lab/blind-spots.md" \
      "$HOME/ansible-lab-deliverables/"

rm -rf "$HOME/ansible-checkmode-lab"

What You Learned

  • A clean --check can precede a failing real run, and you have both recaps side by side to prove it.
  • skipping is the only signal, and it is indistinguishable from a when: exclusion. skipped=1 in a recap deserves the same attention as failed=1.
  • ansible-doc <module> | grep -A4 check_mode is the audit. You produced a per-module support table from the modules themselves rather than from folklore.
  • uri has no check-mode support, so every health-check-based safety argument evaporates in a dry run.
  • creates: converts an unpredictable command into a predictable stat, which is why it is command’s documented check-mode workaround.
  • Check mode is a drift detector, not a rehearsal. On a converged host it is accurate; on a fresh one it produces false failures from order dependence.
  • when: ansible_check_mode lets a play state its own blind spots, which is the smallest change that makes a dry run honest evidence.

Deliverables

  • · A recorded clean --check run and the failing real run of the same play
  • · A per-task check-mode support audit of the play, derived from ansible-doc
  • · The play made check-mode-honest, with the remaining blind spots written down

Verification status

Last reviewed
2026-08-11
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.