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

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. creates tells them not to bother if it is already built; changed_when tells them what counts as work; failed_when tells 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: false on 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, pick ansible-command-changed-when, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.