Files
computer-playbook/utils/roles/guide.py
T
kevinveenbirkenbachandClaude Opus 5.5 91022ad37a 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>
2026-10-28 12:49:51 +01:00

150 lines
5.4 KiB
Python

"""Which deploy row replays the role's README instructions, and in what mode.
The instructions test used to be a job of its own that picked one random role
per run. Riding along on a deploy row instead costs no extra runner and covers
every role the sweep touches. The row it rides on is the role's smallest
variant -- the one enabling the fewest services -- because that is the shape
the README's Production block describes: the bare role, with the optional
providers switched off.
The replay is a second deploy on the same runner, so the row it picks must be
one the guide can actually deploy: the role has to be invokable, because that
is what makes the documentation build give it a Production block, and the
guide's own mode (``compose`` for a role that ships a stack, ``host`` for one
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
from utils.cache.files import PROJECT_ROOT
from utils.roles.deploy import role_has_stack
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, Sequence
from typing import Any
GUIDE_SERVICE_KEY = "docs"
"""The guide host is the replay's own carrier, not a provider the README's
Production block describes. Counting it would make the measure circular: the
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(
1
for key, config in services.items()
if key != GUIDE_SERVICE_KEY
and isinstance(config, dict)
and config.get("enabled")
)
def smallest_variant(
variants: Sequence[Mapping[str, Any]], allowed: Collection[str] | None = None
) -> str:
"""The variant that enables the fewest services, as the matrix writes it;
the lowest index wins a tie, and ``''`` means none qualifies.
Args:
variants: the role's rendered variant configs, in index order.
allowed: the variants this sweep actually deploys. A variant the sweep
cuts as redundant coverage never reaches a runner, so hanging the
replay on it would silently drop the role from the instructions
test; the next-smallest deployed variant carries it instead.
"""
candidates = [
index
for index in range(len(variants))
if allowed is None or str(index) in allowed
]
if not candidates:
return ""
return str(
min(candidates, key=lambda index: (_enabled_services(variants[index]), index))
)
@cache
def guide_deployable(app: str) -> str:
"""The mode the guide would replay *app* in, or ``''`` when it cannot.
Independent of the variant, and cached: the answer is a property of the
role's files, and :func:`utils.github.variant.axes.assign` asks it once per
row of every chunk.
"""
role_dir = PROJECT_ROOT / "roles" / app
if not _is_role_invokable(app, _get_invokable_paths()):
return ""
mode = "compose" if role_has_stack(role_dir) else "host"
if mode in get_role_test_skips(role_dir, role_name=app):
return ""
if not get_role_mode_enabled(role_dir, mode=mode, role_name=app):
return ""
return mode
def guide_variant(
app: str,
variants_per_app: Mapping[str, Sequence[Mapping[str, Any]]] | None,
allowed: Collection[str],
) -> tuple[str, str]:
"""The variant of *app* that carries the replay, and the mode it runs in.
``('', '')`` when the role has no instructions to replay or this sweep
deploys no variant that could carry them.
Args:
app: the role.
variants_per_app: rendered variant configs per app; without them no
variant can be told to be the smallest one.
allowed: the variants this sweep deploys, as the matrix writes them.
"""
variants = (variants_per_app or {}).get(app)
if not variants:
return "", ""
mode = guide_deployable(app)
if not mode:
return "", ""
variant = smallest_variant(variants, allowed)
return (variant, mode) if variant else ("", "")