Skip to main content
RunBook Academy

← All labs in Ansible

Lab · intermediate · ~75 min

Lab: Twelve target sets, twelve patterns, proved before you run

C · SimulationB · Nested virtualisation

Objectives

  • Compose host patterns using union, intersection, exclusion, regex and slice forms
  • Predict a pattern result and then falsify or confirm the prediction with --list-hosts
  • Recognise the two silent failure modes: the empty target set and the pattern that matches more than intended
  • Explain why --limit narrows a pattern and can never widen it

Prerequisites

Objective

By the end of this lab you will have written twelve host patterns against a deliberately awkward 30-host inventory, predicted the result of each one in writing before running it, and verified all twelve with --list-hosts. You will have been wrong at least twice — the inventory is built so that the obvious answer to two of the questions is wrong — and you will be able to say why.

Architecture

A single inventory file describing a two-region estate with overlapping compliance and lifecycle groups. Nothing connects; there is no controller role to play beyond running the parser.

all (30 hosts)
├── build01, build02, jump01        <- no group: they land in @ungrouped
├── eu_west  (12)                   us_east  (15)
│   ├── eu_web    web01-08.eu       ├── us_web    web01-10.us
│   ├── eu_db     db01-02.eu        ├── us_db     db01-03.us
│   └── eu_cache  cache01-02.eu     └── us_cache  cache01-02.us

├── canary          web01.eu, web01.us          <- cuts across regions
├── pci             db01-02.eu, db01-03.us      <- cuts across regions
└── decommissioning web08.eu, web10.us, cache02.us

The three cross-cutting groups are the point. canary, pci and decommissioning each contain hosts that already belong to a region group, so most interesting target sets are intersections and exclusions rather than plain group names.

Requirements

  • A controller with ansible-core 2.21.x. All output below was captured from 2.21.3.
  • A text editor and somewhere to write your predictions down before you run anything. This is not optional decoration — the lab is worthless if you run first and rationalise afterwards.
  • No network access, no SSH keys, no managed nodes. Nothing in this lab connects to anything, so there is no lockout risk and no out-of-band access requirement.

Scenario

You have joined a team operating this estate. On your first day the outgoing engineer says, with total confidence, “just run it against the web servers”. You are going to find out how many different things that sentence can mean, and how to say which one you meant in a form a machine can check.

Tasks

Task 1: Build the inventory

WORKDIR="$HOME/ansible-targeting-lab"
mkdir -p "$WORKDIR"
cd "$WORKDIR"

Write fleet.yml:

