Skip to main content
RunBook Academy

AnsibleXXIV · Assertions and GuardrailsAssertions and guardrails

Automation that refuses

Intermediate⏱ ~22 minansible-playbook

What you'll learn

  • Choose between assert, fail and the mandatory filter for a given refusal
  • Explain why a refusal is evaluated per host and what that means for the fleet
  • Use any_errors_fatal to turn a per-host refusal into a fleet-wide stop
  • Reject meta end_play as a guardrail and say what is wrong with it

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

There are two reasons a playbook stops, and confusing them is expensive.

Something broke. A package repository was unreachable, a service would not start, a disk filled. The automation tried and could not.

The automation declined. Nothing broke. Every component worked perfectly. The playbook looked at the world, decided the operation was not safe, and refused.

Ansible reports both as failed, and the second is the interesting one. This lesson is about making a deliberate refusal look deliberate.

ansible.builtin.fail — one option

   msg     The customized message used for failing execution.
           If omitted, fail will simply bail out with a generic
           message.
        default: Failed as requested from task
        type: str

That default message is worth noticing, because it is the tell for a fail somebody added in a hurry. “Failed as requested from task” tells an operator that a human decided to stop here and did not say why.

fail on its own is unconditional, so in practice it always carries a when::

- name: Refuse to proceed without an approved change ticket
  ansible.builtin.fail:
    msg: >-
      Declining to run. No change ticket was supplied. Re-run with
      -e change_ticket=CHG-12345 once the change is approved.
  when: change_ticket | default('') | length == 0
Read-only / Safea deliberate refusal
$ ansible-playbook -i localhost, ticket-guard.yml
TASK [Refuse to proceed without an approved change ticket] *********************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Declining to run. No change ticket was supplied. Re-run with -e change_ticket=CHG-12345 once the change is approved."}

PLAY RECAP *********************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

Exit code 2, the same code any other task failure produces.

assert or fail? The condition tells you

Both stop the play. Both are action plugins that need no connection. The choice is about which reads more honestly, and there is a reliable rule:

assert when you are stating a truth that must hold. The condition in that reads as the requirement: deploy_env == 'production', backup_age_hours < 24. The positive form is the documentation.

fail when you are describing a situation you refuse to act in. The condition in when: reads as the problem: when: change_ticket | length == 0, when: ansible_hostname in quarantined_hosts. Inverting it into an assertion would be contorted.

The second case where fail is clearly right is inside block/rescue or after a probe, where the refusal depends on a computed result:

- name: Query the cluster health endpoint
  ansible.builtin.uri:
    url: 'https://cluster.example.com/health'
    return_content: true
  register: health
  check_mode: false
  delegate_to: localhost
  run_once: true

- name: Refuse to drain a node from a cluster that is already degraded
  ansible.builtin.fail:
    msg: >-
      Declining to drain {{ inventory_hostname }}. The cluster reports
      status '{{ health.json.status }}' with
      {{ health.json.unavailable | default(0) }} nodes already
      unavailable. Draining another node risks quorum loss.
  when: health.json.status != 'healthy'

Either module would work there. fail reads better because the sentence in the message is the point and the condition is a description of a bad situation rather than a stated requirement.

| mandatory — the terse refusal

The mandatory filter raises an error when the variable it is applied to is undefined:

Read-only / Safea missing required input
$ ansible-playbook -i localhost, mandatory.yml
fatal: [localhost]: FAILED! => {"msg": "Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': The filter plugin 'ansible.builtin.mandatory' failed: Mandatory variable 'app_version' not defined."}

It works, it is one word, and it is the wrong tool for an operator-facing guard. Read that message as somebody who has never seen the repository: it names the variable and nothing else. Not what the variable is for, not where it should come from, not what a valid value looks like.

mandatory earns its place in two situations:

Inside a role, on a variable the role’s interface requires, where the role also has an assert block up front doing the operator-facing checking. The filter is then a backstop against a code path the assert block missed.

