unit 6 · Making it real

Idempotency, deeply

Making shell tasks tell the truth

essential · 8 min · linux + windows

you may want to read 15 · Registering output & debug , 21 · Blocks & error handling first — but nothing is locked

tl;dr

  • Modules check current state and act only on the difference — re-runs converge, ending changed=0.
  • command and shell always execute and always report changed — fix with creates:, removes:, changed_when:.
  • The working test: run the playbook twice — an idempotent second run ends all ok, changed=0.

why it matters A playbook that reports changed on every run cannot be trusted or safely re-run — and unguarded shell tasks are the most common way real playbooks earn that reputation.

Lesson 12 handed you the test before the theory: run a playbook twice, and the second run should end all ok, changed=0. That is idempotent measured rather than defined. Repeated runs converge to the same state; changes happen only when the host differs from what you declared. Modules deliver this by checking current state first: copy compares file contents before writing, service asks the service manager before starting anything. No difference, no action, ok.

The two modules that can’t

ansible.builtin.command and ansible.builtin.shell execute whatever you hand them. Ansible cannot see inside an arbitrary command line to diff anything, so both modules always run and always report changed — even for a pure read like uptime. They are never idempotent by default.

Lesson 15 flagged this and deferred the fix: cfg_check.changed came back true on every run of nginx -t, a command that changes nothing. Time to pay that debt. You have three dials.

Dial 1: creates: and removes:

creates: skips the task when a path already exists; removes: skips it when the path is absent. Both are arguments of command and shell themselves:

- name: Run the vendor installer
  ansible.builtin.shell: /opt/vendor/install.sh
  args:
    creates: /opt/vendor/.installed # path exists? task is skipped
creates: — run once, skip forever after

First run: no marker, the installer executes, changed. Second run: the path exists, so Ansible never runs the command and reports no change. “Always runs” becomes “runs at most once” — crude state checking, but real. removes: is the mirror image, built for cleanup: the task runs while the path exists and skips once it’s gone. Both checks happen per host — twenty machines each decide independently.

Dial 2: changed_when:

changed_when: redefines what changed means, the way lesson 21’s failed_when: redefined failure. Like when:, it takes a bare Jinja expression — no {{ }}. The simplest setting closes lesson 15’s caveat in one line:

- name: Check nginx config syntax
  ansible.builtin.command: nginx -t
  register: cfg_check
  changed_when: false # a pure read — never report changed
an honest read-only check — cfg_check.changed, fixed

Now the diagnostic reports ok on every run, matching reality. For a command that sometimes changes things, key the expression to the registered result instead of hardcoding a verdict:

- name: Sync app config into place
  ansible.builtin.command: /opt/app/bin/sync-config
  register: sync_result
  changed_when: "'updated' in sync_result.stdout" # trust the tool's report
report changed only when the tool itself says so

Note what changed_when: does not do: it never skips anything. The command still executes every run — you’ve fixed the reporting, not the execution. When you need both, combine the dials. creates: stops re-running, changed_when: stops lying, and failed_when: from lesson 21 defines failure — the three keywords that make a raw command behave like a module.

The run-twice test

Run the playbook, then run it again with nothing changed. The second recap must read all ok, changed=0 — lesson 12’s clean re-run, now a deliberate check. Anything still reporting changed is either a raw command missing these dials, or something on the host genuinely fighting you. Both are worth knowing before a colleague finds out. Molecule automates this check in CI: converge, converge again, fail the build if the second pass reports any change (lesson 38).

Lesson 1 warned that bare shell tasks drag Ansible back into scripting. Now you can see the mechanism: a task that always runs and always claims a change is a script line wearing YAML. The order of preference stands — reach for a purpose-built module first; when none exists, drop to command or shell with the dials set. An unguarded shell task is unfinished work; a guarded one is a deliberate exception. Make the run-twice test the last thing you do before a playbook leaves your machine. It costs one command and catches the defect colleagues complain about most.

replay — The run-twice test, with a guarded shell task

a recording, not a simulator — tap ⓘ lines for what they mean

self-check — recall, not a test

  1. 1 Why does ansible.builtin.command report changed on every run, even when the host is untouched?

  2. 2 You guard a shell task with creates: /opt/app/.installed. What happens on the first run and the second?

  3. 3 What is the run-twice test, and what automates it in CI?

lab (optional — never required)

Write a shell task that drops a marker file, guard it with creates:, run the playbook twice and confirm the recap goes changed=1 then changed=0.

needs: The control node from lesson 5.

next up 23 · Jinja2 templating & filters 7 min · unit 6