Files
computer-playbook/cli/administration/deploy/development/inventory/legacy_resolver.py
T
kevinveenbirkenbachandClaude Opus 5 f1635e6a59 feat(variants): let a variant dictate the config of the providers it pulls in
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>
2026-09-01 21:08:08 +02:00

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