fix(cache/applications): stop variant-0 service leak via base-config defaults

`_build_application_defaults` returned variant 0 of every role with a
meta/variants.yml, which then deep-merged with the variant-N override
that build_dev_inventory bakes into the inventory. Any service flag
that variant N did not explicitly disable carried through from
variant 0 into the runtime applications map. The most visible casualty
was svc-bkp-container-2-local variant 1 (postgres + mariadb only),
where gitea + keycloak leaked in via variant 0, sys-service-loader
pulled in svc-db-openldap via keycloak.services.ldap, and the deploy
crashed in sys-svc-compose/04_files.yml on openldap's env.j2 with
"credentials is defined in schema at roles/svc-db-openldap/meta/schema.yml
but not set in application 'svc-db-openldap'".

Defaults now come from each role's variant-free base config
(meta/<topic>.yml plus schema-defaults, no variants overlay). Variants
stay accessible via get_variants for any caller that explicitly needs
a specific variant. Inventory-level applications.<app> overrides win
exactly as before.

Supporting changes that keep the variant matrix consistent under the
new defaults model:

- New lint guard tests/integration/roles/meta/variants/
  test_non_baseline_explicit_disables.py: for every dynamic
  service-key declared in a role's meta/services.yml that variant 0
  pins to literal true, every non-baseline variant MUST explicitly
  pin the same key to either literal true or to literal
  enabled: false + shared: false. Statically-enabled keys are exempt
  (same rule as test_auth_isolation.py).
- roles/web-app-{fusiondirectory,lam,nextcloud,phpldapadmin}/meta/
  variants.yml: explicit `false` pins for dashboard / email / logout /
  matomo / prometheus / talk in restrictive variants so deep-merge no
  longer hands them the deleted variant-0 inheritance.
- roles/web-app-moodle/templates/playwright.env.j2: gate the LAM
  block on `lookup('config', application_id, 'services.lam.enabled',
  false)`. LAM is a transitive helper, not a moodle-owned service, so
  the default-bearing lookup tolerates the missing path when the
  variant does not deploy LAM.
- tests/unit/utils/cache/test_variants.py: rename the legacy
  "always variant zero" coverage to TestMergedApplicationsUsesBaseConfig
  and add test_get_application_defaults_omits_variant_zero_only_keys
  as the regression guard for the leak this commit fixes.
- docs/contributing/artefact/files/role/variants.md +
  docs/contributing/design/variants.md: variant 0 now manually
  enumerates every dynamic services.yml key with
  enabled: true + shared: true; `{}` is only permitted when the role
  has no dynamic-enabled service-key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-05-24 13:04:21 +02:00
1 parent ee01f331a8
commit 29377e1bd3
11 files changed
+318 -35

No files matched your search

