AnsibleV · Inventory Design at Fleet ScaleFleet taxonomy
Lifecycle stages and hosts you must not touch
What you'll learn
- Model host lifecycle as an inventory dimension rather than as tribal knowledge
- Record deliberate exceptions as groups with a reason, a ticket, an owner and an expiry
- Fail a run when an exception has expired instead of letting it become permanent
- Explain why an undocumented exception becomes a snowflake and an exception group does not
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
Environment, site and role are the dimensions people build first, because they are stable. Lifecycle is the one people leave out, because it is not: a host moves through it several times in its life, and the movements are operational events rather than design decisions.
Leaving it out does not remove it. It relocates it — into a colleague’s
memory, into a comment on a ticket, into a when: clause somebody added
during an incident and nobody removed.
The lifecycle dimension
A host is always in exactly one of these states, and the state determines what automation is allowed to do to it:
| Stage | Meaning | What automation may do |
|---|---|---|
building | Provisioned, not yet configured | Everything. There is nothing to break. |
burn_in | Configured, under test, no real traffic | Everything, including disruptive tests. |
live | Serving production traffic | Only changes that respect the rollout rules. |
draining | Being removed from service, still up | Read-only, or the drain procedure itself. |
decommissioned | Retired, may still answer SSH | Nothing. |
The value of writing it down is that it converts a judgement into a lookup. “Can I reboot web-a2?” stops being a question you ask in a chat channel and becomes a group membership that a play can read.
It also gives every play a sane default target. live is not the same set
as all, and most plays want live:
$ ansible -i inventory/production 'all:!maintenance_hold:!do_not_patch' --list-hosts hosts (2):
web-a1.example.com
web-a2.example.comExceptions are inevitable
Every estate over about fifty hosts has machines that are deliberately different:
- The database that must not be rebooted this quarter because the vendor is investigating a firmware fault.
- The appliance whose package set the vendor owns, where a routine
apt upgradevoids support. - The host running the one application that has not been migrated, which needs an ancient library the standard build removed.
- The machine somebody is actively debugging, right now, and must not have its configuration converged out from under them.
None of these are failures of discipline. They are operations. The failure of discipline is in how they get recorded.
How an exception becomes a snowflake
The undocumented version has a predictable shape:
# Somebody adds this during an incident. It is correct at the time.
- name: Apply security updates
ansible.builtin.package:
name: '*'
state: latest
when: inventory_hostname != 'db-a3.example.com'
Six months later, db-a3 has missed twenty patch cycles. Nobody knows
why the exclusion is there, so nobody dares remove it. The person who
added it has changed teams. It is now a snowflake, and the only record of
its existence is one line in a file nobody greps.
Worse, the exclusion is invisible to every tool you have. It is not in the
inventory, so --list-hosts still shows db-a3 in the target set. The
run still reports ok for it. The blast-radius answer is wrong and the
recap agrees with the wrong answer.
Exception groups instead
Make the exception a group. It costs the same three lines and it is visible to everything.
# inventory/production/hosts.yml
all:
children:
live:
hosts:
web-a1.example.com:
draining:
hosts:
web-a2.example.com:
maintenance_hold:
hosts:
db-a1.example.com:
do_not_patch:
hosts:
appliance-a1.example.com:
# inventory/production/group_vars/maintenance_hold.yml
---
exception_reason: 'Vendor investigating a storage firmware fault; no reboots.'
exception_ticket: 'OPS-4471'
exception_owner_team: data-platform
exception_expires: '2026-06-30'
Four fields, each answering a question that will be asked:
exception_reason— why this host is different, in a sentence somebody unfamiliar can act on.exception_ticket— where the decision was made and the discussion lives.exception_owner_team— who to ask whether it still applies.exception_expires— the date on which the exception stops being assumed and starts being questioned.
The expiry is the field that does the work. Without it, an exception is permanent by default, and permanence by default is exactly how a temporary vendor hold becomes a machine three years behind on patches.
Making the expiry mean something
An expiry date that nothing reads is a comment. Because group_vars
resolve at run time, a play can check them, and because assert runs on
the controller it can be a CI job that never touches a managed node.
---
- name: Exceptions must be documented and must expire
hosts: maintenance_hold:do_not_patch
gather_facts: false
tasks:
- name: Every exception carries a reason, a ticket, an owner and an expiry
ansible.builtin.assert:
that:
- exception_reason is defined
- exception_ticket is defined
- exception_owner_team is defined
- exception_expires is defined
fail_msg: '{{ inventory_hostname }} is in an exception group with incomplete metadata'
quiet: true
- name: Expired exceptions must be renewed or removed
ansible.builtin.assert:
that:
- exception_expires >= now(utc=true, fmt='%Y-%m-%d')
fail_msg: >-
Exception for {{ inventory_hostname }} expired on
{{ exception_expires }} (ticket {{ exception_ticket }},
owner {{ exception_owner_team }}). Renew it or remove the host
from the exception group.
quiet: true
Run it after the expiry date has passed:
$ ansible-playbook -i inventory/production exception-audit.ymlTASK [Every exception carries a reason, a ticket, an owner and an expiry] ******
ok: [db-a1.example.com]
ok: [appliance-a1.example.com]
TASK [Expired exceptions must be renewed or removed] ***************************
fatal: [db-a1.example.com]: FAILED! => {"assertion": "exception_expires >= now(utc=true, fmt='%Y-%m-%d')", "changed": false, "evaluated_to": false, "msg": "Exception for db-a1.example.com expired on 2026-06-30 (ticket OPS-4471, owner data-platform). Renew it or remove the host from the exception group."}
ok: [appliance-a1.example.com]
PLAY RECAP *********************************************************************
appliance-a1.example.com : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
db-a1.example.com : ok=1 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0The now() call is evaluated on the controller at run time, and the
%Y-%m-%d format makes a plain string comparison correct for ISO dates,
so no date parsing is needed.
Notice what this changes politically as much as technically. Renewing the exception is now a commit: somebody has to change a date, and somebody else has to approve it. That is a much healthier default than an exclusion that renews itself by being ignored.
Decommissioned hosts: keep or delete?
There is a genuine trade-off here and the course will not pretend otherwise.
Keeping them in a decommissioned group means the hostname still
resolves to something, so a stale reference in an old runbook produces a
host in an inert group rather than a confusing “no hosts matched”. It also
preserves the record that the machine existed, which matters during audit.
Deleting them means the inventory describes only what exists, so the host count is the truth and nobody can accidentally target a machine that was returned to the leasing company.
The workable compromise is time-boxed: move retired hosts into
decommissioned with a removal date recorded the same way an exception
expiry is, and delete them from the inventory when that date passes. The
group is a staging area, not a graveyard. What you must not do is leave
them in live because deleting entries feels risky — a decommissioned
host inside live is a host that fleet-wide plays will keep trying to
reach, and it will train your team to ignore unreachable hosts in the
recap.
Knowledge check
Knowledge check · 4 questions
Q1. Excluding a host with when: inventory_hostname != "db-a3.example.com" is weaker than an exception group partly because --list-hosts still reports db-a3 inside the target set.
Q2. Which field turns an exception group from a record into a control?
Q3. Why does this lesson recommend separate maintenance_hold and do_not_patch groups instead of one exceptions group? Select all that apply.
Q4. Why does this lesson prefer pattern-level exclusion over a task-level when: exception_reason is not defined guard as the primary control?
Passing score: 75%. Answers are checked in this browser.