Skip to main content
RunBook Academy

← All labs in Ansible

Lab · intermediate · ~60 min

Lab: Build an inventory and prove it before you run anything

C · SimulationB · Nested virtualisation

Objectives

  • Translate a written description of an estate into a YAML inventory with groups, children and group variables
  • Prove the resulting host-to-group mapping with ansible-inventory --graph and --list
  • Convert the same estate to INI and prove the two forms are equivalent by diffing the resolved inventory
  • Use --list-hosts to answer "what would this run touch" without connecting to anything

Prerequisites

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-core 2.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, node2 and node4 serve HTTP; node3 is the database. node1 and node2 are production, node3 is production, and node4 is 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 except node4, 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.

Read-only / Safecontroller
$ ansible --version
ansible [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.4

config 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.

Read-only / Safecontroller
$ ansible-inventory -i inventories/lab/hosts.yml --graph
@all:
|--@ungrouped:
|--@production:
|  |--@web:
|  |  |--node1
|  |  |--node2
|  |--@db:
|  |  |--node3
|--@staging:
|  |--@web_staging:
|  |  |--node4

Read 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:

Read-only / Safecontroller
$ ansible-inventory -i inventories/lab/hosts.yml --host node1
{
  "ansible_host": "192.0.2.11",
  "db_port": 5432
}

And the staging host:

Read-only / Safecontroller
$ 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
1Everything
2Production only
3Web servers in production
4Everything except staging
5Hosts that are in both production and web
6node1 and node3 only, by name
7Every host whose name starts with node and ends in an even digit
8Staging 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
Read-only / Safecontroller
$ ansible -i inventories/lab/hosts.yml 'production:&web' --list-hosts
  hosts (2):
  node1
  node2

The 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 --graph shows five groups and four hosts, with @ungrouped empty.

  • ansible-inventory -i inventories/lab/hosts.yml --host node1 reports db_port as 5432; --host node4 reports 5433.

  • The diff -u between the resolved YAML and resolved INI inventories prints EQUIVALENT, or you can name exactly which key differs and why.

  • ansible -i inventories/lab/hosts.yml 'production:&web' --list-hosts returns exactly node1 and node2.

  • An empty target set behaves differently depending on which command you ask. ansible -i inventories/lab/hosts.yml 'all:!production:!staging' --list-hosts prints [WARNING]: No hosts matched, nothing to do and hosts (0):, and exits 0. The same limit through ansible-playbook prints [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 ansible and trusts the exit code will report success for a run that touched nothing.

  • blast-radius.txt exists 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 node4 joins web is not a formatting question; it decides whether a production run reaches staging. You made that call explicitly.
  • ansible-inventory --graph proves the grouping. You confirmed five groups and four hosts from output rather than from re-reading your own YAML, and an empty @ungrouped told you no host had fallen through a typo.
  • Equivalence is proved on the resolved inventory, not the source. The --list JSON 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 a when: silently evaluates false.
  • --list-hosts answers 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.

Deliverables

  • · inventories/lab/hosts.yml describing the estate
  • · inventories/lab/hosts.ini, proven equivalent by a resolved-inventory diff
  • · A written answer sheet mapping eight target descriptions to host patterns

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.