"""Dependency-resolver harness primitive (Phase 2 §4.2 / Q2.3). A Phase-2 recipe may declare a set of OTHER recipes it requires to run its tests (e.g. lasuite-docs requires keycloak as its SSO provider). The orchestrator reads the deps list, deploys each one BEFORE the recipe-under-test, persists their per-run identity to a JSON file the recipe's tests can read, and tears them down at the end of the run. Per Phase-2 DECISIONS: - Deps are declared on the cc-ci side in `tests//recipe_meta.py` as `DEPS = ["keycloak", ...]` (a list of recipe names). This keeps the cc-ci surface authoritative per plan §1.4 (cc-ci is self-contained at runtime). - Each dep is deployed at a unique per-run domain `-<6hex>` (the same naming scheme as the recipe under test, but the 6hex is derived from `recipe + pr + ref + dep_name` so two deps of the same kind by different recipes never collide on a host). - Dep deploys are SEQUENTIAL, never concurrent (per plan §4.2 — heavy deps + recipe under test must share the single node's MAX_TESTS budget without exceeding it). - Each dep is undeployed in the orchestrator's `finally`, in **reverse** order so a recipe-under- test can depend on multiple deps with a dependency chain (a → b → c teardown is c → b → a). Run state: - `$CCCI_DEPS_FILE` — JSON file written by the orchestrator after each dep deploys; each entry is `{"recipe": "", "domain": "", "version": null}`. Tests access via the `deps_apps` pytest fixture defined in `tests/conftest.py`. """ from __future__ import annotations import contextlib import json import os from typing import Iterable from . import lifecycle, naming def declared_deps(recipe: str) -> list[str]: """Read `DEPS` from `tests//recipe_meta.py` — a list of recipe names this recipe needs deployed alongside it. Returns [] if none.""" path = os.path.join( os.path.dirname(__file__), "..", "..", "tests", recipe, "recipe_meta.py" ) if not os.path.exists(path): return [] ns: dict = {} with open(path) as fh: exec(compile(fh.read(), path, "exec"), ns) # noqa: S102 (trusted, in-repo) deps = ns.get("DEPS") or [] return [str(d) for d in deps if d] def dep_domain(parent_recipe: str, pr: str, ref: str | None, dep_recipe: str) -> str: """Per-run domain for a dep app. Distinct from the parent's domain so two recipes' deps don't collide. The 6hex is derived from (parent_recipe, pr, ref, dep_recipe) — stable per run, but different for every (parent, dep) pair so deps belonging to different parents don't collide on the same node.""" # naming.app_domain hashes (recipe, pr, ref). Bake parent_recipe + dep_recipe into the ref so # the hash distinguishes (parent_A,dep_X) from (parent_B,dep_X). The recipe arg drives the # prefix — passing dep_recipe keeps the visible prefix correct (`keyc-...`). synthetic_ref = f"{parent_recipe}|{ref or ''}|dep|{dep_recipe}" return naming.app_domain(dep_recipe, pr, synthetic_ref) def write_run_state(deps_state: list[dict]) -> None: """Write the deps state file ($CCCI_DEPS_FILE) so dependent tests can find their dep apps via the `deps_apps` fixture. No-op if the env var isn't set.""" path = os.environ.get("CCCI_DEPS_FILE") if not path: return with open(path, "w") as f: json.dump(deps_state, f) def deploy_deps( parent_recipe: str, pr: str, ref: str | None, deps: Iterable[str], meta_for: dict[str, dict] | None = None, ) -> list[dict]: """Deploy each declared dep, sequentially, at its per-run domain. Returns the list of state dicts (one per dep). `meta_for` maps dep_recipe -> meta (HEALTH_PATH/HEALTH_OK/timeouts) so the readiness wait uses per-dep config; missing dep meta falls back to (/, 200/301/302, 600s).""" meta_for = meta_for or {} state: list[dict] = [] for dep in deps: domain = dep_domain(parent_recipe, pr, ref, dep) print(f" dep: deploying {dep} -> {domain}", flush=True) # NB: each dep_app gets a fresh deploy_count entry only on `_record_deploy` which fires # inside `lifecycle.deploy_app`. For Phase 2 the deploy-count guard (DG4.1) counts the # parent + its deps as distinct install events — by design, since each is a separate app. lifecycle.deploy_app(dep, domain, secrets=True) # Use dep's own recipe_meta if provided dm = meta_for.get(dep, {}) try: lifecycle.wait_healthy( domain, ok_codes=tuple(dm.get("HEALTH_OK", (200, 301, 302))), path=dm.get("HEALTH_PATH", "/"), deploy_timeout=int(dm.get("DEPLOY_TIMEOUT", 600)), http_timeout=int(dm.get("HTTP_TIMEOUT", 600)), ) except Exception: # If a dep fails to converge, abort the whole resolve — let the caller teardown print(f" dep: {dep} ({domain}) failed readiness; tearing down", flush=True) with contextlib.suppress(Exception): lifecycle.teardown_app(domain, verify=False) raise state.append({"recipe": dep, "domain": domain}) print(f" dep: {dep} ready @ {domain}", flush=True) write_run_state(state) return state def teardown_deps(state: list[dict]) -> None: """Undeploy each dep in reverse order. Suppresses exceptions per-dep so one teardown failure doesn't strand the others. Mirrors the orchestrator's teardown_app(verify=False) pattern.""" for entry in reversed(state): domain = entry.get("domain") if not domain: continue with contextlib.suppress(Exception): print(f" dep: tearing down {entry.get('recipe')} @ {domain}", flush=True) lifecycle.teardown_app(domain, verify=False) def load_run_state() -> list[dict]: """Read the current run's deps state (used by the `deps_apps` fixture). Returns [] if unset.""" path = os.environ.get("CCCI_DEPS_FILE") if not path or not os.path.exists(path): return [] try: with open(path) as f: return json.load(f) or [] except (OSError, ValueError): return []