mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
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(
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user