feat(harness): P1 — single registry-backed meta loader (rcust)
All checks were successful
continuous-integration/drone/push Build is passing

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>/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.
This commit is contained in:
autonomic-bot
2026-06-10 16:46:58 +00:00
parent 49fb818c60
commit 472a68b32c
18 changed files with 740 additions and 240 deletions

View File

@ -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 | | L5 | `runner/harness/deps.py:declared_deps` | `DEPS` only |
| L6 | `runner/harness/canonical.py:is_canonical_enrolled` | `WARM_CANONICAL` 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.
<!-- META-TABLE-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 |
|---|---|---|---|
| `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/<recipe>/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. |
<!-- META-TABLE-END -->
### 4.1 HTTP / health / timing (base 4 — seen by L1 AND L2) ### 4.1 HTTP / health / timing (base 4 — seen by L1 AND L2)
| Key | Type / default | Meaning | Used by | | Key | Type / default | Meaning | Used by |

View File

@ -30,17 +30,13 @@ import subprocess
import time import time
from . import abra, warm, warmsnap from . import abra, warm, warmsnap
from . import meta as meta_mod
def is_enrolled(recipe: str) -> bool: def is_enrolled(recipe: str) -> bool:
"""True if `tests/<recipe>/recipe_meta.py` sets `WARM_CANONICAL = True`. Missing meta → False.""" """True if `tests/<recipe>/recipe_meta.py` sets `WARM_CANONICAL = True`. Missing meta → False.
path = os.path.join(os.path.dirname(__file__), "..", "..", "tests", recipe, "recipe_meta.py") Reads through the single meta loader (rcust P1 — no per-module exec)."""
if not os.path.exists(path): return bool(meta_mod.load(recipe).WARM_CANONICAL)
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"))
def canonical_domain(recipe: str) -> str: def canonical_domain(recipe: str) -> str:
@ -51,7 +47,7 @@ def canonical_domain(recipe: str) -> str:
def enrolled_recipes() -> list[str]: def enrolled_recipes() -> list[str]:
"""All recipes enrolled as data-warm canonicals (recipe_meta.WARM_CANONICAL=True), sorted. Used """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.""" 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 = [] out = []
try: try:
for name in sorted(os.listdir(tests_dir)): for name in sorted(os.listdir(tests_dir)):

View File

@ -31,19 +31,7 @@ import os
from collections.abc import Iterable from collections.abc import Iterable
from . import lifecycle, naming from . import lifecycle, naming
from . import meta as meta_mod
def declared_deps(recipe: str) -> list[str]:
"""Read `DEPS` from `tests/<recipe>/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: def dep_domain(parent_recipe: str, pr: str, ref: str | None, dep_recipe: str) -> str:
@ -81,11 +69,12 @@ def deploy_deps(
pr: str, pr: str,
ref: str | None, ref: str | None,
deps: Iterable[str], deps: Iterable[str],
meta_for: dict[str, dict] | None = None, meta_for: dict | None = None,
) -> list[dict]: ) -> list[dict]:
"""Deploy each declared dep, sequentially, at its per-run domain. Returns the list of state """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 dicts (one per dep). `meta_for` maps dep_recipe -> RecipeMeta (HEALTH_PATH/HEALTH_OK/timeouts)
readiness wait uses per-dep config; missing dep meta falls back to (/, 200/301/302, 600s).""" 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 {} meta_for = meta_for or {}
state: list[dict] = [] state: list[dict] = []
for dep in deps: 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 # 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 # 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. # 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( lifecycle.deploy_app(
dep, dep,
domain, domain,
secrets=True, secrets=True,
deploy_timeout=int(dm.get("DEPLOY_TIMEOUT", 900)), deploy_timeout=int(dm.DEPLOY_TIMEOUT),
meta=dm,
) )
try: try:
lifecycle.wait_healthy( lifecycle.wait_healthy(
domain, domain,
ok_codes=tuple(dm.get("HEALTH_OK", (200, 301, 302))), ok_codes=tuple(dm.HEALTH_OK),
path=dm.get("HEALTH_PATH", "/"), path=dm.HEALTH_PATH,
deploy_timeout=int(dm.get("DEPLOY_TIMEOUT", 600)), deploy_timeout=int(dm.DEPLOY_TIMEOUT),
http_timeout=int(dm.get("HTTP_TIMEOUT", 600)), http_timeout=int(dm.HTTP_TIMEOUT),
) )
except Exception: except Exception:
# If a dep fails to converge, abort the whole resolve — let the caller teardown # If a dep fails to converge, abort the whole resolve — let the caller teardown

View File

@ -19,6 +19,7 @@ import ssl
import time import time
from . import abra, lifecycle 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. # 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) _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) 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). """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 `recipe_meta.BACKUP_CAPABLE` (bool) overrides when explicitly set (RecipeMeta default is None =
compose*.yml for a truthy `backupbot.backup` label (the Co-op Cloud backup convention).""" unset); otherwise auto-detect by scanning the recipe's compose*.yml for a truthy
if meta and "BACKUP_CAPABLE" in meta: `backupbot.backup` label (the Co-op Cloud backup convention)."""
return bool(meta["BACKUP_CAPABLE"]) 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")): for path in glob.glob(os.path.join(_recipe_dir(recipe), "compose*.yml")):
try: try:
with open(path) as fh: 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}") 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 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): 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 12 are BOUNDED POLLS (no bare sleep), so a state-mutating op (upgrade/restore) that leaves Steps 12 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.""" 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): while time.time() < deadline and not lifecycle.services_converged(domain):
time.sleep(5) time.sleep(5)
assert lifecycle.services_converged(domain), f"{domain}: services did not converge" assert lifecycle.services_converged(domain), f"{domain}: services did not converge"
path = meta["HEALTH_PATH"] path = meta.HEALTH_PATH
ok = tuple(meta["HEALTH_OK"]) ok = tuple(meta.HEALTH_OK)
deadline = time.time() + meta["HTTP_TIMEOUT"] deadline = time.time() + meta.HTTP_TIMEOUT
served = False served = False
status, body = 0, "" status, body = 0, ""
while time.time() < deadline: while time.time() < deadline:
@ -141,7 +143,7 @@ def op_state() -> dict:
return {} 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 """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 `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). 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 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 """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).""" healthy + serving again (assert_serving polls, so the post-restore reconverge settles)."""
assert_serving(domain, meta) assert_serving(domain, meta)
@ -226,7 +228,7 @@ def perform_upgrade(
recipe: str, recipe: str,
head_ref: str | None, head_ref: str | None,
deploy_timeout: int = 900, deploy_timeout: int = 900,
meta: dict | None = None, meta=None,
) -> dict[str, str | None]: ) -> dict[str, str | None]:
"""Perform the UPGRADE op once, in place, to the PR-HEAD code under test (HC1): re-checkout the """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` 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 + 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 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.""" 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) before = lifecycle.deployed_identity(domain)
if head_ref: if head_ref:
lifecycle.recipe_checkout_ref(recipe, 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 # (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 # 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. # picks up the new .env. Dict or callable(domain)->dict. No-op for recipes without it.
upgrade_env = meta.get("UPGRADE_EXTRA_ENV") or {} upgrade_env = meta_mod.upgrade_extra_env(meta, domain)
if callable(upgrade_env):
upgrade_env = upgrade_env(domain) or {}
for k, v in upgrade_env.items(): for k, v in upgrade_env.items():
print(f" upgrade-env: {k}={v}", flush=True) print(f" upgrade-env: {k}={v}", flush=True)
abra.env_set(domain, k, v) abra.env_set(domain, k, v)
@ -266,14 +267,12 @@ def perform_upgrade(
# Own the convergence verification (abra's monitor was skipped via -c). # Own the convergence verification (abra's monitor was skipped via -c).
lifecycle.wait_healthy( lifecycle.wait_healthy(
domain, domain,
ok_codes=tuple(meta.get("HEALTH_OK", (200, 301, 302))), ok_codes=tuple(meta.HEALTH_OK),
path=meta.get("HEALTH_PATH", "/"), path=meta.HEALTH_PATH,
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", deploy_timeout)), deploy_timeout=int(meta.DEPLOY_TIMEOUT),
http_timeout=int(meta.get("HTTP_TIMEOUT", 300)), http_timeout=int(meta.HTTP_TIMEOUT),
)
lifecycle.wait_ready_probes(
meta, domain, timeout=int(meta.get("DEPLOY_TIMEOUT", deploy_timeout))
) )
lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.DEPLOY_TIMEOUT))
after = lifecycle.deployed_identity(domain) after = lifecycle.deployed_identity(domain)
# Evidence (HC1): the chaos-version label = the deployed recipe commit; it should match the # 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. # PR-head we checked out — proving the upgrade deployed the code under test, not a published tag.

View File

@ -19,6 +19,7 @@ import time
import urllib.request import urllib.request
from . import abra, lifetime 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) GATEWAY_IP = "143.244.213.108" # *.ci.commoninternet.net -> gateway (TLS passthrough to cc-ci)
# A run app domain is "<recipe[:4]>-<6hex>.ci.commoninternet.net" (see DECISIONS.md). Used by the # A run app domain is "<recipe[:4]>-<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>/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>/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: def _record_deploy() -> None:
"""Increment the per-run deploy counter (DG4.1: one deploy per run). No-op unless the """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.""" orchestrator set CCCI_DEPLOY_COUNT_FILE — so it never affects standalone/manual use."""
@ -238,15 +208,22 @@ def deploy_app(
secrets: bool = True, secrets: bool = True,
install_steps_hook: tuple[str, str] | None = None, install_steps_hook: tuple[str, str] | None = None,
deploy_timeout: int = 900, deploy_timeout: int = 900,
meta=None,
) -> None: ) -> None:
"""Create + configure + deploy an app. Forces LETS_ENCRYPT_ENV='' so traefik serves the """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 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. 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 `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 `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 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.""" 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() _record_deploy()
# Lock BEFORE the app exists: a concurrent run's janitor must never see this app without a # 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 # 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 + # 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 # 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. # 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( print(
f" deploy_app({recipe}@{version}): CHAOS_BASE_DEPLOY set → chaos base deploy of the " 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)", "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). # 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, "DOMAIN", domain)
abra.env_set(domain, "LETS_ENCRYPT_ENV", "") 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) abra.env_set(domain, k, v)
if secrets: if secrets:
abra.secret_generate(domain) 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) 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. """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,)}, ...]` 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 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 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.""" 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): if not callable(probe_fn):
return return
probes = probe_fn(domain) or [] probes = probe_fn(domain) or []

267
runner/harness/meta.py Normal file
View File

@ -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>/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 L1L6) 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/<recipe>/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)

View File

@ -33,12 +33,19 @@ def screenshot_path(run_artifact_dir: str) -> str:
return os.path.join(run_artifact_dir, "screenshot.png") 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. """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.""" The hook drives Playwright to a safe post-login view; default is the landing page.
if not recipe_meta:
`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 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 return hook if callable(hook) else None

View File

@ -58,6 +58,9 @@ from harness import ( # noqa: E402
from harness import ( # noqa: E402 from harness import ( # noqa: E402
deps as deps_mod, deps as deps_mod,
) )
from harness import ( # noqa: E402
meta as meta_mod,
)
from harness import ( # noqa: E402 from harness import ( # noqa: E402
results as results_mod, results as results_mod,
) )
@ -247,40 +250,11 @@ def snapshot_recipe_tests(recipe: str) -> str | None:
return dst 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: def _tier_env(domain: str) -> dict:
return dict(os.environ, CCCI_APP_DOMAIN=domain, CCCI_BASE_URL=f"https://{domain}") 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). """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_<OP>, or the recipe's Opt-out, any of: env CCCI_SKIP_GENERIC (all ops), env CCCI_SKIP_GENERIC_<OP>, or the recipe's
declarative recipe_meta.SKIP_GENERIC list (op name, or "all"/"*").""" declarative recipe_meta.SKIP_GENERIC list (op name, or "all"/"*")."""
@ -288,11 +262,11 @@ def _skip_generic(op: str, meta: dict) -> bool:
return True return True
if _truthy(os.environ.get(f"CCCI_SKIP_GENERIC_{op.upper()}")): if _truthy(os.environ.get(f"CCCI_SKIP_GENERIC_{op.upper()}")):
return True 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 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_<op>`) BEFORE the harness performs the """Run the optional pre-op seed hook (recipe ops.py `pre_<op>`) BEFORE the harness performs the
op (HC3 op/assertion split): overlays seed data-continuity markers / the backup→restore mutation op (HC3 op/assertion split): overlays seed data-continuity markers / the backup→restore mutation
here, then assert post-op in test_<op>.py. cc-ci's ops.py is trusted; a repo-local ops.py is here, then assert post-op in test_<op>.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, head_ref: str | None,
op_state: dict, op_state: dict,
deploy_timeout: int = 900, deploy_timeout: int = 900,
meta: dict | None = None, meta=None,
) -> None: ) -> None:
"""Perform the single mutating op ONCE (the harness owns the op, HC3). install has no op. Records """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 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 # 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). # 3 attempts. Recipes without BACKUP_VERIFY are unaffected (single backup, as before).
snap = generic.perform_backup(domain) snap = generic.perform_backup(domain)
verify = meta.get("BACKUP_VERIFY") if meta else None verify = meta.BACKUP_VERIFY if meta else None
attempt = 1 attempt = 1
while callable(verify) and not verify(domain) and attempt < 3: while callable(verify) and not verify(domain) and attempt < 3:
attempt += 1 attempt += 1
@ -371,7 +345,7 @@ def run_lifecycle_tier(
op: str, op: str,
repo_local: str | None, repo_local: str | None,
domain: str, domain: str,
meta: dict, meta,
head_ref: str | None, head_ref: str | None,
op_state: dict, op_state: dict,
records: list[dict] | None = None, records: list[dict] | None = None,
@ -411,7 +385,7 @@ def run_lifecycle_tier(
recipe, recipe,
head_ref, head_ref,
op_state, op_state,
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)), deploy_timeout=int(meta.DEPLOY_TIMEOUT),
meta=meta, meta=meta,
) )
with open(os.environ["CCCI_OP_STATE_FILE"], "w") as f: with open(os.environ["CCCI_OP_STATE_FILE"], "w") as f:
@ -523,7 +497,7 @@ def _provision_deps(
if wd: if wd:
print(f" dep: {d} warm provider {wd} not up — cold fallback", flush=True) print(f" dep: {d} warm provider {wd} not up — cold fallback", flush=True)
cold_deps.append(d) 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_list = (
deps_mod.deploy_deps(recipe, os.environ.get("PR", "0"), ref, cold_deps, meta_for=dep_metas) deps_mod.deploy_deps(recipe, os.environ.get("PR", "0"), ref, cold_deps, meta_for=dep_metas)
if cold_deps if cold_deps
@ -609,7 +583,7 @@ def _wait_undeployed(domain: str, timeout: int = 120) -> None:
def run_quick( 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: ) -> int:
"""WC4 `--quick` opt-in fast lane (plan §2). Reattach the data-warm canonical (known-good volume) """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) + → upgrade IN PLACE to the PR head (chaos) → assert generic UPGRADE (reconverge+moved+serving) +
@ -645,7 +619,7 @@ def run_quick(
op_state: dict = {} op_state: dict = {}
results: dict[str, str] = {} results: dict[str, str] = {}
declared = deps_mod.declared_deps(recipe) declared = list(meta.DEPS)
deps_state: dict = {} deps_state: dict = {}
deps_ready = True deps_ready = True
deps_not_ready_reason = "" deps_not_ready_reason = ""
@ -657,13 +631,13 @@ def run_quick(
try: try:
# 1) reattach the canonical (warm boot at the known-good version + retained volume) # 1) reattach the canonical (warm boot at the known-good version + retained volume)
try: try:
canonical.deploy_canonical(recipe, timeout=int(meta.get("DEPLOY_TIMEOUT", 900))) canonical.deploy_canonical(recipe, timeout=int(meta.DEPLOY_TIMEOUT))
lifecycle.wait_healthy( lifecycle.wait_healthy(
domain, domain,
ok_codes=tuple(meta["HEALTH_OK"]), ok_codes=tuple(meta.HEALTH_OK),
path=meta["HEALTH_PATH"], path=meta.HEALTH_PATH,
deploy_timeout=meta["DEPLOY_TIMEOUT"], deploy_timeout=meta.DEPLOY_TIMEOUT,
http_timeout=meta["HTTP_TIMEOUT"], http_timeout=meta.HTTP_TIMEOUT,
) )
warm_ok = True warm_ok = True
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
@ -678,7 +652,7 @@ def run_quick(
for d in declared: for d in declared:
wd = warm.warm_domain(d) wd = warm.warm_domain(d)
(warm_deps if (wd and warm.is_warm_up(d, wd)) else cold_deps).append(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_list = (
deps_mod.deploy_deps( deps_mod.deploy_deps(
recipe, os.environ.get("PR", "0"), ref, cold_deps, meta_for=dep_metas 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: if not latest:
print(f"WC5 promote: no version tags for {recipe} — skip", flush=True) print(f"WC5 promote: no version tags for {recipe} — skip", flush=True)
return 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. # The cold run's deploy-count was already asserted + the countfile removed; don't perturb it.
os.environ.pop("CCCI_DEPLOY_COUNT_FILE", None) os.environ.pop("CCCI_DEPLOY_COUNT_FILE", None)
print( print(
@ -860,14 +834,15 @@ def promote_canonical(recipe: str, head_ref: str | None) -> None:
domain, domain,
version=latest, version=latest,
secrets=True, secrets=True,
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)), deploy_timeout=int(meta.DEPLOY_TIMEOUT),
meta=meta,
) )
lifecycle.wait_healthy( lifecycle.wait_healthy(
domain, domain,
ok_codes=tuple(meta["HEALTH_OK"]), ok_codes=tuple(meta.HEALTH_OK),
path=meta["HEALTH_PATH"], path=meta.HEALTH_PATH,
deploy_timeout=meta["DEPLOY_TIMEOUT"], deploy_timeout=meta.DEPLOY_TIMEOUT,
http_timeout=meta["HTTP_TIMEOUT"], http_timeout=meta.HTTP_TIMEOUT,
) )
abra.undeploy(domain) abra.undeploy(domain)
_wait_undeployed(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 (the catalogue current) for a non-PR `!testme`. Captured before any version-tag checkout.
head_ref = ref or lifecycle.recipe_head_commit(recipe) head_ref = ref or lifecycle.recipe_head_commit(recipe)
repo_local = snapshot_recipe_tests(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 # 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). # 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.) # override must be an exact published version tag (deployed as a pinned base). (Adversary §7.1.)
want_upgrade = "upgrade" in stages want_upgrade = "upgrade" in stages
prev = ( prev = (
(meta.get("UPGRADE_BASE_VERSION") or lifecycle.previous_version(recipe)) (meta.UPGRADE_BASE_VERSION or lifecycle.previous_version(recipe)) if want_upgrade else None
if want_upgrade
else None
) )
base = prev or target base = prev or target
backup_cap = generic.backup_capable(recipe, meta) backup_cap = generic.backup_capable(recipe, meta)
@ -974,12 +947,12 @@ def main() -> int:
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
os.remove(skipfile) os.remove(skipfile)
os.environ["CCCI_DEPS_SKIP_REPORT"] = 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 # 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 # 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 # 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. # 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: if declared:
when = "BEFORE deploy (install-time OIDC)" if oidc_at_install else "AFTER generic tiers" when = "BEFORE deploy (install-time OIDC)" if oidc_at_install else "AFTER generic tiers"
print(f"\n===== DEPS declared (provision {when}): {declared} =====", flush=True) print(f"\n===== DEPS declared (provision {when}): {declared} =====", flush=True)
@ -1023,18 +996,19 @@ def main() -> int:
version=base, version=base,
secrets=True, secrets=True,
install_steps_hook=hook, install_steps_hook=hook,
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)), deploy_timeout=int(meta.DEPLOY_TIMEOUT),
meta=meta,
) )
lifecycle.wait_healthy( lifecycle.wait_healthy(
domain, domain,
ok_codes=tuple(meta["HEALTH_OK"]), ok_codes=tuple(meta.HEALTH_OK),
path=meta["HEALTH_PATH"], path=meta.HEALTH_PATH,
deploy_timeout=meta["DEPLOY_TIMEOUT"], deploy_timeout=meta.DEPLOY_TIMEOUT,
http_timeout=meta["HTTP_TIMEOUT"], http_timeout=meta.HTTP_TIMEOUT,
) )
# Recipe READY_PROBE (e.g. lasuite-drive collabora WOPI discovery) — readiness beyond # Recipe READY_PROBE (e.g. lasuite-drive collabora WOPI discovery) — readiness beyond
# replica convergence + app HEALTH_PATH; no-op for recipes without one. # 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 deploy_ok = True
except Exception as e: # noqa: BLE001 — a failed deploy is a reported INSTALL failure except Exception as e: # noqa: BLE001 — a failed deploy is a reported INSTALL failure
print(f"!! deploy/readiness failed: {e}", flush=True) 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 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 screenshot=screenshot_rel, # Phase 3 U1 (R4): relative PNG name iff capture succeeded
finished_ts=time.time(), 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). # Real (if narrow) leak check: no known infra-secret value may appear in the artifact (R7).
blob = json.dumps(data) blob = json.dumps(data)

71
scripts/gen-meta-docs.py Normal file
View File

@ -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 `<!-- META-TABLE-START -->` / `<!-- META-TABLE-END -->` 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 = "<!-- META-TABLE-START -->"
END = "<!-- META-TABLE-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())

View File

@ -15,33 +15,13 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "runner")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "runner"))
from harness import deps as deps_mod # noqa: E402 from harness import deps as deps_mod # noqa: E402
from harness import lifecycle, naming from harness import lifecycle, naming
from harness import meta as meta_mod
def _short(s: str, n: int = 8) -> str: def _short(s: str, n: int = 8) -> str:
return "".join(c for c in s if c.isalnum())[:n] or "local" 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>/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") @pytest.fixture(scope="session")
def recipe() -> str: def recipe() -> str:
return os.environ.get("RECIPE", "custom-html") return os.environ.get("RECIPE", "custom-html")
@ -58,8 +38,10 @@ def app_domain(recipe) -> str:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def meta(recipe) -> dict: def meta(recipe):
return _recipe_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") @pytest.fixture(scope="session")
@ -138,10 +120,10 @@ def pytest_configure(config):
def _wait_healthy(domain, meta): def _wait_healthy(domain, meta):
lifecycle.wait_healthy( lifecycle.wait_healthy(
domain, domain,
ok_codes=tuple(meta["HEALTH_OK"]), ok_codes=tuple(meta.HEALTH_OK),
path=meta["HEALTH_PATH"], path=meta.HEALTH_PATH,
deploy_timeout=meta["DEPLOY_TIMEOUT"], deploy_timeout=meta.DEPLOY_TIMEOUT,
http_timeout=meta["HTTP_TIMEOUT"], http_timeout=meta.HTTP_TIMEOUT,
) )

View File

@ -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')}" assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}"
cfg = r["server_config"] cfg = r["server_config"]
assert cfg, f"server did not send a ServerConfig message — {r!r}" 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"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 # 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). # is the real ServerConfig (not an empty/garbled decode).

View File

@ -20,7 +20,7 @@ import recipe_meta # noqa: E402
def test_configured_welcome_text_surfaces_in_serversync(live_app): 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) r = _mumble_proto.retry_handshake(attempts=12, interval=5.0)
assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}" assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}"

View File

@ -31,18 +31,19 @@ HEALTH_OK = (200,)
DEPLOY_TIMEOUT = 900 # two images to pull (mumble-server + mumble-web) on a cold node DEPLOY_TIMEOUT = 900 # two images to pull (mumble-server + mumble-web) on a cold node
HTTP_TIMEOUT = 300 HTTP_TIMEOUT = 300
# A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol. # A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol
WELCOME_TEXT_MARKER = "cc-ci-mumble-welcome-7f3a9c" # (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. # 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 # 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 # set here and persists across the upgrade so it takes effect on the latest (where the custom config
# round-trip tests assert it). # round-trip tests assert it).
EXTRA_ENV = { EXTRA_ENV = {
"COMPOSE_FILE": "compose.yml:compose.mumbleweb.yml", "COMPOSE_FILE": "compose.yml:compose.mumbleweb.yml",
"WELCOME_TEXT": WELCOME_TEXT_MARKER, "WELCOME_TEXT": _WELCOME_TEXT_MARKER,
"USERS": str(MAX_USERS), "USERS": str(_MAX_USERS),
} }
# UPGRADE-target deploy (latest 1.0.0+): add the NATIVE compose.host-ports.yml so 64738 is # UPGRADE-target deploy (latest 1.0.0+): add the NATIVE compose.host-ports.yml so 64738 is

View File

@ -13,6 +13,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import canonical, warm # noqa: E402 from harness import canonical, warm # noqa: E402
from harness import meta as harness_meta # noqa: E402
def test_canonical_domain(): 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 = tmp_path / "tests" / recipe
tests_dir.mkdir(parents=True) tests_dir.mkdir(parents=True)
(tests_dir / "recipe_meta.py").write_text("WARM_CANONICAL = True\n") (tests_dir / "recipe_meta.py").write_text("WARM_CANONICAL = True\n")
# canonical.is_enrolled builds the path from canonical.__file__/../../tests/<recipe>; emulate by # is_enrolled reads through the single meta loader (rcust P1); point its tests/ root at the
# creating the layout under a fake harness dir and pointing __file__ there. # temp layout.
fake_harness = tmp_path / "runner" / "harness" monkeypatch.setattr(harness_meta, "TESTS_DIR", str(tmp_path / "tests"))
fake_harness.mkdir(parents=True)
monkeypatch.setattr(canonical, "__file__", str(fake_harness / "canonical.py"))
assert canonical.is_enrolled(recipe) is True assert canonical.is_enrolled(recipe) is True
(tests_dir / "recipe_meta.py").write_text("WARM_CANONICAL = False\n") (tests_dir / "recipe_meta.py").write_text("WARM_CANONICAL = False\n")
assert canonical.is_enrolled(recipe) is False 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): def test_enrolled_recipes_scans_meta(tmp_path, monkeypatch):
# enrolled_recipes() lists recipes whose tests/<r>/recipe_meta.py sets WARM_CANONICAL=True. # enrolled_recipes() lists recipes whose tests/<r>/recipe_meta.py sets WARM_CANONICAL=True.
fake_harness = tmp_path / "runner" / "harness" monkeypatch.setattr(harness_meta, "TESTS_DIR", str(tmp_path / "tests"))
fake_harness.mkdir(parents=True)
monkeypatch.setattr(canonical, "__file__", str(fake_harness / "canonical.py"))
for name, body in ( for name, body in (
("aaa", "WARM_CANONICAL = True\n"), ("aaa", "WARM_CANONICAL = True\n"),
("bbb", "DEPS=['x']\n"), ("bbb", "DEPS=['x']\n"),

View File

@ -1,9 +1,9 @@
"""Unit tests for runner/harness/deps.py (Phase 2 §4.2 / Q2.3). """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 Pure-Python: no real deploys. Tests the declarative parts of the dep resolver — DEPS declaration
reading from `tests/<recipe>/recipe_meta.py`, the per-dep domain derivation, and write/load of the (read through the single meta loader since rcust P1), the per-dep domain derivation, and write/load
run state file. The deploy_deps + teardown_deps integration is exercised by real e2e against cc-ci of the run state file. The deploy_deps + teardown_deps integration is exercised by real e2e against
(Q2.4 acceptance). cc-ci (Q2.4 acceptance).
""" """
from __future__ import annotations from __future__ import annotations
@ -13,42 +13,23 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import deps # noqa: E402 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): def test_declared_deps_empty_for_no_meta(monkeypatch, tmp_path):
"""A recipe with no recipe_meta.py returns [].""" """A recipe with no recipe_meta.py declares no deps (rcust P1: DEPS via meta.load)."""
fake_recipe = "ccci-no-meta" monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path / "tests"))
# No file at tests/<fake_recipe>/recipe_meta.py -> declared_deps reads nothing -> [] assert meta_mod.load("ccci-no-meta").DEPS == []
monkeypatch.chdir(tmp_path)
assert deps.declared_deps(fake_recipe) == []
def test_declared_deps_reads_DEPS_list(tmp_path, monkeypatch): def test_declared_deps_reads_DEPS_list(tmp_path, monkeypatch):
"""A recipe_meta.py with `DEPS = [...]` returns the list.""" """A recipe_meta.py with `DEPS = [...]` surfaces the list on the loaded meta (the orchestrator
fake_recipe = "ccci-with-deps" reads meta.DEPS — the successor of the deleted deps.declared_deps loader)."""
# Build a fake repo layout under tmp_path recipe_dir = tmp_path / "tests" / "ccci-with-deps"
recipe_dir = tmp_path / "tests" / fake_recipe
recipe_dir.mkdir(parents=True) recipe_dir.mkdir(parents=True)
(recipe_dir / "recipe_meta.py").write_text('HEALTH_PATH = "/"\nDEPS = ["keycloak", "redis"]\n') (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 monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path / "tests"))
# function indirectly: declared_deps uses `os.path.dirname(__file__), "..", "..", "tests"` — assert meta_mod.load("ccci-with-deps").DEPS == ["keycloak", "redis"]
# 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)
def test_dep_domain_distinct_per_dep(): def test_dep_domain_distinct_per_dep():

View File

@ -14,6 +14,7 @@ So `-c` + owned-wait is non-vacuous: a genuinely-broken upgrade stays RED.
from __future__ import annotations from __future__ import annotations
import dataclasses
import os import os
import sys import sys
@ -21,6 +22,7 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import lifecycle as lc # noqa: E402 from harness import lifecycle as lc # noqa: E402
from harness import meta as harness_meta # noqa: E402
def _fake_clock(monkeypatch): def _fake_clock(monkeypatch):
@ -31,11 +33,13 @@ def _fake_clock(monkeypatch):
return state return state
_DRIVE_META = { # RecipeMeta (rcust P1: wait_ready_probes reads meta.READY_PROBE off the loaded object); defaults
"READY_PROBE": lambda d: [ # + the drive-style probe hook.
{"host": f"collabora-{d}", "path": "/hosting/discovery", "ok": (200,)} _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): 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): 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).""" """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 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): def test_wait_healthy_raises_when_services_never_converge(monkeypatch):

204
tests/unit/test_meta.py Normal file
View File

@ -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"
)

View File

@ -11,6 +11,7 @@ import os
import sys import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) 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 from harness import screenshot as S # noqa: E402
@ -29,3 +30,19 @@ def test_hook_returned_when_callable():
pass pass
assert S._load_screenshot_hook({"SCREENSHOT": hook}) is hook 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