AnsibleXXIV · Assertions and GuardrailsAssertions and guardrails
Check the world before you change it
What you'll learn
- State the four questions a precondition block exists to answer
- Place a guard where it runs before any connection is opened
- Explain why fact gathering can silence a guard you thought ran first
- Judge when a precondition is worth more than a rollback plan
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
Every incident this part exists to prevent has the same shape. The playbook was correct. The modules were correct. The operator was competent, had done this before, and was not in a hurry. The playbook ran against the wrong fleet, or against a fleet that was not in the state the playbook assumed, and two hundred machines were reconfigured in eleven seconds.
A precondition is a task that states one of the playbook’s assumptions as an executable expression and stops the run if it does not hold. It is the mechanism behind the claim this course keeps making: automation should refuse to perform an unsafe operation.
Not warn. Not log. Refuse.
Why a precondition beats a rollback plan
Rollback is the standard answer to “what if this goes wrong”, and it is a weaker answer than it sounds.
A rollback plan is a promise about the future, written by someone who has just demonstrated they can be wrong about the present. It runs after the damage, under time pressure, on a system in a state nobody designed. Some changes have no rollback at all: a schema migration that dropped a column, a package downgrade the vendor does not support, a certificate already distributed to four hundred hosts.
A precondition runs before the damage, with no time pressure, on a system in a known state. Its failure mode is an error message.
The economics are not close. A rollback plan costs a day to write, a day to test, and is exercised once a year under the worst possible conditions. A precondition costs four lines and is exercised on every single run.
The four questions
A precondition block is not a grab bag of checks. Four questions cover almost every real guard, and naming them keeps the block from growing into ceremony:
| Question | The assumption being made explicit | Typical evidence |
|---|---|---|
| Am I on the right fleet? | This play is for production web servers | inventory_hostname, group membership, an explicit environment variable |
| Is the fleet in the state I assume? | The cluster is healthy, the service is running, the previous version is what I think it is | A read-only probe, an API call, a gathered fact |
| Do I have what I need? | Enough disk, enough memory, a current backup, a supplied version number | Facts, a backup catalogue query, a mandatory variable |
| Is now the right time? | We are inside the change window, no other change is running | A clock check, a lock file, a maintenance flag |
The fourth is the one most teams skip and the one that catches the strangest incidents. It is also the hardest to implement honestly, and lesson 5 returns to it.
Where the guard goes
Placement decides whether the guard runs before the connection or after it, and that difference is larger than it looks.
# guard.yml - the environment guard as the first play in the file
- name: Refuse to proceed unless this is the production web tier
hosts: webservers
gather_facts: false # deliberate: see below
tasks:
- name: Confirm the target environment
ansible.builtin.assert:
that:
- deploy_env == 'production'
- inventory_hostname in groups['webservers']
fail_msg: >-
Refusing: this play changes production web servers, but
deploy_env is '{{ deploy_env }}' and the target is
{{ inventory_hostname }}.
quiet: true
Run that against an inventory of hosts that do not exist and it still works, because nothing in it needs the target:
$ ansible-playbook -i inv.ini guard.ymlTASK [Confirm the target environment] ******************************************
fatal: [web01.example.com]: FAILED! => {"assertion": "deploy_env == 'production'", "changed": false, "evaluated_to": false, "msg": "Refusing: this play changes production web servers, but deploy_env is 'staging' and the target is web01.example.com."}
fatal: [web02.example.com]: FAILED! => {"assertion": "deploy_env == 'production'", "changed": false, "evaluated_to": false, "msg": "Refusing: this play changes production web servers, but deploy_env is 'staging' and the target is web02.example.com."}
PLAY RECAP *********************************************************************
web01.example.com : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
web02.example.com : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0unreachable=0 on two hosts that are, in fact, unreachable. The guard
evaluated and failed on the controller. It returned immediately rather
than waiting out an SSH timeout, which is the observable proof that no
connection was attempted.
That is the property worth designing for: a guard that costs no connection can be run against production inventory from anywhere, at any time, by anyone. It becomes a thing you check before the change window rather than during it.
The three placements, and when each is right
A separate first play. Best for guards that apply to the whole run
and need no facts. It fails the entire ansible-playbook invocation
before any subsequent play starts, and it is visible at the top of the
file where a reviewer will read it.
pre_tasks in the play that does the work. Best for guards that need
this play’s variables or facts. Runs after fact gathering, before roles
and tasks, and handlers flush after it.
The first task file of the role. Best for guards that protect the role itself rather than this particular play. This is the placement that survives reuse: the guard travels with the dangerous thing, so the next playbook to import the role inherits the protection without its author having to know the protection exists.
Lesson 4 argues that for anything genuinely destructive, the third placement is the only one that holds up.
What a precondition is not
It is not validation of the change. The guard checks the world before the change. Whether the change worked is a postcondition, and needs separate tasks after the fact — the testing part builds those.
It is not error handling. block/rescue deals with a task that
tried and failed. A precondition stops the attempt. When the guard is
correct there is nothing to rescue, because nothing was touched.
It is not a substitute for --check. Check mode predicts what the
modules would do. A precondition asserts what must be true for that
prediction to mean anything. They compose: the next part shows that a
--check run of a well-guarded play is a far more informative artefact
than a --check run of an unguarded one.
Knowledge check
Knowledge check · 4 questions
Q1. A guard play targets two hosts in 192.0.2.0/24, which are unroutable. With gather_facts: false the recap reads unreachable=0 failed=1 for each. What does that establish?
Q2. Which of these are genuine reasons to prefer a precondition over a rollback plan? Select all that apply.
Q3. Putting an assert in pre_tasks of a play with gather_facts: true means the assertion may never run at all when the host is unreachable.
Q4. A destructive role is imported by four different playbooks, written by three teams. Where should its environment guard live?
Passing score: 75%. Answers are checked in this browser.