AnsibleXXX · Host Targeting and Blast RadiusScope from inside the play
Guardrails: refusing to run too wide
What you'll learn
- Build a pre-flight play that refuses an oversized or unauthorised target
- Assert that a run does not intersect a protected group
- Require an explicit acknowledgement before production is touched
- Choose an order: value, and explain why shuffle is incompatible with canarying
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
Everything in this part has been a habit: print the list, read the count, compare it against a number. Habits work until the night they do not — the incident at 03:00, the new joiner, the one run that skipped the wrapper because somebody was in a hurry.
This lesson turns the habit into machinery. A pre-flight play that travels with the playbook and refuses, before anything changes, when the target set is too large, contains a protected host, or reaches production without somebody having said so out loud.
Part XXIV built the case for guardrails and the “wrong fleet” guard. This is the targeting-specific one, assembled from the variables in the previous lesson, and every refusal below is real output.
The pre-flight play
- name: Pre-flight - refuse to run too wide
hosts: all
gather_facts: false
vars:
max_hosts: 5
protected_group: do_not_automate
confirm_production: false
tasks:
- name: The target set must not be empty
run_once: true
ansible.builtin.assert:
that: ansible_play_hosts_all | length > 0
fail_msg: 'Refusing: the target set is empty.'
- name: The target set must not exceed the declared ceiling
run_once: true
ansible.builtin.assert:
that: ansible_play_hosts_all | length <= max_hosts | int
fail_msg: >-
Refusing: this run targets {{ ansible_play_hosts_all | length }} hosts,
ceiling is {{ max_hosts }}. Narrow it with --limit, or raise max_hosts
deliberately.
success_msg: '{{ ansible_play_hosts_all | length }} hosts, within the ceiling of {{ max_hosts }}'
- name: The target set must not intersect the protected group
run_once: true
vars:
protected_hit: "{{ ansible_play_hosts_all | intersect(groups[protected_group] | default([])) }}"
ansible.builtin.assert:
that: protected_hit | length == 0
fail_msg: 'Refusing: {{ protected_hit | join(", ") }} are in {{ protected_group }}.'
- name: Production requires an explicit acknowledgement
run_once: true
vars:
prod_hit: "{{ ansible_play_hosts_all | intersect(groups['production'] | default([])) }}"
ansible.builtin.assert:
that: prod_hit | length == 0 or confirm_production | bool
fail_msg: >-
Refusing: {{ prod_hit | length }} production hosts targeted without
-e confirm_production=true.Four properties of that play are deliberate and worth naming.
hosts: all, not the group the change targets. The guard must see
whatever the operator asked for, so it takes the widest possible pattern
and lets --limit narrow it exactly as it narrows the real play. A
guard scoped to web cannot notice that somebody limited to db.
A separate play, not a first task. run_once under serial means
once per batch, so a guard placed inside a batched play runs several
times — harmless for an assertion, harmful for anything with a side
effect. As its own play with no serial, it runs exactly once.
groups[...] | default([]) so a missing group is an empty list
rather than an undefined-variable error. A guard that crashes when the
protected group has not been created yet is a guard people delete.
confirm_production defaults to false. The safe value is the
default, and the unsafe one has to be typed. Never the other way round.
It refuses, verified
$ ansible-playbook -i inventory/hosts.yml preflight.yml; echo exit=$?TASK [The target set must not be empty] ****************************************
ok: [web01.example.com] => {
"msg": "All assertions passed"
}
TASK [The target set must not exceed the declared ceiling] *********************
fatal: [web01.example.com]: FAILED! => {
"assertion": "ansible_play_hosts_all | length <= max_hosts | int",
"evaluated_to": false,
"msg": "Refusing: this run targets 10 hosts, ceiling is 5. Narrow it with --limit, or raise max_hosts deliberately."
}
exit=2$ ansible-playbook -i inventory/hosts.yml preflight.yml --limit canary; echo exit=$? "msg": "1 hosts, within the ceiling of 5"
...
fatal: [web01.example.com]: FAILED! => {
"msg": "Refusing: 1 production hosts targeted without -e confirm_production=true."
}
exit=2$ ansible-playbook -i inventory/hosts.yml preflight.yml --limit canary -e confirm_production=truePLAY RECAP *********************************************************************
web01.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0$ ansible-playbook -i inventory/hosts.yml preflight.yml --limit do_not_automate -e confirm_production=true "msg": "1 hosts, within the ceiling of 5"
...
fatal: [db01.example.com]: FAILED! => {
"msg": "Refusing: db01.example.com are in do_not_automate."
}What the guard cannot do
Being precise about this matters, because a guard believed to cover more than it does is worse than none.
It cannot catch zero hosts. With an empty target there is nothing
for the task to run on, so the “must not be empty” assertion never
executes — the play reports skipping: no hosts matched and the run
proceeds to the next play. That check is there for the case where a
later play empties out, and the genuinely-zero case needs the wrapper
from lesson 6.
It cannot stop a run that skips it. ansible-playbook site.yml
without the pre-flight play in front of it is unguarded. That is why the
role form matters: the guard belongs inside the playbook it protects,
not beside it. import_playbook at the top of site.yml is the
strongest version, because then there is no invocation that omits it.
It cannot know your intent. max_hosts is a number somebody chose.
Set it too high and it never fires; set it too low and people learn to
pass -e max_hosts=9999, which is a routed-around guard and the subject
of the second guardrails lesson in Part XXIV.
order: — and the value that quietly breaks canarying
The order: play keyword controls the sequence in which hosts are
processed, which under serial decides which hosts are in the first
batch. Five values, all verified:
$ ansible-playbook -i inventory/hosts.yml order.ymlorder: inventory web01 web02 web03 web04
order: sorted web01 web02 web03 web04
order: reverse_sorted web04 web03 web02 web01
order: reverse_inventory web04 web03 web02 web01
order: shuffle web04 web02 web03 web01An invalid value is a hard error rather than a fallback, which is the right behaviour:
$ ansible-playbook -i inventory/hosts.yml order.yml[ERROR]: Invalid 'order' specified for inventory hosts: bogusThe full pre-execution sequence
- Confirm the inventory source and its freshness: ansible-config dump --only-changed, and --flush-cache if the source is cached and the run changes state.
- Print the effective host list with the exact command you will run: ansible-playbook <pb> --limit <pattern> --list-hosts. Read the count out loud.
- Confirm the count against the change ticket. A mismatch stops here, before anything else.
- Confirm the task set: --list-tasks, with any --tags you intend to pass.
- Run --check --diff and read the diff, remembering it is evidence rather than proof.
- Confirm the stop procedure: how you will notice, how you will halt, and what recovers the hosts already changed.
- Run, with the pre-flight guard in front and the first batch small enough that being wrong is survivable.
This is the checklist the rest of the course refers back to. It is seven lines and most of it is read-only.
Knowledge check
Knowledge check · 5 questions
Q1. Why does the pre-flight play use hosts: all rather than the group the change targets?
Q2. A wrapper script runs the playbook and treats exit code 1 as a guardrail refusal. What is wrong with that?
Q3. Which limitations of the pre-flight guard are real? Select all that apply.
Q4. order: shuffle is incompatible with using the first serial batch as a canary.
Q5. Why does the guard include a success_msg rather than staying silent when everything is fine?
Passing score: 75%. Answers are checked in this browser.