all:
  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:
        eu_cache:
          hosts:
            cache[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:
        us_cache:
          hosts:
            cache[01:02].us.example.com:
    canary:
      hosts:
        web01.eu.example.com:
        web01.us.example.com:
    pci:
      hosts:
        db[01:02].eu.example.com:
        db[01:03].us.example.com:
    decommissioning:
      hosts:
        web08.eu.example.com:
        web10.us.example.com:
        cache02.us.example.com:

Confirm the total before you start:

Read-only / Safecontroller
$ ansible -i fleet.yml all --list-hosts | head -1
  hosts (30):

Thirty. If you got a different number, the range syntax did not expand — web[01:08] needs no quotes in YAML but does need the colon form, not a hyphen.

Task 2: Write your predictions

Copy this table into answers.md and fill in the Pattern and Predicted count columns. Do not run anything yet.

#Target setPatternPredictedActual
1Every web server in either region
2Everything in EU West
3EU web servers that are not being decommissioned
4The canary hosts
5Every database that is not in PCI scope
6Hosts that are in both us_east and pci
7Everything that belongs to at least one group
8US web servers, excluding canaries and decommissioning hosts
9Every cache host, matched by name rather than group
10The first five hosts of us_web, in inventory order
11Caches in both regions
12Hosts that belong to no region

Task 3: Verify each prediction

Run each pattern and record the actual count. Quote every pattern in single quotes — ! triggers history expansion in an interactive bash shell and & backgrounds the command.

INVENTORY=fleet.yml

# 1 — union of two groups
ansible -i "$INVENTORY" 'eu_web:us_web' --list-hosts | head -1

# 3 — union minus a cross-cutting group
ansible -i "$INVENTORY" 'eu_web:!decommissioning' --list-hosts | head -1

# 6 — intersection
ansible -i "$INVENTORY" 'us_east:&pci' --list-hosts | head -1

The intersection is worth seeing in full:

Read-only / Safecontroller
$ ansible -i fleet.yml 'us_east:&pci' --list-hosts
  hosts (3):
  db01.us.example.com
  db02.us.example.com
  db03.us.example.com

Question 12 is the one that reveals the orphans:

Read-only / Safecontroller
$ ansible -i fleet.yml 'all:!eu_west:!us_east' --list-hosts
  hosts (3):
  build01.example.com
  build02.example.com
  jump01.example.com

Three hosts you probably forgot were in the inventory at all. They are in all, so hosts: all reaches them. Every play that targets all has been touching your build agents and your jump host.

Task 4: The two that catch people

Run question 5 now, if you have not.

Read-only / Safecontroller
$ ansible -i fleet.yml 'eu_db:us_db:!pci' --list-hosts
[WARNING]: No hosts matched, nothing to do
hosts (0):

Zero. Every database in this estate is in PCI scope, so “databases not in PCI” is the empty set. The pattern is correct; the assumption behind the question was wrong.

Now question 10, the slice:

Read-only / Safecontroller
$ ansible -i fleet.yml 'us_web[0:4]' --list-hosts
  hosts (5):
  web01.us.example.com
  web02.us.example.com
  web03.us.example.com
  web04.us.example.com
  web05.us.example.com

Five hosts, not four. Ansible’s host-range slice is inclusive of both ends, unlike Python’s. [0:4] is elements 0 through 4. If you wanted four hosts you needed [0:3].

Task 5: Regex patterns and their trap

Question 9 asks for cache hosts matched by name. The ~ prefix makes the rest of the pattern a regular expression:

Read-only / Safecontroller
$ ansible -i fleet.yml '~cache[0-9]+\\..*' --list-hosts
  hosts (4):
  cache01.eu.example.com
  cache02.eu.example.com
  cache01.us.example.com
  cache02.us.example.com

Compare that with the group union, eu_cache:us_cache, which returns the same four hosts today.

They are not the same pattern. The group union means “the hosts we have decided are caches”. The regex means “the hosts somebody named cache-something”. The day a host is named cache03.eu.example.com and nobody adds it to eu_cache, the regex reaches it and the group does not — or the day a cache is renamed redis01, the group reaches it and the regex does not.

Task 6: Prove that --limit can only narrow

A persistent misconception is that --limit selects hosts. It does not — it intersects with whatever the play already targets.

INVENTORY=fleet.yml

# The play targets us_web. The limit names canary, which contains
# one EU host and one US host.
ansible -i "$INVENTORY" 'us_web' --limit 'canary' --list-hosts
Read-only / Safecontroller
$ ansible -i fleet.yml 'us_web' --limit 'canary' --list-hosts
  hosts (1):
  web01.us.example.com

One host, not two. web01.eu.example.com is in canary but not in us_web, so it is outside the play’s pattern and --limit cannot pull it in.

Task 7: Write the note

In answers.md, finish with three sentences naming the two questions whose obvious answer was wrong, and what each one taught you. If you predicted both correctly, name instead the two that would catch a colleague and why.

Validation

  • answers.md has a pattern, a predicted count and an actual count for all twelve target sets.
  • The actual counts are: 18, 12, 7, 2, 0, 3, 27, 8, 4, 5, 4, 3.
  • ansible -i fleet.yml 'eu_db:us_db:!pci' --list-hosts; echo $? prints a warning, hosts (0):, and 0.
  • ansible -i fleet.yml 'us_web[0:4]' --list-hosts returns five hosts.
  • ansible -i fleet.yml 'us_web' --limit 'canary' --list-hosts returns exactly one host.
  • ansible -i fleet.yml 'all:!ungrouped' --list-hosts | head -1 reports 27 — the 30 hosts minus the three orphans.

Expected Outcome

ansible-targeting-lab/
├── answers.md
└── fleet.yml

answers.md is a completed table plus a short written note. You can compose union, intersection, exclusion, regex and slice patterns without reaching for documentation, and you reach for --list-hosts reflexively before any pattern you have not run before. Nothing outside ~/ansible-targeting-lab has changed and no host was contacted.

Troubleshooting

bash: !pci: event not found. History expansion. Single-quote the pattern. This happens only in an interactive shell, which is exactly where you type ad-hoc patterns, so it will happen to you.

The command backgrounds itself and returns immediately. An unquoted & in an intersection pattern. Same fix.

hosts (30): for a pattern you expected to be narrow. Check whether you supplied any plain term at all. A pattern consisting only of exclusions — '!decommissioning' — implicitly targets all first, so it selects 27 hosts rather than none. Whitespace around separators is harmless: 'eu_web: !decommissioning' returns the same seven hosts as the unspaced form.

The regex matches nothing. The ~ must be the first character of the pattern with no space after it, and the expression is matched against the inventory hostname — which for these hosts includes the domain. '~cache' matches because Ansible uses a search, not a full match; '~^cache$' does not.

A host you expected is missing from a group union. Print the host’s memberships directly rather than guessing:

TARGET=web01.eu.example.com

ansible-inventory -i fleet.yml --host "$TARGET"
ansible-inventory -i fleet.yml --graph | grep -B5 "$TARGET" | head -20

Cleanup

Nothing outside the working directory was touched, no connection was made and no configuration was written. Confirm that, then remove the directory.

Step 1. Confirm no ansible.cfg was created in scope:

cd "$HOME/ansible-targeting-lab"
ansible --version | grep 'config file'

It should report the same value it reported before the lab — for most readers, config file = None. If it now names a file inside the lab directory, you created one; note what it set before deleting it, in case you meant to keep it.

Step 2. Keep the answer sheet. It is the deliverable, and it is 2 KB.

mkdir -p "$HOME/ansible-lab-deliverables"
cp -a answers.md "$HOME/ansible-lab-deliverables/targeting-answers.md"

Step 3. Remove the working directory, by absolute path:

rm -rf "$HOME/ansible-targeting-lab"

What You Learned

  • Term order does not matter, but the implicit all does. Ansible reorders every pattern into plain, then &, then !. A pattern with no plain term silently gets all prepended, so '!decommissioning' selects 27 hosts rather than none.
  • The empty target set exits 0 through ansible. Question 5 returned zero hosts and a green exit code. Any wrapper that trusts the exit status will report success for a run that did nothing.
  • Host slices are inclusive at both ends. [0:4] is five hosts. You found the off-by-one by running it rather than by an incident.
  • A regex over hostnames and a group are not interchangeable, even when they return the same list today. One tracks a naming convention, the other tracks a decision.
  • --limit narrows and can never widen. us_web limited to canary gave one host, not two, because the play’s pattern is the ceiling.
  • all is wider than you think. Three build and jump hosts sat outside every region group and inside every hosts: all play you would have written.

Deliverables

  • · An answer sheet with a written prediction and the verified result for each of twelve target sets
  • · A short note naming the two questions whose obvious answer is wrong, and why

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.