feat(harness): P1 — single registry-backed meta loader (rcust)
All checks were successful
continuous-integration/drone/push Build is passing
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:
@ -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 |
|
||||||
|
|||||||
@ -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)):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 1–2 are BOUNDED POLLS (no bare sleep), so a state-mutating op (upgrade/restore) that leaves
|
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."""
|
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.
|
||||||
|
|||||||
@ -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
267
runner/harness/meta.py
Normal 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 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/<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)
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
71
scripts/gen-meta-docs.py
Normal 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())
|
||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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')}"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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
204
tests/unit/test_meta.py
Normal 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"
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user