Modules and Idempotency

K
Kai··5 min read

Idempotency (safe to re-run) is the property that sets Ansible apart from a bash script. Article 4 already showed it: running the playbook a second time gives changed=0. This article is the deep dive — how a module achieves that, which is also the basis for writing your own module in Article 11.

Module: a recap

Recall Article 1: a module is a piece of code (usually Python) that Ansible ships to the host and runs with the host's Python, taking parameters as JSON and returning results as JSON. Each task is one call to a module. Ansible ships thousands of modules, grouped by the kind of job: packages (dnf, apt), files (copy, template, file), services (service, systemd), users (user), and so on.

How idempotency works

The key: a module doesn't run blind — it checks the current state before acting. You declare the desired state (Article 4: state: present), and the module handles the comparison and changes only if needed:

   Task: "file X must have content Y"   (desired state)
        │
   Module runs on the host:
        ├─ read the CURRENT state of X
        ├─ compare to the DESIRED state (Y)
        ├─ same? ──────► do NOTHING        → returns "changed": false  (ok)
        └─ different? ─► fix it to match Y  → returns "changed": true   (changed)

This is the reason (Article 4):

  • Run 1: nginx not installed, service not running, file absent → the module sees "differs from desired" → acts → changed=3.
  • Run 2: everything is already correct → the module sees "already matches" → does nothing → changed=0.

The changed field in the returned JSON is exactly how Ansible knows whether a task changed anything, and prints ok (yellow if changed) for you.

Observing idempotency: check mode and diff

You can see what a module "thinks" without actually applying it, using check mode (--check) plus --diff. Try it: change the file content in the playbook, then run a check:

ansible-playbook site.yml --check --diff
TASK [Deploy the index page] *********************
--- before: /usr/share/nginx/html/index.html
+++ after:  /usr/share/nginx/html/index.html
@@ -1 +1 @@
-<h1>Trien khai bang Ansible</h1>
+<h1>Trien khai bang Ansible v2</h1>
changed: [lab]

PLAY RECAP ****************************************
lab : ok=4  changed=1  ...

The copy module: read the current content on the host, compared it to the desired content (v2), saw a difference → reports it will be changed along with the exact diff. But because it's check mode, it didn't actually apply — verify it: curl right afterward still returns the old content (no "v2" yet). This is the power of check mode: see ahead of time what a playbook will change in production before running it for real (Article 13).

Why command and shell are NOT idempotent

This is an extremely important point. Dedicated modules (dnf, copy, service) understand state so they're idempotent. But command/shell just run an arbitrary command — Ansible doesn't know what that command does, so it can't compare state. The upshot: they always report changed on every run (as we saw in Article 2).

   ansible -m dnf -a "name=nginx state=present"   → nginx already there: ok (idempotent)
   ansible -m command -a "dnf install -y nginx"   → ALWAYS changed (Ansible is blind)

The golden rule: use a dedicated module instead of command/shell whenever possible. When you're forced to use command/shell, make them idempotent manually with:

  • creates: / removes: — "only run if this file does NOT/DOES already exist". Example command: ./install.sh creates=/opt/app → skip if /opt/app already exists.
  • changed_when: / failed_when: — define yourself when something counts as changed/failed based on the result (Article 7).

The state parameter: the language of state

Most modules use the state parameter to declare the desired state:

   state: present    the thing must EXIST (install package, create file/user)
   state: absent     the thing must NOT exist (remove/delete)
   state: started    the service must be RUNNING
   state: stopped    the service must be STOPPED
   state: latest     the package must be at the LATEST version

To remove nginx, just change state: presentstate: absent — the module handles the rest, idempotent in both directions.

A tour of common modules

A few modules you'll meet constantly (all in ansible.builtin, idempotent):

   Packages: dnf / apt / package    install/remove software (package = cross-distro)
   Files:    copy                   copy a file/content to the host
             template               like copy but renders Jinja2 first (Article 6)
             file                   create/delete files/dirs, set permissions (state, mode)
             lineinfile             ensure ONE line is present/absent in a file
             blockinfile            ensure a BLOCK of text is in a file
   Services: service / systemd      start/stop/enable a service
   Users:    user / group           manage users/groups (recall Linux series Article 12)
   Get code: git                    clone/checkout a repo
   Commands: command / shell        run a command (NOT idempotent — use sparingly)

To look up a module's docs: ansible-doc <module_name> (e.g. ansible-doc copy) — prints the description, parameters, and examples right in the terminal. This is the quick way to look things up instead of opening docs.ansible.com.

Wrap-up

A module achieves idempotency by reading the current state on the host, comparing it to the desired state you declared (state:), changing only the difference, then returning changed: true/false. That's why re-running a playbook is always safe — it's the reason changed=3 (first run) becomes changed=0 (later runs). --check --diff shows the change ahead of time without applying it. command/shell are NOT idempotent (Ansible doesn't understand arbitrary commands) — prefer dedicated modules, or add creates/removes/changed_when. Use ansible-doc <module> to look things up.

So far we've hard-coded values in the playbook. Article 6 makes them flexible: variables, facts (host information), and Jinja2 templates to generate config files dynamically.