How --check Actually Works
Scenario: A change-approval dry run against production appended to an audit log on every host, then failed with "'dict object' has no attribute 'stdout'". The dry run was neither dry nor a run.
New words, in plain English
- Check mode -
--check: modules report what they WOULD do instead of doing it. check_mode: false- forces a task to genuinely execute even during a dry run.check_mode: true- forces a task to never execute, even during a real run.- Supports check mode - not every module implements it; those that do not are skipped.
--check asks every module to predict rather than act. Well-behaved modules - copy, template, file, service, user - compare current state with desired state and report changed or ok without touching anything.
Two behaviours produce nearly all check-mode surprises.
command and shell are skipped in check mode by default. Ansible cannot know whether an arbitrary command is safe, so it declines to run it. That is correct, and it means any task registering output from a command has no result during --check - so a later task referencing result.stdout fails with the dict-attribute error from the scenario.
check_mode: false forces real execution even during a dry run. This exists for genuinely read-only tasks - reading a version, querying a quota - so that later tasks have the value they need. Applied to a task that mutates the host, it silently makes --check destructive, which is exactly the first half of the scenario.
The combination is the correct fix: put check_mode: false on the read-only command (so it always produces a value) and leave the mutating task obeying check mode normally.
Analogy: A dry run is a fire drill. Marking one task check_mode: false because it is 'only a log entry' is deciding to light a small real fire during the drill - and the point of the drill was that nothing burns.
A worked example
# WRONG - the dry run mutates, then crashes
- name: Note the run in the audit log
ansible.builtin.shell: "date >> /var/log/audit.log"
check_mode: false # genuinely executes during --check
changed_when: true
- name: Read the licensed quota
ansible.builtin.command: /opt/bin/read-quota.sh
register: quota # SKIPPED in check mode -> no stdout
- name: Render the config
ansible.builtin.copy:
dest: /etc/app/quota.conf
content: "max = {{ quota.stdout }}" # fails during --check
# RIGHT
- name: Note the run in the audit log
ansible.builtin.shell: "date >> /var/log/audit.log"
changed_when: false # a log note is not a config change
# (no check_mode override: skipped in --check)
- name: Read the licensed quota # READ-ONLY, so safe to force
ansible.builtin.command: /opt/bin/read-quota.sh
register: quota
check_mode: false
changed_when: false
- name: Render the config
ansible.builtin.copy:
dest: /etc/app/quota.conf
content: "max = {{ quota.stdout }}"
--check pairs with --diff to produce the best change ticket available: a line-by-line preview of every file that would change, on every host, generated from the actual configuration rather than from someone's description of it.
The honest test for whether your dry run is real: fingerprint everything the play can touch, run --check, and fingerprint again. If anything moved, --check is lying, and every review that relied on it was worthless.
Warning:check_mode: falseon a task that changes anything makes--checkdestructive. Use it only for genuinely read-only work.
Goal: Put this to work in the ansible-check-mode-safety lab. Open/labs/ansible, pickansible-check-mode-safety, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.