Files
computer-playbook/docs/contributing/design/inventory.md
kevinveenbirkenbachandClaude Fable 5.1 113113e553 perf(applications): render the applications SPOT once per play instead of once per task
Ansible forks a fresh worker process for every task, so the in-process
cache behind lookup('applications') was cold in every task. Each task that
touched the merged applications tree (config, domains, nginx, tls, cdn and
their callers) paid a full render: about 16 s on main and 21 to 23 s on this
branch, where ten more application roles carry MCP and AI Jinja. Measured on
the workspace jobs of run 33574056480 against the main run: 331 such tasks
(93 min, 39 % of the job) versus 391 (141 min, 43 %); the same unit is 9 to
43 % of every compose and swarm deploy job on disk. The parent process
rendered once per play, late, while templating a task name, and only the
tasks after that point ran warm.

The constructor now renders once through the new applications_carrier
action and parks the payload with its cache key in the host fact
_INFINITO_APPLICATIONS_RENDERED. Every later worker inherits the fact at
fork time and get_merged_applications serves it while the key (roles dir,
applications, users, DOMAIN_PRIMARY, SYSTEM_EMAIL_DOMAIN) still matches; a
mismatch falls back to the old render. The action masks its own result: the
payload is the whole tree with every credential, and CI runs with
MASK_CREDENTIALS_IN_LOGS false.

The task sits after the networks, design and service_provider merges, after
the add_host placements and after the token store. An independent review
showed that an earlier position bakes stale values into the play: the
web-app-wordpress title rendered to 'Blog by ' because service_provider did
not exist yet. User records inside the payload are the token store's state
at render time; readers of tokens captured later keep using lookup('users')
or lookup('config', app, 'users.…'), which read the users cache fresh.

Expected savings per job: about 130 min on a fork workspace run, 16 min on
an average compose deploy and 28 min on an average swarm deploy. The
constructor's own lookups before the carrier stay cold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:55:32 +02:00

10 KiB

Inventory 🏗️

How an Ansible inventory is assembled from the per-role meta/services.yml declarations, what gets resolved at build time, and what stays as an unrendered Jinja template until play runtime. For general documentation rules such as links, writing style, RFC 2119 keywords, and Sphinx behavior, see documentation.md. For the matrix-variant folder-per-round model that wraps this pipeline, see variants.md. For the on-disk shape of a role's metadata see layout.md.

Pipeline overview 🛤️

The build pipeline runs entirely on the operator's host, before any Ansible play starts. Three stages produce the inventory file plus the matching host_vars/<host>.yml:

Stage Code path Output
1. Variant resolution applications.py (cache) _build_variants, inventory/payload.py _resolve_variant_payloads Per-app payload = meta/services.yml deep-merged with the round's meta/variants.yml entry. Jinja strings are preserved verbatim.
2. Group materialisation cli.administration.inventory.devices, filters.py filter_dynamic_inventory Inventory file lists one group per included application_id, with the host as a member.
3. Host-vars baking cli.administration.inventory.provision apply_vars_overrides, host_vars.py host_vars/<host>.yml carries the variant-resolved applications.<app> block plus connection vars, become password, etc.

After stage 3 the inventory is fully variant-resolved on disk. Ansible loads it as plain YAML, no rendering yet.

What decides which roles enter the inventory 🎯

The include set passed to stage 2 is built per round by the matrix planner: deploy/cli.py and init.py call plan_dev_inventory_matrix in planner.py, which resolves each round's include via _resolve_round_include in legacy_resolver.py using the CombinedResolver with follow_run_after=False. The swarm test path computes its include separately via derive_includes.py, reading the active variant map from INFINITO_APP_VARIANTS. Both expand the selection along two transitive paths:

  1. Ansible meta dependencies: from each role's meta/main.yml.
  2. Shared-service auto-include from each role's meta/services.yml.<entity>.<service> block, when both enabled AND shared pass is_explicit_truth in registry.py: the literal True or the dynamic form "{{ '<role>' in group_names }}". A literal false (as baked by a variant override) does NOT qualify.

run_after: from meta/services.yml.<entity>.run_after is an ordering hint only (see base.md); it MUST NOT pull roles into the include set.

This is the mechanism that lets variants control whether a shared service is pulled in: a variant override pins services.<service>.{enabled,shared} to a literal boolean; the round's include resolution reads the variant-merged services map; the provider role's group materialises in (or drops out of) the inventory accordingly.

What lives in the inventory file vs. host_vars 📋

