Skip to main content
RunBook Academy

← All labs in Ansible

Lab · advanced · ~75 min

Lab: A guardrail that actually refuses, and a limit file you can review

C · SimulationB · Nested virtualisation

Objectives

  • Implement a pre-flight guard that refuses an unbounded or over-wide run
  • Demonstrate the two ways a guard silently does not execute, and fix each
  • Generate a limit file from an inventory query and use it as the change artefact
  • Explain why the guard belongs in the playbook and the target belongs on the command line

Prerequisites

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-core 2.21.x. Output captured from 2.21.3.
  • The fleet.yml inventory 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:

Read-only / Safecontroller
$ ansible-playbook -i fleet.yml guard-v1.yml -e target=us_web
TASK [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:

Read-only / Safecontroller
$ ansible-playbook -i fleet.yml guard-v1.yml; echo "exit=$?"
PLAY [Guarded change] **********************************************************
skipping: no hosts matched

PLAY RECAP *********************************************************************

exit=0

Task 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 }}"
Read-only / Safecontroller
$ 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=4

Exit 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
Read-only / Safecontroller
$ ansible-playbook -i fleet.yml preflight.yml -e target=eu_web --limit @wave1.limit --list-hosts
playbook: 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.yml with no -e target= exits 0 and runs nothing — you have reproduced the silent skip.
  • ansible-playbook -i fleet.yml guard-v2.yml with no target exits 4 with Mandatory variable 'target' not defined.
  • ansible-playbook -i fleet.yml preflight.yml with no target exits 2 with the Refusing to run message from the assert.
  • ansible-playbook -i fleet.yml preflight.yml -e target=us_web refuses with a message naming 10 hosts and policy maximum is 5.
  • ansible-playbook -i fleet.yml preflight.yml -e target=canary runs the change task against exactly two hosts.
  • With --limit @wave1.limit and no localhost line, --list-hosts reports hosts (0): for play 1. With the localhost line, it reports hosts (1):.
  • wave1.limit begins 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.yml exit 0 having run nothing, with the refusal logic sitting unexecuted inside it.
  • {{ target | mandatory }} in hosts: 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 mandatory cannot: all, and a group wider than the change policy allows.
  • --limit applies to every play, so a careful limit file that omits localhost switches off a controller-side guard. Generate limit files with localhost included, 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.

Deliverables

  • · A preflight.yml play that refuses an undefined, over-wide or all-fleet target
  • · A wave1.limit file generated from an inventory query, with its generating command recorded
  • · A short note explaining why hosts with a mandatory filter and a pre_tasks assert catch different failures

Verification status

Last reviewed
2026-08-11
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.