Objective
By the end of this lab you will have written an inventory from a prose description of an estate, and — more importantly — you will have proved that the grouping Ansible resolved is the grouping you intended, using only commands that never open a connection. Every claim you make about which hosts a run will touch will be backed by command output, not by reading your own YAML back to yourself.
Architecture
The estate you are describing is the standard course topology: one controller and four managed nodes. Nothing connects during this lab, so the managed nodes need not exist yet.
controller (192.0.2.10)
|
+----------+----------+----------+
| | | |
node1 node2 node3 node4
192.0.2.11 192.0.2.12 192.0.2.13 192.0.2.14
web web db web (staging)
Everything you run happens on the controller. ansible-inventory and
--list-hosts parse the inventory, resolve group membership and variables,
and print the result. Neither one contacts a managed node — which is
precisely why they are the right tools for checking your work before the
first real run.
Requirements
- A controller with
ansible-core2.21.x installed. Verified against 2.21.3; the output in this lab was captured from that version. - Python 3.12 or newer on the controller.
- No SSH access to anything. No credentials. No
become. Nothing in this lab connects to a managed node. - Roughly 300 MB of disk for the working directory. No out-of-band access requirement: nothing here can lock you out of anything.
Scenario
You have inherited an estate from someone who left no inventory, only a paragraph in a wiki:
We run four boxes.
node1,node2andnode4serve HTTP;node3is the database.node1andnode2are production,node3is production, andnode4is the staging web host. Production talks to the database on port 5432; staging has its own database on the same box as the web server, on port 5433. Everything is Debian 12 exceptnode4, which someone rebuilt on Ubuntu 24.04 last year and never told anyone.
Your job is to turn that into an inventory, and then to demonstrate — with output, not assertion — that it says what the paragraph says.
Tasks
Task 1: Establish what configuration is actually in effect
Before writing an inventory, find out which ansible.cfg Ansible will
read. An inherited config can set inventory, host_key_checking or
forks in ways that will confuse everything that follows.
$ ansible --versionansible [core 2.21.3]
config file = None
configured module search path = ['/home/operator/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /opt/ansible/lib/python3.14/site-packages/ansible
ansible collection location = /home/operator/.ansible/collections:/usr/share/ansible/collections
executable location = /opt/ansible/bin/ansible
python version = 3.14.4config file = None is what you want here: no configuration is being
inherited from anywhere. If it names a file, read that file before
continuing, and record what it sets — you will need it in Cleanup.
Capture the starting state so you can restore it later:
WORKDIR="$HOME/ansible-inventory-lab"
mkdir -p "$WORKDIR/inventories/lab"
# Record the pre-lab config resolution. Cleanup compares against this.
ansible --version > "$WORKDIR/pre-lab-version.txt"
ansible-config dump --only-changed > "$WORKDIR/pre-lab-config.txt"
cd "$WORKDIR"
cat pre-lab-config.txt
An empty pre-lab-config.txt means nothing is overridden. Good.
Task 2: Write the inventory as YAML
Create inventories/lab/hosts.yml. Read the scenario paragraph one clause
at a time and make each clause a structural decision, not a comment.
# inventories/lab/hosts.yml
all:
children:
production:
children:
web:
hosts:
node1:
ansible_host: 192.0.2.11
node2:
ansible_host: 192.0.2.12
db:
hosts:
node3:
ansible_host: 192.0.2.13
vars:
db_port: 5432
staging:
children:
web_staging:
hosts:
node4:
ansible_host: 192.0.2.14
vars:
db_port: 5433
Two decisions are worth naming.
node4 is not in web. The paragraph says it serves HTTP, so the
temptation is to put it there. Resist it. web is what a production web
change targets; if node4 is in it, every production run reaches staging
too. Model the blast radius you want, then add a separate group for “all
things serving HTTP” if you genuinely need one.
The distro difference is a fact, not a group — yet. node4 runs a
different OS. That is discoverable at runtime from ansible_facts, and
encoding it as a group here means it is a claim you must maintain by hand.
Leave it out for now; you will meet the constructed inventory plugin
later, which derives such groups from facts instead.
Task 3: Prove the grouping
This is the point of the lab. Do not read your YAML back and nod at it — make Ansible tell you what it resolved.
$ ansible-inventory -i inventories/lab/hosts.yml --graph@all:
|--@ungrouped:
|--@production:
| |--@web:
| | |--node1
| | |--node2
| |--@db:
| | |--node3
|--@staging:
| |--@web_staging:
| | |--node4Read it against the paragraph. Three production hosts, two of them web.
One staging host. @ungrouped is empty, which tells you no host slipped
in without a group — a host in @ungrouped is almost always a typo in a
group name.
Now check the variables actually landed where you meant:
$ ansible-inventory -i inventories/lab/hosts.yml --host node1{
"ansible_host": "192.0.2.11",
"db_port": 5432
}And the staging host:
$ ansible-inventory -i inventories/lab/hosts.yml --host node4{
"ansible_host": "192.0.2.14",
"db_port": 5433
}db_port differs because the two hosts inherit from different parents.
That is the inventory doing the work you would otherwise do with when:
conditionals scattered across a playbook.
Task 4: Convert to INI and prove equivalence
You will meet INI inventories in inherited repositories for years yet. Write the same estate in INI form:
; inventories/lab/hosts.ini
[web]
node1 ansible_host=192.0.2.11
node2 ansible_host=192.0.2.12
[db]
node3 ansible_host=192.0.2.13
[web_staging]
node4 ansible_host=192.0.2.14
[production:children]
web
db
[staging:children]
web_staging
[production:vars]
db_port=5432
[staging:vars]
db_port=5433
Now prove the two forms resolve identically. Do not compare the files — they cannot be compared, they are different languages. Compare what Ansible resolved from each:
ansible-inventory -i inventories/lab/hosts.yml --list > /tmp/resolved-yaml.json
ansible-inventory -i inventories/lab/hosts.ini --list > /tmp/resolved-ini.json
diff -u /tmp/resolved-yaml.json /tmp/resolved-ini.json && echo 'EQUIVALENT'
On ansible-core 2.21.3 this diff is empty: the INI parser runs each value
through Python’s literal evaluation, so db_port=5432 arrives as the
integer 5432, exactly as YAML produced it.
Task 5: Answer the targeting questions with evidence
For each description below, write the pattern, then prove it with
--list-hosts. Write your answer before running the command; the point
of the exercise is calibrating your prediction against reality.
| # | Target set |
|---|---|
| 1 | Everything |
| 2 | Production only |
| 3 | Web servers in production |
| 4 | Everything except staging |
| 5 | Hosts that are in both production and web |
| 6 | node1 and node3 only, by name |
| 7 | Every host whose name starts with node and ends in an even digit |
| 8 | Staging web, excluding nothing |
Prove each one. --list-hosts resolves the pattern and prints the result
without connecting:
INVENTORY=inventories/lab/hosts.yml
ansible -i "$INVENTORY" 'production:!staging' --list-hosts
$ ansible -i inventories/lab/hosts.yml 'production:&web' --list-hosts hosts (2):
node1
node2The regex form for question 7 needs the ~ prefix, and the whole pattern
must be quoted so the shell does not touch it:
INVENTORY=inventories/lab/hosts.yml
ansible -i "$INVENTORY" '~node[0-9]*[24]$' --list-hosts
Task 6: Record the blast radius of a hypothetical run
The habit this lab is building: before any run, you can state how many hosts it touches and name them. Produce that statement as a file.
First, a playbook to have a run to describe. It does nothing; it exists so
ansible-playbook --list-hosts has something to parse:
# site.yml
- name: Placeholder play for targeting practice
hosts: production
gather_facts: false
tasks:
- name: Report which host this play selected
ansible.builtin.debug:
msg: "would run against {{ inventory_hostname }}"
Now capture the blast radius:
INVENTORY=inventories/lab/hosts.yml
PATTERN='production:!db'
{
echo "PATTERN: $PATTERN"
echo "DATE: $(date -Is)"
ansible -i "$INVENTORY" "$PATTERN" --list-hosts
} > blast-radius.txt
cat blast-radius.txt
That file is a change artefact. In a review, it is the difference between “I targeted the web servers” and “here are the two hostnames this touched”.
Validation
Work through each of these and confirm the stated result:
-
ansible-inventory -i inventories/lab/hosts.yml --graphshows five groups and four hosts, with@ungroupedempty. -
ansible-inventory -i inventories/lab/hosts.yml --host node1reportsdb_portas5432;--host node4reports5433. -
The
diff -ubetween the resolved YAML and resolved INI inventories printsEQUIVALENT, or you can name exactly which key differs and why. -
ansible -i inventories/lab/hosts.yml 'production:&web' --list-hostsreturns exactlynode1andnode2. -
An empty target set behaves differently depending on which command you ask.
ansible -i inventories/lab/hosts.yml 'all:!production:!staging' --list-hostsprints[WARNING]: No hosts matched, nothing to doandhosts (0):, and exits 0. The same limit throughansible-playbookprints[ERROR]: Specified inventory, host pattern and/or --limit leaves us with no hosts to target.and exits 1. Confirm both:ansible -i inventories/lab/hosts.yml 'all:!production:!staging' --list-hosts echo "ansible exit: $?" ansible-playbook -i inventories/lab/hosts.yml site.yml \ --limit 'all:!production:!staging' --list-hosts echo "ansible-playbook exit: $?"This matters in CI: a wrapper script that shells out to
ansibleand trusts the exit code will report success for a run that touched nothing. -
blast-radius.txtexists and names two hosts.
Expected Outcome
A working directory containing:
ansible-inventory-lab/
├── blast-radius.txt
├── inventories/
│ └── lab/
│ ├── hosts.ini
│ └── hosts.yml
├── pre-lab-config.txt
├── pre-lab-version.txt
└── site.yml
hosts.yml and hosts.ini describe the same estate and resolve to the
same JSON. You can state, with command output to back it, which hosts any
of eight patterns selects. Nothing on the system outside
~/ansible-inventory-lab has changed, and no managed node was contacted.
Troubleshooting
ansible-inventory prints nothing but @all and @ungrouped. The
file parsed, but nothing in it matched the expected structure. In YAML
inventories the top-level key must be a group name — usually all — and
hosts live under hosts:. A file that starts with a list (- node1) is
valid YAML and an empty inventory.
Every host lands in @ungrouped. You have hosts at the top level of
all: rather than inside children:. That is legal and occasionally what
you want, but it means no group targeting works.
Could not match supplied host pattern, ignoring: web. The group does
not exist under that name. Check for an underscore/hyphen mismatch —
web_staging and web-staging are different groups, and INI section
headers are case-sensitive.
The INI diff shows quotes around numbers. Expected; see the callout in
Task 4. Either accept it and compare with | string in your conditionals,
or move the variable to a YAML group_vars file where its type survives.
A pattern with ! does nothing. Your shell ate it. In interactive
bash, ! triggers history expansion. Single-quote the whole pattern:
'production:!staging', never production:!staging bare.
Cleanup
Nothing in this lab modified the system outside the working directory, and nothing connected to a managed node. Cleanup is therefore genuinely just removal — but confirm that before deleting, rather than assuming it.
Step 1. Confirm the config resolution is unchanged from what you captured in Task 1:
cd "$HOME/ansible-inventory-lab"
ansible-config dump --only-changed > /tmp/post-lab-config.txt
diff -u pre-lab-config.txt /tmp/post-lab-config.txt && echo 'CONFIG UNCHANGED'
If that diff is not empty, you created an ansible.cfg somewhere it is
being picked up. Find it before you delete the directory:
ansible --version | grep 'config file'
Step 2. Keep the deliverables if you want them; they are three small text files and they are the evidence that you did the lab.
mkdir -p "$HOME/ansible-lab-deliverables"
cp -a inventories/lab/hosts.yml inventories/lab/hosts.ini blast-radius.txt \
"$HOME/ansible-lab-deliverables/"
Step 3. Remove the working directory and the scratch files. The path is
fully qualified deliberately — a relative rm -rf run from the wrong
directory is how people lose work.
rm -rf "$HOME/ansible-inventory-lab"
rm -f /tmp/resolved-yaml.json /tmp/resolved-ini.json /tmp/post-lab-config.txt
What You Learned
- Translating prose to an inventory is a design act. Whether
node4joinswebis not a formatting question; it decides whether a production run reaches staging. You made that call explicitly. ansible-inventory --graphproves the grouping. You confirmed five groups and four hosts from output rather than from re-reading your own YAML, and an empty@ungroupedtold you no host had fallen through a typo.- Equivalence is proved on the resolved inventory, not the source. The
--listJSON diff is a real proof; comparing an INI file to a YAML file is not. It also surfaces INI’s string typing, which is invisible everywhere else until awhen:silently evaluates false. --list-hostsanswers the targeting question offline. You produced eight pattern answers with evidence and no credentials, and you saw that an empty target set exits non-zero rather than quietly doing nothing.- A blast-radius file is a change artefact. You can now name the hosts a run will touch before it runs, in a form somebody else can review.