@@ -24,9 +24,10 @@ For how the file is consumed at runtime (folder-per-round model, `--variant` / `
A **variant** is the role's assembled per-role meta payload (the same payload `applications.<app>` exposes; see [layout.md](../../../design/role/services/layout.md)) deep-merged with the matching list entry. The deep-merge follows the same rules as the [`applications`](../plugins/lookup/applications.md) lookup: dictionaries merge recursively, scalars and lists are replaced, and the entry has precedence.
- Entry `{}` produces the unchanged assembled payload, which becomes variant 0.
- 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 therefore be `{}` whenever the role has a meaningful "default deploy" shape, so consumers reading variant 0 keep getting the assembled payload unchanged.
- Variant 0 is the canonical baseline. The first entry SHOULD manually enumerate every dynamic service-key declared in `meta/services.yml` and pin each one to `enabled: 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](../../../../../tests/integration/roles/meta/variants/test_non_baseline_explicit_disables.py)).
- `{}` is permitted only when `meta/services.yml` declares no dynamic-enabled service-key, so there is nothing for the baseline to pin (for example pure matrix-driver roles such as `svc-bkp-container-2-local`, whose every service-key originates in `variants.yml`).
## Credentials Interaction 🔐
@@ -40,8 +41,17 @@ See [Credentials Generation](../../../design/variants.md#credentials-generation-
```yaml
# roles/web-app-wordpress/meta/variants.yml
# variant 0: canonical Single-Site deploy.
- {}
# 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:
@@ -51,19 +61,29 @@ See [Credentials Generation](../../../design/variants.md#credentials-generation-
- "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 is the baseline; variant 1 flips Multisite on against three canonical domains.
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 ➕
1. Edit `roles/<application_id>/meta/variants.yml` (create the file if absent).
2. Append a list entry containing only the keys that differ from variant 0.
3. 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.
4. Add or extend the deep-merge edge-case tests in [test_variants.py](../../../../../tests/unit/utils/cache/test_variants.py) when the new variant exercises behaviour beyond the existing fixtures (for example list replacement vs. nested scalar override).
2. Make sure variant 0 pins every dynamic service-key from `meta/services.yml` to `enabled: true, shared: true`.
3. 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.
4. 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.
5. Add or extend the deep-merge edge-case tests in [test_variants.py](../../../../../tests/unit/utils/cache/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 🚫
+1 -1
View File
@@ -18,7 +18,7 @@ The development deploy CLI uses a **folder-per-round model** that splits cleanly
6. **`--full-cycle` (async update pass).** When set (or `full_cycle=true` on `make compose-deploy`, or `INFINITO_FULL_CYCLE=true` in the environment), the wrapper runs each round's deploy TWICE: first the regular sync pass, then immediately a second pass with `-e ASYNC_ENABLED=true` overriding the host_var, against the SAME variant's folder, BEFORE moving to the next round. The two passes are therefore always co-located on the same host state; the async pass never accidentally targets a host that a previous round's variant left behind.
7. **Pinning to a single round.** Both init and deploy accept `--variant <idx>` (or read the `INFINITO_VARIANT` environment variable) to pin operations to a single round's folder. In single-round mode no inter-round cleanup runs, since there is no previous round to diff against. The `make compose-deploy mode=update` flows honour the same `INFINITO_VARIANT` env-var by suffixing `INFINITO_INVENTORY_DIR` / `INFINITO_INVENTORY_FILE` to `<base>-<idx>`, so a redeploy can target one specific variant without iterating the full matrix.
The in-play loader (`utils.cache.applications.get_merged_applications`) always uses variant 0 as the default and lets the inventory's `applications.<app>` overrides win via deep merge: the inventory itself is the source of truth for what variant a round runs against.
The in-play loader (`utils.cache.applications.get_merged_applications`) uses each role's variant-free base config (the assembled `meta/<topic>.yml` payload) as the default and lets the inventory's `applications.<app>` overrides win via deep merge: the inventory itself is the source of truth for what variant a round runs against. Variant 0 enumerates every dynamic service-key from `meta/services.yml` with `enabled: true, shared: true` so the "all flags on" baseline is explicit and deep-merge with a non-baseline override yields the expected variant-N shape (see [variants.md](../artefact/files/role/variants.md) for the file-format rule and [test_non_baseline_explicit_disables.py](../../../tests/integration/roles/meta/variants/test_non_baseline_explicit_disables.py) for the lint guard).
## Credentials Generation 🔐
@@ -33,7 +33,8 @@
system_service_tpl_exec_start: >
{{ system_service_script_exec }}
--web-protocol {{ lookup('tls', DOMAIN_PRIMARY, 'protocols.web') }}
--expectations '{{ lookup('applications') | web_health_expectations(www_enabled=WWW_REDIRECT_ENABLED | bool, group_names=lookup('deployment').deployed) | to_json }}'
--expectations '{{ lookup('applications') | web_health_expectations(www_enabled=WWW_REDIRECT_ENABLED | bool, group_names=lookup('deployment').deployed) | to_json
}}'
system_service_force_flush_final: true
- include_tasks: utils/once/flag.yml
@@ -60,3 +60,18 @@
css:
enabled: false
shared: false
dashboard:
enabled: false
shared: false
email:
enabled: false
shared: false
logout:
enabled: false
shared: false
matomo:
enabled: false
shared: false
prometheus:
enabled: false
shared: false
+12
View File
@@ -61,3 +61,15 @@
css:
enabled: false
shared: false
dashboard:
enabled: false
shared: false
logout:
enabled: false
shared: false
matomo:
enabled: false
shared: false
prometheus:
enabled: false
shared: false
@@ -2,7 +2,7 @@ APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
MOODLE_OIDC_SCOPE_NAME={{ MOODLE_OIDC_CUSTOM_SCOPE | dotenv_quote }}
OIDC_CLIENT_ID={{ OIDC.CLIENT.ID | dotenv_quote }}
{% if lookup('config', application_id, 'services.lam.enabled') | bool %}
{% if lookup('config', application_id, 'services.lam.enabled', false) | bool %}
LAM_BASE_URL={{ lookup('tls', 'web-app-lam', 'url.base') | dotenv_quote }}
LAM_PASSWORD={{ lookup('config', 'web-app-lam', 'credentials.administrator_password') | dotenv_quote }}
LAM_OAUTH2_FRONTED={{ lookup('config', 'web-app-lam', 'services.oauth2.enabled') | string | lower }}
@@ -25,4 +25,4 @@ MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enab
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }}
LAM_SERVICE_ENABLED={{ lookup('config', application_id, 'services.lam.enabled') | string | lower }}
LAM_SERVICE_ENABLED={{ lookup('config', application_id, 'services.lam.enabled', false) | string | lower }}
@@ -80,6 +80,7 @@
shared: false
talk:
enabled: false
shared: false
- services:
ldap:
enabled: true
@@ -57,3 +57,15 @@
css:
enabled: false
shared: false
dashboard:
enabled: false
shared: false
logout:
enabled: false
shared: false
matomo:
enabled: false
shared: false
prometheus:
enabled: false
shared: false
@@ -0,0 +1,199 @@
"""Integration guard: for every dynamic service-key declared in a
role's ``meta/services.yml`` (i.e. ``enabled`` is a Jinja
``{{ ... }}`` expression) that variant 0 pins to literal ``true`` in
``meta/variants.yml``, every non-baseline variant (index > 0) MUST
explicitly pin the same key to either literal ``enabled: true`` (to
re-enable it) or to literal ``enabled: false`` AND ``shared: false``
(to disable it).
Why
---
`utils.cache.applications._build_application_defaults` returns each
role's base config (assembled `meta/<topic>.yml` payload, no
variants overlay). The matrix-deploy CLI bakes variant N as an
inventory-level `applications.<app>` override, which then deep-merges
on top of those defaults. For dynamic service-keys declared in
``meta/services.yml`` the base value is the Jinja expression, but
``variants.yml`` pins them to a literal boolean. The non-baseline
override only sees the variant's own services map, so any key
variant 0 pins to ``true`` and variant N omits stays at variant 0's
literal value via deep-merge.
The auth-only special case of this rule already lives in
[test_auth_isolation.py](./test_auth_isolation.py); this file
generalises it to every dynamic flag the role's variant matrix
toggles to ``true`` in the baseline.
Variant-only keys (declared only in ``meta/variants.yml`` and not in
``meta/services.yml``) are out of scope. Defaults do not carry them,
so omitting them from a non-baseline variant cannot leak them in.
Statically-enabled keys (``services.<k>.enabled`` declared as the
literal ``true`` in ``meta/services.yml``) are exempt: pinning them
``false`` in a variant would only break the role's own deploy
without trimming any closure (the resolver pulls the static dep in
regardless). The auth-isolation rule applies the same exemption.
Exemption
---------
Place ``# nocheck: variants-explicit-disables`` on the same line as
the variant's leading ``- services:`` (or on the line immediately
above) to skip the check for that single variant entry. Reserve the
exemption for variants whose contract genuinely tolerates the
inherited flags (e.g. a multisite variant whose enabled set is a
true superset of variant 0).
"""
from __future__ import annotations
import unittest
from typing import TYPE_CHECKING
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_META_VARIANTS
from . import PROJECT_ROOT
if TYPE_CHECKING:
from pathlib import Path
ROLES_DIR = PROJECT_ROOT / "roles"
_RULE = "variants-explicit-disables"
def _is_enabled_true(entry: object) -> bool:
return isinstance(entry, dict) and entry.get("enabled") is True
def _is_literal_false(value: object) -> bool:
return value is False
def _entry_pinned_false(entry: object) -> bool:
return (
isinstance(entry, dict)
and _is_literal_false(entry.get("enabled"))
and _is_literal_false(entry.get("shared"))
)
def _dynamic_enabled_keys_in_services_yml(services_file: Path) -> set[str]:
try:
services_raw = load_yaml_any(str(services_file), default_if_missing={})
except Exception:
return set()
if not isinstance(services_raw, dict):
return set()
out: set[str] = set()
for key, entry in services_raw.items():
if not (isinstance(key, str) and isinstance(entry, dict)):
continue
enabled = entry.get("enabled")
if isinstance(enabled, str) and "in group_names" in enabled:
out.add(key)
return out
def _variant_header_line_numbers(variants_file: Path) -> dict[int, int]:
lines = read_text(str(variants_file)).splitlines()
out: dict[int, int] = {}
variant_index = -1
for idx, raw in enumerate(lines):
if raw.startswith("- "):
variant_index += 1
out[variant_index] = idx + 1
return out
def _baseline_enabled_keys(baseline: dict, in_scope: set[str]) -> set[str]:
services = baseline.get("services") or {}
if not isinstance(services, dict):
return set()
return {
key
for key, entry in services.items()
if isinstance(key, str) and key in in_scope and _is_enabled_true(entry)
}
class TestVariantsExplicitDisables(unittest.TestCase):
def test_non_baseline_variants_explicitly_disable_baseline_only_keys(self):
offenders: list[str] = []
for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()):
role_name = role_dir.name
variants_file = role_dir / ROLE_FILE_META_VARIANTS
if not variants_file.is_file():
continue
try:
variants_raw = load_yaml_any(str(variants_file), default_if_missing=[])
except Exception as exc:
offenders.append(f"{role_name}: meta/variants.yml parse error: {exc}")
continue
if not isinstance(variants_raw, list) or len(variants_raw) < 2:
continue
baseline = variants_raw[0] if isinstance(variants_raw[0], dict) else {}
dynamic_scope = _dynamic_enabled_keys_in_services_yml(
role_dir / ROLE_FILE_META_SERVICES
)
baseline_keys = _baseline_enabled_keys(baseline, dynamic_scope)
if not baseline_keys:
continue
header_lines = _variant_header_line_numbers(variants_file)
text_lines = read_text(str(variants_file)).splitlines()
for index in range(1, len(variants_raw)):
variant = variants_raw[index]
if not isinstance(variant, dict):
continue
header_line = header_lines.get(index)
if header_line is not None and is_suppressed_at(
text_lines, header_line, _RULE
):
continue
services = variant.get("services") or {}
if not isinstance(services, dict):
services = {}
missing: list[str] = []
for key in sorted(baseline_keys):
entry = services.get(key)
if _is_enabled_true(entry):
continue
if _entry_pinned_false(entry):
continue
missing.append(key)
if missing:
offenders.append(
f"{role_name}: variant[{index}] inherits "
f"{', '.join(missing)} from variant 0 via deep-merge. "
f"Pin each key to `enabled: false, shared: false` "
f"(or re-enable it explicitly), otherwise the "
f"matrix-deploy round silently runs with the "
f"inherited flag(s) on. Mark the variant with "
f"``# nocheck: {_RULE}`` only when the inheritance "
f"is intentional."
)
if offenders:
self.fail(
f"meta/variants.yml non-baseline variants must explicitly "
f"pin baseline-only dynamic keys to literal false "
f"({_RULE}, {len(offenders)} offender(s)):\n"
+ "\n".join(f" - {o}" for o in offenders)
)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+38 -9
View File
@@ -1,6 +1,7 @@
"""Unit tests for the per-role `meta/variants.yml` matrix-deploy
loader (`utils.cache.applications.get_variants` and the variant-zero
default in `get_merged_applications`)."""
loader (`utils.cache.applications.get_variants`) and the variant-free
base-config default that `get_merged_applications` falls back on when
no inventory-level `applications.<app>` override is set."""
import os
import sys
@@ -158,7 +159,7 @@ class TestApplicationVariants(unittest.TestCase):
["bar.example", "shop.bar.example"],
)
def test_legacy_get_application_defaults_returns_first_variant(self):
def test_get_application_defaults_returns_variant_free_base_config(self):
_write_role(
self.roles_dir,
"web-app-baz",
@@ -175,6 +176,33 @@ class TestApplicationVariants(unittest.TestCase):
defaults = get_application_defaults(roles_dir=self.roles_dir)
self.assertEqual(defaults["web-app-baz"]["services"]["baz"]["value"], 1)
def test_get_application_defaults_omits_variant_zero_only_keys(self):
"""Defaults MUST come from the variant-free base config, not
variant 0. Otherwise a service key that only variant 0 enables
(and that variant N intentionally omits) leaks into variant N's
runtime view via deep-merge in `get_merged_applications`."""
_write_role(
self.roles_dir,
"web-app-leak",
config="alpha:\n enabled: false\n",
meta=textwrap.dedent(
"""
- services:
alpha:
enabled: true
shared: true
- services:
beta:
enabled: true
shared: true
"""
),
)
defaults = get_application_defaults(roles_dir=self.roles_dir)
alpha = defaults["web-app-leak"]["services"]["alpha"]
self.assertEqual(alpha.get("enabled"), False)
self.assertNotIn("beta", defaults["web-app-leak"]["services"])
def test_get_variants_caches_per_roles_dir(self):
_write_role(self.roles_dir, "web-app-cache", config="cache:\n x: 1\n")
first = get_variants(roles_dir=self.roles_dir)
@@ -190,13 +218,14 @@ class TestApplicationVariants(unittest.TestCase):
)
class TestMergedApplicationsAlwaysVariantZero(unittest.TestCase):
"""The runtime loader no longer reads any active-variant selector.
Variant N data is baked into the inventory by
class TestMergedApplicationsUsesBaseConfig(unittest.TestCase):
"""The runtime loader reads no active-variant selector. Variant N
data is baked into the inventory by
`cli.administration.deploy.development.inventory.build_dev_inventory` at init time
and reaches the merged payload as an `applications.<app>` override.
These tests pin that contract: the loader's defaults are ALWAYS
variant 0, and inventory-level overrides win as before."""
The loader's defaults are the role's variant-free base config so
no variant's service flags leak into another variant's runtime view
via deep-merge; inventory-level overrides remain authoritative."""
def setUp(self):
import tempfile
@@ -231,7 +260,7 @@ class TestMergedApplicationsAlwaysVariantZero(unittest.TestCase):
shutil.rmtree(self.tmp, ignore_errors=True)
_reset_cache_for_tests()
def test_no_inventory_overrides_yield_variant_zero(self):
def test_no_inventory_overrides_yield_base_config(self):
merged = get_merged_applications(
variables={},
roles_dir=str(self.roles_dir),
+8 -14
View File
@@ -246,13 +246,14 @@ def _build_variants(roles_dir: Path) -> dict[str, list[Any]]:
def _build_application_defaults(roles_dir: Path) -> dict[str, Any]:
"""Backward-compatible shim: every consumer that historically saw
one mapping per application now sees the FIRST variant (index 0).
The full list is exposed via :func:`get_variants`."""
return {
application_id: copy.deepcopy(variant_list[0])
for application_id, variant_list in _build_variants(roles_dir).items()
}
"""Return ``{application_id: base_config}`` — each role's variant-free
``meta/<topic>.yml`` payload only. Variants stay accessible via
:func:`get_variants`. Using variant 0 here would leak its service
flags into variant-N consumers' runtime view via deep-merge."""
defaults: dict[str, Any] = {}
for role_dir in _iter_application_role_dirs(roles_dir):
defaults[role_dir.name] = _build_role_base_config(role_dir, roles_dir)
return {key: defaults[key] for key in sorted(defaults)}
def get_application_defaults(
@@ -307,13 +308,6 @@ def get_merged_applications(
if cached is not None:
return cached
# Defaults always come from variant 0 (= the legacy `meta/services.yml`
# payload, deep-merged with the empty `{}` entry). Per-round variant
# overrides are baked into the inventory's `applications.<app>` block at
# init time (see `cli.administration.deploy.development.inventory.build_dev_inventory`)
# and applied below as overrides on top of these defaults, so the deploy
# stage needs no runtime variant selector: the inventory itself is
# variant-resolved.
defaults = get_application_defaults(roles_dir=resolved_roles_dir)
overrides = _resolve_override_mapping(variables, "applications", templar=templar)