Skip to main content
RunBook Academy

AnsibleXXIX · Dynamic InventoryOperating a dynamic source

When new hosts appear on their own

Advanced⏱ ~28 minbashdiff

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

Not yet marked complete on this device.

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 the web group. 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.

Read-only / Safesnapshot the effective host list
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:

Read-only / Safeone host appeared
$ diff inventory-snapshots/yesterday.txt inventory-snapshots/today.txt
4a5
> web04.example.com

comm is better for scripting, because it separates additions from removals and gives you each as a plain list:

Read-only / Safeadditions only
$ comm -13 inventory-snapshots/yesterday.txt inventory-snapshots/today.txt
web04.example.com

Make the delta fail something

A snapshot nobody reads is a file. The value comes from the comparison being enforced:

Read-only / Safean inventory delta gate
#!/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:

Read-only / Safebaseline, quiet, then the signal
$ inventory-gate.sh all; inventory-gate.sh all; inventory-gate.sh all
no baseline; establishing one
rc=0

inventory unchanged: 6 hosts
rc=0

inventory changed since the last approved snapshot
ADDED:
web06.example.com
rc=1

Two 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”.

Read-only / Safethe same estate, two grouping rules
# 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.

  1. 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>.
  2. Diff that file against the last approved snapshot, and stop if it differs.
  3. 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.
  4. 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.
  5. 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

  1. 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?

  2. Q2. Why does the inventory delta script refuse to update its baseline automatically?

  3. Q3. Which of these make an inventory delta gate more likely to stay useful rather than being rubber-stamped? Select all that apply.

  4. Q4. Choosing opt-in grouping is only an improvement if you also run a reconciliation report for machines that were never enrolled.

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