Skip to content

Lesson 3 of 9 · 24m

Playbooks and the Idempotency Contract

What a play actually is, why modules beat shell commands, and how changed/ok/failed tell you whether your automation is honest.

The senior-engineer Ansible smell test: run the playbook twice. Second run should be all green — ok, no changed. If tasks report changed on a converged system, the playbook is lying about idempotency, and one day that lie becomes "we restarted nginx 400 times in a rolling deploy" or "the same cron line got appended 40 times."

Anatomy: plays map hosts to tasks

# playbooks/web.yml
- name: Configure web tier
  hosts: webservers
  become: true

  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Ship site config
      ansible.builtin.template:
        src: nginx-site.conf.j2
        dest: /etc/nginx/sites-available/acme.conf
        mode: "0644"
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

A play binds hosts: to a list of tasks:. A playbook is a list of plays — web.yml above might be followed by a Configure db tier play in the same file. Handlers run once at the end, only if something notified them.

Modules vs. command: the honesty problem

command/shell run a string and can't know if it changed anything, so they report changed every time — which destroys the signal that makes Ansible useful. Modules inspect, then act:

# dishonest — reports changed every run, appends duplicates
- name: Add repo
  ansible.builtin.shell: echo "deb ..." >> /etc/apt/sources.list

# honest — checks state, only changes when needed
- name: Add repo
  ansible.builtin.apt_repository:
    repo: "deb https://apt.postgresql.org/pub/repos/apt jammy-pgdg main"
    state: present

If you must use command, restore honesty manually: creates: / removes: args, or changed_when/failed_when on a registered result. A task that reports ok when it did nothing and changed when it did something is telling the truth; that's the whole contract.

Check mode and diff: read-only rehearsal

ansible-playbook web.yml --check --diff

--check asks modules "would you change anything?" without doing it — apt supports it, command is skipped by default. --diff shows the before/after for file changes. Run check+diff in CI on every PR and "what does this playbook do?" becomes a diff review.

Field notes

  • name: on every task. The output line is your run log; "TASK [apt]" tells an on-call engineer nothing at 3 a.m.
  • FQCN names (ansible.builtin.apt) survived collections; bare apt: still works but lint will flag it.
  • Idempotency is per-attribute: state: present installs nginx but won't upgrade it — latest is a different promise.
  • Execution order bites: tasks run top-to-bottom, handlers fire at play end (or on meta: flush_handlers), and a failed host drops out of that play while others continue — any_errors_fatal/serial exist for when that default is wrong.