5. Modules and idempotence
Ansible · 5. Modules and idempotenceChapter 1 said idempotence is a property of the module, not of Ansible. This chapter is what that means in practice, because it is the difference between a playbook you can run every hour and one nobody dares run twice.
What a module is
Section titled “What a module is”A module is a program that:
- Reads its arguments
- Looks at the current state of the system
- Decides whether anything needs doing
- Does it, if so
- Prints one line of JSON saying what happened
Step 2 is the whole thing. ansible.builtin.package asks the package manager whether nginx is installed before deciding to install it. That check is what makes it idempotent, and it is code somebody wrote inside the module.
{"changed": false, "msg": "", "name": "nginx", "state": "present"}changed is a claim, and it should be true
Section titled “changed is a claim, and it should be true”The changed flag drives more than the colour of the output:
changed → notifies handlers (chapter 4) changed → counts in the play recap changed → fails an idempotence test (chapter 13) changed → shows a diff under --diff changed → is what a human scans for in a logA playbook whose second run reports twenty changes is a playbook nobody can read. The signal is gone: you can no longer tell “something actually happened” from “this always says that”. Recovering that signal is worth real effort, and it is mostly about the next section.
command and shell are never idempotent
Section titled “command and shell are never idempotent”# This reports "changed" every single time, forever.- ansible.builtin.command: /usr/bin/mkdir /opt/appThe module ran a command. It has no idea whether the command did anything. So it reports changed, because something might have happened.
There are three fixes, in strict order of preference.
1. Use the real module
Section titled “1. Use the real module”- ansible.builtin.file: path: /opt/app state: directory mode: '0755'This is nearly always available and nearly always the answer. Before writing command, search for the module — there are thousands, and the ones you need most often are in the table at the end of this chapter.
2. creates and removes
Section titled “2. creates and removes”If there genuinely is no module, command and shell take two arguments that give you idempotence for free:
- name: Extract the release ansible.builtin.command: cmd: tar xzf /tmp/app-2.4.0.tar.gz -C /opt creates: /opt/app-2.4.0/bin/app # skip if this existscreates skips the task entirely if the path exists. removes skips it if the path does not exist. These turn an unconditional command into a conditional one, and the task reports skipped on subsequent runs — which is honest.
3. changed_when and failed_when
Section titled “3. changed_when and failed_when”When the command must run every time, tell Ansible how to interpret the result:
- name: Check the cluster status ansible.builtin.command: /usr/local/bin/cluster-status register: status changed_when: false # read-only: never a change failed_when: status.rc not in [0, 2] # 2 means "degraded"
- name: Reload the application ansible.builtin.command: /opt/app/bin/reload register: reload changed_when: "'reloaded' in reload.stdout" failed_when: reload.rc != 0 or 'ERROR' in reload.stderrchanged_when: false on every read-only command is the single highest-value habit in this chapter. Anything that queries, checks, or reports is not a change, and saying so removes most of the noise from a second run.
Note also that failed_when replaces the default check entirely. Once you write it, a non-zero return code is no longer automatically a failure — your expression is the only thing that decides. Include the return code in it unless you mean to ignore it.
And the one that is not a fix
Section titled “And the one that is not a fix”- ansible.builtin.command: /opt/app/bin/reload ignore_errors: true # ← almost always wrongignore_errors means “carry on regardless”, and it usually appears because a task failed and someone wanted the playbook to finish. It hides the failure without addressing it. failed_when says what failure means; ignore_errors says failure does not matter. The second is rarely true.
command versus shell
Section titled “command versus shell”- ansible.builtin.command: ls /opt # no shell- ansible.builtin.shell: ls /opt | wc -l # a shellcommand does not use a shell. No pipes, no redirection, no globbing, no &&, no environment variable expansion. The arguments go straight to execve. This makes it safer — nothing is interpreted — and it is why command should be your default.
shell runs through /bin/sh, so all of that works, and so does shell injection if any part of the command comes from a variable. If you must use shell with untrusted input, quote it:
- ansible.builtin.shell: "grep {{ pattern | quote }} /var/log/app.log"The | quote filter is not optional there. A variable containing ; rm -rf / is a real thing that has happened to real people.
Check mode
Section titled “Check mode”--check runs the playbook without changing anything, and reports what would change.
ansible-playbook site.yml --check --diff TASK [Deploy site configuration] ****************** --- before: /etc/nginx/conf.d/site.conf +++ after: /etc/nginx/conf.d/site.conf @@ -3,7 +3,7 @@ server { - worker_connections 1024; + worker_connections 4096; changed: [web1.example.com]--check --diff together is the most useful pair of flags in Ansible. It answers “what will this do to production” with a line-by-line answer.
Three limitations to know:
Not every module supports it. Modules declare whether they do. command and shell skip entirely in check mode rather than guessing, which means a check run of a playbook full of command tasks tells you very little.
Read-only commands should opt in:
- ansible.builtin.command: /usr/local/bin/cluster-status register: status changed_when: false check_mode: false # actually run this, even in --checkWithout that, status is undefined in check mode and every later task that references it fails. This is the commonest reason --check does not work on a playbook that works fine normally.
Check mode cascades. If task 3 would have created a file, task 8 that expects the file to exist will fail — because in check mode task 3 did not create it. A playbook that bootstraps something from nothing often cannot be checked end to end, and that is inherent rather than a bug.
The module vocabulary
Section titled “The module vocabulary”The ones you will use constantly, all from ansible.builtin unless noted:
FILES copy push a file (or inline content) template push a file through Jinja2 — chapter 7 file create/remove paths, set owner and mode lineinfile ensure ONE line matches a regex blockinfile ensure a marked BLOCK is present replace regex substitution across a file fetch pull a file back from the target unarchive extract a tarball or zip stat inspect a path without changing it find search for files by criteria assemble build one file from a directory of parts
PACKAGES package generic — picks apt/yum/dnf/etc apt / dnf / yum / zypper / apk / pacman pip Python packages package_facts what is installed, as data
SERVICES service generic systemd_service the real one on modern Linux ansible.posix.mount
USERS user / group ansible.posix.authorized_key
EXECUTION command no shell — the safer default shell a shell, when you need one raw no Python needed — chapter 3 script copy a local script and run it
CONTROL debug print a message or a variable assert fail unless conditions hold fail fail with a message set_fact define a variable at runtime include_tasks / import_tasks / include_role meta flush_handlers, end_play, clear_facts pause / wait_for / wait_for_connection uri make an HTTP request get_url download a fileThree of these deserve a warning.
lineinfile is overused. It is right for a single line in a file you do not otherwise own — one setting in /etc/ssh/sshd_config. It is wrong as a way to build a config file line by line, which produces a playbook nobody can read and a file whose contents depend on the order rules ran. If you own the file, use template and own all of it.
replace has no idempotence guarantee you can see. A regex that matches its own output loops forever across runs, appending or doubling. Test the second run.
systemd_service over service on modern Linux. The generic service module works, and systemd_service gives you daemon_reload, masked, and proper handling of units that the generic one guesses at. All three matter: a unit file change does nothing until daemon-reload, and masking is the only thing that stops a unit being started by something else.
Return values
Section titled “Return values”Every module returns a dictionary, and the common keys are worth knowing because register puts all of them in reach:
changed did something happen failed did it fail msg human-readable message rc return code (command/shell) stdout full output as one string stdout_lines the same, split into a list stderr / stderr_lines results the per-item list when using `loop` skipped was it skippedstdout_lines is almost always what you want over parsing stdout yourself. And when a task loops, the result is results — a list of individual results — which is why register plus loop needs result.results rather than result.stdout. Chapter 8 covers that.
Writing your own module
Section titled “Writing your own module”Rarely necessary, and worth knowing the shape because it demystifies the whole system:
#!/usr/bin/pythonfrom ansible.module_utils.basic import AnsibleModule
def main(): module = AnsibleModule( argument_spec=dict( name=dict(type='str', required=True), state=dict(type='str', default='present', choices=['present', 'absent']), ), supports_check_mode=True, ) name = module.params['name']
current = look_at_the_system(name) # step 2 needed = (module.params['state'] == 'present')
if current == needed: module.exit_json(changed=False, name=name)
if not module.check_mode: do_the_thing(name, needed)
module.exit_json(changed=True, name=name)
if __name__ == '__main__': main()Put that in a library/ directory beside your playbook and it is available immediately. The shape is the lesson: check first, act only if needed, declare check-mode support, and print JSON. That is all a module is.
Before writing one, though: a task that shells out and parses output with changed_when is usually enough, and a module is worth it when you would otherwise repeat that in twenty places.
What to take from this chapter
Section titled “What to take from this chapter”- A module inspects the system before acting. That check is what idempotence is; Ansible does not provide it.
changeddrives handlers, the recap, diffs, and idempotence tests. A playbook whose second run reports twenty changes has lost its signal.commandandshellalways reportchanged. Fix it, in order: use the real module, thencreates/removes, thenchanged_when.changed_when: falseon every read-only command is the highest-value habit here.failed_whenreplaces the default return-code check entirely — includercin your expression unless you mean to ignore it.ignore_errorsis almost always the wrong tool.commanduses no shell and is the safer default. Withshell,| quoteany variable in the command string.--check --diffis the most useful flag pair.command/shellskip in check mode, so addcheck_mode: falseto read-only commands or later tasks will fail on undefined variables.lineinfileis for files you do not own. If you own the file, usetemplate.stdout_linesover parsingstdout, and a looped task returnsresults, notstdout.- A custom module is just check, act, print JSON, and lives in
library/beside the playbook.
Next: variables — where they come from, the precedence order that decides which one wins, and the scoping rules that produce most of the confusion in this tool.