Skip to main content
RunBook Academy

AnsibleV · Inventory Design at Fleet ScaleFleet taxonomy

Role groups and who owns them

Intermediate⏱ ~17 minansible-core

What you'll learn

  • Name role groups by service function rather than by installed software
  • Attach an owning team, escalation path and review requirement to every service group
  • Apply naming rules that do not require renaming when the estate grows
  • Fail a run that targets a host belonging to no owned service group

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.

The role dimension is the one people get to first, because it is the one that maps to the work. You write a play to configure web servers, so you need a group of web servers.

It is also the dimension that ages worst, for two reasons: role names get chosen after the software rather than the service, and role groups get created without anybody being responsible for them. Both are cheap to fix on the day the group is created and expensive to fix five years later.

Name the service, not the package

The instinct is to call the group after what is installed on the hosts: nginx, postgres, redis, haproxy. It reads accurately today, and it is wrong in a specific way.

A group name is a contract about purpose. The hosts in the group exist to serve HTTP, or to store the primary customer database, or to terminate TLS at the edge. The software implementing that purpose is an implementation detail that will change — you will migrate from nginx to something else, or run both during a transition — and when it does, either you rename a group that is referenced in forty places, or you keep a group called nginx full of hosts that no longer run nginx.

web            not   nginx
database       not   postgres
cache          not   redis
edge_proxy     not   haproxy
message_queue  not   rabbitmq

Four naming rules

These exist because each one has a specific failure attached.

1. Singular function nouns, consistently. web, not webs, not webservers, not web_servers, and above all not two of those in the same inventory. Ansible does not care; humans typing patterns at 03:00 do, and websevers matching nothing is a warning you will read as a successful run. Pick one form, write it in the repository README, and enforce it in review — the point is consistency, not which form you chose.

2. No environment inside a role name. prod_web re-fuses two dimensions that lesson 1 separated, and it multiplies as environments multiply. If a role genuinely behaves differently in production, that is a variable in group_vars/production.yml, not a different group.

3. No hostnames or counts inside a group name. web_a1_a2 and web_pair both encode membership in the name, so the name is wrong as soon as a third host is added — and nothing will tell you, because a group name is just a string.

4. Valid Python identifiers. Underscores, not hyphens, and never start with a digit. edge_proxy can be written groups.edge_proxy in Jinja; edge-proxy forces groups['edge-proxy'] everywhere and will eventually be written the wrong way in a template that only fails on the host where it matters.

Every group has an owner

This is the part that is usually missing, and it is the part that decides whether the inventory is a description of the fleet or a governance artefact.

For each service group, three facts should be recorded next to the group itself:

  • Which team owns the service. Not a person — people change teams.
  • How that team is reached when a change to this group goes wrong.
  • Whether changes to this group require their review before merge.

They live in the group’s group_vars file, because that file is already the thing a reviewer opens when a change touches the group:

# inventory/production/group_vars/web.yml
---
# --- ownership -------------------------------------------------------
service_owner_team: platform-web
service_escalation: '#page-platform-web'
service_review_required: true
service_change_window: 'Tue-Thu 10:00-16:00 UTC'

# --- behaviour -------------------------------------------------------
http_worker_processes: auto
http_keepalive_timeout: 65

Two properties make this worth doing rather than writing in a wiki. It is in the same commit as any change to the group’s behaviour, so it cannot drift out of date without somebody seeing it. And it is available at run time, which makes the next section possible.

Refusing to run against an unowned host

Ownership metadata that nothing checks decays. Because the variables resolve at run time, a play can assert on them, which turns “this group has no owner” from a thing somebody might notice into a run that stops:

---
- name: Every targeted host must belong to an owned service group
  hosts: all
  gather_facts: false
  tasks:
    - name: Refuse to proceed without a named owning team
      ansible.builtin.assert:
        that:
          - service_owner_team is defined
          - service_owner_team | default('') | length > 0
        fail_msg: >-
          {{ inventory_hostname }} belongs to no group declaring
          service_owner_team. Add it to a service group, or give that
          group an owner.
        quiet: true

Run against an inventory where cache-a1 was added to a group nobody gave an owner:

Read-only / Safeassert runs on the controller
$ ansible-playbook -i inventory/production ownership-check.yml
fatal: [cache-a1.example.com]: FAILED! => {"assertion": "service_owner_team is defined", "changed": false, "evaluated_to": false, "msg": "cache-a1.example.com belongs to no group declaring service_owner_team. Add it to a service group, or give that group an owner."}
ok: [db-a1.example.com]

PLAY RECAP *********************************************************************
cache-a1.example.com       : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
db-a1.example.com          : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web-a1.example.com         : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Because it makes no connection, this is a cheap CI job: run it against each environment inventory on every pull request and an unowned host can never reach main.

Finding the groups nobody owns

Two audits are worth running periodically. Neither needs a connection.

Read-only / Safegroups defined in the inventory
ansible-inventory -i inventory/production --list \
| python3 -c 'import json,sys; [print(g) for g in sorted(json.load(sys.stdin)) if g != "_meta"]'

Compare that list against ls inventory/production/group_vars/. A group with no group_vars file has no owner, no behaviour and no reviewer — it is either a mistake or a group that should be deleted.

The second audit is the inverse: which groups are actually named by a play?

Read-only / Safegroups referenced by playbooks
grep -rhoE '^\s+hosts:\s+.*' --include='*.yml' playbooks/ \
| sed -E 's/^\s+hosts:\s+//' | sort | uniq -c | sort -rn

A group that no play targets is not necessarily wrong — some groups exist only to carry variables — but it should be a deliberate decision rather than a leftover.

Knowledge check

Knowledge check · 4 questions

  1. Q1. Why does this course prefer a role group named web over one named nginx?

  2. Q2. Which group names violate the naming rules in this lesson? Select all that apply.

  3. Q3. The right way to find every host running a vulnerable version of nginx during a CVE response is to maintain an nginx inventory group.

  4. Q4. An assert task tests that service_owner_team is defined, and also references that variable in success_msg. Against a host where it is undefined, what does the operator actually see on ansible-core 2.21.3?

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