AnsibleXXIV · Assertions and GuardrailsAssertions and guardrails
Automation that refuses
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
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
$ ansible-playbook -i localhost, ticket-guard.ymlTASK [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=0Exit 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:
$ ansible-playbook -i localhost, mandatory.ymlfatal: [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:
$ ansible-playbook -i inv.ini quarantine.ymlTASK [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=0The 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:
$ ansible-playbook -i inv.ini quarantine-fatal.ymlTASK [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=0ok=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.
$ 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=0An 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
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?
Q2. Why is meta: end_play unsuitable as a guardrail? Select all that apply.
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.
Q4. Where is the mandatory filter the right tool rather than an assert?
Passing score: 75%. Answers are checked in this browser.