mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
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 🚫
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Vendored
+8
-14
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user