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-core2.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:
$ 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 set | Pattern | Predicted | Actual |
|---|---|---|---|---|
| 1 | Every web server in either region | |||
| 2 | Everything in EU West | |||
| 3 | EU web servers that are not being decommissioned | |||
| 4 | The canary hosts | |||
| 5 | Every database that is not in PCI scope | |||
| 6 | Hosts that are in both us_east and pci | |||
| 7 | Everything that belongs to at least one group | |||
| 8 | US web servers, excluding canaries and decommissioning hosts | |||
| 9 | Every cache host, matched by name rather than group | |||
| 10 | The first five hosts of us_web, in inventory order | |||
| 11 | Caches in both regions | |||
| 12 | Hosts 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:
$ ansible -i fleet.yml 'us_east:&pci' --list-hosts hosts (3):
db01.us.example.com
db02.us.example.com
db03.us.example.comQuestion 12 is the one that reveals the orphans:
$ ansible -i fleet.yml 'all:!eu_west:!us_east' --list-hosts hosts (3):
build01.example.com
build02.example.com
jump01.example.comThree 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.
$ 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:
$ 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.comFive 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:
$ 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.comCompare 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
$ ansible -i fleet.yml 'us_web' --limit 'canary' --list-hosts hosts (1):
web01.us.example.comOne 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.mdhas 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):, and0.ansible -i fleet.yml 'us_web[0:4]' --list-hostsreturns five hosts.ansible -i fleet.yml 'us_web' --limit 'canary' --list-hostsreturns exactly one host.ansible -i fleet.yml 'all:!ungrouped' --list-hosts | head -1reports 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
alldoes. Ansible reorders every pattern into plain, then&, then!. A pattern with no plain term silently getsallprepended, 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.
--limitnarrows and can never widen.us_weblimited tocanarygave one host, not two, because the play’s pattern is the ceiling.allis wider than you think. Three build and jump hosts sat outside every region group and inside everyhosts: allplay you would have written.