Making command and shell Behave
Scenario: A nightly run rebuilds a search index for forty minutes even though the index is already there, and the play goes red every time the health probe returns its normal 'degraded but serving' exit code 2.
New words, in plain English
creates- a path that, if it already exists, causes the task to be skipped entirely.removes- the mirror image: run only if the given path exists.changed_when- an expression that decides whether this task reportschanged.failed_when- an expression that decides whether this task counts as failed.register- captures a task's full result (stdout, stderr, rc) into a variable for later tasks to inspect.rc- return code - the numeric exit status of a command. 0 conventionally means success.
When you must run a raw command, you have to supply the judgement the module would otherwise have. There are three separate questions, and they need three separate answers.
Should it run at all? creates: skips the task when a path already exists - perfect for expensive one-off work. removes: is the inverse.
Did it change anything? changed_when: takes an expression. changed_when: false for anything read-only. changed_when: "'DRIFT' in result.stdout" when the command itself tells you.
Did it fail? failed_when: overrides the default "non-zero means failure". A probe where 2 means "degraded but serving" needs failed_when: result.rc not in [0, 2].
These are independent, and the common mistake is conflating them - reaching for ignore_errors: true (which hides genuine failures and still reports the task as failed) when what you needed was a precise failed_when.
Analogy: A raw command is a contractor who reports 'job done' regardless of whether they did anything, and storms off if a single tool is missing.createstells them not to bother if it is already built;changed_whentells them what counts as work;failed_whentells them which problems are actually problems.
A worked example
- name: Audit the catalogue
ansible.builtin.command: /opt/bin/audit.sh
register: audit
changed_when: "'DRIFT' in audit.stdout" # only real drift counts
notify: reload catalogue # so this only fires on drift
- name: Bootstrap the search index (expensive, once)
ansible.builtin.command: /opt/bin/bootstrap.sh
args:
creates: /var/lib/app/index.db # skipped once this exists
- name: Probe service health
ansible.builtin.command: /opt/bin/healthcheck.sh
register: health
changed_when: false # read-only, never a change
failed_when: health.rc not in [0, 2] # 2 = degraded but serving
register is what makes all of this possible: it captures stdout, stdout_lines, stderr, rc and more into a variable you can test. Use -v on a run to see the full structure of a registered result - guessing at field names is a slow way to work.
One subtlety worth internalising: ignore_errors: true does not make a task succeed. It marks it failed, prints it red, and continues. That is occasionally what you want, but if the non-zero exit is expected and fine, failed_when is the correct and far more honest tool - it makes the playbook self-documenting about which outcomes are acceptable.
Warning:changed_when: falseon a task that genuinely mutates the host is a lie that will hide real changes from--diff, from your handlers, and from anyone reading the output. Use it only for genuinely read-only work.
Goal: Put this to work in the ansible-command-changed-when lab. Open/labs/ansible, pickansible-command-changed-when, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.