Configuration management exists to make hosts the same. This anti-pattern
makes what “the same” means depend on the date the host was converged.
It enters at four layers, and most estates have it at all four without
anyone having chosen it.
Layer 1: state: latest in roles
Service impact possiblethe task, and what it actually means— SERVICE-IMPACT: upgrades a package and, with the handler, restarts the service. What version arrives depends on when the task runs.
state: latest does not mean “install it”. It means “upgrade it to
whatever the repository offers, right now”.
The consequences arrive as a set:
The converged state is not defined by the repository. Two hosts
converged a week apart hold different versions. Both runs succeeded and
both reported convergence.
Every run is a potential upgrade. A play intended to fix a config
typo can also bump nginx across three hundred hosts, because the task
was in the same role.
The handler fires on the upgrade. So the service restarts, on a run
nobody expected to restart anything.
The change is unannounced. No ticket, no window, no review — the
upstream maintainer’s release schedule is now your change schedule.
The corrected form is state: present for “ensure it is installed”,
and an explicit version where the version matters:
Configuration changepresent, and pinned where it matters— CONFIGURATION: installs if absent. The second form installs an exact version, so convergence means the same thing on every host regardless of date.
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Install the application at the version this release deploys
ansible.builtin.package:
name: 'app-server={{ app_server_version }}'
state: present
Upgrading then becomes a deliberate act: change app_server_version in
a commit, review it, and roll it out with the staging from lesson 3.
Part XL covers patching as its own scheduled activity, which is where
upgrades belong.
Layer 2: unpinned collections
Read-only / Safethree requirements.yml files, only one of which pins— Read-only: file contents. The first two re-resolve on every install; only the third is a specification.
# Unpinned - installs whatever is current today.
collections:
- name: community.general
# A range - narrower, and still not reproducible.
collections:
- name: community.general
version: '>=12.0.0,<13.0.0'
# Pinned - the same artefact every time.
collections:
- name: community.general
version: 12.0.0
The failure. A collection release changes a module’s default, adds a
parameter, or fixes a bug you were depending on. Nothing in your
repository changed, and the fleet’s behaviour did.
The version that makes this concrete: a module gains stricter validation
in a minor release, and a playbook that has run nightly for a year starts
failing at 02:00 with an error nobody has seen. Your first hypothesis
will be that something changed on the hosts. Nothing did.
Layer 3: no requirements.yml at all
The version of layer 2 where there is nothing to unpin. Collections were
installed by hand, on the controller, over several years.
The failure is the one Part L is built on. The controller is lost, or
a second one is built, and there is no definition to install from. The
collection set is unrecoverable knowledge, and the rebuild produces a
controller that works until the first play that needs the collection
somebody installed during an incident in March.
Layer 4: no controller pinning
ansible-core and its Python dependencies installed from whatever the
distribution or PyPI currently offers.
The failure is a behaviour difference between controllers, or across
time on the same controller. ansible-core minor releases deprecate
keywords, change module behaviour and occasionally alter defaults. The
porting guides exist precisely because these changes are real.
The estate this produces
Hosts differ by their build date, so "which version of nginx is on the fleet" has as many answers as there were provisioning waves.
Reproducing a bug in staging fails, because staging was built last month and production was built last year.
A rollback target cannot be named. "The previous version" is a different string on different hosts.
Incident timelines have an unexplained variable: something changed, and the change is not in any repository you control.
Compliance evidence is weak. "The estate runs approved versions" is unprovable if the versions were never declared.
This is the snowflake problem from Part I arriving through a different
door. Part I described snowflakes made by hand; this is the same
divergence produced by automation that ran correctly.
Finding it
Read-only / Safeaudit the four layers— READ-ONLY: greps and version reads only. Run all four - most estates have findings at every layer.
cd /srv/ansible
# Layer 1
grep -rn 'state:\s*latest' --include='*.yml' --include='*.yaml' .
# Layer 2 and 3
test -f requirements.yml && cat requirements.yml || echo 'NO requirements.yml'
ansible-galaxy collection list
# Layer 4
ansible --version | head -1
pip --disable-pip-version-check list 2>/dev/null | grep -i ansible
# The whole picture: what is actually installed versus what is declared.
ansible-lint --profile production
The comparison that matters is between ansible-galaxy collection list
and requirements.yml. Every installed collection missing from the
declaration is a rebuild failure that has not happened yet.
Knowledge check
Knowledge check · 4 questions
Q1. A role uses `state: latest` for nginx and notifies a restart handler. An engineer runs the play to fix a typo in an unrelated config file. What happens?
Q2. Which of these make a controller rebuild fail to reproduce the original environment? Select all that apply.
Q3. With unpinned collections, a fleet behaviour change can occur with no commit anywhere in your repository.
Q4. A team objects that pinning will leave the estate unpatched. What is the correct response?
Passing score: 75%. Answers are checked in this browser.