Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.5 KiB
meta/variants.yml 🧬
Per-role matrix-deploy variant overrides. Each list entry declares one variant of the role's effective configuration; the development deploy CLI materialises one inventory folder per entry so every variant can be exercised against a real host.
For general documentation rules such as links, writing style, RFC 2119 keywords, and Sphinx behavior, see documentation.md.
For how the file is consumed at runtime (folder-per-round model, --variant / --full-cycle, cleanup behaviour), see variants.md.
Placement 📁
- The file MUST live at
roles/<application_id>/meta/variants.yml. - It MUST NOT be named
meta/inventory.yml. The Ansible language server auto-applies the inventory schema to that filename, and the variant list does not satisfy it. - A role MAY omit the file entirely. The loader then exposes exactly one variant equal to the assembled per-role meta payload (
meta/services.yml+meta/server.yml+meta/rbac.yml+meta/volumes.yml+apply_schema()'dmeta/secrets.yml) unchanged.
File Format 📋
- The top-level node MUST be a YAML list. A non-list root is a hard error.
- Each list entry MUST be either:
- the empty mapping
{}(the canonical no-override entry), or - a YAML mapping that mirrors the assembled application payload, so it can override anything reachable under
applications.<app>.<root>(see layout.md). Every root is the name of ameta/topic —services,server,rbac,volumes,secrets,addons,csp,domains,networks,info,users— because the entry is baked intohost_varsraw, which makes its top-level key the inventory path. A key nometa/<topic>.ymlproduces lands where nothing reads it and the override is silently discarded;TestVariantTopLevelKeysin test_layout.py rejects it.
- the empty mapping
- The literal
nullis normalised to{}so a bare-list item stays valid. - Scalars at entry level (numbers, strings, lists) are rejected.
- Variants are addressed by their zero-based index in the list.
Entry Semantics 🧩
A variant is the role's assembled per-role meta payload (the same payload applications.<app> exposes; see layout.md) deep-merged with the matching list entry. The deep-merge follows the same rules as the applications lookup: dictionaries merge recursively, scalars and lists are replaced, and the entry has precedence.
- Entry
{}produces the unchanged assembled payload. - An entry with overrides produces a derived shape (for example WordPress Multisite domains).
- Variant 0 is the canonical baseline. The first entry SHOULD manually enumerate every dynamic service-key declared in
meta/services.ymland pin each one toenabled: true, shared: true. The literal-true pins document the role's "all dynamics on" maximum-footprint deploy shape that every non-baseline variant either re-affirms or explicitly disables (see test_non_baseline_explicit_disables.py). {}is permitted only whenmeta/services.ymldeclares no dynamic-enabled service-key, so there is nothing for the baseline to pin (for example pure matrix-driver roles such assvc-bkp-volume-2-local, whose every service-key originates invariants.yml).
Credentials Interaction 🔐
A variant entry MAY toggle services.<key>.enabled+shared to pull additional shared providers into the round's closure (for example services.ldap.enabled: true to add svc-db-openldap).
The credential generator participates in this overlay so the providers a variant enables receive their schema-defined credentials in the inventory before deploy time. See Credentials Generation in the design doc for the full CI/CD vs standalone flow and the involved CLI flags.
Example 📝
# roles/web-app-wordpress/meta/variants.yml
# variant 0: canonical Single-Site deploy with every dynamic flag on.
- services:
dashboard:
enabled: true
shared: true
matomo:
enabled: true
shared: true
oauth2:
enabled: true
shared: true
# variant 1: Multisite deploy across blog/shop/news subdomains.
- server:
domains:
canonical:
- "blog.{{ DOMAIN_PRIMARY }}"
- "shop.{{ DOMAIN_PRIMARY }}"
- "news.{{ DOMAIN_PRIMARY }}"
services:
dashboard:
enabled: true
shared: true
matomo:
enabled: true
shared: true
oauth2:
enabled: true
shared: true
wordpress:
multisite:
enabled: true
This declares two variants. Variant 0 pins every dynamic service-key from meta/services.yml to enabled: true, shared: true so the maximum-footprint baseline is explicit. Variant 1 keeps the same dynamic flags on and additionally flips Multisite on against three canonical domains.
Adding A Variant ➕
- Edit
roles/<application_id>/meta/variants.yml(create the file if absent). - Make sure variant 0 pins every dynamic service-key from
meta/services.ymltoenabled: true, shared: true. - Append a list entry for the new variant. For every dynamic service-key variant 0 enables, the new entry MUST either keep it on (re-pin
enabled: true, shared: true) or explicitly disable it (enabled: false, shared: false). Add the variant-specific overrides that justify the new entry. - If the new variant relies on cleanup steps that the standard inter-round entity purge does not cover, extend the role's purge handling. The matrix wrapper invokes the standard purge between rounds for every app whose variant changed.
- Add or extend the deep-merge edge-case tests in test_variants.py when the new variant exercises behaviour beyond the existing fixtures (for example list replacement vs. nested scalar override).
What Not To Do 🚫
- You MUST NOT put per-environment overrides into
inventories/<env>/default.ymlfor cases that belong to a single role; use this file instead. The environment inventory keeps only cross-cutting environment knobs. - You MUST NOT introduce conditionals or templating tricks at the variant-list level. The deep-merge is straight YAML; complex shape decisions belong inside the role itself.
- You MUST NOT name the file
meta/inventory.ymlfor any reason. See the placement rule.