In a template, where there is no task to hang an assert on and a silently empty rendered value would be worse than a stopped play:

listen {{ app_listen_port | mandatory }};
server_name {{ app_server_name | mandatory }};

That second use is genuinely valuable. A template that renders listen ; because a variable was misspelled produces a config file that fails validation at best and starts a broken service at worst. Refusing to render is better.

For everything else, assert with a written fail_msg is worth the three extra lines.

A refusal is evaluated per host

This is the part that surprises people, and it changes how guards must be written.

A play with three hosts, where the guard refuses on one of them:

Configuration changethe guard refuses one host and the other two proceed
$ ansible-playbook -i inv.ini quarantine.yml
TASK [Confirm this host is allowed] ********************************************
ok: [web01.example.com]
fatal: [web02.example.com]: FAILED! => {"assertion": "inventory_hostname != 'web02.example.com'", "changed": false, "evaluated_to": false, "msg": "Refusing: web02.example.com is quarantined."}
ok: [web03.example.com]

TASK [The change itself] *******************************************************
ok: [web01.example.com] => {
  "msg": "changing web01.example.com"
}
ok: [web03.example.com] => {
  "msg": "changing web03.example.com"
}

PLAY RECAP *********************************************************************
web01.example.com          : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web02.example.com          : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
web03.example.com          : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The default behaviour is exactly right for a host-specific guard — web02 is quarantined, the other two are not, and there is no reason to punish them. It is exactly wrong for a fleet-wide guard.

If the guard is checking something about the change rather than about the host — the environment, the change window, the version being deployed — then a refusal on one host means the whole run is wrong, and the default lets it proceed on the rest of the fleet while you read the error.

Adding it produces the fleet-wide stop:

Configuration changethe same play with any_errors_fatal: true
$ ansible-playbook -i inv.ini quarantine-fatal.yml
TASK [Confirm this host is allowed] ********************************************
ok: [web01.example.com]
fatal: [web02.example.com]: FAILED! => {"assertion": "inventory_hostname != 'web02.example.com'", "changed": false, "evaluated_to": false, "msg": "Refusing: web02.example.com is quarantined."}
ok: [web03.example.com]

PLAY RECAP *********************************************************************
web01.example.com          : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web02.example.com          : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
web03.example.com          : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

ok=1 on the two passing hosts rather than ok=2: they completed the guard and never reached the change. That is what a fleet-wide refusal looks like.

What refusal is not: meta: end_play

There is a mechanism that stops a play without failing it, and it is occasionally proposed as a gentler guardrail. It is not a guardrail at all.

Read-only / Safea guard built on meta: end_play
$ ansible-playbook -i inv.ini end-play-guard.yml; echo "exit=$?"
PLAY [end_play is not a refusal] ***********************************************

TASK [Stop the play if this is not production] *********************************

PLAY RECAP *********************************************************************

exit=0

An empty PLAY RECAP, no host lines at all, and exit code 0.

To CI, to a scheduler, to a wrapper script, to a dashboard, and to the person who ran it, that is a successful run. The change did not happen and nothing anywhere says so. meta: end_host behaves the same way per host: the host silently leaves the play and its absence from the recap is the only trace.

end_play and end_host are legitimate flow control — ending a play early once its work is genuinely complete, or dropping a host that does not need the rest of a play. They are not refusals, because a refusal that reports success is worse than no refusal at all.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play over 200 hosts asserts deploy_env == production in pre_tasks. One host has a stale host_vars file and refuses. What happens by default?

  2. Q2. Why is meta: end_play unsuitable as a guardrail? Select all that apply.

  3. Q3. ansible-playbook returns a distinct exit code for a deliberate refusal, so a wrapper script can tell it apart from a module that failed halfway through.

  4. Q4. Where is the mandatory filter the right tool rather than an assert?

Passing score: 75%. Answers are checked in this browser.