"""Matrix-deploy planner. Pure function — reads role metadata via cached YAML helpers and emits ``PlanEntry`` tuples; no filesystem side effects. The include set per round is resolved by the ``CombinedResolver`` (see ``.legacy_resolver``): each role contributes its own pulled-in providers and the include is the union. A service-key is therefore pulled in as soon as ANY role in the round-aware closure says it should be (literal ``True`` OR the ``"{{ '' in group_names }}"`` Jinja form, both accepted by ``utils.roles.applications.services.registry.is_explicit_truth``); if EVERY role marks it ``False`` it stays out of the include. """ from __future__ import annotations from typing import TYPE_CHECKING from utils.cache.applications import get_variants from . import legacy_resolver if TYPE_CHECKING: from collections.abc import Sequence from .spec import PlanEntry def filter_plan_to_variants( plan: list[PlanEntry], variants: list[int] | None, ) -> list[PlanEntry]: """Pin a matrix plan to a subset of rounds when `variants` is set. `variants=None` returns the plan unchanged (full-matrix mode). This is the runner-split entry point: a role with more variants than one runner should iterate is sliced into bundles (e.g. ``0,1,2`` on one runner and ``3,4`` on the next), each bundle passed as `variants`. Out-of-range indices raise `ValueError` so the caller surfaces a clean operator error. """ if variants is None: return plan available = sorted(entry[0] for entry in plan) missing = sorted(v for v in variants if v not in available) if missing: raise ValueError( f"variants {missing} out of range; available rounds: {available}" ) wanted = set(variants) return [entry for entry in plan if entry[0] in wanted] def plan_dev_inventory_matrix( *, roles_dir: str, primary_apps: Sequence[str], base_inventory_dir: str, ) -> list[PlanEntry]: """Return ``[(round_index, inventory_dir, round_variants, include, purge_set), ...]`` — per-round variant-closure plus a plan-constant union for the inter-round wipe.""" if not primary_apps: raise ValueError("plan_dev_inventory_matrix: primary_apps must not be empty") variants_per_app = get_variants(roles_dir=roles_dir) primary_variant_counts = { app_id: max(1, len(variants_per_app.get(app_id) or [{}])) for app_id in primary_apps } total_rounds = max(primary_variant_counts.values(), default=1) base = str(base_inventory_dir).rstrip("/") per_variant_includes: list[tuple[str, ...]] = [] primary_round_variants_per_round: list[dict[str, int]] = [] for round_index in range(total_rounds): primary_round_variants = { app_id: round_index if round_index < count else 0 for app_id, count in primary_variant_counts.items() } primary_round_variants_per_round.append(primary_round_variants) services_overrides = legacy_resolver._build_services_overrides_for_round( roles_dir=roles_dir, round_index=round_index, primary_app_variants=primary_round_variants, ) per_variant_includes.append( legacy_resolver._resolve_round_include( primary_apps=primary_apps, services_overrides=services_overrides, ) ) seen_union: set[str] = set() union_apps: list[str] = [] for variant_include in per_variant_includes: for dep in variant_include: if dep not in seen_union: seen_union.add(dep) union_apps.append(dep) purge_set: tuple[str, ...] = tuple(union_apps) plan: list[PlanEntry] = [] for round_index in range(total_rounds): primary_round_variants = primary_round_variants_per_round[round_index] round_include = per_variant_includes[round_index] round_variants = dict(primary_round_variants) for dep, dep_variants in variants_per_app.items(): if dep in round_variants: continue dep_count = max(1, len(dep_variants or [{}])) round_variants[dep] = round_index if round_index < dep_count else 0 inv_dir = f"{base}-{round_index}" if total_rounds > 1 else base plan.append((round_index, inv_dir, round_variants, round_include, purge_set)) return plan