Objective
By the end of this lab you will have a pre-flight guard that refuses to run
a change against an undefined target, against all, or against more hosts
than policy allows — and you will have watched that same guard fail to run
at all in two different ways, both of which look like success. You will
also produce a limit file that a reviewer can read and approve before the
change happens.
Architecture
The 30-host inventory from the targeting lab, plus a two-play playbook. The first play runs on the controller only; the second is the change.
controller
│
├── play 1: hosts: localhost <- the guard
│
└── play 2: hosts: "{{ target }}" <- the change
│
30-host fleet, limited by --limit @wave1.limit
Nothing connects to a managed node: the “change” in play 2 is a debug
task. The lab is about the control structure, not the change.
Requirements
- A controller with
ansible-core2.21.x. Output captured from 2.21.3. - The
fleet.ymlinventory from the targeting patterns lab, or the copy reproduced in Task 1. - No SSH, no credentials, no managed nodes, no privilege escalation. Nothing here can lock you out.
Scenario
Your team’s change policy says: no automated change may touch more than
five hosts in one invocation without a named exception, and no change may
be run against all. That policy currently lives in a wiki page. Your job
is to move it into the playbook, where it can refuse.
Tasks
Task 1: Set up the inventory
WORKDIR="$HOME/ansible-guardrail-lab"
mkdir -p "$WORKDIR"
cd "$WORKDIR"
Write fleet.yml. The ansible_connection: local line means every “host”
resolves to the controller, so the lab needs no managed nodes at all:
all:
vars:
ansible_connection: local
hosts:
build01.example.com:
build02.example.com:
jump01.example.com:
children:
eu_west:
children:
eu_web:
hosts:
web[01:08].eu.example.com:
eu_db:
hosts:
db[01:02].eu.example.com:
us_east:
children:
us_web:
hosts:
web[01:10].us.example.com:
us_db:
hosts:
db[01:03].us.example.com:
canary:
hosts:
web01.eu.example.com:
web01.us.example.com:
decommissioning:
hosts:
web08.eu.example.com:
web10.us.example.com:
Task 2: Write the guard the obvious way, and watch it not fire
The instinct is to put the guard in pre_tasks of the play doing the work:
# guard-v1.yml
- name: Guarded change
hosts: "{{ target | default('none') }}"
gather_facts: false
vars:
max_hosts_per_run: 5
pre_tasks:
- name: Refuse a run that was not explicitly targeted
ansible.builtin.assert:
that:
- target is defined
- target != 'all'
fail_msg: >-
Refusing to run: pass -e target=GROUP explicitly.
'all' is not an acceptable target for this playbook.
success_msg: "target={{ target }}"
run_once: true
delegate_to: localhost
- name: Refuse a run wider than the batch policy
ansible.builtin.assert:
that:
- ansible_play_hosts_all | length <= max_hosts_per_run
fail_msg: >-
Refusing to run: {{ ansible_play_hosts_all | length }} hosts in
scope, policy maximum is {{ max_hosts_per_run }}.
Hosts: {{ ansible_play_hosts_all | join(', ') }}
success_msg: "{{ ansible_play_hosts_all | length }} host(s) in scope"
run_once: true
delegate_to: localhost
tasks:
- name: The actual change
ansible.builtin.debug:
msg: "changing {{ inventory_hostname }}"
Test the two cases it is supposed to catch. First, an over-wide target:
$ ansible-playbook -i fleet.yml guard-v1.yml -e target=us_webTASK [Refuse a run wider than the batch policy] *********************************
fatal: [web01.us.example.com -> localhost]: FAILED! => {
"assertion": "ansible_play_hosts_all | length <= max_hosts_per_run",
"changed": false,
"evaluated_to": false,
"msg": "Refusing to run: 10 hosts in scope, policy maximum is 5. Hosts: web01.us.example.com, web02.us.example.com, web03.us.example.com, web04.us.example.com, web05.us.example.com, web06.us.example.com, web07.us.example.com, web08.us.example.com, web09.us.example.com, web10.us.example.com"
}That works, and it works well: the message names the count, the policy and every host. A reviewer reading a CI log knows exactly what was refused.
Then a target of all:
ansible-playbook -i fleet.yml guard-v1.yml -e target=all
Also refused. So far the guard looks solid. Now run it with no target at all:
$ ansible-playbook -i fleet.yml guard-v1.yml; echo "exit=$?"PLAY [Guarded change] **********************************************************
skipping: no hosts matched
PLAY RECAP *********************************************************************
exit=0Task 3: Fix it two ways, because they catch different things
Fix one: make the hosts: keyword itself refuse. The mandatory
filter raises rather than defaulting:
# guard-v2.yml
- name: Guarded change
hosts: "{{ target | mandatory }}"
gather_facts: false
tasks:
- name: The actual change
ansible.builtin.debug:
msg: "changing {{ inventory_hostname }}"
$ ansible-playbook -i fleet.yml guard-v2.yml; echo "exit=$?"[ERROR]: Error processing keyword 'hosts': The filter plugin 'ansible.builtin.mandatory' failed: Mandatory variable 'target' not defined.
Origin: /home/operator/ansible-guardrail-lab/guard-v2.yml:2:10
1 - name: Guarded change
2 hosts: "{{ target | mandatory }}"
^ column 10
exit=4Exit 4, with the file and column of the offending line. That is the behaviour you want from an undefined target.
Fix two: put the guard in its own play, on the controller. This catches
policy violations, which mandatory cannot:
# preflight.yml
- name: Pre-flight - runs on the controller, before any managed node is touched
hosts: localhost
connection: local
gather_facts: false
vars:
max_hosts_per_run: 5
tasks:
- name: Require an explicit, non-fleet-wide target
ansible.builtin.assert:
that:
- target is defined
- target | length > 0
- target != 'all'
fail_msg: >-
Refusing to run: pass -e target=GROUP.
'all' is not an acceptable target for this playbook.
success_msg: "target={{ target }}"
- name: Resolve the target and refuse a run wider than policy
ansible.builtin.assert:
that:
- groups[target] | default([]) | length <= max_hosts_per_run
fail_msg: >-
Refusing to run: group '{{ target }}' contains
{{ groups[target] | default([]) | length }} hosts, policy maximum
is {{ max_hosts_per_run }}. Use a limit file for a narrower wave.
success_msg: >-
{{ groups[target] | default([]) | length }} host(s) in '{{ target }}'
- name: The change
hosts: "{{ target | mandatory }}"
gather_facts: false
tasks:
- name: The actual change
ansible.builtin.debug:
msg: "changing {{ inventory_hostname }}"
Run it with no target:
ansible-playbook -i fleet.yml preflight.yml
echo "exit=$?"
You get a fatal: on localhost and exit 2 — a real failure, on a
play that always has a host to run on.
Task 4: Find the second way the guard silently does not run
Generate a limit file for a narrower wave. This is the workflow the policy pushes you towards: target a group, then limit to the hosts you actually mean to touch this time.
INVENTORY=fleet.yml
PATTERN='eu_web:!decommissioning'
ansible -i "$INVENTORY" "$PATTERN" --list-hosts \
| tail -n +2 | tr -d ' ' > wave1.limit
# Record how the file was generated. A limit file with no provenance
# is a list of hostnames somebody typed.
{
echo "# generated: $(date -Is)"
echo "# inventory: $INVENTORY"
echo "# pattern: $PATTERN"
} > wave1.limit.meta
cat wave1.limit
Now run the guarded playbook against it:
ansible-playbook -i fleet.yml preflight.yml -e target=eu_web \
--limit @wave1.limit --list-hosts
$ ansible-playbook -i fleet.yml preflight.yml -e target=eu_web --limit @wave1.limit --list-hostsplaybook: preflight.yml
play #1 (localhost): Pre-flight - runs on the controller TAGS: []
pattern: ['localhost']
hosts (0):
play #2 (eu_web): The change TAGS: []
pattern: ['eu_web']
hosts (7):
web01.eu.example.com
web02.eu.example.com
...The fix is to put localhost in the limit file, and to make generating it
the only supported way to produce one:
INVENTORY=fleet.yml
PATTERN='eu_web:!decommissioning'
{
echo "# generated: $(date -Is)"
echo "# inventory: $INVENTORY"
echo "# pattern: $PATTERN"
echo "localhost"
ansible -i "$INVENTORY" "$PATTERN" --list-hosts | tail -n +2 | tr -d ' '
} > wave1.limit
ansible-playbook -i fleet.yml preflight.yml -e target=eu_web \
--limit @wave1.limit --list-hosts
Play 1 now reports one host and the guard runs.
Task 5: Prove each refusal independently
A guard you have not seen refuse is a guard you have not tested. Run all four cases and record the exit code of each:
run_case() {
echo "--- $1"
shift
ansible-playbook -i fleet.yml preflight.yml "$@" >/dev/null 2>&1
echo "exit=$?"
}
run_case "no target"
run_case "target=all" -e target=all
run_case "target=us_web (10)" -e target=us_web
run_case "target=canary (2)" -e target=canary
Expected: the first three fail, the last succeeds. If any of the first three exits 0, you have a guard that does not guard.
Validation
ansible-playbook -i fleet.yml guard-v1.ymlwith no-e target=exits 0 and runs nothing — you have reproduced the silent skip.ansible-playbook -i fleet.yml guard-v2.ymlwith no target exits 4 withMandatory variable 'target' not defined.ansible-playbook -i fleet.yml preflight.ymlwith no target exits 2 with theRefusing to runmessage from the assert.ansible-playbook -i fleet.yml preflight.yml -e target=us_webrefuses with a message naming10 hostsandpolicy maximum is 5.ansible-playbook -i fleet.yml preflight.yml -e target=canaryruns the change task against exactly two hosts.- With
--limit @wave1.limitand nolocalhostline,--list-hostsreportshosts (0):for play 1. With thelocalhostline, it reportshosts (1):. wave1.limitbegins with three#comment lines recording the inventory and pattern that generated it.
Expected Outcome
ansible-guardrail-lab/
├── fleet.yml
├── guard-v1.yml <- the version that silently skips
├── guard-v2.yml <- hosts: "{{ target | mandatory }}"
├── preflight.yml <- the version you would ship
├── notes.md
└── wave1.limit
preflight.yml refuses an undefined target, a fleet-wide target and an
over-wide target, each with a message that names the specific violation.
wave1.limit carries its own provenance and includes localhost so the
guard survives being limited. You can state, without checking, that a
--limit applies to every play in the file.
Troubleshooting
groups[target] raises 'dict object' has no attribute .... The
target names a host or a pattern rather than a group, so there is no
groups entry. | default([]) as shown handles it, but then a typo’d
group name resolves to zero hosts and passes the width check. Add
- target in groups to the first assert if your policy requires a real
group.
The width assert passes but the run is wider than five hosts. You are
counting groups[target], which ignores --limit, and something else
widened the run. Count ansible_play_hosts_all in a pre_tasks assert on
the change play as well — the two checks answer different questions and a
strict setup wants both.
fatal: [web01.us.example.com -> localhost]. That arrow is normal for
a delegate_to: localhost task; the assert ran on the controller on
behalf of that host. It is only surprising the first time.
The run_once assert fires once and you wanted it per host. run_once
evaluates on the first host of the batch only. Under serial it runs once
per batch, not once per play — which is usually what you want for a
guard and occasionally a surprise.
--limit @wave1.limit errors with Could not match supplied host pattern. A hostname in the file is not in the inventory. Note that this
is a warning, not an error, and the run continues against whatever did
match — so a typo in a limit file silently narrows the wave rather than
stopping it. Diff the file against --list-hosts output after generating.
Cleanup
Nothing outside the working directory was written, no managed node was
contacted, and no privilege was escalated. The change task was a debug.
Step 1. Confirm nothing was left behind in the Ansible search path:
cd "$HOME/ansible-guardrail-lab"
ansible --version | grep 'config file'
ls -la ~/.ansible.cfg 2>/dev/null || echo 'no user ansible.cfg'
The config file line should read the same as it did before the lab.
Step 2. Keep the guard. preflight.yml is the deliverable and it is the
thing you will reuse:
mkdir -p "$HOME/ansible-lab-deliverables"
cp -a preflight.yml wave1.limit notes.md "$HOME/ansible-lab-deliverables/"
Step 3. Remove the working directory by absolute path:
rm -rf "$HOME/ansible-guardrail-lab"
What You Learned
- A guard inside the play it guards cannot catch an empty play. You
watched
guard-v1.ymlexit 0 having run nothing, with the refusal logic sitting unexecuted inside it. {{ target | mandatory }}inhosts:fails at keyword-templating time, before host resolution, with exit 4 and the exact line. That is the only reliable defence against an undefined target.- A controller-side pre-flight play catches policy, which
mandatorycannot:all, and a group wider than the change policy allows. --limitapplies to every play, so a careful limit file that omitslocalhostswitches off a controller-side guard. Generate limit files withlocalhostincluded, from a recorded query.- A limit file needs provenance. Ansible ignores
#lines, so the pattern that produced the list can live in the same file the reviewer reads. - Refusals must be tested individually. Four cases, four recorded exit codes; anything less and you are trusting a control you have never seen work.