Artifact Contents
inventory.yml (or equivalent) Group-per-application map plus host membership. Source of truth for group_names at play time.
host_vars/<host>.yml Connection vars, the variant-baked applications.<app> block, vault-encrypted ansible_become_password, plus any --vars / --vars-file overlays (which is where deployment-wide flags like DOMAIN_PRIMARY, TLS_ENABLED, and networks.internet.{ip4,ip6} enter when set; otherwise group_vars provides the env-driven defaults).

host_vars/<host>.yml is where variant data ends up after the deep-merge. The strings inside are still raw, including any Jinja templates from meta/services.yml that no variant override replaced.

When Jinja gets rendered ⏱️

Rendering happens at play runtime, never during the build pipeline above. The render call is centralised in applications.py (cache) get_merged_applications, which:

  1. Loads variant 0 as the default application payload.
  2. Layers on inventory variables['applications'] (the host_vars block) as overrides via deep merge.
  3. Calls _render_with_templar with the play's full variable scope, including group_names, inventory_hostname, and any other facts.
  4. Returns the fully rendered payload.

get_merged_applications is invoked transitively by lookup('applications', ...) and by lookup('config', application_id, 'services.<x>.<y>'). Tasks that read service flags through these lookups always see real Python values, never raw Jinja strings.

The render runs once per play. The constructor stage parks the payload and its cache key in the host fact _INFINITO_APPLICATIONS_RENDERED, and every task's worker process (Ansible forks one per task) serves that payload instead of rendering again. See applications.md for the carrier contract.

A role's task that reads its own service flag therefore picks up the runtime-resolved value, even when the inventory carried a Jinja string for that flag: the conditional evaluates against the play's actual group_names, which reflects which other application groups the host joined.

Interaction between the three inclusion mechanisms 🧬

A role can be pulled into the inventory through any of three paths and they compose, not exclude one another:

Path When it fires
Operator-supplied --apps (or --include) Always. The named roles enter the include set verbatim.
run_after: walk When a role in the include set declares run_after: [<other>], <other> is added to the include set.
Shared-service auto-include When a role in the include set declares services.<svc> with literal enabled: true and shared: true, the role behind that service is added.

The matrix-variant planner (see variants.md) calls the expansion once per round, so each round's inventory gets the right transitive closure for that round's variant set.

Worked example: WordPress and Discourse 🧪

The wp-discourse plugin install in roles/web-app-wordpress/tasks/addons/wp-discourse.yml does container exec discourse rake api_key:create_master[...] during the WordPress play. The Discourse container therefore has to exist by the time WordPress runs.

The wiring that makes this work is purely in the services declaration, not in run_after:

  • roles/web-app-wordpress/meta/services.yml declares services.discourse.shared: true plus a fallback enabled: "{{ 'web-app-discourse' in group_names }}".
  • roles/web-app-wordpress/meta/variants.yml variant 0 (canonical Single-Site) overrides services.discourse.enabled: true. Variant 1 (Multisite) overrides it to false.
  • The variant override is baked as a real boolean into the inventory, so the shared-service auto-include picks it up at build time. Variant 0 therefore pulls web-app-discourse into the include set. Variant 1 does not.
  • Discourse's role itself has no run_after, so deploying just --apps web-app-discourse does not drag WordPress along.

The fallback Jinja string in the base meta/services.yml is the safety net for the case where neither variant overrides the flag and the operator still adds web-app-discourse to the inventory groups manually: the runtime lookup('config', application_id, 'services.discourse.enabled') then renders the conditional against the real group_names and the wp-discourse plugin install enables itself accordingly.

Reference files 📌

File Purpose
applications.py (cache) get_variants, get_application_defaults, get_merged_applications. Stage 1 variant loader and the runtime renderer.
service_registry.py build_service_registry_from_roles_dir, resolve_service_dependency_roles_from_config. Strict-identity auto-include.
in_group_deps.py applications_if_group_and_all_deps. Recursive walk over Ansible meta dependencies: plus shared-service deps.
inventory/ Package root re-exporting the public API. Per-submodule split: payload.py (_resolve_variant_payloads, _bake_overrides) and builder.py (build_dev_inventory). Stage 1 variant resolution and stage 3 host-vars baking.
cli.administration.inventory.devices Stage 2 group materialisation: emits one group per invokable application_id for the given host.
filters.py filter_dynamic_inventory. Restricts the materialised groups to the --include set.
host_vars.py Connection vars, vars-file/JSON overlays, become-password handling.
config.py (lookup) lookup('config', application_id, 'services.<x>.<y>'). Resolves nested config paths and renders Jinja via _render_with_templar.
applications.py (lookup) lookup('applications'[, application_id]). Returns the rendered application payload.