Skip to main content
RunBook Academy

AnsibleXXIV · Assertions and GuardrailsAssertions and guardrails

Check the world before you change it

Intermediate⏱ ~20 minansible-playbook

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

Not yet marked complete on this device.

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:

QuestionThe assumption being made explicitTypical evidence
Am I on the right fleet?This play is for production web serversinventory_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 isA 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 numberFacts, a backup catalogue query, a mandatory variable
Is now the right time?We are inside the change window, no other change is runningA 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:

Read-only / Safethe guard fires without a connection
$ ansible-playbook -i inv.ini guard.yml
TASK [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=0

unreachable=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

  1. 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?

  2. Q2. Which of these are genuine reasons to prefer a precondition over a rollback plan? Select all that apply.

  3. 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.

  4. 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.