AnsibleXV · Conditionals and LoopsConditionals
Conditionals that should have been groups
What you'll learn
- Recognise a when: chain that encodes group membership
- Refactor branching task logic into groups plus group_vars
- Explain why --limit can target a group and cannot target a conditional
- Choose between task when, inventory group and separate play for a given requirement
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
Part XV closes with a design argument rather than a feature.
Here is a task from a real-shaped repository:
- name: Restart the collector on hosts that need the new agent
ansible.builtin.systemd_service:
name: metrics-collector
state: restarted
when:
- ansible_facts.os_family == 'Debian'
- ansible_facts.distribution_major_version | int >= 12
- "'legacy' not in group_names"
- inventory_hostname not in metrics_collector_exclusions
- metrics_agent_version is version('2.0', '>=')Five conditions. It parses, it reviews plausibly, and it restarts a service.
Now answer a question an operator must be able to answer before running it: which hosts does this restart?
You cannot. Not from reading it, not from --list-hosts, not from
ansible-inventory. The only way to find out is to run it and read
what happened afterwards — which is the wrong order for a task that
restarts things.
The problem stated precisely
--limit and host patterns operate on inventory. They can express
“the webservers group”, “everything in prod except db”, “hosts
matching web*”.
when: operates on evaluation at task time, per host, after the
play has already targeted that host, gathered its facts, and started
running tasks against it.
So a decision written as when: is invisible to every targeting tool
the course has taught you. ansible-playbook --list-hosts shows the
hosts the play targets, not the hosts the task will act on. Your
pre-flight blast-radius check reports a number that is not the number.
The refactor
Every condition in that chain answers a question about the host, not about the moment. Which means every one of them can be answered when the inventory is written.
all:
children:
metrics_collectors_v2:
hosts:
app01.example.com:
app02.example.com:
app03.example.com:
metrics_collectors_legacy:
hosts:
app04.example.com:
app05.example.com:- name: Restart the collector
hosts: metrics_collectors_v2
serial: 2
tasks:
- name: Restart the collector
ansible.builtin.systemd_service:
name: metrics-collector
state: restartedThe task now has no when: at all, and every tool works again:
$ ansible-playbook --list-hosts restart-collectors.ymlplaybook: restart-collectors.yml
play #1 (metrics_collectors_v2): Restart the collector TAGS: []
pattern: ['metrics_collectors_v2']
hosts (3):
app01.example.com
app02.example.com
app03.example.com$ ansible-inventory -i inventory/production.yml --graph metrics_collectors_v2@metrics_collectors_v2:
|--app01.example.com
|--app02.example.com
|--app03.example.comThree hosts. Stated before the run, verifiable by two independent
tools, and available to --limit and serial.
Where the policy goes
Moving membership to groups leaves the values to group_vars, which
is the layering rule from Part XIII doing exactly what it was designed
for:
metrics_agent_version: '2.4.1'
metrics_collector_interval: 15
metrics_collector_endpoint: 'https://metrics.example.com/ingest'metrics_agent_version: '1.8.9'
metrics_collector_interval: 60
metrics_collector_endpoint: 'https://metrics.example.com/ingest-v1'The role becomes unconditional. It reads its variables and applies them, and it does not know or care which environment it is in. That is the property that makes a role reusable, and it is why Part XXII treats role interfaces as a design question rather than a filing one.
When when: is still right
This is not an argument against conditionals. It is an argument about which kind of decision belongs where.
Keep when: for genuine runtime state. Something only discoverable
on the host, at that moment:
- name: Reboot to activate the new kernel
ansible.builtin.reboot:
when: reboot_required.stat.exists
Whether a reboot flag file exists is not knowable when the inventory is written. That is a real conditional.
Keep when: for a result you just registered. Lesson 2’s territory.
The value came from a task in this run.
Keep when: for a single, stable, mechanical fact. One
os_family branch inside a role that must work on both families is
fine, and pushing it into inventory would mean every consumer of the
role maintaining a debian_hosts group, which is worse.
The test is the same as Part XIV lesson 7’s, applied to conditions rather than values:
Could you have known, before the run started, whether this task applies to this host?
If yes, it is group membership written in the wrong place.
The decision table
Three ways to express “this applies to some hosts and not others”:
Task when: | Inventory group | Separate play | |
|---|---|---|---|
| Where the decision is visible | In the task body, at run time | In the inventory, before the run | In the playbook hosts: line |
Can --list-hosts show the affected set? | No | Yes | Yes |
Can --limit target it? | No | Yes | Yes |
Does serial batch the right population? | No | Yes | Yes |
| Cost of adding a variant | A branch in shared code | A file and a group | A play |
| Right for | Runtime state, registered results | Host properties and policy | Different work, different targets |
The third column deserves a word. A separate play is the right answer
when the work differs, not just the values — when the Debian path and
the RHEL path are genuinely different sequences rather than the same
sequence with different parameters. Two plays with clear hosts: lines
read better than one play with parallel branches throughout, and each
gets its own honest blast radius.
Doing the refactor safely
The refactor is behaviour-preserving in intent and it is still a change to which hosts get touched, so prove it:
- Enumerate the current behaviour. Run the existing play with the
conditional replaced by a
debugthat printsinventory_hostnamewhen the condition is true. That gives you the real affected set, from the current logic, without changing anything. - Build the group from that list. Not from your reading of the condition — from the output of step 1. The two differ more often than you expect, and the difference is the bug you are removing.
- Compare.
ansible-inventory --graph <newgroup>against the list from step 1. They must match exactly. - Check-mode the new play against a staging inventory and compare the changed set to a check-mode run of the old one.
- Delete the conditional and the exclusion variable. Leaving them in place “just in case” gives you two sources of truth, which is the condition you started with.
Step 1 is the one people skip and the one that finds things. A five-condition chain evaluated across 400 hosts frequently turns out to match a different set than anyone believed, and discovering that during a refactor is enormously cheaper than discovering it during an incident.
Knowledge check
Knowledge check · 4 questions
Q1. A task restarting a service is guarded by five conditions on facts and group membership. Why can an operator not determine the blast radius before running it?
Q2. Which of these is a genuine runtime conditional that should stay as a when: rather than becoming a group?
Q3. What does moving a targeting decision from a when: chain into an inventory group make possible? Select all that apply.
Q4. Because the refactor is behaviour-preserving, it is safe to build the new group directly from reading the conditions rather than from observing which hosts they currently match.
Passing score: 75%. Answers are checked in this browser.