"""Single recipe-meta loader + declarative key registry (recipe-custom restructure P1; spec docs/recipe-customization.md §8 R1). THE one place `tests//recipe_meta.py` is `exec()`d. Every consumer (orchestrator, pytest `meta` fixture, deploy env shaping, deps, warm-canonical enrollment, screenshot) reads the ONE loaded `RecipeMeta` object instead of re-exec'ing the file and cherry-picking keys — that drift (six divergent loaders, spec §4 L1–L6) is what made `SCREENSHOT` an unreachable knob (R2) and let key typos silently disable coverage (R6). Validation (locked decision, recipe-custom-restructure-full-plan.md): - unknown ALL-CAPS top-level name → MetaError (hard error, fails fast at load; the all-recipes unit test catches it at PR time). Underscore-prefixed names (`_FOO`) are recipe-private and exempt; lowercase names (helper functions/imports) are ignored. - type mismatch → MetaError. Callables are accepted ONLY for hook-typed keys. The KEYS registry is the single source of truth for the key set: it drives validation, the RecipeMeta dataclass fields, and the generated reference table in docs/recipe-customization.md §4 (scripts/gen-meta-docs.py; a unit test asserts the committed table matches). """ from __future__ import annotations import copy import dataclasses import difflib import inspect import json import os from collections.abc import Callable ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) TESTS_DIR = os.path.join(ROOT, "tests") class MetaError(Exception): """A recipe_meta.py failed registry validation (unknown key / type mismatch / callable on a data key). Hard error by design: a typo'd key must fail the run at load, not silently reduce coverage (spec §8 R6 — the worst failure mode for a CI harness).""" @dataclasses.dataclass(frozen=True) class Key: """One registered recipe_meta key: name, type tag, default, one-line doc (rendered into the generated reference table), optional extra validator, and a deprecation marker (deprecated keys still load+validate but are scheduled for deletion).""" name: str type: str # "int"|"str"|"tuple[int]"|"bool"|"dict_or_hook"|"hook"|"list[str]"|"dict" default: object doc: str validate: Callable[[object], None] | None = None deprecated: bool = False # Expected positional-parameter names for a callable value (rcust P3 uniform ctx convention). # Enforced at load so a legacy-signature hook (e.g. `def READY_PROBE(domain)`) fails with a # CLEAR MetaError naming the migration — never a silent TypeError mid-run. hook_params: tuple[str, ...] | None = None KEYS: tuple[Key, ...] = ( Key( "HEALTH_PATH", "str", "/", "Path probed for serving/health checks (deploy wait + generic `assert_serving`).", ), Key("HEALTH_OK", "tuple[int]", (200, 301, 302), "Acceptable HTTP status codes for health."), Key("DEPLOY_TIMEOUT", "int", 600, "Max seconds to wait for swarm convergence per deploy."), Key("HTTP_TIMEOUT", "int", 300, "Max seconds to wait for HTTP health after convergence."), Key( "BACKUP_CAPABLE", "bool", None, "Override the backup-tier capability auto-detect (compose `backupbot.backup` labels). `False` forces an intentional skip of the backup/restore rung; `True` forces the tier on; unset = auto-detect.", ), Key( "EXPECTED_NA", "dict", None, "Declare a non-run rung an INTENTIONAL skip: `{rung: reason}` — the level climbs past it; an undeclared non-run rung is *unverified* and blocks the level above it (classification table: machine-docs/DECISIONS.md phase lvl5). Never overrides an exercised pass/fail; the `lint` rung has no escape hatch.", ), Key( "READY_PROBE", "hook", None, "Callable `(ctx) -> [probe, ...]` returning extra readiness probes, run after install AND after upgrade: HTTP `{host, path, ok}` or TCP `{tcp_host, tcp_port, stable}`.", hook_params=("ctx",), ), Key( "UPGRADE_BASE_VERSION", "str", None, "Exact published tag overriding the upgrade tier's base (default: `recipe_versions[-2]`).", ), Key( "BACKUP_VERIFY", "hook", None, "Callable `(ctx) -> bool` post-backup data-capture check; `False` re-runs the backup (truncated-dump race guard), retried up to 3 attempts.", hook_params=("ctx",), ), Key( "UPGRADE_EXTRA_ENV", "dict_or_hook", None, "Extra `.env` keys applied after the PR-head checkout, before the chaos redeploy (env that exists only at head). Dict, or callable `(ctx) -> dict`.", hook_params=("ctx",), ), Key( "EXTRA_ENV", "dict_or_hook", {}, "Extra `.env` keys applied at EVERY deploy (base install AND upgrade old-app). Dict, or callable `(ctx) -> dict` deriving values from the per-run domain (`ctx.domain`).", hook_params=("ctx",), ), Key( "DEPS", "list[str]", [], 'Dep recipes deployed/provisioned alongside (e.g. `["keycloak"]`); creds land in `$CCCI_DEPS_FILE`.', ), Key( "WARM_CANONICAL", "bool", False, "Enroll the recipe in the warm/canonical app system (docs/warm.md): green cold runs on LATEST advance the canonical snapshot.", ), Key( "SCREENSHOT", "hook", None, "Callable `(page, ctx)` driving Playwright to a safe, credential-free post-login view for the results-card screenshot (default: landing page).", hook_params=("page", "ctx"), ), # (CHAOS_BASE_DEPLOY, OIDC_AT_INSTALL and SKIP_GENERIC were deleted in restructure P2: # compose.ccci.yml is first-class + auto-chaos; install-time deps wiring is the only mode; # the generic floor is suppressible only via the dev-only CCCI_SKIP_GENERIC* env form.) ) _REGISTRY: dict[str, Key] = {k.name: k for k in KEYS} # The one validated, attribute-access view of a recipe's customization. Generated from KEYS so the # field set can never drift from the registry (frozen: consumers share one immutable object). RecipeMeta = dataclasses.make_dataclass( "RecipeMeta", [(k.name, object, dataclasses.field(default=None)) for k in KEYS], frozen=True, ) RecipeMeta.__doc__ = ( "Validated per-recipe customization (one field per registered key; attribute access). " "Built ONLY by meta.load()." ) def meta_path(recipe: str, tests_dir: str | None = None) -> str: """Canonical path of a recipe's meta file (pure).""" return os.path.join(tests_dir or TESTS_DIR, recipe, "recipe_meta.py") def check_hook_signature(fn, expected: tuple[str, ...], where: str) -> None: """Enforce the uniform ctx hook convention (rcust P3): a hook callable's positional parameters must be exactly `expected` (e.g. ("ctx",) or ("page", "ctx")). A legacy-signature hook (the pre-restructure `(domain)` / `(domain, meta)` / `(page, domain, meta)` forms) raises a CLEAR MetaError naming the migration — never a silent TypeError mid-run.""" try: params = [ p.name for p in inspect.signature(fn).parameters.values() if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) ] except (TypeError, ValueError): # builtins/odd callables — let the call site surface it return if tuple(params) != expected: raise MetaError( f"{where}: hook signature is ({', '.join(params)}) — the recipe-customization " f"restructure (P3) changed ALL recipe hook signatures to ({', '.join(expected)}); " f"read fields off the HookCtx (ctx.domain, ctx.base_url, ctx.meta, ctx.deps, ctx.op). " f"See docs/recipe-customization.md §5." ) def _coerce(key: Key, value: object, path: str) -> object: """Validate `value` against `key`'s declared type; normalize containers (tuple[int]/list[str]). Raises MetaError on mismatch — including a callable supplied for a data-typed key.""" t = key.type if callable(value) and t not in ("hook", "dict_or_hook"): raise MetaError( f"{path}: {key.name} is a data key (type {t}) — callables are accepted only for " f"hook-typed keys" ) if t == "int": if isinstance(value, int) and not isinstance(value, bool): return value elif t == "str": if isinstance(value, str): return value elif t == "bool": if isinstance(value, bool): return value elif t == "tuple[int]": if isinstance(value, tuple | list) and all( isinstance(x, int) and not isinstance(x, bool) for x in value ): return tuple(value) elif t == "list[str]": if isinstance(value, tuple | list) and all(isinstance(x, str) for x in value): return list(value) elif t == "dict": if isinstance(value, dict): return value elif ( t == "hook" and callable(value) or t == "dict_or_hook" and (isinstance(value, dict) or callable(value)) ): return value raise MetaError(f"{path}: {key.name} must be {t}, got {type(value).__name__} ({value!r})") def load(recipe: str, tests_dir: str | None = None): """Load + validate a recipe's customization -> RecipeMeta. THE only exec() of recipe_meta.py. Missing file -> all registry defaults (the zero-config baseline, spec §2). Unknown non-underscore ALL-CAPS top-level name or type mismatch -> MetaError (hard error). `tests_dir` overrides the recipe-meta root (unit tests / fixtures).""" path = meta_path(recipe, tests_dir) values = {k.name: copy.copy(k.default) for k in KEYS} if os.path.exists(path): ns: dict = {} with open(path) as fh: exec(compile(fh.read(), path, "exec"), ns) # noqa: S102 (trusted, in-repo) for name in sorted(ns): if name.startswith("_") or not name.isupper(): continue # _FOO = recipe-private (exempt); lowercase = helpers/imports (ignored) key = _REGISTRY.get(name) if key is None: near = difflib.get_close_matches(name, _REGISTRY, n=1) hint = f" — did you mean {near[0]!r}?" if near else "" raise MetaError( f"{path}: unknown recipe_meta key {name!r}{hint}. Registered keys: " f"{', '.join(sorted(_REGISTRY))}. Recipe-private constants must be " f"underscore-prefixed (e.g. _{name})." ) values[name] = _coerce(key, ns[name], path) if key.hook_params and callable(values[name]): check_hook_signature(values[name], key.hook_params, f"{path}: {name}") if key.validate: key.validate(values[name]) return RecipeMeta(**values) def as_dict(meta) -> dict: """RecipeMeta -> {key: value} (every registered key, defaults included).""" return dataclasses.asdict(meta) def non_default(meta) -> dict: """The keys a recipe explicitly customized: {key: value} where value differs from the registry default. Hooks compare by identity-vs-None (a set hook is always non-default). Feeds the run's customization manifest (P5).""" out = {} for k in KEYS: v = getattr(meta, k.name) if v != k.default: out[k.name] = v return out @dataclasses.dataclass(frozen=True) class HookCtx: """The single argument every recipe hook receives (rcust P3 uniform ctx convention): `EXTRA_ENV(ctx)`, `UPGRADE_EXTRA_ENV(ctx)`, `READY_PROBE(ctx)`, `BACKUP_VERIFY(ctx)`, `SCREENSHOT(page, ctx)`, ops.py `pre_(ctx)`.""" domain: str # the app's per-run domain base_url: str # https:// meta: object # the recipe's full RecipeMeta deps: dict | None # provisioned dep creds ({dep_recipe: entry}) or None if absent/empty op: str | None # current lifecycle op (install|upgrade|backup|restore) or None def _run_deps() -> dict | None: """The current run's provisioned dep creds from $CCCI_DEPS_FILE (either shape), or None. Read directly (not via harness.deps) to keep meta.py import-cycle-free.""" path = os.environ.get("CCCI_DEPS_FILE") if not path or not os.path.exists(path): return None try: with open(path) as f: data = json.load(f) except (OSError, ValueError): return None if isinstance(data, dict): return data or None if isinstance(data, list): out = {e["recipe"]: e for e in data if isinstance(e, dict) and e.get("recipe")} return out or None return None def hook_ctx(domain: str, meta, *, op: str | None = None) -> HookCtx: """Build the HookCtx for a hook call site. Dep creds are picked up from the run's $CCCI_DEPS_FILE when present (None otherwise).""" return HookCtx(domain=domain, base_url=f"https://{domain}", meta=meta, deps=_run_deps(), op=op) def _env_map(value, ctx: HookCtx) -> dict[str, str]: if callable(value): value = value(ctx) return {str(k): str(v) for k, v in (value or {}).items()} def extra_env(meta, ctx: HookCtx) -> dict[str, str]: """Resolve EXTRA_ENV (dict or callable(ctx)->dict) to the concrete per-run env map.""" return _env_map(meta.EXTRA_ENV, ctx) def upgrade_extra_env(meta, ctx: HookCtx) -> dict[str, str]: """Resolve UPGRADE_EXTRA_ENV (dict or callable(ctx)->dict) to the concrete env map.""" return _env_map(meta.UPGRADE_EXTRA_ENV, ctx)