Your Ansible Playbook Says ‘changed’ Every Run. It’s Lying — and It Matters.

Every run, the same handful of tasks light up yellow: changed. Nothing on the host actually changed. It’s the kind of noise you learn to scroll past — until the day a real change hides in the wall of false ones, or a handler fires a needless service restart in production because a task cried wolf. Broken idempotency isn’t cosmetic. It’s a broken signal.

Idempotency is the whole promise

The entire value proposition of a configuration tool is convergence: run it against a host already in the desired state and it should report ok, do nothing, and change nothing. A task that reports changed on a converged host has broken that promise — and worse, it can no longer tell you when something genuinely drifted, because it says the same thing either way.

The usual culprit: command and shell

The command and shell modules have no idea what “changed” means. They run something and, unless told otherwise, report changed every single time by definition. Most false positives trace back to shelling out where a real module — or a proper guard — belonged.

# The trap: shell has no concept of state, so it's always "changed"
- name: Ensure the app user exists
  ansible.builtin.shell: useradd appuser
  # Reports changed forever, and errors out the second time the user exists.
# Fix 1 - use the module that actually understands desired state
- name: Ensure the app user exists
  ansible.builtin.user:
    name: appuser
    state: present          # idempotent by design: creates once, then reports ok

# Fix 2 - when you MUST shell out, tell Ansible how to judge the result
- name: Rebuild the search index only when the marker is stale
  ansible.builtin.command: /opt/app/reindex.sh
  args:
    creates: /var/lib/app/.reindexed   # skip entirely if this file exists

# Fix 3 - define "changed" yourself from the command's own output
- name: Apply schema migrations
  ansible.builtin.command: migrate --apply
  register: migrate_out
  changed_when: "'Applied' in migrate_out.stdout"
  failed_when: migrate_out.rc != 0 and 'up to date' not in migrate_out.stderr

Prove it, don’t assume it

You don’t have to eyeball the yellow. Ansible has a built-in idempotency test: run the play, then run it again in check mode and treat any changed as a failure.

ansible-playbook site.yml                 # converge
ansible-playbook site.yml --check --diff  # second run should be all "ok"
# In CI, assert zero changed tasks on the second pass - that's your regression gate.

Wire that second pass into CI and idempotency stops being something you hope for and becomes something you enforce. A playbook that isn’t green on the second run doesn’t converge, and a playbook that doesn’t converge can’t be trusted to tell you the truth about your fleet.

The lesson

A changed you can’t trust is worse than no signal at all, because it looks like information. Make every task earn its status: reach for the stateful module first, guard your commands with creates/removes, and define changed_when whenever you shell out. The goal isn’t a quiet run for its own sake — it’s a run where yellow always means something.

Example plays and the CI idempotency gate are on GitHub: github.com/waghmaredb/vexpose-labs. Have a favorite idempotency trick I skipped? Share it on LinkedIn or X.

Comments

Leave a Reply

Discover more from {{ vExpose }}.Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading