mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-11 21:26:44 +00:00
A round deploys a provider once, so the role that pulls it in is the only place that can say how lean it has to be. A variant entry may now carry any application config topic under a service it depends on: a mapping deep-merges, so naming one key changes that key alone, and null empties the topic. Empty was the case that needed a marker of its own, because merging an empty mapping is a no-op and would leave exactly the services the round was trying to shed. The topic vocabulary and the merge semantics live in utils.roles.applications.topics, which is also where resources.py now gets them rather than spelling the same null-or-merge out a second time. Collecting who demands what stays in the inventory tree, refuses two roles demanding different values for one provider in the same round, and skips a role's own entity entry so the existing services.<own>.users declarations are not read as overrides. The CI set-cover learns the same fact. Its greedy cover retires a role's variant-0 row when an earlier row of another role embeds it, on the stated premise that embedding pulls the provider's variant 0. A variant that dictates that provider's config breaks the premise from the other side: whatever ran, it was not the provider as the provider declares it. Such a pair now covers nothing, so the retired row keeps its job, its artefact and its verdict. Measured on the current tree: 20 override pairs, 41 covered rows before and after, so nothing is revoked today. The rule bites the moment an override lands in a row that covers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
7.2 KiB
Python
187 lines
7.2 KiB
Python
"""Default (non-variant) include resolution for the matrix planner.
|
|
|
|
When NO primary app ships a `meta/variants.yml`, the planner falls back
|
|
to this path — the `CombinedResolver` walks `dependencies` and
|
|
`shared:`-edges in the variant-merged services map and Jinja flags are
|
|
evaluated at deploy time against `group_names`. `run_after` is NOT
|
|
followed for inclusion (it is a pure ordering hint), so a variant that
|
|
disables a service does not get the provider re-added through the
|
|
ordering edge. This module owns the two helpers that path needs and
|
|
nothing else; the variant-only path lives in `.variants`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from pathlib import Path
|
|
|
|
from utils.cache.applications import get_variants
|
|
from utils.cache.base import _deep_merge
|
|
from utils.cache.yaml import load_yaml_any
|
|
from utils.roles.mapping import ROLE_FILE_META_SERVICES
|
|
|
|
from .nested_overrides import apply_topic, collect_provider_overrides
|
|
|
|
|
|
def _build_services_overrides_for_round(
|
|
*,
|
|
roles_dir: str,
|
|
round_index: int,
|
|
primary_app_variants: Mapping[str, int],
|
|
) -> dict[str, dict]:
|
|
"""For every role with `meta/variants.yml`, return the services map
|
|
that results from merging the round's variant payload onto the
|
|
role's on-disk `meta/services.yml`.
|
|
|
|
Apps in `primary_app_variants` use the supplied (already clamped)
|
|
index. Other roles with their own variants clamp `round_index` to
|
|
their own variant count. 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=...)`
|
|
eliminates the topology-vs-host_vars drift that variant-blind
|
|
resolution produced.
|
|
"""
|
|
variants_per_app = get_variants(roles_dir=roles_dir)
|
|
overrides: dict[str, dict] = {}
|
|
roles_path = Path(roles_dir)
|
|
for role_name, variant_list in variants_per_app.items():
|
|
if not variant_list:
|
|
continue
|
|
variant_count = max(1, len(variant_list))
|
|
if role_name in primary_app_variants:
|
|
idx = primary_app_variants[role_name]
|
|
else:
|
|
idx = round_index if round_index < variant_count else 0
|
|
if not 0 <= idx < len(variant_list):
|
|
idx = 0
|
|
variant_payload = variant_list[idx] if variant_list else {}
|
|
if not isinstance(variant_payload, Mapping):
|
|
variant_payload = {}
|
|
variant_services = variant_payload.get("services", {})
|
|
if not isinstance(variant_services, Mapping):
|
|
continue
|
|
services_path = roles_path / role_name / ROLE_FILE_META_SERVICES
|
|
if not services_path.exists():
|
|
continue
|
|
try:
|
|
base_services = load_yaml_any(services_path) or {}
|
|
except Exception: # noqa: S112 best-effort iteration over role files; skip malformed input
|
|
continue
|
|
if not isinstance(base_services, Mapping):
|
|
continue
|
|
merged = _deep_merge(dict(base_services), dict(variant_services))
|
|
if isinstance(merged, dict):
|
|
overrides[role_name] = merged
|
|
_apply_nested_service_maps(overrides, roles_dir=roles_dir)
|
|
return overrides
|
|
|
|
|
|
def _apply_nested_service_maps(overrides: dict[str, dict], *, roles_dir: str) -> None:
|
|
"""Replace a pulled-in provider's services map with what the round demands.
|
|
|
|
Only the ``services`` topic reaches this path: the closure walks service
|
|
edges, so the other config topics a variant may override are the inventory
|
|
bake's business.
|
|
|
|
Args:
|
|
overrides: ``{role: services map}``, mutated in place.
|
|
roles_dir: roles directory the service registry is built from.
|
|
"""
|
|
payloads = {role: {"services": services} for role, services in overrides.items()}
|
|
for provider, topics in collect_provider_overrides(
|
|
payloads, roles_dir=roles_dir
|
|
).items():
|
|
if "services" not in topics:
|
|
continue
|
|
overrides[provider] = apply_topic(overrides.get(provider), topics["services"])
|
|
|
|
|
|
def _resolve_round_include(
|
|
*,
|
|
primary_apps: Sequence[str],
|
|
services_overrides: dict[str, dict],
|
|
) -> tuple[str, ...]:
|
|
"""Resolve transitive prerequisites for each primary app under a
|
|
round's variant-merged services map. Returns the full include set
|
|
in stable order (deps first per primary, primary last; primaries
|
|
iterated in the user-provided order).
|
|
|
|
The resolver import is deferred so the host-side import surface
|
|
stays lean for callers that never need the variant-aware planner
|
|
(e.g. lint/validation).
|
|
"""
|
|
from cli.meta.roles.applications.resolution.combined.resolver import (
|
|
CombinedResolver,
|
|
)
|
|
|
|
resolver = CombinedResolver(
|
|
services_overrides=services_overrides, follow_run_after=False
|
|
)
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for app_id in primary_apps:
|
|
deps = resolver.resolve(app_id)
|
|
for dep in deps:
|
|
if dep != app_id and dep not in seen:
|
|
out.append(dep)
|
|
seen.add(dep)
|
|
if app_id not in seen:
|
|
out.append(app_id)
|
|
seen.add(app_id)
|
|
return tuple(out)
|
|
|
|
|
|
def prune_orphans_after_disable(
|
|
*,
|
|
include: Sequence[str],
|
|
primary_apps: Sequence[str],
|
|
disabled_app_ids: Iterable[str],
|
|
services_overrides: dict[str, dict],
|
|
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
"""Split a round's ``include`` into ``(kept, pruned)`` after `disable`.
|
|
|
|
`kept` are the members still reachable from a surviving primary app
|
|
once every role in `disabled_app_ids` is cut from the graph; `pruned`
|
|
are the orphaned remainder (the disabled roots themselves excluded).
|
|
Reachability walks the same `dependencies` + `services` edges the
|
|
round's closure was built from — `run_after` is NOT followed, matching
|
|
``CombinedResolver(follow_run_after=False)`` — using `services_overrides`
|
|
so the variant-merged topology matches what the inventory bakes.
|
|
|
|
Returned order follows `include` for both tuples.
|
|
|
|
Args:
|
|
include: the round's full closure (deps-first, primary last).
|
|
primary_apps: the operator-selected roots (already `disable`-filtered).
|
|
disabled_app_ids: provider roles cut by `disable`.
|
|
services_overrides: round variant-merged services maps per role.
|
|
"""
|
|
from cli.meta.roles.applications.resolution.combined.resolver import (
|
|
CombinedResolver,
|
|
)
|
|
|
|
disabled = set(disabled_app_ids)
|
|
resolver = CombinedResolver(
|
|
services_overrides=services_overrides, follow_run_after=False
|
|
)
|
|
reachable: set[str] = set()
|
|
stack = [a for a in primary_apps if a not in disabled]
|
|
while stack:
|
|
node = stack.pop()
|
|
if node in reachable or node in disabled:
|
|
continue
|
|
reachable.add(node)
|
|
edges = resolver.edges_for(node)
|
|
stack.extend(
|
|
dep
|
|
for dep in (*edges.dependencies, *edges.services)
|
|
if dep not in reachable and dep not in disabled
|
|
)
|
|
kept = tuple(role for role in include if role in reachable)
|
|
pruned = tuple(
|
|
role for role in include if role not in reachable and role not in disabled
|
|
)
|
|
return kept, pruned
|