AnsibleXXIX · Dynamic InventoryOperating a dynamic source
When new hosts appear on their own
What you'll learn
- Explain how blast radius can grow with no change to any reviewed artefact
- Build an inventory snapshot and diff that turns host-list growth into a signal
- Distinguish opt-in from opt-out grouping and choose deliberately
- Design a change process where the host list is reviewed like code
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
This is the lesson the part exists for.
Every safety practice in this course so far rests on an assumption so basic that it is rarely stated: that a change in what your automation does is preceded by a change you can see. Somebody edits a playbook. Somebody edits an inventory file. There is a commit, a diff, a reviewer, a timestamp.
A dynamic inventory breaks that assumption completely.
A new instance is created, tagged
role=web, and joins thewebgroup. Your playbook is unchanged. Your inventory config is unchanged. Your repository has no new commits. The next scheduled run configures a machine you have never seen, and there was nothing to review.
Nothing failed. Nothing was misconfigured. The system did precisely what it was built to do, and the blast radius grew.
The incident, in the order it actually happens
The version of this that reaches a post-mortem usually runs like this.
Monday. A patching playbook targets linux_servers and reboots what
it patches. It has run every week for eight months without incident.
Tuesday. A different team stands up a new database server. They tag
it according to the organisation’s tagging standard, which includes
os=linux. They have never heard of your playbook. Nothing they did was
wrong.
Wednesday, 02:00. The dynamic inventory maps os=linux into
linux_servers. The new database joins. It is patched and rebooted
during its first business day of production traffic.
Wednesday, 09:00. Somebody opens git log on the Ansible repository
looking for what changed. Nothing changed. That is the part that costs
the most time, because every instinct says a change must have caused
this, and the evidence trail is not in git at all. It is in the
inventory.
Defence one: keep the host list, and diff it
The host list is data. Snapshot it, and a change in the fleet becomes a diff like any other.
SNAP_DIR=/var/lib/ansible/inventory-snapshots
mkdir -p "$SNAP_DIR"
ansible -i inventory/ 'linux_servers' --list-hosts --flush-cache | tail -n +2 | tr -d ' ' | sort > "$SNAP_DIR/today.txt"Compare it with yesterday’s, and the fleet change is visible as text:
$ diff inventory-snapshots/yesterday.txt inventory-snapshots/today.txt4a5
> web04.example.comcomm is better for scripting, because it separates additions from
removals and gives you each as a plain list:
$ comm -13 inventory-snapshots/yesterday.txt inventory-snapshots/today.txtweb04.example.comMake the delta fail something
A snapshot nobody reads is a file. The value comes from the comparison being enforced:
#!/usr/bin/env bash
set -euo pipefail
SNAP_DIR=/var/lib/ansible/inventory-snapshots
PATTERN=${1:?usage: inventory-gate.sh <pattern>}
PREV="$SNAP_DIR/previous.txt"
CURR="$SNAP_DIR/current.txt"
ansible -i inventory/ "$PATTERN" --list-hosts --flush-cache | tail -n +2 | tr -d ' ' | sort > "$CURR"
if [ ! -f "$PREV" ]; then
echo "no baseline; establishing one"
cp "$CURR" "$PREV"
exit 0
fi
added=$(comm -13 "$PREV" "$CURR")
removed=$(comm -23 "$PREV" "$CURR")
if [ -n "$added" ] || [ -n "$removed" ]; then
echo "inventory changed since the last approved snapshot"
[ -n "$added" ] && printf 'ADDED:\n%s\n' "$added"
[ -n "$removed" ] && printf 'REMOVED:\n%s\n' "$removed"
exit 1
fi
echo "inventory unchanged: $(wc -l < "$CURR") hosts"Three runs against a source that gained a host between the second and the third:
$ inventory-gate.sh all; inventory-gate.sh all; inventory-gate.sh allno baseline; establishing one
rc=0
inventory unchanged: 6 hosts
rc=0
inventory changed since the last approved snapshot
ADDED:
web06.example.com
rc=1Two design decisions in that script are worth defending.
It fails on removals too, not only additions. A host disappearing is either a decommission you know about or a source returning partial results, and the second one is the more dangerous. Treating both directions as “tell me” costs nothing.
Updating the baseline is a deliberate act. previous.txt is only
overwritten by a human — or by a pipeline step that requires an
approval. That is what makes it a review gate rather than a log line. A
script that silently rebaselines on every run has all the cost of this
control and none of the benefit.
Defence two: opt in, do not opt out
The Monday-to-Wednesday incident above has a design cause, and it is the grouping rule.
Opt-out grouping: a host is in scope unless something excludes it.
os=linux maps to linux_servers. Every new Linux machine anybody
creates, anywhere in the account, is automatically in scope for
everything that targets linux_servers. The default is “managed”.
Opt-in grouping: a host is in scope only if something explicitly
includes it. A host joins linux_servers only when it carries
ansible_managed=true, applied by someone who knows what that means.
The default is “not managed”.
# Opt-out: everything Linux is in scope. New machines join silently.
groups:
linux_servers: provider_tags.os == 'linux'
# Opt-in: only machines explicitly enrolled are in scope.
groups:
linux_servers: >-
provider_tags.os == 'linux'
and provider_tags.get('ansible_managed', 'false') == 'true'The cost of opt-in is real and worth stating honestly: a machine that should have been patched and was not tagged goes unpatched, and that is also an incident. You are choosing which failure you would rather have.
The reason production estates should still choose opt-in is asymmetry. A machine missing from automation is a gap you find with a reconciliation report comparing the provider’s full list against the managed list — the subject of the source-of-truth lesson in Part V, and a report you can run daily. A machine unexpectedly in automation is a reboot during business hours, and you find it by having caused it.
Defence three: treat the host list as a reviewable artefact
The deepest fix is to stop letting an unreviewed host list reach a change at all.
- Query the inventory as a separate, earlier step than the change, and write the result to a file: ansible-inventory --list --flush-cache --limit <target-group>.
- Diff that file against the last approved snapshot, and stop if it differs.
- Attach the file to the change ticket. It is the answer to "which hosts" in a form that can be read, counted and disagreed with.
- Convert it into a limit file and run the change with --limit @that-file, so the run targets the reviewed list rather than re-querying and possibly getting a different answer.
- Update the approved snapshot as an explicit step after the change, not automatically.
Step four is the one that closes the loop, and it is the subject of Part
XXX lesson 4. A run that re-queries the provider at execution time can
target a host list that differs from the one reviewed ten minutes
earlier. A run pinned to --limit @approved-hosts.txt cannot: the list
was frozen at review time, and a host created since then is simply not
in the file.
That is as close as a dynamic inventory gets to the property a static inventory has for free — that the thing you approved is the thing that runs.
Knowledge check
Knowledge check · 5 questions
Q1. A patching playbook that has run cleanly for eight months reboots a production database. git log on the Ansible repository shows no commits for three weeks. What is the most likely cause?
Q2. Why does the inventory delta script refuse to update its baseline automatically?
Q3. Which of these make an inventory delta gate more likely to stay useful rather than being rubber-stamped? Select all that apply.
Q4. Choosing opt-in grouping is only an improvement if you also run a reconciliation report for machines that were never enrolled.
Q5. Why can Ansible not warn you that the resolved host list is larger than usual?
Passing score: 75%. Answers are checked in this browser.