#!/usr/bin/env python3 """Print, one per line, the role IDs the provision CLI should ``--include`` so the resulting inventory has every group APP_ID's transitive deps will need at deploy time. ``svc-swarm-manager`` (subset marker), ``svc-storage-nfs-client`` (no ``application_id``), ``svc-registry-docker`` (consumed by the swarm handler that pushes locally-built images, but not declared as a meta-dep so it stays out of compose-mode deploys) are added explicitly because the dep-walker only knows roles that are in the ``applications`` dict. The DR-drill backup roles arrive implicitly: the apps' ``container_backup`` service declaration pulls ``svc-bkp-volume-2-local`` into the closure, and ``svc-bkp-nfs-2-local`` deploys via its group membership on the NFS server host (``extend_inventory``) through the group-driven role includes. Input (env): ``APP_ID``. Optional ``INFINITO_APP_VARIANTS`` (JSON ``{app_id: variant_index}``, set per round by the matrix orchestrator) selects the active variant so a variant that pins ``services.*`` flags to ``false`` prunes those providers from the closure instead of the provision step pulling them in from the variant-free base config. In-process callers that already hold the round map pass it as ``derive_includes(app_id, variants=...)``; the env var is read only when that argument is omitted. DB-provider services are exempt from that pruning: the dep walk applies the same force-shared view as ``utils.tests.swarm.force_shared_db``. """ from __future__ import annotations import json import os import sys from typing import Any from utils import PROJECT_ROOT from utils.cache.applications import get_merged_applications, get_variants from utils.roles.applications.in_group_deps import applications_if_group_and_all_deps from utils.roles.applications.services.registry import ( build_service_registry_from_applications, ) from utils.tests.swarm.force_shared_db import db_provider_service_keys, pinned_local_db _ROLES_DIR = PROJECT_ROOT / "roles" _EXPLICIT_INCLUDES: tuple[str, ...] = ( "svc-swarm-manager", "svc-storage-nfs-client", "svc-registry-docker", ) def _active_variant_map(override: dict[str, int] | None = None) -> dict[str, int]: """Normalise the active ``{app_id: variant_index}`` map. Args: override: map supplied by an in-process caller; ``None`` reads and parses ``INFINITO_APP_VARIANTS`` instead. Returns: the entries with a ``str`` key and a non-bool ``int`` index. """ if override is None: raw = os.environ.get("INFINITO_APP_VARIANTS") if not raw: return {} try: override = json.loads(raw) except json.JSONDecodeError: return {} if not isinstance(override, dict): return {} return { app_id: index for app_id, index in override.items() if isinstance(app_id, str) and isinstance(index, int) and not isinstance(index, bool) } def _applications_for_active_variants( base_applications: dict[str, Any], variants: dict[str, int] | None = None, ) -> dict[str, Any]: """Swap every app listed in the active variant map to its variant config. Index 0 is swapped too: a variant-0 override MAY disable flags the base config's dynamic-Jinja form counts as enabled (web-app-nextcloud pins 11 partner services off in variant 0), so "variant 0 == base" does not hold. Every entry in the map is swapped, not just the primary app, because host_vars baking honours the dep roles' indices as well and the include closure must match that topology. Args: base_applications: rendered application configs. variants: active ``{app_id: variant_index}`` map; ``None`` falls back to ``INFINITO_APP_VARIANTS``. """ overrides = _active_variant_map(variants) if not overrides: return base_applications variants_per_app = get_variants(roles_dir=str(_ROLES_DIR)) applications = dict(base_applications) for app_id, index in overrides.items(): app_variants = variants_per_app.get(app_id) or [] if 0 <= index < len(app_variants): applications[app_id] = app_variants[index] return applications def _force_shared_db_view(applications: dict[str, Any]) -> dict[str, Any]: """Mirror ``force_shared_db``: flip ``shared: true`` on every existing svc-db-* provider service entry so the dep walk sees the topology the swarm deploy will actually run.""" db_keys = db_provider_service_keys(_ROLES_DIR) if not db_keys: return applications result = dict(applications) for app_id, config in applications.items(): services = config.get("services") if isinstance(config, dict) else None if not isinstance(services, dict): continue flipped = { key for key, entry in services.items() if key in db_keys and isinstance(entry, dict) and entry.get("enabled") is not False and entry.get("shared") is not True and not pinned_local_db(app_id, key) } if not flipped: continue patched_services = dict(services) for key in flipped: patched_services[key] = {**services[key], "shared": True} result[app_id] = {**config, "services": patched_services} return result def applications_for_round( variants: dict[str, int] | None = None, ) -> dict[str, Any]: """Return the round's application configs with its variants applied. Args: variants: active ``{app_id: variant_index}`` map; ``None`` falls back to ``INFINITO_APP_VARIANTS``. Returns: Application id to its config for this round. """ return _applications_for_active_variants( get_merged_applications(roles_dir=str(_ROLES_DIR)), variants ) def derive_includes( app_id: str, *, variants: dict[str, int] | None = None ) -> list[str]: """Resolve APP_ID's transitive include set under the active variants. The service registry (service key -> provider role) stays built from the full rendered base set so provider discovery is unaffected by the active variants; only the dep-walk sees the variant configs (with the swarm force-shared DB view applied on top). Args: app_id: role whose closure is resolved. variants: active ``{app_id: variant_index}`` map; ``None`` falls back to ``INFINITO_APP_VARIANTS``, which only the subprocess callers have in their environment. """ base_applications = get_merged_applications(roles_dir=str(_ROLES_DIR)) service_registry = build_service_registry_from_applications(base_applications) applications = _force_shared_db_view( _applications_for_active_variants(base_applications, variants) ) disabled_roles = _disabled_provider_roles(service_registry) - {app_id} transitive = applications_if_group_and_all_deps( applications, [app_id], project_root=str(PROJECT_ROOT), roles_dir=str(_ROLES_DIR), service_registry=service_registry, blocked=disabled_roles, ) found = set(transitive) | set(_EXPLICIT_INCLUDES) | {app_id} found -= disabled_roles return sorted(found) def variant_scope(app_id: str, *, variants: dict[str, int] | None = None) -> list[str]: """Deployed roles for APP_ID that must carry their own variant config. Args: app_id: role whose closure is resolved. variants: active ``{app_id: variant_index}`` map, as ``derive_includes``. """ explicit = set(_EXPLICIT_INCLUDES) - {app_id} return [ role for role in derive_includes(app_id, variants=variants) if role not in explicit ] def _disabled_provider_roles(service_registry: dict[str, Any]) -> set[str]: """Provider roles the ``disable`` env removes from the closure, so the swarm include set matches what the provision-time services_disabler disables (otherwise extend_inventory re-adds the pruned providers via this same dep walk). Accepts service keys and provider application ids. """ from utils.roles.applications.services.registry import ( canonical_service_key, expand_service_tokens, ) raw = os.environ.get("disable", "").strip() if not raw: return set() tokens = expand_service_tokens( [t.strip() for t in raw.replace(",", " ").split() if t.strip()], _ROLES_DIR ) roles: set[str] = set() for token in tokens: if token not in service_registry: continue primary = canonical_service_key(service_registry, token) role = (service_registry.get(primary) or {}).get("role") if isinstance(role, str) and role: roles.add(role) return roles def main() -> int: for role_id in derive_includes(os.environ["APP_ID"]): print(role_id) return 0 if __name__ == "__main__": sys.exit(main())