Files
computer-playbook/cli/administration/deploy/development/inventory/legacy_resolver.py
T
kevinveenbirkenbachandClaude Opus 4.7 f102c229f0 refactor(inventory): split monolith into SRP submodules + variant-only mode
Two changes ship together because the variant-only matrix-deploy logic
was written into the legacy 697-line inventory.py and then re-split:

* Source package (max 240 lines per submodule, well under the 300 budget):
  - spec.py            DevInventorySpec + PlanEntry data types
  - payload.py         _resolve_variant_payloads + _bake_overrides
  - legacy_resolver.py CombinedResolver fallback (default-mode path)
  - variants.py        strict variant-only include resolution
  - planner.py         plan_dev_inventory_matrix + filter_plan_to_variant
  - builder.py         build_dev_inventory + build_dev_inventory_matrix
  - __init__.py        public-API re-exports + back-compat shims

* Test package mirrors the source 1:1 with a shared _fixtures.make_spec
  to avoid duplicating the DevInventorySpec defaults across files.

* New variant-only matrix mode (gated on any primary shipping
  meta/variants.yml): only services whose merged variant payload pins
  both enabled and shared to literal True become inventory groups;
  Jinja flags never qualify (group_names does not exist yet at build
  time); run_after only orders the include, never expands it;
  cross-primary disagreement on a (service-key, round) raises ValueError.
  Default mode (no variants.yml on any primary) keeps the legacy
  CombinedResolver path untouched.

* Docs in design/{inventory,variants}.md updated to point at the new
  per-submodule paths; test_nginx_vhosts.py's Test classes now also
  inherit directly from unittest.TestCase so the AST-level lint in
  test_all_test_files_have_tests.py picks them up (transitive base via
  NginxVhostsTestBase alone is not enough).

Coverage: 458 cli unit tests, 349 utils unit tests, 99 dev-deploy tests,
all green; ruff check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:17:26 +02:00

105 lines
4.0 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 both `run_after` and
`shared:`-edges in the variant-merged services map and Jinja flags are
evaluated at deploy time against `group_names`. 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 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
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
return overrides
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).
"""
# Late import keeps the host-side import surface 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)
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)