04 · command and shell Without Breaking Idempotency¶
Sometimes no module exists and you must run a command. ansible.builtin.command and ansible.builtin.shell are the escape hatches, and they are where most Ansible bugs come from: wrong quoting, hidden failures in pipes, injection from variables, and tasks that report changed forever. This lesson runs each hazard.
What was run
All outputs come from src/l3/cmd.yml on macOS (zsh is the user's shell, but Ansible's shell module used /bin/sh, and /bin/bash where I set executable). Results of shell features can differ on Linux with dash as /bin/sh; test on your target.
command versus shell¶
commandruns a program without a shell: no pipes, no redirection, no&&, no$(...), no globbing.shellruns the line through a shell (/bin/shby default), so all of those work, and so do their dangers.rawsends text over SSH without Python (for bootstrapping);scriptcopies and runs a local script.
My experiment: echo $HOME | wc -c.
command: stdout = "/Users/bhanuja | wc -c" # $HOME was expanded; the pipe and wc were passed as literal arguments
shell: stdout is a number (the character count)
Two surprises: first, command still expanded $HOME (it expands environment variables in arguments; I had assumed it did not), while the | became a plain argument to echo. Second, $(basename $PWD) earlier in Lesson 08 was not evaluated by command. Do not rely on partial shell behaviour; if you need shell syntax use shell, and if you do not, use command with the argv form.
argv: no quoting problems¶
printed a b c;d: each list element is one argument, with no shell and no splitting. This is the safest form when arguments contain spaces or special characters, or come from variables.
Injection through variables¶
Output lines: ok and INJECTED. The variable's text was pasted into the shell line, so ; echo INJECTED ran as a second command. In real life the variable comes from inventory, a facts value, a user-provided -e, or an API, and the injected command could be rm -rf. With the quote filter:
the output was one line, ok; echo INJECTED, treated as data. Rule: every variable interpolated into shell: must go through | quote, or use command with argv, or pass data through environment variables (environment: {NAME: "{{ value }}"} and "$NAME" in the script). The CI/CD course's script-injection lesson (Level 2 · 10 there) is the same bug in another place.
Pipes hide failures¶
Result: rc=0. The pipeline's status is the status of the last command, so the failing false was invisible. With bash and pipefail:
rc=1. Always set executable: /bin/bash and set -o pipefail for pipelines you depend on (ansible-lint has a rule for this, risky-shell-pipe). /bin/sh may be dash, which lacks pipefail.
Making commands idempotent¶
- Use a module instead (
filerather thanmkdir,get_urlrather thancurl,lineinfilerather thanecho >>). Checkansible-doc -l | grepand collections first. creates/removes: skip the command if a path exists / does not exist.
touch made with creates -> changed=True (file did not exist)
rm made with removes -> changed=True (file existed)
Both fired in my run because the sequence creates and then removes the same file, so every run created and removed it again: a good reminder that creates/removes look at a file, not at the intent, and combined carelessly they make a task pair that flips on each run (Lesson 08).
3. Check first, act second: stat (or a read-only command) registers the state; the action task has when:.
4. changed_when: derive changed from the output. A script printing created or unchanged:
- ansible.builtin.shell: "test -f flag && echo unchanged || { touch flag; echo created; }"
register: ch
changed_when: "'created' in ch.stdout"
First run changed=True, second run changed=False (measured). Use changed_when: false for read-only commands.
5. failed_when for tools whose exit codes mean something else (grep returns 1 for "no match").
6. Make the command itself idempotent (mkdir -p, ln -sfn, useradd guarded by id), and still add changed_when.
Environment, working directory, and privilege¶
chdir:sets the working directory;environment:sets variables;args: {executable: ...}picks the shell.becomeapplies to the command like any task.commandruns with a minimal environment;PATHmay not include what your interactive shell has. Use absolute paths or setenvironment: {PATH: ...}.- Output is captured in
stdout/stderrand printed in logs; do not echo secrets (no_log).
Check mode¶
command and shell are skipped in check mode (unless check_mode: false on the task), so tasks after them may not see their output. For a read-only command whose output later tasks need, set check_mode: false and changed_when: false. For commands that change things, you can pair with creates/removes, which Ansible can evaluate in check mode.
A pre-flight review checklist for every shell:¶
- Is there a module that does this?
- Does it need shell features? If not, use
command+argv. - Is every interpolated variable quoted?
- If it has a pipe, is
pipefailon and bash selected? - How does it report
changed? Is it guarded bycreates,removes,whenorchanged_when? - What does it do when run twice? When run in check mode?
- Does it print or leak secrets?
How It Actually Works¶
The command module splits its argument string with shlex-like rules into an argument vector and calls module.run_command which executes it with subprocess (no shell), after applying os.path.expandvars-style expansion to the arguments, which is consistent with $HOME being replaced. shell calls the same helper with use_unsafe_shell=True, which hands the string to /bin/sh -c (or executable), so the shell interprets everything. Both return rc, stdout, stderr and always set changed: true, because the module has no idea what the command did, unless creates/removes short-circuits it before running. That is the whole story behind "command is never idempotent": idempotency has to be supplied by you (guards or changed_when), not by the module.
Common mistakes¶
- Unquoted variables in
shell. - Pipelines without
pipefail. changed_when: falseon a command that changes something.commandwith shell syntax, half working.- Relying on a login shell's PATH.
- Commands that print secrets.
- A
createspath that is never created, so the command re-runs forever.
Exercise¶
- Run
src/l3/cmd.yml. Which task in it changes state on every run? Fix the pair so both runs reportchanged=0on the second run. - Write a
shelltask with a pipeline and prove with a failing first command thatpipefailchanges the result. - Convert three
shell:tasks from a playbook of your own to modules orcommand+argv, and say what you gained. - Write the injection test: a variable containing
; touch /tmp/pwnedrun throughshellwith and withoutquote, and verify the file is created only in the unsafe case.