Root cause of the DinD DNS-check failure on test-deploy-server
(networks.internet.ip4 falling through to 127.0.0.1 despite
INFINITO_IP4=172.30.0.10 reaching every link of the env pipeline):
cli/administration/inventory/provision/cli.py declared --ip4 with a
hardcoded default of "127.0.0.1" and ensure_host_vars_file wrote that
value unconditionally into host_vars/<host>.yml. group_vars'
defaults_networks.internet.ip4 resolves "172.30.0.10" via
\`lookup('env', 'INFINITO_IP4')\`, but tasks/stages/01_constructor.yml
merges \`defaults_networks | combine(networks | default({}, true))\` so
host_vars wins. INFINITO_IP4 looked broken end-to-end; the breakage
actually lived in the provision-CLI host_vars overlay.
INFINITO_DNS_IP and INFINITO_DOMAIN propagated correctly because
the provision CLI had no --dns and the --primary-domain write was
None-gated and never invoked.
While here, --ssl-disabled and --primary-domain are removed too:
neither had any effect in the dev flow. provision/cli.py applies
\`--vars-file inventories/development/default.yml\` AFTER
ensure_host_vars_file via apply_vars_overrides_from_file, whose
_deep_update_commented_map unconditionally overwrites
(\`target[key] = value\`). The dev vars-file always set
\`TLS_ENABLED: true\`, so \`--ssl-disabled\` (which forced
\`TLS_ENABLED: false\` in host_vars) was systematically reverted to
true. Dev/CI ran with TLS enabled the whole time. Inventory stays at
\`TLS_ENABLED: true\` to preserve that effective behaviour.
Removed from cli/administration/inventory/provision/cli.py:
- --ip4, --ip6: defaulted to 127.0.0.1 / ::1, shadowed group_vars
- --primary-domain: never set by any caller, None-gated write
- --ssl-disabled: always re-overwritten by --vars-file later in the
same provision pass
Removed from cli/administration/inventory/provision/host_vars.py the
matching writes in ensure_host_vars_file; the function now only sets
\`ansible_connection: local\` for localhost-class hosts. Defaults for
DOMAIN_PRIMARY / TLS_ENABLED / networks.internet.{ip4,ip6} live in
group_vars (env-driven) and the inventory vars-file (deployment-wide
flags); host_vars stops duplicating them.
Callers cleaned up:
- cli/administration/deploy/development/inventory/builder.py
- cli/administration/deploy/container/command.py
- scripts/tests/deploy/local/utils/fresh-kept-all-init-and-deploy.sh
- scripts/tests/deploy/local/reset/inventory.sh
Tests + docs follow:
- tests/unit/cli/administration/inventory/provision/test_host_vars.py
now asserts host_vars no longer carries baked DOMAIN_PRIMARY /
TLS_ENABLED / networks defaults
- docs/administration/deploy.md drops --ssl-disabled from the
provision example
- docs/contributing/design/inventory.md re-scopes the host_vars
column: the DOMAIN_PRIMARY / TLS_ENABLED / networks.internet.{ip4,ip6}
values arrive via --vars / --vars-file overlays, otherwise group_vars
provides the env-driven default
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.4 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 by the deploy CLI in deploy.py via resolve_deploy_ids_for_apps. It expands the operator's --apps selection along three transitive paths, all walked together:
- Ansible meta
dependencies:from each role'smeta/main.yml. - Per-role
run_after:from each role'smeta/services.yml.<entity>.run_after. - Shared-service auto-include from each role's
meta/services.yml.<entity>.<service>block, when bothenabled is TrueANDshared is True. The check lives in service_registry.pyresolve_service_dependency_roles_from_config.
The auto-include uses strict identity with Python's True. A Jinja-templated string such as "{{ 'web-app-discourse' in group_names }}" is NOT the value True, so it does NOT trigger auto-include at build time. Only literal booleans (or boolean-typed variant overrides that bake one in) qualify.
This is the mechanism that lets variants control whether a shared service is pulled in: a variant override services.<service>.enabled: true pins a real boolean; the build-time auto-include picks it up; the dependent role's group materialises in the inventory.
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:
- Loads variant 0 as the default application payload.
- Layers on inventory
variables['applications'](the host_vars block) as overrides via deep merge. - Calls
_render_with_templarwith the play's full variable scope, includinggroup_names,inventory_hostname, and any other facts. - 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.
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/plugins/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: trueplus a fallbackenabled: "{{ '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 tofalse. - 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-discourseinto the include set. Variant 1 does not. - Discourse's role itself has no
run_after, so deploying just--apps web-app-discoursedoes 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. |