Files
computer-playbook/cli/administration/deploy/development/inventory/payload.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

126 lines
4.8 KiB
Python

"""Variant payload baking for the development inventory.
Two helpers, both pure: `_resolve_variant_payloads` picks the variant
payload for each app in a round's include set, and `_bake_overrides`
merges those payloads under `applications:` inside the `--vars` JSON
the inventory provision step consumes. Caller-supplied `extra_vars`
always win over the variant bake — that's the documented "user vars
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.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(
*,
roles_dir: str,
include: Sequence[str],
active_variants: Mapping[str, int],
) -> dict[str, Any]:
"""Return ``{app_id: variant_override}`` for the requested round.
Each value is the raw `meta/variants.yml` entry — NOT the
deep-merge of variants.yml on top of `meta/services.yml`. Baking
only the override keeps host_vars sparse so downstream passes
(notably `apply_mirror_overrides`) can populate fields like
`image`/`version` that the role declares in `meta/services.yml`.
Apps without `meta/variants.yml` collapse to a single empty
override `{}`. Out-of-range indices clamp to 0.
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] = {}
for app_id in include:
variant_list = overrides_per_app.get(app_id) or [{}]
if not variant_list:
continue
index = active_variants.get(app_id, 0)
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():
if provider not in resolved:
continue
payload = resolved[provider]
payload = dict(payload) if isinstance(payload, Mapping) else {}
for topic, value in topics.items():
payload[topic] = apply_topic(payload.get(topic), value)
resolved[provider] = payload
return resolved
def _bake_overrides(
*,
base_overrides: Mapping[str, Any],
variant_payloads: Mapping[str, Any],
) -> dict[str, Any]:
"""Merge the per-app variant payloads under `applications:` into the
`--vars` JSON the init step will hand to
`infinito administration inventory provision`.
Caller-supplied `extra_vars` (already inside `base_overrides`) win
over the variant bake when the same key is set on both sides — that
matches the existing "user vars always win" rule."""
merged: dict[str, Any] = dict(base_overrides)
if not variant_payloads:
return merged
existing_apps = merged.get("applications")
if not isinstance(existing_apps, Mapping):
existing_apps = {}
apps: dict[str, Any] = dict(variant_payloads.items())
for app_id, override in existing_apps.items():
base_payload = apps.get(app_id)
if isinstance(base_payload, Mapping) and isinstance(override, Mapping):
apps[app_id] = {**base_payload, **override}
else:
apps[app_id] = override
merged["applications"] = apps
return merged