From 472a68b32c07b1097f904af5801f0428f0b2392a Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Wed, 10 Jun 2026 16:46:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(harness):=20P1=20=E2=80=94=20single=20regi?= =?UTF-8?q?stry-backed=20meta=20loader=20(rcust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One loader: runner/harness/meta.py::load(recipe) -> RecipeMeta (frozen dataclass, attribute access), backed by the declarative KEYS registry (14 final keys + 3 P2-deprecated). The ONLY exec() of tests//recipe_meta.py. Validation per the locked decision: unknown ALL-CAPS top-level name or type mismatch = MetaError (hard error at load); underscore-prefixed names recipe-private; callables only on hook-typed keys. Migrated all six legacy loaders (spec §4 L1–L6): - run_recipe_ci.py::_load_meta deleted; orchestrator loads once, passes meta down - tests/conftest.py::_recipe_meta deleted; meta fixture returns full RecipeMeta (R3) - lifecycle.py::_recipe_extra_env/_recipe_meta_flag deleted; deploy_app takes meta - deps.py::declared_deps deleted; callers read meta.DEPS - canonical.py::is_enrolled reads through meta.load() - screenshot.py now actually receives SCREENSHOT through the orchestrator path (R2 fix; proven by unit test through the real load path) Mumble private constants underscore-prefixed (_WELCOME_TEXT_MARKER/_MAX_USERS) + importers fixed. New tests/unit/test_meta.py (all-recipes-load-clean typo gate, MetaError cases, spec §2 baseline defaults, underscore exemption, doc sync). Docs §4 key table now GENERATED from the registry (scripts/gen-meta-docs.py); drift fails CI. Verified on cc-ci: cc-ci-run -m pytest tests/unit -q -> 175 passed; scripts/lint.sh -> PASS. --- docs/recipe-customization.md | 33 +++ runner/harness/canonical.py | 14 +- runner/harness/deps.py | 34 +-- runner/harness/generic.py | 47 ++- runner/harness/lifecycle.py | 47 +-- runner/harness/meta.py | 267 ++++++++++++++++++ runner/harness/screenshot.py | 15 +- runner/run_recipe_ci.py | 102 +++---- scripts/gen-meta-docs.py | 71 +++++ tests/conftest.py | 36 +-- .../functional/test_server_config_limits.py | 4 +- .../functional/test_welcome_text_roundtrip.py | 2 +- tests/mumble/recipe_meta.py | 11 +- tests/unit/test_canonical.py | 13 +- tests/unit/test_deps.py | 47 +-- tests/unit/test_f212_upgrade_convergence.py | 16 +- tests/unit/test_meta.py | 204 +++++++++++++ tests/unit/test_screenshot.py | 17 ++ 18 files changed, 740 insertions(+), 240 deletions(-) create mode 100644 runner/harness/meta.py create mode 100644 scripts/gen-meta-docs.py create mode 100644 tests/unit/test_meta.py diff --git a/docs/recipe-customization.md b/docs/recipe-customization.md index f2ac579..2210a0e 100644 --- a/docs/recipe-customization.md +++ b/docs/recipe-customization.md @@ -96,6 +96,39 @@ single loader; six independent code paths each `exec()` the file and pick out th | L5 | `runner/harness/deps.py:declared_deps` | `DEPS` only | | L6 | `runner/harness/canonical.py:is_canonical_enrolled` | `WARM_CANONICAL` only | +> **Restructure status (rcust P1):** the six loaders above are HISTORY — they have been replaced by +> the single registry-backed loader `runner/harness/meta.py::load(recipe) -> RecipeMeta` (the only +> `exec()` of `recipe_meta.py`). Unknown ALL-CAPS keys / type mismatches are now hard errors; +> underscore-prefixed names are recipe-private. The authoritative key reference is the generated +> table below; the per-loader subsections §4.1–§4.8 are retained for context until the P6 doc +> rewrite. + + + +_This table is GENERATED from the `runner/harness/meta.py` KEYS registry by `scripts/gen-meta-docs.py` — do not edit by hand (a unit test pins the sync)._ + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `HEALTH_PATH` | `str` | `'/'` | Path probed for serving/health checks (deploy wait + generic `assert_serving`). | +| `HEALTH_OK` | `tuple[int]` | `(200, 301, 302)` | Acceptable HTTP status codes for health. | +| `DEPLOY_TIMEOUT` | `int` | `600` | Max seconds to wait for swarm convergence per deploy. | +| `HTTP_TIMEOUT` | `int` | `300` | Max seconds to wait for HTTP health after convergence. | +| `BACKUP_CAPABLE` | `bool` | `None` | Override the backup-tier capability auto-detect (compose `backupbot.backup` labels). `False` forces N/A; `True` forces the tier on; unset = auto-detect. | +| `EXPECTED_NA` | `dict` | `None` | Declare an N/A rung intentional: `{rung: reason}`. The cap stands either way; only the report wording changes. | +| `READY_PROBE` | `hook` | `None` | Callable returning extra readiness probes, run after install AND after upgrade: HTTP `{host, path, ok}` or TCP `{tcp_host, tcp_port, stable}`. | +| `UPGRADE_BASE_VERSION` | `str` | `None` | Exact published tag overriding the upgrade tier's base (default: `recipe_versions[-2]`). | +| `BACKUP_VERIFY` | `hook` | `None` | Callable `-> bool` post-backup data-capture check; `False` re-runs the backup (truncated-dump race guard), retried up to 3 attempts. | +| `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). | +| `EXTRA_ENV` | `dict_or_hook` | `{}` | Extra `.env` keys applied at EVERY deploy (base install AND upgrade old-app). Callable form derives values from the per-run domain. | +| `DEPS` | `list[str]` | `[]` | Dep recipes deployed/provisioned alongside (e.g. `["keycloak"]`); creds land in `$CCCI_DEPS_FILE`. | +| `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. | +| `SCREENSHOT` | `hook` | `None` | Callable driving Playwright to a safe, credential-free post-login view for the results-card screenshot (default: landing page). | +| `CHAOS_BASE_DEPLOY` **(deprecated)** | `bool` | `False` | DEPRECATED (P2 deletes): ship `tests//compose.ccci.yml` instead — the harness auto-copies it and auto-uses `--chaos` for the base deploy. | +| `OIDC_AT_INSTALL` **(deprecated)** | `bool` | `False` | DEPRECATED (P2 deletes): install-time deps provisioning becomes the ONLY mode when `DEPS` is set. | +| `SKIP_GENERIC` **(deprecated)** | `list[str]` | `[]` | DEPRECATED (P2 deletes; zero users): suppress the generic floor for the listed ops. The env form `CCCI_SKIP_GENERIC*` stays as a dev-only escape hatch. | + + + ### 4.1 HTTP / health / timing (base 4 — seen by L1 AND L2) | Key | Type / default | Meaning | Used by | diff --git a/runner/harness/canonical.py b/runner/harness/canonical.py index a80d299..e176369 100644 --- a/runner/harness/canonical.py +++ b/runner/harness/canonical.py @@ -30,17 +30,13 @@ import subprocess import time from . import abra, warm, warmsnap +from . import meta as meta_mod def is_enrolled(recipe: str) -> bool: - """True if `tests//recipe_meta.py` sets `WARM_CANONICAL = True`. Missing meta → False.""" - path = os.path.join(os.path.dirname(__file__), "..", "..", "tests", recipe, "recipe_meta.py") - if not os.path.exists(path): - return False - ns: dict = {} - with open(path) as fh: - exec(compile(fh.read(), path, "exec"), ns) # noqa: S102 (trusted, in-repo) - return bool(ns.get("WARM_CANONICAL")) + """True if `tests//recipe_meta.py` sets `WARM_CANONICAL = True`. Missing meta → False. + Reads through the single meta loader (rcust P1 — no per-module exec).""" + return bool(meta_mod.load(recipe).WARM_CANONICAL) def canonical_domain(recipe: str) -> str: @@ -51,7 +47,7 @@ def canonical_domain(recipe: str) -> str: def enrolled_recipes() -> list[str]: """All recipes enrolled as data-warm canonicals (recipe_meta.WARM_CANONICAL=True), sorted. Used by the WC6 nightly sweep to know which canonicals to refresh via a green cold run on latest.""" - tests_dir = os.path.join(os.path.dirname(__file__), "..", "..", "tests") + tests_dir = meta_mod.TESTS_DIR out = [] try: for name in sorted(os.listdir(tests_dir)): diff --git a/runner/harness/deps.py b/runner/harness/deps.py index bd679a1..11445ae 100644 --- a/runner/harness/deps.py +++ b/runner/harness/deps.py @@ -31,19 +31,7 @@ import os from collections.abc 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] +from . import meta as meta_mod def dep_domain(parent_recipe: str, pr: str, ref: str | None, dep_recipe: str) -> str: @@ -81,11 +69,12 @@ def deploy_deps( pr: str, ref: str | None, deps: Iterable[str], - meta_for: dict[str, dict] | None = None, + meta_for: 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).""" + dicts (one per dep). `meta_for` maps dep_recipe -> RecipeMeta (HEALTH_PATH/HEALTH_OK/timeouts) + so the readiness wait uses per-dep config; a missing dep meta is loaded via meta.load() + (defaults: /, 200/301/302, 600s).""" meta_for = meta_for or {} state: list[dict] = [] for dep in deps: @@ -94,20 +83,21 @@ def deploy_deps( # 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. - dm = meta_for.get(dep, {}) + dm = meta_for.get(dep) or meta_mod.load(dep) lifecycle.deploy_app( dep, domain, secrets=True, - deploy_timeout=int(dm.get("DEPLOY_TIMEOUT", 900)), + deploy_timeout=int(dm.DEPLOY_TIMEOUT), + meta=dm, ) 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)), + ok_codes=tuple(dm.HEALTH_OK), + path=dm.HEALTH_PATH, + deploy_timeout=int(dm.DEPLOY_TIMEOUT), + http_timeout=int(dm.HTTP_TIMEOUT), ) except Exception: # If a dep fails to converge, abort the whole resolve — let the caller teardown diff --git a/runner/harness/generic.py b/runner/harness/generic.py index d468dbf..62a062d 100644 --- a/runner/harness/generic.py +++ b/runner/harness/generic.py @@ -19,6 +19,7 @@ import ssl import time from . import abra, lifecycle +from . import meta as meta_mod # A recipe is backup-capable iff a compose file carries a truthy backupbot.backup label. _BACKUPBOT_RE = re.compile(r"backupbot\.backup\b[^\n]*\btrue\b", re.IGNORECASE) @@ -28,13 +29,14 @@ def _recipe_dir(recipe: str) -> str: return abra.recipe_dir(recipe) # the per-run tree inside a CI run ($ABRA_DIR) -def backup_capable(recipe: str, meta: dict | None = None) -> bool: +def backup_capable(recipe: str, meta=None) -> bool: """Whether the harness should run the backup/restore tiers (else they are a clean N/A skip, DG3). - `recipe_meta.BACKUP_CAPABLE` (bool) overrides; otherwise auto-detect by scanning the recipe's - compose*.yml for a truthy `backupbot.backup` label (the Co-op Cloud backup convention).""" - if meta and "BACKUP_CAPABLE" in meta: - return bool(meta["BACKUP_CAPABLE"]) + `recipe_meta.BACKUP_CAPABLE` (bool) overrides when explicitly set (RecipeMeta default is None = + unset); otherwise auto-detect by scanning the recipe's compose*.yml for a truthy + `backupbot.backup` label (the Co-op Cloud backup convention).""" + if meta is not None and meta.BACKUP_CAPABLE is not None: + return bool(meta.BACKUP_CAPABLE) for path in glob.glob(os.path.join(_recipe_dir(recipe), "compose*.yml")): try: with open(path) as fh: @@ -75,7 +77,7 @@ def served_cert(domain: str, port: int = 443) -> tuple[bool, str]: return (True, f"CN={cn} SAN={sans}") -def assert_serving(domain: str, meta: dict) -> None: +def assert_serving(domain: str, meta) -> None: """The single generic "is the app really serving?" assertion (DG1). The app-vs-Traefik-fallback proof is steps 1+2 (both load-bearing, verified by the Adversary): @@ -90,14 +92,14 @@ def assert_serving(domain: str, meta: dict) -> None: Steps 1–2 are BOUNDED POLLS (no bare sleep), so a state-mutating op (upgrade/restore) that leaves the app briefly reconverging settles, while a persistent failure still fails within the timeout.""" - deadline = time.time() + meta["DEPLOY_TIMEOUT"] + deadline = time.time() + meta.DEPLOY_TIMEOUT while time.time() < deadline and not lifecycle.services_converged(domain): time.sleep(5) assert lifecycle.services_converged(domain), f"{domain}: services did not converge" - path = meta["HEALTH_PATH"] - ok = tuple(meta["HEALTH_OK"]) - deadline = time.time() + meta["HTTP_TIMEOUT"] + path = meta.HEALTH_PATH + ok = tuple(meta.HEALTH_OK) + deadline = time.time() + meta.HTTP_TIMEOUT served = False status, body = 0, "" while time.time() < deadline: @@ -141,7 +143,7 @@ def op_state() -> dict: return {} -def assert_upgraded(domain: str, meta: dict) -> None: +def assert_upgraded(domain: str, meta) -> None: """Generic UPGRADE assertion (post-op): the orchestrator already performed the upgrade once via `abra app deploy --chaos` of the PR-head checkout. Assert it reconverged + still serves AND that the deployment is genuinely the PR-head code under test (HC1) — non-vacuously (guarding F1d-2). @@ -212,7 +214,7 @@ def assert_backup_artifact(domain: str) -> str: return snap_id -def assert_restore_healthy(domain: str, meta: dict) -> None: +def assert_restore_healthy(domain: str, meta) -> None: """Generic RESTORE assertion (post-op): the orchestrator already restored. Assert the app is healthy + serving again (assert_serving polls, so the post-restore reconverge settles).""" assert_serving(domain, meta) @@ -226,7 +228,7 @@ def perform_upgrade( recipe: str, head_ref: str | None, deploy_timeout: int = 900, - meta: dict | None = None, + meta=None, ) -> dict[str, str | None]: """Perform the UPGRADE op once, in place, to the PR-HEAD code under test (HC1): re-checkout the PR head (the prev-tag base deploy reset the recipe working tree), then `abra app deploy --chaos` @@ -244,7 +246,8 @@ def perform_upgrade( STRICTER convergence+health wait here: services N/N (wait_healthy) + app HEALTH_PATH healthy + any recipe READY_PROBE (collabora WOPI discovery 200). This bounds readiness by OUR generous deadline, not abra's impatient one — and is stronger evidence than abra's monitor.""" - meta = meta or {} + if meta is None: + meta = meta_mod.load(recipe) before = lifecycle.deployed_identity(domain) if head_ref: lifecycle.recipe_checkout_ref(recipe, head_ref) @@ -253,9 +256,7 @@ def perform_upgrade( # (target) version, so the base deploys minimally WITHOUT it and the upgrade adds it to COMPOSE_FILE # here, after the PR-head checkout (which ships the overlay) and before the chaos redeploy that # picks up the new .env. Dict or callable(domain)->dict. No-op for recipes without it. - upgrade_env = meta.get("UPGRADE_EXTRA_ENV") or {} - if callable(upgrade_env): - upgrade_env = upgrade_env(domain) or {} + upgrade_env = meta_mod.upgrade_extra_env(meta, domain) for k, v in upgrade_env.items(): print(f" upgrade-env: {k}={v}", flush=True) abra.env_set(domain, k, v) @@ -266,14 +267,12 @@ def perform_upgrade( # Own the convergence verification (abra's monitor was skipped via -c). lifecycle.wait_healthy( domain, - ok_codes=tuple(meta.get("HEALTH_OK", (200, 301, 302))), - path=meta.get("HEALTH_PATH", "/"), - deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", deploy_timeout)), - http_timeout=int(meta.get("HTTP_TIMEOUT", 300)), - ) - lifecycle.wait_ready_probes( - meta, domain, timeout=int(meta.get("DEPLOY_TIMEOUT", deploy_timeout)) + ok_codes=tuple(meta.HEALTH_OK), + path=meta.HEALTH_PATH, + deploy_timeout=int(meta.DEPLOY_TIMEOUT), + http_timeout=int(meta.HTTP_TIMEOUT), ) + lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.DEPLOY_TIMEOUT)) after = lifecycle.deployed_identity(domain) # Evidence (HC1): the chaos-version label = the deployed recipe commit; it should match the # PR-head we checked out — proving the upgrade deployed the code under test, not a published tag. diff --git a/runner/harness/lifecycle.py b/runner/harness/lifecycle.py index 10a1d4b..c670496 100644 --- a/runner/harness/lifecycle.py +++ b/runner/harness/lifecycle.py @@ -19,6 +19,7 @@ import time import urllib.request from . import abra, lifetime +from . import meta as meta_mod GATEWAY_IP = "143.244.213.108" # *.ci.commoninternet.net -> gateway (TLS passthrough to cc-ci) # A run app domain is "-<6hex>.ci.commoninternet.net" (see DECISIONS.md). Used by the @@ -111,37 +112,6 @@ def _residual(domain: str) -> dict: } -def _recipe_extra_env(recipe: str, domain: str) -> dict[str, str]: - """Per-recipe extra .env keys, applied at every deploy (install + upgrade's old_app) so a recipe - with multi-domain / config needs is enrolled with NO shared-harness change (D5/M6.5). A recipe - declares `EXTRA_ENV` in tests//recipe_meta.py as either a dict or a callable - `EXTRA_ENV(domain) -> dict` (callable form lets it derive values from the per-run domain, e.g. - cryptpad's SANDBOX_DOMAIN). 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) - ee = ns.get("EXTRA_ENV") - if callable(ee): - ee = ee(domain) - return {str(k): str(v) for k, v in (ee or {}).items()} - - -def _recipe_meta_flag(recipe: str, key: str) -> bool: - """Read a boolean flag from tests//recipe_meta.py (e.g. CHAOS_BASE_DEPLOY). Returns - False if the recipe ships no meta or the flag is absent/falsey. Trusted in-repo exec, same as - _recipe_extra_env.""" - path = os.path.join(os.path.dirname(__file__), "..", "..", "tests", recipe, "recipe_meta.py") - if not os.path.exists(path): - return False - ns: dict = {} - with open(path) as fh: - exec(compile(fh.read(), path, "exec"), ns) # noqa: S102 (trusted, in-repo) - return bool(ns.get(key)) - - def _record_deploy() -> None: """Increment the per-run deploy counter (DG4.1: one deploy per run). No-op unless the orchestrator set CCCI_DEPLOY_COUNT_FILE — so it never affects standalone/manual use.""" @@ -238,15 +208,22 @@ def deploy_app( secrets: bool = True, install_steps_hook: tuple[str, str] | None = None, deploy_timeout: int = 900, + meta=None, ) -> None: """Create + configure + deploy an app. Forces LETS_ENCRYPT_ENV='' so traefik serves the wildcard cert via the file provider and NEVER attempts ACME (adversary finding A1). Applies any per-recipe EXTRA_ENV (recipe_meta.py) and the custom install-steps hook (Phase 1d) before deploy. + `meta` is the recipe's loaded RecipeMeta (EXTRA_ENV / CHAOS_BASE_DEPLOY); the orchestrator + loads once and passes it down. Callers without one in hand (fixtures, warm reconcile) may omit + it — it is then loaded here via the single meta.load() path. + `deploy_timeout` is the subprocess timeout for `abra app deploy`. Caller (orchestrator) passes `recipe_meta.DEPLOY_TIMEOUT` so heavy recipes (ghost, matrix-synapse, lasuite-meet) can extend past the 900s default. abra's INTERNAL TIMEOUT (recipe's TIMEOUT env, default 300s) is set via EXTRA_ENV; this is the Python subprocess wrapper's timeout so abra doesn't get SIGKILLed mid-deploy.""" + if meta is None: + meta = meta_mod.load(recipe) _record_deploy() # Lock BEFORE the app exists: a concurrent run's janitor must never see this app without a # held app lock (it would probe it as an orphan and reap an in-flight deploy). Also the @@ -280,7 +257,7 @@ def deploy_app( # abra's pinned-deploy clean-tree check FATA ('has locally unstaged changes'); chaos skips lint + # the clean-tree gate and deploys the EXPLICITLY-checked-out pinned version (we already ran # recipe_checkout(version) above) — NOT latest. Same mechanism as the lightweight-tag branch. - elif _recipe_meta_flag(recipe, "CHAOS_BASE_DEPLOY"): + elif meta.CHAOS_BASE_DEPLOY: print( f" deploy_app({recipe}@{version}): CHAOS_BASE_DEPLOY set → chaos base deploy of the " "checked-out pinned version (skips clean-tree/lint; deploys version, not LATEST)", @@ -293,7 +270,7 @@ def deploy_app( # it ourselves is recipe-agnostic and canonical (the run domain IS the app's domain). abra.env_set(domain, "DOMAIN", domain) abra.env_set(domain, "LETS_ENCRYPT_ENV", "") - for k, v in _recipe_extra_env(recipe, domain).items(): + for k, v in meta_mod.extra_env(meta, domain).items(): abra.env_set(domain, k, v) if secrets: abra.secret_generate(domain) @@ -510,7 +487,7 @@ def chaos_redeploy( abra.deploy(domain, chaos=True, timeout=deploy_timeout, no_converge_checks=no_converge_checks) -def wait_ready_probes(meta: dict, domain: str, timeout: int = 600) -> None: +def wait_ready_probes(meta, domain: str, timeout: int = 600) -> None: """Poll a recipe's optional READY_PROBE endpoints until each returns an accepted status, or raise. A recipe_meta may define `READY_PROBE(domain) -> [{"host":..., "path":..., "ok":(200,)}, ...]` @@ -527,7 +504,7 @@ def wait_ready_probes(meta: dict, domain: str, timeout: int = 600) -> None: must be released by the old task + rebound by the new) the voice server can be down while HTTP-200 still passes — and backup-bot then execs into a not-running app container (409). Requiring the voice port to be stably listening before proceeding closes that window.""" - probe_fn = meta.get("READY_PROBE") + probe_fn = meta.READY_PROBE if not callable(probe_fn): return probes = probe_fn(domain) or [] diff --git a/runner/harness/meta.py b/runner/harness/meta.py new file mode 100644 index 0000000..8214cee --- /dev/null +++ b/runner/harness/meta.py @@ -0,0 +1,267 @@ +"""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 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 + + +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 N/A; `True` forces the tier on; unset = auto-detect.", + ), + Key( + "EXPECTED_NA", + "dict", + None, + "Declare an N/A rung intentional: `{rung: reason}`. The cap stands either way; only the report wording changes.", + ), + Key( + "READY_PROBE", + "hook", + None, + "Callable returning extra readiness probes, run after install AND after upgrade: HTTP `{host, path, ok}` or TCP `{tcp_host, tcp_port, stable}`.", + ), + 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 `-> bool` post-backup data-capture check; `False` re-runs the backup (truncated-dump race guard), retried up to 3 attempts.", + ), + 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).", + ), + Key( + "EXTRA_ENV", + "dict_or_hook", + {}, + "Extra `.env` keys applied at EVERY deploy (base install AND upgrade old-app). Callable form derives values from the per-run domain.", + ), + 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 driving Playwright to a safe, credential-free post-login view for the results-card screenshot (default: landing page).", + ), + # ---- Deprecated (deleted in restructure P2; registered so P1 lands green before P2 removes + # them — see recipe-custom-restructure-full-plan.md "Decisions locked") ----------------------- + Key( + "CHAOS_BASE_DEPLOY", + "bool", + False, + "DEPRECATED (P2 deletes): ship `tests//compose.ccci.yml` instead — the harness auto-copies it and auto-uses `--chaos` for the base deploy.", + deprecated=True, + ), + Key( + "OIDC_AT_INSTALL", + "bool", + False, + "DEPRECATED (P2 deletes): install-time deps provisioning becomes the ONLY mode when `DEPS` is set.", + deprecated=True, + ), + Key( + "SKIP_GENERIC", + "list[str]", + [], + "DEPRECATED (P2 deletes; zero users): suppress the generic floor for the listed ops. The env form `CCCI_SKIP_GENERIC*` stays as a dev-only escape hatch.", + deprecated=True, + ), +) + +_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 _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.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 + + +def _env_map(value, domain: str) -> dict[str, str]: + if callable(value): + value = value(domain) + return {str(k): str(v) for k, v in (value or {}).items()} + + +def extra_env(meta, domain: str) -> dict[str, str]: + """Resolve EXTRA_ENV (dict or callable(domain)->dict) to the concrete per-run env map.""" + return _env_map(meta.EXTRA_ENV, domain) + + +def upgrade_extra_env(meta, domain: str) -> dict[str, str]: + """Resolve UPGRADE_EXTRA_ENV (dict or callable(domain)->dict) to the concrete env map.""" + return _env_map(meta.UPGRADE_EXTRA_ENV, domain) diff --git a/runner/harness/screenshot.py b/runner/harness/screenshot.py index de69766..f7e47ee 100644 --- a/runner/harness/screenshot.py +++ b/runner/harness/screenshot.py @@ -33,12 +33,19 @@ def screenshot_path(run_artifact_dir: str) -> str: return os.path.join(run_artifact_dir, "screenshot.png") -def _load_screenshot_hook(recipe_meta: dict | None): +def _load_screenshot_hook(recipe_meta): """Return the recipe's optional SCREENSHOT hook (a callable) if it declared one, else None. - The hook drives Playwright to a safe post-login view; default is the landing page.""" - if not recipe_meta: + The hook drives Playwright to a safe post-login view; default is the landing page. + + `recipe_meta` is the loaded RecipeMeta (rcust P1 — the single loader actually delivers + SCREENSHOT now; under the old L1 allowlist the key never arrived, spec §8 R2). A plain dict + is still accepted for direct/manual callers.""" + if recipe_meta is None: return None - hook = recipe_meta.get("SCREENSHOT") + if isinstance(recipe_meta, dict): + hook = recipe_meta.get("SCREENSHOT") + else: + hook = getattr(recipe_meta, "SCREENSHOT", None) return hook if callable(hook) else None diff --git a/runner/run_recipe_ci.py b/runner/run_recipe_ci.py index 77891cf..2045b2f 100644 --- a/runner/run_recipe_ci.py +++ b/runner/run_recipe_ci.py @@ -58,6 +58,9 @@ from harness import ( # noqa: E402 from harness import ( # noqa: E402 deps as deps_mod, ) +from harness import ( # noqa: E402 + meta as meta_mod, +) from harness import ( # noqa: E402 results as results_mod, ) @@ -247,40 +250,11 @@ def snapshot_recipe_tests(recipe: str) -> str | None: return dst -def _load_meta(recipe: str) -> dict: - """Mirror tests/conftest._recipe_meta so the orchestrator's deploy/wait uses the same per-recipe - config the tiers see (timeouts, health path/codes).""" - meta = { - "HEALTH_PATH": "/", - "HEALTH_OK": (200, 301, 302), - "DEPLOY_TIMEOUT": 600, - "HTTP_TIMEOUT": 300, - } - path = os.path.join(ROOT, "tests", recipe, "recipe_meta.py") - 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 k in list(meta) + [ - "BACKUP_CAPABLE", - "SKIP_GENERIC", - "EXPECTED_NA", - "OIDC_AT_INSTALL", - "READY_PROBE", - "UPGRADE_BASE_VERSION", - "BACKUP_VERIFY", - "UPGRADE_EXTRA_ENV", - ]: - if k in ns: - meta[k] = ns[k] - return meta - - def _tier_env(domain: str) -> dict: return dict(os.environ, CCCI_APP_DOMAIN=domain, CCCI_BASE_URL=f"https://{domain}") -def _skip_generic(op: str, meta: dict) -> bool: +def _skip_generic(op: str, meta) -> bool: """Whether the generic assertion for `op` is opted out (Phase 1e HC3). Default: run (additive). Opt-out, any of: env CCCI_SKIP_GENERIC (all ops), env CCCI_SKIP_GENERIC_, or the recipe's declarative recipe_meta.SKIP_GENERIC list (op name, or "all"/"*").""" @@ -288,11 +262,11 @@ def _skip_generic(op: str, meta: dict) -> bool: return True if _truthy(os.environ.get(f"CCCI_SKIP_GENERIC_{op.upper()}")): return True - sg = [str(s).lower() for s in (meta.get("SKIP_GENERIC") or [])] + sg = [str(s).lower() for s in (meta.SKIP_GENERIC or [])] return "all" in sg or "*" in sg or op in sg -def _run_pre_hook(recipe: str, op: str, repo_local: str | None, domain: str, meta: dict) -> None: +def _run_pre_hook(recipe: str, op: str, repo_local: str | None, domain: str, meta) -> None: """Run the optional pre-op seed hook (recipe ops.py `pre_`) BEFORE the harness performs the op (HC3 op/assertion split): overlays seed data-continuity markers / the backup→restore mutation here, then assert post-op in test_.py. cc-ci's ops.py is trusted; a repo-local ops.py is @@ -322,7 +296,7 @@ def _perform_op( head_ref: str | None, op_state: dict, deploy_timeout: int = 900, - meta: dict | None = None, + meta=None, ) -> None: """Perform the single mutating op ONCE (the harness owns the op, HC3). install has no op. Records what the assertions need (pre-upgrade identity, backup snapshot_id) into op_state. None of these @@ -345,7 +319,7 @@ def _perform_op( # verify fails we re-run the WHOLE backup (fresh restic snapshot) with a re-stabilised DB, up to # 3 attempts. Recipes without BACKUP_VERIFY are unaffected (single backup, as before). snap = generic.perform_backup(domain) - verify = meta.get("BACKUP_VERIFY") if meta else None + verify = meta.BACKUP_VERIFY if meta else None attempt = 1 while callable(verify) and not verify(domain) and attempt < 3: attempt += 1 @@ -371,7 +345,7 @@ def run_lifecycle_tier( op: str, repo_local: str | None, domain: str, - meta: dict, + meta, head_ref: str | None, op_state: dict, records: list[dict] | None = None, @@ -411,7 +385,7 @@ def run_lifecycle_tier( recipe, head_ref, op_state, - deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)), + deploy_timeout=int(meta.DEPLOY_TIMEOUT), meta=meta, ) with open(os.environ["CCCI_OP_STATE_FILE"], "w") as f: @@ -523,7 +497,7 @@ def _provision_deps( if wd: print(f" dep: {d} warm provider {wd} not up — cold fallback", flush=True) cold_deps.append(d) - dep_metas = {d: _load_meta(d) for d in cold_deps} + dep_metas = {d: meta_mod.load(d) for d in cold_deps} deps_list = ( deps_mod.deploy_deps(recipe, os.environ.get("PR", "0"), ref, cold_deps, meta_for=dep_metas) if cold_deps @@ -609,7 +583,7 @@ def _wait_undeployed(domain: str, timeout: int = 120) -> None: def run_quick( - recipe: str, ref: str | None, head_ref: str | None, repo_local: str | None, meta: dict + recipe: str, ref: str | None, head_ref: str | None, repo_local: str | None, meta ) -> int: """WC4 `--quick` opt-in fast lane (plan §2). Reattach the data-warm canonical (known-good volume) → upgrade IN PLACE to the PR head (chaos) → assert generic UPGRADE (reconverge+moved+serving) + @@ -645,7 +619,7 @@ def run_quick( op_state: dict = {} results: dict[str, str] = {} - declared = deps_mod.declared_deps(recipe) + declared = list(meta.DEPS) deps_state: dict = {} deps_ready = True deps_not_ready_reason = "" @@ -657,13 +631,13 @@ def run_quick( try: # 1) reattach the canonical (warm boot at the known-good version + retained volume) try: - canonical.deploy_canonical(recipe, timeout=int(meta.get("DEPLOY_TIMEOUT", 900))) + canonical.deploy_canonical(recipe, timeout=int(meta.DEPLOY_TIMEOUT)) lifecycle.wait_healthy( domain, - ok_codes=tuple(meta["HEALTH_OK"]), - path=meta["HEALTH_PATH"], - deploy_timeout=meta["DEPLOY_TIMEOUT"], - http_timeout=meta["HTTP_TIMEOUT"], + ok_codes=tuple(meta.HEALTH_OK), + path=meta.HEALTH_PATH, + deploy_timeout=meta.DEPLOY_TIMEOUT, + http_timeout=meta.HTTP_TIMEOUT, ) warm_ok = True except Exception as e: # noqa: BLE001 @@ -678,7 +652,7 @@ def run_quick( for d in declared: wd = warm.warm_domain(d) (warm_deps if (wd and warm.is_warm_up(d, wd)) else cold_deps).append(d) - dep_metas = {d: _load_meta(d) for d in cold_deps} + dep_metas = {d: meta_mod.load(d) for d in cold_deps} deps_list = ( deps_mod.deploy_deps( recipe, os.environ.get("PR", "0"), ref, cold_deps, meta_for=dep_metas @@ -848,7 +822,7 @@ def promote_canonical(recipe: str, head_ref: str | None) -> None: if not latest: print(f"WC5 promote: no version tags for {recipe} — skip", flush=True) return - meta = _load_meta(recipe) + meta = meta_mod.load(recipe) # The cold run's deploy-count was already asserted + the countfile removed; don't perturb it. os.environ.pop("CCCI_DEPLOY_COUNT_FILE", None) print( @@ -860,14 +834,15 @@ def promote_canonical(recipe: str, head_ref: str | None) -> None: domain, version=latest, secrets=True, - deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)), + deploy_timeout=int(meta.DEPLOY_TIMEOUT), + meta=meta, ) lifecycle.wait_healthy( domain, - ok_codes=tuple(meta["HEALTH_OK"]), - path=meta["HEALTH_PATH"], - deploy_timeout=meta["DEPLOY_TIMEOUT"], - http_timeout=meta["HTTP_TIMEOUT"], + ok_codes=tuple(meta.HEALTH_OK), + path=meta.HEALTH_PATH, + deploy_timeout=meta.DEPLOY_TIMEOUT, + http_timeout=meta.HTTP_TIMEOUT, ) abra.undeploy(domain) _wait_undeployed(domain) @@ -906,7 +881,7 @@ def main() -> int: # HEAD (the catalogue current) for a non-PR `!testme`. Captured before any version-tag checkout. head_ref = ref or lifecycle.recipe_head_commit(recipe) repo_local = snapshot_recipe_tests(recipe) - meta = _load_meta(recipe) + meta = meta_mod.load(recipe) # WC4/WC7: opt-in `--quick` fast lane. Requires an existing data-warm canonical; if none, fall # back cleanly to the full COLD run below so the PR is still tested (DECISIONS Phase-2w). @@ -929,9 +904,7 @@ def main() -> int: # override must be an exact published version tag (deployed as a pinned base). (Adversary §7.1.) want_upgrade = "upgrade" in stages prev = ( - (meta.get("UPGRADE_BASE_VERSION") or lifecycle.previous_version(recipe)) - if want_upgrade - else None + (meta.UPGRADE_BASE_VERSION or lifecycle.previous_version(recipe)) if want_upgrade else None ) base = prev or target backup_cap = generic.backup_capable(recipe, meta) @@ -974,12 +947,12 @@ def main() -> int: with contextlib.suppress(OSError): os.remove(skipfile) os.environ["CCCI_DEPS_SKIP_REPORT"] = skipfile - declared = deps_mod.declared_deps(recipe) + declared = list(meta.DEPS) # Q3.2a: a recipe that tolerates OIDC env at first boot AND whose deps are live-warm wires OIDC # at INSTALL time (provision the realm BEFORE the single deploy; install_steps.sh writes the env # into it) instead of the post-deploy in-place `--chaos` redeploy — which is flaky on the heavy # 12-service lasuite-drive stack (collabora WOPI race; see JOURNAL Step 0). Opt-in per recipe. - oidc_at_install = bool(meta.get("OIDC_AT_INSTALL")) and bool(declared) + oidc_at_install = bool(meta.OIDC_AT_INSTALL) and bool(declared) if declared: when = "BEFORE deploy (install-time OIDC)" if oidc_at_install else "AFTER generic tiers" print(f"\n===== DEPS declared (provision {when}): {declared} =====", flush=True) @@ -1023,18 +996,19 @@ def main() -> int: version=base, secrets=True, install_steps_hook=hook, - deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)), + deploy_timeout=int(meta.DEPLOY_TIMEOUT), + meta=meta, ) lifecycle.wait_healthy( domain, - ok_codes=tuple(meta["HEALTH_OK"]), - path=meta["HEALTH_PATH"], - deploy_timeout=meta["DEPLOY_TIMEOUT"], - http_timeout=meta["HTTP_TIMEOUT"], + ok_codes=tuple(meta.HEALTH_OK), + path=meta.HEALTH_PATH, + deploy_timeout=meta.DEPLOY_TIMEOUT, + http_timeout=meta.HTTP_TIMEOUT, ) # Recipe READY_PROBE (e.g. lasuite-drive collabora WOPI discovery) — readiness beyond # replica convergence + app HEALTH_PATH; no-op for recipes without one. - lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.get("DEPLOY_TIMEOUT", 900))) + lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.DEPLOY_TIMEOUT)) deploy_ok = True except Exception as e: # noqa: BLE001 — a failed deploy is a reported INSTALL failure print(f"!! deploy/readiness failed: {e}", flush=True) @@ -1314,7 +1288,7 @@ def main() -> int: no_secret_leak=True, # narrowed below by an actual scan of the serialised artifact screenshot=screenshot_rel, # Phase 3 U1 (R4): relative PNG name iff capture succeeded finished_ts=time.time(), - expected_na=meta.get("EXPECTED_NA"), # declared intentional-skip map (recipe_meta) + expected_na=meta.EXPECTED_NA, # declared intentional-skip map (recipe_meta) ) # Real (if narrow) leak check: no known infra-secret value may appear in the artifact (R7). blob = json.dumps(data) diff --git a/scripts/gen-meta-docs.py b/scripts/gen-meta-docs.py new file mode 100644 index 0000000..f9135f9 --- /dev/null +++ b/scripts/gen-meta-docs.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Render the harness.meta KEYS registry to the markdown key-reference table in +docs/recipe-customization.md §4 (rcust P1.5; kills the R5 doc-drift class). + +Usage: + python3 scripts/gen-meta-docs.py # rewrite the table in-place between the markers + python3 scripts/gen-meta-docs.py --print # print the rendered table to stdout (used by the + # doc-sync unit test, tests/unit/test_meta.py) + +The table lives between `` / `` markers; a unit +test asserts the committed table equals this rendering, so editing it by hand fails CI. +""" + +from __future__ import annotations + +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "runner")) +from harness.meta import KEYS # noqa: E402 + +DOC = os.path.join(ROOT, "docs", "recipe-customization.md") +START = "" +END = "" + + +def _default_repr(v) -> str: + if v is None: + return "`None`" + return f"`{v!r}`" + + +def render() -> str: + lines = [ + START, + "", + "_This table is GENERATED from the `runner/harness/meta.py` KEYS registry by" + " `scripts/gen-meta-docs.py` — do not edit by hand (a unit test pins the sync)._", + "", + "| Key | Type | Default | Meaning |", + "|---|---|---|---|", + ] + for k in KEYS: + doc = k.doc.replace("|", "\\|") + name = f"`{k.name}`" + (" **(deprecated)**" if k.deprecated else "") + lines.append(f"| {name} | `{k.type}` | {_default_repr(k.default)} | {doc} |") + lines += ["", END] + return "\n".join(lines) + + +def main() -> int: + table = render() + if "--print" in sys.argv: + print(table) + return 0 + with open(DOC) as f: + text = f.read() + if START not in text or END not in text: + print(f"{DOC}: missing {START}/{END} markers", file=sys.stderr) + return 1 + head, _, rest = text.partition(START) + _, _, tail = rest.partition(END) + with open(DOC, "w") as f: + f.write(head + table + tail) + print(f"{DOC}: key table rewritten from the registry ({len(KEYS)} keys)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index e9be5cb..017a279 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,33 +15,13 @@ import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "runner")) from harness import deps as deps_mod # noqa: E402 from harness import lifecycle, naming +from harness import meta as meta_mod def _short(s: str, n: int = 8) -> str: return "".join(c for c in s if c.isalnum())[:n] or "local" -def _recipe_meta(recipe: str) -> dict: - """Optional per-recipe config so enrolling a recipe needs NO shared-harness change (D5). - A recipe may ship tests//recipe_meta.py with any of: HEALTH_PATH (str), - HEALTH_OK (tuple of status codes), DEPLOY_TIMEOUT (int), HTTP_TIMEOUT (int).""" - path = os.path.join(os.path.dirname(__file__), recipe, "recipe_meta.py") - meta = { - "HEALTH_PATH": "/", - "HEALTH_OK": (200, 301, 302), - "DEPLOY_TIMEOUT": 600, - "HTTP_TIMEOUT": 300, - } - 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 k in meta: - if k in ns: - meta[k] = ns[k] - return meta - - @pytest.fixture(scope="session") def recipe() -> str: return os.environ.get("RECIPE", "custom-html") @@ -58,8 +38,10 @@ def app_domain(recipe) -> str: @pytest.fixture(scope="session") -def meta(recipe) -> dict: - return _recipe_meta(recipe) +def meta(recipe): + """The recipe's FULL validated customization (RecipeMeta, attribute access) via the single + loader (rcust P1 — previously this fixture saw only the 4 base keys, spec §8 R3).""" + return meta_mod.load(recipe) @pytest.fixture(scope="session") @@ -138,10 +120,10 @@ def pytest_configure(config): def _wait_healthy(domain, meta): lifecycle.wait_healthy( domain, - ok_codes=tuple(meta["HEALTH_OK"]), - path=meta["HEALTH_PATH"], - deploy_timeout=meta["DEPLOY_TIMEOUT"], - http_timeout=meta["HTTP_TIMEOUT"], + ok_codes=tuple(meta.HEALTH_OK), + path=meta.HEALTH_PATH, + deploy_timeout=meta.DEPLOY_TIMEOUT, + http_timeout=meta.HTTP_TIMEOUT, ) diff --git a/tests/mumble/functional/test_server_config_limits.py b/tests/mumble/functional/test_server_config_limits.py index c84f2aa..5f326d3 100644 --- a/tests/mumble/functional/test_server_config_limits.py +++ b/tests/mumble/functional/test_server_config_limits.py @@ -26,9 +26,9 @@ def test_configured_max_users_surfaces_in_serverconfig(live_app): assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}" cfg = r["server_config"] assert cfg, f"server did not send a ServerConfig message — {r!r}" - assert cfg.get("max_users") == recipe_meta.MAX_USERS, ( + assert cfg.get("max_users") == recipe_meta._MAX_USERS, ( f"ServerConfig.max_users={cfg.get('max_users')!r} does not match the configured " - f"USERS={recipe_meta.MAX_USERS} — deploy-time server-limit config did not propagate" + f"USERS={recipe_meta._MAX_USERS} — deploy-time server-limit config did not propagate" ) # allow_html defaults true in the recipe; assert it is present/boolean to prove the field set # is the real ServerConfig (not an empty/garbled decode). diff --git a/tests/mumble/functional/test_welcome_text_roundtrip.py b/tests/mumble/functional/test_welcome_text_roundtrip.py index 62c60ab..9ed4440 100644 --- a/tests/mumble/functional/test_welcome_text_roundtrip.py +++ b/tests/mumble/functional/test_welcome_text_roundtrip.py @@ -20,7 +20,7 @@ import recipe_meta # noqa: E402 def test_configured_welcome_text_surfaces_in_serversync(live_app): - marker = recipe_meta.WELCOME_TEXT_MARKER + marker = recipe_meta._WELCOME_TEXT_MARKER r = _mumble_proto.retry_handshake(attempts=12, interval=5.0) assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}" diff --git a/tests/mumble/recipe_meta.py b/tests/mumble/recipe_meta.py index 228b5b9..a461e83 100644 --- a/tests/mumble/recipe_meta.py +++ b/tests/mumble/recipe_meta.py @@ -31,18 +31,19 @@ HEALTH_OK = (200,) DEPLOY_TIMEOUT = 900 # two images to pull (mumble-server + mumble-web) on a cold node HTTP_TIMEOUT = 300 -# A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol. -WELCOME_TEXT_MARKER = "cc-ci-mumble-welcome-7f3a9c" +# A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol +# (underscore prefix = recipe-private constant, exempt from registry validation — rcust P1). +_WELCOME_TEXT_MARKER = "cc-ci-mumble-welcome-7f3a9c" # A distinctive max-users value (not the recipe default 100) the server_config test asserts. -MAX_USERS = 42 +_MAX_USERS = 42 # BASE deploy (0.2.0): mumble-web only — NO host-ports (0.2.0 predates it). The voice-config env is # set here and persists across the upgrade so it takes effect on the latest (where the custom config # round-trip tests assert it). EXTRA_ENV = { "COMPOSE_FILE": "compose.yml:compose.mumbleweb.yml", - "WELCOME_TEXT": WELCOME_TEXT_MARKER, - "USERS": str(MAX_USERS), + "WELCOME_TEXT": _WELCOME_TEXT_MARKER, + "USERS": str(_MAX_USERS), } # UPGRADE-target deploy (latest 1.0.0+): add the NATIVE compose.host-ports.yml so 64738 is diff --git a/tests/unit/test_canonical.py b/tests/unit/test_canonical.py index d31a193..58896f4 100644 --- a/tests/unit/test_canonical.py +++ b/tests/unit/test_canonical.py @@ -13,6 +13,7 @@ import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) from harness import canonical, warm # noqa: E402 +from harness import meta as harness_meta # noqa: E402 def test_canonical_domain(): @@ -33,11 +34,9 @@ def test_is_enrolled_reads_flag(tmp_path, monkeypatch): tests_dir = tmp_path / "tests" / recipe tests_dir.mkdir(parents=True) (tests_dir / "recipe_meta.py").write_text("WARM_CANONICAL = True\n") - # canonical.is_enrolled builds the path from canonical.__file__/../../tests/; emulate by - # creating the layout under a fake harness dir and pointing __file__ there. - fake_harness = tmp_path / "runner" / "harness" - fake_harness.mkdir(parents=True) - monkeypatch.setattr(canonical, "__file__", str(fake_harness / "canonical.py")) + # is_enrolled reads through the single meta loader (rcust P1); point its tests/ root at the + # temp layout. + monkeypatch.setattr(harness_meta, "TESTS_DIR", str(tmp_path / "tests")) assert canonical.is_enrolled(recipe) is True (tests_dir / "recipe_meta.py").write_text("WARM_CANONICAL = False\n") assert canonical.is_enrolled(recipe) is False @@ -65,9 +64,7 @@ def test_registry_roundtrip(tmp_path, monkeypatch): def test_enrolled_recipes_scans_meta(tmp_path, monkeypatch): # enrolled_recipes() lists recipes whose tests//recipe_meta.py sets WARM_CANONICAL=True. - fake_harness = tmp_path / "runner" / "harness" - fake_harness.mkdir(parents=True) - monkeypatch.setattr(canonical, "__file__", str(fake_harness / "canonical.py")) + monkeypatch.setattr(harness_meta, "TESTS_DIR", str(tmp_path / "tests")) for name, body in ( ("aaa", "WARM_CANONICAL = True\n"), ("bbb", "DEPS=['x']\n"), diff --git a/tests/unit/test_deps.py b/tests/unit/test_deps.py index d160d2b..1a99a7e 100644 --- a/tests/unit/test_deps.py +++ b/tests/unit/test_deps.py @@ -1,9 +1,9 @@ """Unit tests for runner/harness/deps.py (Phase 2 §4.2 / Q2.3). -Pure-Python: no real deploys. Tests the declarative parts of the dep resolver — declared_deps -reading from `tests//recipe_meta.py`, the per-dep domain derivation, and write/load of the -run state file. The deploy_deps + teardown_deps integration is exercised by real e2e against cc-ci -(Q2.4 acceptance). +Pure-Python: no real deploys. Tests the declarative parts of the dep resolver — DEPS declaration +(read through the single meta loader since rcust P1), the per-dep domain derivation, and write/load +of the run state file. The deploy_deps + teardown_deps integration is exercised by real e2e against +cc-ci (Q2.4 acceptance). """ from __future__ import annotations @@ -13,42 +13,23 @@ import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) from harness import deps # noqa: E402 +from harness import meta as meta_mod # noqa: E402 -def test_declared_deps_returns_empty_for_no_meta(monkeypatch, tmp_path): - """A recipe with no recipe_meta.py returns [].""" - fake_recipe = "ccci-no-meta" - # No file at tests//recipe_meta.py -> declared_deps reads nothing -> [] - monkeypatch.chdir(tmp_path) - assert deps.declared_deps(fake_recipe) == [] +def test_declared_deps_empty_for_no_meta(monkeypatch, tmp_path): + """A recipe with no recipe_meta.py declares no deps (rcust P1: DEPS via meta.load).""" + monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path / "tests")) + assert meta_mod.load("ccci-no-meta").DEPS == [] def test_declared_deps_reads_DEPS_list(tmp_path, monkeypatch): - """A recipe_meta.py with `DEPS = [...]` returns the list.""" - fake_recipe = "ccci-with-deps" - # Build a fake repo layout under tmp_path - recipe_dir = tmp_path / "tests" / fake_recipe + """A recipe_meta.py with `DEPS = [...]` surfaces the list on the loaded meta (the orchestrator + reads meta.DEPS — the successor of the deleted deps.declared_deps loader).""" + recipe_dir = tmp_path / "tests" / "ccci-with-deps" recipe_dir.mkdir(parents=True) (recipe_dir / "recipe_meta.py").write_text('HEALTH_PATH = "/"\nDEPS = ["keycloak", "redis"]\n') - # Patch the deps module's idea of "where the repo is" by monkey-patching __file__ for the - # function indirectly: declared_deps uses `os.path.dirname(__file__), "..", "..", "tests"` — - # which resolves to the real repo's `tests/`. So instead, override that with a symlink/dir - # under tmp_path: deps.__file__ points at the runner module. We can't easily relocate that. - # Instead, mock the path by writing the fake recipe under the REAL tests/ dir. - real_tests = os.path.join(os.path.dirname(deps.__file__), "..", "..", "tests") - target_dir = os.path.join(real_tests, fake_recipe) - os.makedirs(target_dir, exist_ok=True) - target_meta = os.path.join(target_dir, "recipe_meta.py") - try: - with open(target_meta, "w") as f: - f.write('DEPS = ["keycloak", "redis"]\n') - result = deps.declared_deps(fake_recipe) - assert result == ["keycloak", "redis"] - finally: - if os.path.exists(target_meta): - os.remove(target_meta) - if os.path.isdir(target_dir): - os.rmdir(target_dir) + monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path / "tests")) + assert meta_mod.load("ccci-with-deps").DEPS == ["keycloak", "redis"] def test_dep_domain_distinct_per_dep(): diff --git a/tests/unit/test_f212_upgrade_convergence.py b/tests/unit/test_f212_upgrade_convergence.py index 5ca9d88..2fab6bf 100644 --- a/tests/unit/test_f212_upgrade_convergence.py +++ b/tests/unit/test_f212_upgrade_convergence.py @@ -14,6 +14,7 @@ So `-c` + owned-wait is non-vacuous: a genuinely-broken upgrade stays RED. from __future__ import annotations +import dataclasses import os import sys @@ -21,6 +22,7 @@ import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) from harness import lifecycle as lc # noqa: E402 +from harness import meta as harness_meta # noqa: E402 def _fake_clock(monkeypatch): @@ -31,11 +33,13 @@ def _fake_clock(monkeypatch): return state -_DRIVE_META = { - "READY_PROBE": lambda d: [ - {"host": f"collabora-{d}", "path": "/hosting/discovery", "ok": (200,)} - ] -} +# RecipeMeta (rcust P1: wait_ready_probes reads meta.READY_PROBE off the loaded object); defaults +# + the drive-style probe hook. +_DRIVE_META = dataclasses.replace( + harness_meta.load("ccci-no-such-recipe"), + READY_PROBE=lambda d: [{"host": f"collabora-{d}", "path": "/hosting/discovery", "ok": (200,)}], +) +_NO_PROBE_META = harness_meta.load("ccci-no-such-recipe") def test_wait_ready_probes_raises_when_never_ready(monkeypatch): @@ -57,7 +61,7 @@ def test_wait_ready_probes_returns_when_ready(monkeypatch): def test_wait_ready_probes_noop_without_probe(monkeypatch): """A recipe with no READY_PROBE is a clean no-op (default behavior preserved for all recipes).""" monkeypatch.setattr(lc, "http_get", lambda *a, **k: 599) # would fail if it were consulted - lc.wait_ready_probes({}, "x.ci.commoninternet.net", timeout=1) # no raise, no call + lc.wait_ready_probes(_NO_PROBE_META, "x.ci.commoninternet.net", timeout=1) # no raise, no call def test_wait_healthy_raises_when_services_never_converge(monkeypatch): diff --git a/tests/unit/test_meta.py b/tests/unit/test_meta.py new file mode 100644 index 0000000..ea363c3 --- /dev/null +++ b/tests/unit/test_meta.py @@ -0,0 +1,204 @@ +"""Unit tests for the single recipe-meta loader + key registry (rcust P1; spec §8 R1/R6). + +Covers: every in-repo recipe_meta.py loads clean through the registry (THE typo gate), validation +hard-errors (unknown key, wrong type, callable on a data key), the zero-config baseline defaults +(spec §2), the underscore exemption for recipe-private constants, and the registry↔generated-doc +sync (P1.5; drift fails CI). Run: cc-ci-run -m pytest tests/unit/test_meta.py -q +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import meta as meta_mod # noqa: E402 +from harness.meta import KEYS, MetaError, RecipeMeta # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _recipes_with_meta() -> list[str]: + tests_dir = os.path.join(ROOT, "tests") + return sorted( + n + for n in os.listdir(tests_dir) + if os.path.isfile(os.path.join(tests_dir, n, "recipe_meta.py")) + ) + + +# ---- the typo gate: every in-repo recipe meta must validate against the registry -------------- + + +@pytest.mark.parametrize("recipe", _recipes_with_meta()) +def test_every_recipe_meta_loads_clean(recipe): + """All tests/*/recipe_meta.py in the repo load + validate through the registry. A typo'd or + unregistered ALL-CAPS key in any recipe meta fails HERE, at PR time — not silently at run + time (the R6 failure mode this restructure kills).""" + meta = meta_mod.load(recipe) + assert isinstance(meta, RecipeMeta) + # sanity: the 4 base keys always materialize with usable types + assert isinstance(meta.HEALTH_PATH, str) + assert isinstance(meta.HEALTH_OK, tuple) and meta.HEALTH_OK + assert isinstance(meta.DEPLOY_TIMEOUT, int) and isinstance(meta.HTTP_TIMEOUT, int) + + +# ---- zero-config baseline (spec §2) ------------------------------------------------------------ + + +def test_missing_meta_yields_spec_baseline(tmp_path): + meta = meta_mod.load("no-such-recipe", tests_dir=str(tmp_path)) + assert meta.HEALTH_PATH == "/" + assert meta.HEALTH_OK == (200, 301, 302) + assert meta.DEPLOY_TIMEOUT == 600 + assert meta.HTTP_TIMEOUT == 300 + assert meta.BACKUP_CAPABLE is None # None = auto-detect (tri-state, not False) + assert meta.EXPECTED_NA is None + assert meta.READY_PROBE is None + assert meta.UPGRADE_BASE_VERSION is None + assert meta.BACKUP_VERIFY is None + assert meta.UPGRADE_EXTRA_ENV is None + assert meta.EXTRA_ENV == {} + assert meta.DEPS == [] + assert meta.WARM_CANONICAL is False + assert meta.SCREENSHOT is None + assert meta_mod.non_default(meta) == {} + + +def test_registry_field_set_matches_dataclass(): + """The RecipeMeta field set is generated from KEYS — no drift possible, pinned anyway.""" + import dataclasses + + assert [f.name for f in dataclasses.fields(RecipeMeta)] == [k.name for k in KEYS] + # the 14 final keys + the 3 P2-deprecated ones, no more + assert len([k for k in KEYS if not k.deprecated]) == 14 + assert sorted(k.name for k in KEYS if k.deprecated) == [ + "CHAOS_BASE_DEPLOY", + "OIDC_AT_INSTALL", + "SKIP_GENERIC", + ] + + +# ---- validation hard errors (locked decision: fail fast at load) ------------------------------- + + +def _write_meta(tmp_path, body: str, recipe: str = "r") -> str: + d = tmp_path / recipe + d.mkdir(exist_ok=True) + (d / "recipe_meta.py").write_text(body) + return recipe + + +def test_unknown_key_raises_with_suggestion(tmp_path): + r = _write_meta(tmp_path, "READINESS_PROBE = None\n") # the R6 typo example + with pytest.raises(MetaError) as ei: + meta_mod.load(r, tests_dir=str(tmp_path)) + msg = str(ei.value) + assert "READINESS_PROBE" in msg and "READY_PROBE" in msg # names the typo + nearest key + + +def test_unknown_key_without_near_match_lists_registry(tmp_path): + r = _write_meta(tmp_path, "TOTALLY_BOGUS_KNOB = 1\n") + with pytest.raises(MetaError) as ei: + meta_mod.load(r, tests_dir=str(tmp_path)) + assert "HEALTH_PATH" in str(ei.value) # registered keys listed for the reader + + +def test_wrong_type_raises(tmp_path): + r = _write_meta(tmp_path, 'DEPLOY_TIMEOUT = "900"\n') + with pytest.raises(MetaError, match="DEPLOY_TIMEOUT"): + meta_mod.load(r, tests_dir=str(tmp_path)) + + +def test_bool_not_accepted_as_int(tmp_path): + r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = True\n") + with pytest.raises(MetaError, match="DEPLOY_TIMEOUT"): + meta_mod.load(r, tests_dir=str(tmp_path)) + + +def test_callable_on_data_key_rejected(tmp_path): + r = _write_meta(tmp_path, "def HEALTH_PATH():\n return '/'\n") + with pytest.raises(MetaError, match="hook-typed"): + meta_mod.load(r, tests_dir=str(tmp_path)) + + +def test_non_callable_on_hook_key_rejected(tmp_path): + r = _write_meta(tmp_path, "READY_PROBE = ['not', 'a', 'callable']\n") + with pytest.raises(MetaError, match="READY_PROBE"): + meta_mod.load(r, tests_dir=str(tmp_path)) + + +def test_underscore_names_are_private_and_exempt(tmp_path): + r = _write_meta( + tmp_path, + "_WELCOME_TEXT_MARKER = 'marker-xyz'\n_MAX_USERS = 42\n" + "EXTRA_ENV = {'WELCOME_TEXT': _WELCOME_TEXT_MARKER, 'USERS': str(_MAX_USERS)}\n", + ) + meta = meta_mod.load(r, tests_dir=str(tmp_path)) + assert meta.EXTRA_ENV == {"WELCOME_TEXT": "marker-xyz", "USERS": "42"} + + +def test_lowercase_helpers_ignored(tmp_path): + r = _write_meta( + tmp_path, + "def _helper(d):\n return {'K': d}\n\ndef EXTRA_ENV(domain):\n return _helper(domain)\n", + ) + meta = meta_mod.load(r, tests_dir=str(tmp_path)) + assert meta_mod.extra_env(meta, "x.example") == {"K": "x.example"} + + +# ---- normalization + helpers -------------------------------------------------------------------- + + +def test_health_ok_list_normalized_to_tuple(tmp_path): + r = _write_meta(tmp_path, "HEALTH_OK = [200, 302]\n") + assert meta_mod.load(r, tests_dir=str(tmp_path)).HEALTH_OK == (200, 302) + + +def test_extra_env_dict_and_callable_forms(tmp_path): + r = _write_meta(tmp_path, "EXTRA_ENV = {'A': 1}\n") + meta = meta_mod.load(r, tests_dir=str(tmp_path)) + assert meta_mod.extra_env(meta, "d") == {"A": "1"} # values stringified + r2 = _write_meta( + tmp_path, "UPGRADE_EXTRA_ENV = lambda domain: {'COMPOSE_FILE': domain}\n", recipe="r2" + ) + meta2 = meta_mod.load(r2, tests_dir=str(tmp_path)) + assert meta_mod.upgrade_extra_env(meta2, "dom.x") == {"COMPOSE_FILE": "dom.x"} + assert meta_mod.extra_env(meta2, "dom.x") == {} # unset EXTRA_ENV resolves to {} + + +def test_non_default_reports_only_customized_keys(tmp_path): + r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = 1500\nDEPS = ['keycloak']\n") + nd = meta_mod.non_default(meta_mod.load(r, tests_dir=str(tmp_path))) + assert nd == {"DEPLOY_TIMEOUT": 1500, "DEPS": ["keycloak"]} + + +def test_meta_is_frozen(): + import dataclasses + + meta = meta_mod.load("custom-html") + with pytest.raises(dataclasses.FrozenInstanceError): + meta.DEPLOY_TIMEOUT = 1 + + +# ---- doc generation sync (P1.5: the committed §4 table == the registry rendering) --------------- + + +def test_generated_doc_table_in_sync(): + """docs/recipe-customization.md's key reference table is GENERATED from the registry + (scripts/gen-meta-docs.py). If this fails: re-run `python3 scripts/gen-meta-docs.py` and + commit the result — the table must never drift from the registry (R5).""" + gen = os.path.join(ROOT, "scripts", "gen-meta-docs.py") + doc = os.path.join(ROOT, "docs", "recipe-customization.md") + rendered = subprocess.run( + [sys.executable, gen, "--print"], capture_output=True, text=True, check=True + ).stdout + with open(doc) as f: + committed = f.read() + assert rendered.strip() in committed, ( + "docs/recipe-customization.md key table is out of sync with the harness.meta registry — " + "run `python3 scripts/gen-meta-docs.py` and commit" + ) diff --git a/tests/unit/test_screenshot.py b/tests/unit/test_screenshot.py index f033946..a5cc277 100644 --- a/tests/unit/test_screenshot.py +++ b/tests/unit/test_screenshot.py @@ -11,6 +11,7 @@ import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import meta as meta_mod # noqa: E402 from harness import screenshot as S # noqa: E402 @@ -29,3 +30,19 @@ def test_hook_returned_when_callable(): pass assert S._load_screenshot_hook({"SCREENSHOT": hook}) is hook + + +def test_screenshot_reachable_through_real_load_path(tmp_path): + """R2 proof (rcust P1): a recipe SCREENSHOT hook declared in recipe_meta.py arrives at + screenshot._load_screenshot_hook through the REAL orchestrator load path (meta.load — the + object run_recipe_ci passes to capture()). Under the old six-loader world the orchestrator's + L1 allowlist dropped SCREENSHOT, so the hook was unreachable (spec §8 R2).""" + d = tmp_path / "shotrecipe" + d.mkdir() + (d / "recipe_meta.py").write_text( + "def SCREENSHOT(page, ctx):\n return None\n", + ) + meta = meta_mod.load("shotrecipe", tests_dir=str(tmp_path)) + hook = S._load_screenshot_hook(meta) + assert callable(hook), "SCREENSHOT hook did not survive the orchestrator load path (R2)" + assert S._load_screenshot_hook(meta_mod.load("no-such", tests_dir=str(tmp_path))) is None