Skip to main content
RunBook Academy

AnsibleLII · Anti-PatternsAnti-patterns of reporting

Anti-pattern: encoding the inventory in when

Intermediate⏱ ~28 minbash

What you'll learn

  • Recognise inventory logic that has migrated into playbook conditionals
  • Explain why the migration destroys reviewability and makes check mode misleading
  • Move the logic back into groups, group variables and role defaults
  • Recognise the three related structural failures: no roles, trivial roles, and -e overriding everything

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.

Ansible gives you two places to express “which hosts get what”: the inventory, and conditionals in the play. The inventory is declarative, inspectable and reviewable. Conditionals are none of those, and they are always closer to hand.

So the logic migrates. One when: at a time, each perfectly reasonable in isolation.

What it looks like

Service impact possiblethe anti-pattern - a real shape from a real repository
- name: Configure the application
hosts: all
tasks:
  - name: Set the worker count
    ansible.builtin.template:
      src: app.conf.j2
      dest: /etc/app/app.conf
    vars:
      workers: "{{ 16 if inventory_hostname is match('web(0[1-9]|1[0-2])\\..*')
                   else 4 }}"
    when:
      - inventory_hostname is match('^(web|api)')
      - inventory_hostname not in ['web07.example.com', 'api03.example.com']
      - ansible_facts['distribution_major_version'] | int >= 9
      - not (inventory_hostname is match('.*-canary$') and skip_canary | default(false))

  - name: Apply the legacy tuning
    ansible.builtin.template:
      src: legacy-tuning.conf.j2
      dest: /etc/sysctl.d/99-app.conf
    when: inventory_hostname in ['web03.example.com', 'web04.example.com',
                                 'db01.example.com']
    notify: Restart app

Every line arrived for a reason. web07 had an incident. Version 8 hosts need different handling. The canary needed an escape hatch during a rollout that finished eighteen months ago.

The failure: nobody can answer the targeting question

The question this course opens with — which hosts does this change touch? — is unanswerable here without running the play.

--list-hosts does not help. It reports the play’s host pattern, which is all. It knows nothing about task-level conditionals.

--check on one host proves nothing about the others. The conditional depends on the hostname and on facts, so a successful check against web01 tells you nothing about web07, api03 or the version-8 hosts. Verifying the change means check-running the whole fleet and reading three hundred results.

Review is not possible. A reviewer would have to evaluate four conditions against three hundred hostnames and their facts, in their head. They will approve it instead.

The exclusion list is unmaintainable. web07 is excluded. Why? The commit message says “fix web07”. The person who wrote it has left. Is the reason still true? Nobody will ever remove it, and it will silently stop applying when web07 is rebuilt with a different name.

The corrected form

Move the logic into the inventory, where it is data.

Read-only / Safeinventory/hosts.yml - the conditions become groups
all:
children:
  app_tier:
    children:
      web:
        hosts:
          web[01:12].example.com:
      api:
        hosts:
          api[01:04].example.com:
  high_capacity:
    hosts:
      web[01:12].example.com:
  legacy_tuning:
    hosts:
      web03.example.com:
      web04.example.com:
      db01.example.com:
  excluded_from_app_config:
    # web07: hardware fault under investigation, ticket OPS-4412,
    # review 2026-09-01. api03: pending decommission.
    hosts:
      web07.example.com:
      api03.example.com:
Read-only / Safegroup_vars, and a play that reads
# group_vars/app_tier.yml
workers: 4

# group_vars/high_capacity.yml
workers: 16

# site.yml
- name: Configure the application
hosts: 'app_tier:!excluded_from_app_config'
tasks:
  - name: Set the worker count
    ansible.builtin.template:
      src: app.conf.j2
      dest: /etc/app/app.conf

- name: Apply the legacy tuning
hosts: legacy_tuning
tasks:
  - name: Install the sysctl drop-in
    ansible.builtin.template:
      src: legacy-tuning.conf.j2
      dest: /etc/sysctl.d/99-app.conf
    notify: Restart app

Now the question has an answer, before anything runs:

Read-only / Safethe targeting question, answered by a read-only command
# Which hosts are in each group?
ansible-inventory -i inventory/ --graph

# Exactly who does this play target, with the exclusion applied?
ansible-playbook site.yml --list-hosts

# What does a specific host resolve to?
ansible-inventory -i inventory/ --host web03.example.com

The exclusion is now a group with a comment carrying a ticket number and a review date. It is still an exception — but it is a documented, greppable, reviewable one, which is the whole difference.

Three structural failures that travel with it

One monolithic playbook, no roles

A 900-line site.yml with no roles has the same reviewability problem at a different scale: no unit of the automation has a name, an interface, defaults or a boundary, so nothing can be reasoned about, tested or reused in isolation. Part XXII covers the refactor.

Hundreds of trivial roles

The overcorrection, and it is genuinely worse in one respect. A role per task — install-nginx, start-nginx, configure-nginx — gives you all the ceremony of roles with none of the encapsulation, plus a dependency graph nobody can hold in their head and a requirements.yml with two hundred entries.

The heuristic that separates them: a role should own a service or a concern, and should be usable on its own. nginx is a role. start-nginx is a task.

-e overriding everything

Extra-vars sit at the top of the precedence order and cannot be overridden by anything. That makes -e the universal fix for a variable that resolved wrongly — and the reason nobody can predict what a value will be.

Service impact possiblethe invocation that defeats every layer below it
ansible-playbook site.yml -e workers=32 -e app_version=2.1.0   -e skip_canary=true -e max_hosts=9999

The failure. The run happened with values that exist only in one person’s shell history. The repository does not describe what was deployed. A rerun without the same flags produces a different result, which means the run is not reproducible — and when somebody reruns it to recover from an incident, they get the un-overridden values.

The corrected form. Put the value where its precedence layer belongs: a group variable if it is a property of those hosts, a play vars: if it is a property of that play, a role default if it is an interface. Reserve -e for genuinely per-invocation values — a version being deployed, an acknowledgement flag — and pass them from a file that is committed:

Service impact possiblean override that leaves a record
# releases/2026-08-11-app-2.1.0.yml holds the values, and is committed.
ansible-playbook site.yml --limit app_prod   -e @releases/2026-08-11-app-2.1.0.yml

Part XIII covers precedence in full, including the incident lesson on what happens when it is misused.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play uses `hosts: all` with four task-level conditions including hostname regexes and an exclusion list. Why does `--check` on one host fail to validate the change?

  2. Q2. Which properties does moving targeting logic from conditionals into inventory groups restore? Select all that apply.

  3. Q3. Any `when:` condition in a playbook is an instance of this anti-pattern and should be replaced by inventory groups.

  4. Q4. A deployment is run with `-e workers=32 -e app_version=2.1.0 -e skip_canary=true`. What is the specific problem?

Passing score: 75%. Answers are checked in this browser.