mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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>
105 lines
4.0 KiB
Python
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)
|