fix(ci): let only the primary app's declared variant bring the guide host

Every role's base services carry `docs.enabled: "{{ 'web-app-docs' in
group_names }}"`, and the static closure counts any "in group_names" flag
as possibly true (is_explicit_truth). A round therefore pulled web-app-docs
in through any member whose active variant did not pin docs off, and a
round hands every role its index or clamps it to variant 0, where 84 roles
declare docs on. The README replay and everything it drags in (dashboard,
matomo, keycloak, prometheus with its MCP partners) landed in 365 of 469
rounds instead of one per role, which ran CI run 40625679270 out of
resources: Listmonk#0 memory, OpenProject#0 I/O in the DR drill,
Nextcloud#0/#2 budget (4310-4539 s in the replay each, 60 s over),
BookWyrm#0 drill disk, Matrix#1/#3 pids in the replay; none of those
primaries declares docs in the failing variant.

The guide host now stays on only where the primary app's own variant
declares it (declares_guide, the predicate instructions.py already used);
every other role has it switched off (guide_off), in the planner's round
overrides and in the swarm include set. The host_vars bake drops a docs
edge whenever the round does not deploy web-app-docs. Measured for
listmonk, openproject, translate and mariadb: web-app-docs is deployed in
exactly each primary's declared guide round, swarm and planner agree in
all 12 rounds, and rounds shed up to ten apps (mariadb#1 19 -> 9).

test_guide_rides_the_primary_only pins that on real role data (it fails
with the rule disabled); test_variant_pin_required leaves the guide host
out of its clone comparison and no longer counts the docs key as a
pairing edge, because the replay no longer follows a partner's index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-28 12:49:51 +01:00
1 parent e042c9dbf9
commit 91022ad37a
9 files changed
+241 -19

No files matched your search

@@ -11,11 +11,42 @@ always win" rule.
from __future__ import annotations
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
from utils.cache.applications import get_variant_overrides_only
from utils.roles.applications.topics import apply_topic
from utils.roles.applications.services.registry import (
build_service_registry_from_roles_dir,
)
from utils.roles.applications.topics import apply_topic, provider_of
from utils.roles.applications.variants import collect_provider_overrides
from utils.roles.guide import GUIDE_SERVICE_KEY, without_guide
def _guide_services(payload: Any) -> Mapping[str, Any] | None:
services = payload.get("services") if isinstance(payload, Mapping) else None
if isinstance(services, Mapping) and GUIDE_SERVICE_KEY in services:
return services
return None
def _drop_undeployed_guide(
resolved: dict[str, Any], *, roles_dir: str, include: Sequence[str]
) -> dict[str, Any]:
if not any(_guide_services(payload) for payload in resolved.values()):
return resolved
registry = build_service_registry_from_roles_dir(Path(roles_dir))
if provider_of(GUIDE_SERVICE_KEY, registry) in include:
return resolved
stripped: dict[str, Any] = {}
for app_id, payload in resolved.items():
services = _guide_services(payload)
stripped[app_id] = (
payload
if services is None
else {**payload, "services": without_guide(services)}
)
return stripped
def _resolve_variant_payloads(
@@ -38,6 +69,9 @@ def _resolve_variant_payloads(
A config topic a variant declares under a service it depends on is
finally moved onto that service's provider, so the role pulling a
dependency in is what decides how lean the dependency runs.
A guide host entry is baked only while the round deploys the guide
host itself.
"""
overrides_per_app = get_variant_overrides_only(roles_dir=roles_dir)
resolved: dict[str, Any] = {}
@@ -49,6 +83,7 @@ def _resolve_variant_payloads(
if not 0 <= index < len(variant_list):
index = 0
resolved[app_id] = variant_list[index]
resolved = _drop_undeployed_guide(resolved, roles_dir=roles_dir, include=include)
for provider, topics in collect_provider_overrides(
resolved, roles_dir=roles_dir
).items():
@@ -0,0 +1,53 @@
"""The README replay host joins a round only through the primary app's declared guide variant."""
from __future__ import annotations
import unittest
from typing import ClassVar
from cli.administration.deploy.development.inventory.planner import (
plan_dev_inventory_matrix,
)
from utils.cache.applications import get_variants
from utils.cache.files import PROJECT_ROOT
from utils.roles.guide import declares_guide
from utils.tests.swarm.derive_includes import derive_includes
_ROLES_DIR = str(PROJECT_ROOT / "roles")
_GUIDE_HOST = "web-app-docs"
class TestGuideRidesThePrimaryOnly(unittest.TestCase):
PRIMARIES: ClassVar[tuple[str, ...]] = ("web-app-listmonk", "svc-db-mariadb")
def test_the_guide_host_joins_exactly_the_declared_round(self) -> None:
variants = get_variants(roles_dir=_ROLES_DIR, readonly=True)
for primary in self.PRIMARIES:
plan = plan_dev_inventory_matrix(
roles_dir=_ROLES_DIR,
primary_apps=[primary],
base_inventory_dir="/tmp/guide-rides-the-primary-only",
)
for round_index, _inv, round_variants, include, *_ in plan:
payload = variants[primary][round_variants[primary]]
declared = declares_guide(payload.get("services") or {})
swarm = derive_includes(primary, variants=round_variants)
with self.subTest(primary=primary, round=round_index):
self.assertEqual(
_GUIDE_HOST in include,
declared,
f"{primary}#{round_index}: the planned round must deploy "
f"{_GUIDE_HOST} exactly when {primary}'s own variant declares "
f"it; a partner falling back to its variant 0 must not pull "
f"it in",
)
self.assertEqual(
_GUIDE_HOST in swarm,
declared,
f"{primary}#{round_index}: the swarm include set must agree "
f"with the planned round on {_GUIDE_HOST}",
)
if __name__ == "__main__":
unittest.main()
@@ -43,6 +43,7 @@ from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.applications.variant_pins import pins_of
from utils.roles.applications.variants import services_overrides_for_round
from utils.roles.guide import GUIDE_SERVICE_KEY, guide_off
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_META_VARIANTS
from . import PROJECT_ROOT
@@ -64,7 +65,7 @@ def _edges() -> dict[str, dict[str, str]]:
if not isinstance(services, dict):
continue
for key, entry in services.items():
if not isinstance(entry, dict):
if not isinstance(entry, dict) or key == GUIDE_SERVICE_KEY:
continue
match = _GROUP_MEMBERSHIP.search(str(entry.get("enabled", "")))
if match and match.group(1) != role_dir.name:
@@ -91,17 +92,20 @@ def _rounds_pinned_true(role: str, key: str) -> tuple[int, ...]:
@functools.cache
def _closure(role: str, index: int) -> frozenset[str]:
"""The apps one round deploys.
"""The apps one round deploys, apart from the guide host the primary carries.
``cli.meta.ci.matrix.deployed_rounds`` answers the same question but
resolves every round of the role; a partner with fourteen variants then
costs fourteen resolutions to read one.
"""
overrides = services_overrides_for_round(
roles_dir=str(ROLES_DIR),
round_index=index,
primary_app_variants={role: index},
)
overrides = {
app: guide_off(services)
for app, services in services_overrides_for_round(
roles_dir=str(ROLES_DIR),
round_index=index,
primary_app_variants={role: index},
).items()
}
return frozenset(
_resolve_round_include(primary_apps=[role], services_overrides=overrides)
)
@@ -3,6 +3,7 @@
from __future__ import annotations
import unittest
from typing import ClassVar
from unittest.mock import MagicMock, patch
from cli.administration.deploy.development.inventory import (
@@ -63,6 +64,44 @@ class TestResolveVariantPayloads(unittest.TestCase):
self.assertEqual(resolved, {"web-app-multi": {"variant": 0}})
@patch(
"cli.administration.deploy.development.inventory.payload.build_service_registry_from_roles_dir",
autospec=True,
return_value={"docs": {"role": "web-app-docs"}},
)
@patch(
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
autospec=True,
)
class TestGuideEdgeBake(unittest.TestCase):
OVERRIDES: ClassVar[dict] = {
"svc-db-postgres": [{"services": {"docs": {"enabled": True}, "tor": {}}}],
"web-app-docs": [{}],
}
def test_an_edge_to_an_undeployed_guide_host_is_not_baked(
self, get_overrides_mock: MagicMock, _registry: MagicMock
) -> None:
get_overrides_mock.return_value = self.OVERRIDES
resolved = _resolve_variant_payloads(
roles_dir="/roles", include=["svc-db-postgres"], active_variants={}
)
self.assertEqual(resolved, {"svc-db-postgres": {"services": {"tor": {}}}})
def test_the_edge_stays_while_the_round_deploys_the_guide_host(
self, get_overrides_mock: MagicMock, _registry: MagicMock
) -> None:
get_overrides_mock.return_value = self.OVERRIDES
resolved = _resolve_variant_payloads(
roles_dir="/roles",
include=["svc-db-postgres", "web-app-docs"],
active_variants={},
)
self.assertEqual(
resolved["svc-db-postgres"]["services"]["docs"], {"enabled": True}
)
class TestBakeOverrides(unittest.TestCase):
def test_user_supplied_applications_override_wins_over_variant_payload(self):
baked = _bake_overrides(
+32 -1
View File
@@ -3,13 +3,44 @@ import unittest.mock as mock
from typing import ClassVar
from utils.cache.applications import get_variants
from utils.roles.guide import guide_deployable, guide_variant, smallest_variant
from utils.roles.guide import (
declares_guide,
guide_deployable,
guide_off,
guide_variant,
smallest_variant,
)
def _variant(*enabled: str) -> dict:
return {"services": {name: {"enabled": True} for name in enabled}}
class TestDeclaresGuide(unittest.TestCase):
def test_a_literal_true_declares_it(self) -> None:
self.assertTrue(declares_guide({"docs": {"enabled": True}}))
def test_the_group_gated_base_flag_does_not(self) -> None:
self.assertFalse(
declares_guide({"docs": {"enabled": "{{ 'web-app-docs' in group_names }}"}})
)
def test_a_role_without_the_edge_does_not(self) -> None:
self.assertFalse(declares_guide({"postgres": {"enabled": True}}))
class TestGuideOff(unittest.TestCase):
def test_the_edge_is_switched_off_and_its_other_keys_kept(self) -> None:
services = {"docs": {"bond": 0.5, "enabled": True, "shared": True}, "a": {}}
self.assertEqual(
guide_off(services),
{"docs": {"bond": 0.5, "enabled": False, "shared": False}, "a": {}},
)
def test_a_role_without_the_edge_is_left_as_it_is(self) -> None:
self.assertEqual(guide_off({"a": {"enabled": True}}), {"a": {"enabled": True}})
class TestSmallestVariant(unittest.TestCase):
def test_the_variant_with_the_fewest_enabled_services_wins(self) -> None:
variants = [_variant("a", "b", "c"), _variant("a"), _variant("a", "b")]
+2 -3
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from utils.roles.guide import GUIDE_SERVICE_KEY, guide_deployable
from utils.roles.guide import declares_guide, guide_deployable
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
@@ -39,8 +39,7 @@ def _carries_guide(
variants = (variants_per_app or {}).get(app) or []
if not variant.isdigit() or int(variant) >= len(variants):
return False
services = (variants[int(variant)] or {}).get("services") or {}
return (services.get(GUIDE_SERVICE_KEY) or {}).get("enabled") is True
return declares_guide((variants[int(variant)] or {}).get("services") or {})
def mark(
+11 -4
View File
@@ -28,6 +28,7 @@ from utils.roles.applications.services.registry import (
)
from utils.roles.applications.topics import CONFIG_TOPICS, apply_topic, provider_of
from utils.roles.applications.variant_pins import resolve_pins
from utils.roles.guide import declares_guide, guide_off
from utils.roles.mapping import ROLE_FILE_META_SERVICES
__all__ = [
@@ -182,9 +183,11 @@ def services_overrides_for_round(
their own variant count, unless a `# variant-pin:` marker in the
round's active variant of another role pins them (see
:mod:`utils.roles.applications.variant_pins`); a primary app keeps
its supplied index and no pin overrides it. Roles without variants
are absent from the result so the resolver falls through to its
disk-read path.
its supplied index and no pin overrides it. The guide host stays on
only where a primary app's variant declares it; every other role has
it off.
Roles without variants are absent from the result so the resolver
falls through to its disk-read path.
The merged map is what the inventory ALSO bakes into host_vars, so
feeding the same dict into `CombinedResolver(services_overrides=...)`
@@ -222,6 +225,10 @@ def services_overrides_for_round(
continue
merged = _deep_merge(dict(base_services), dict(variant_services))
if isinstance(merged, dict):
overrides[role_name] = merged
overrides[role_name] = (
merged
if role_name in primary_app_variants and declares_guide(merged)
else guide_off(merged)
)
_apply_nested_service_maps(overrides, roles_dir=roles_dir)
return overrides
+33 -1
View File
@@ -16,6 +16,7 @@ installed onto the machine) has to be a mode the role runs and tests.
from __future__ import annotations
from collections.abc import Mapping
from functools import cache
from typing import TYPE_CHECKING
@@ -25,7 +26,7 @@ from utils.roles.meta_lookup import get_role_mode_enabled, get_role_test_skips
from utils.roles.validation.invokable import _get_invokable_paths, _is_role_invokable
if TYPE_CHECKING:
from collections.abc import Collection, Mapping, Sequence
from collections.abc import Collection, Sequence
from typing import Any
@@ -36,6 +37,37 @@ variant this function picks is the one that then switches the guide on, which
on the next read makes that variant one service larger than it was."""
def without_guide(services: Mapping[str, Any]) -> dict[str, Any]:
"""``services`` without the guide host's entry.
Args:
services: a role's services map or a variant's services override.
"""
return {key: value for key, value in services.items() if key != GUIDE_SERVICE_KEY}
def declares_guide(services: Mapping[str, Any]) -> bool:
"""Whether ``services`` switches the guide host on, as a literal.
Args:
services: a role's resolved services map.
"""
entry = services.get(GUIDE_SERVICE_KEY)
return isinstance(entry, Mapping) and entry.get("enabled") is True
def guide_off(services: Mapping[str, Any]) -> dict[str, Any]:
"""``services`` with the guide host switched off, when it declares one.
Args:
services: a role's resolved services map.
"""
entry = services.get(GUIDE_SERVICE_KEY)
if not isinstance(entry, Mapping):
return dict(services)
return {**services, GUIDE_SERVICE_KEY: {**entry, "enabled": False, "shared": False}}
def _enabled_services(variant: Mapping[str, Any]) -> int:
services = variant.get("services") or {}
return sum(
+24 -2
View File
@@ -42,6 +42,7 @@ from utils.roles.applications.in_group_deps import applications_if_group_and_all
from utils.roles.applications.services.registry import (
build_service_registry_from_applications,
)
from utils.roles.guide import declares_guide, guide_off
from utils.tests.swarm.force_shared_db import db_provider_service_keys, pinned_local_db
_ROLES_DIR = PROJECT_ROOT / "roles"
@@ -82,6 +83,24 @@ def _active_variant_map(override: dict[str, int] | None = None) -> dict[str, int
}
def _guide_only_for(applications: dict[str, Any], primary: str) -> dict[str, Any]:
"""Switch the guide host off everywhere but a ``primary`` that declares it.
Args:
applications: the round's application configs.
primary: the app whose closure is resolved.
"""
result = dict(applications)
for app_id, config in applications.items():
services = config.get("services") if isinstance(config, dict) else None
if not isinstance(services, dict):
continue
if app_id == primary and declares_guide(services):
continue
result[app_id] = {**config, "services": guide_off(services)}
return result
def _applications_for_active_variants(
base_applications: dict[str, Any],
variants: dict[str, int] | None = None,
@@ -176,8 +195,11 @@ def derive_includes(
"""
base_applications = get_merged_applications(roles_dir=str(_ROLES_DIR))
service_registry = build_service_registry_from_applications(base_applications)
applications = _force_shared_db_view(
_applications_for_active_variants(base_applications, variants)
applications = _guide_only_for(
_force_shared_db_view(
_applications_for_active_variants(base_applications, variants)
),
app_id,
)
disabled_roles = _disabled_provider_roles(service_registry) - {app_id}
transitive = applications_if_group_and_all_deps(