feat(harness): P1 — single registry-backed meta loader (rcust)
continuous-integration/drone/push Build is passing
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:
+38
-64
@@ -58,6 +58,9 @@ from harness import ( # noqa: E402
|
||||
from harness import ( # noqa: E402
|
||||
deps as deps_mod,
|
||||
)
|
||||
from harness import ( # noqa: E402
|
||||
meta as meta_mod,
|
||||
)
|
||||
from harness import ( # noqa: E402
|
||||
results as results_mod,
|
||||
)
|
||||
@@ -247,40 +250,11 @@ def snapshot_recipe_tests(recipe: str) -> str | None:
|
||||
return dst
|
||||
|
||||
|
||||
def _load_meta(recipe: str) -> dict:
|
||||
"""Mirror tests/conftest._recipe_meta so the orchestrator's deploy/wait uses the same per-recipe
|
||||
config the tiers see (timeouts, health path/codes)."""
|
||||
meta = {
|
||||
"HEALTH_PATH": "/",
|
||||
"HEALTH_OK": (200, 301, 302),
|
||||
"DEPLOY_TIMEOUT": 600,
|
||||
"HTTP_TIMEOUT": 300,
|
||||
}
|
||||
path = os.path.join(ROOT, "tests", recipe, "recipe_meta.py")
|
||||
if os.path.exists(path):
|
||||
ns: dict = {}
|
||||
with open(path) as fh:
|
||||
exec(compile(fh.read(), path, "exec"), ns) # noqa: S102 (trusted, in-repo)
|
||||
for k in list(meta) + [
|
||||
"BACKUP_CAPABLE",
|
||||
"SKIP_GENERIC",
|
||||
"EXPECTED_NA",
|
||||
"OIDC_AT_INSTALL",
|
||||
"READY_PROBE",
|
||||
"UPGRADE_BASE_VERSION",
|
||||
"BACKUP_VERIFY",
|
||||
"UPGRADE_EXTRA_ENV",
|
||||
]:
|
||||
if k in ns:
|
||||
meta[k] = ns[k]
|
||||
return meta
|
||||
|
||||
|
||||
def _tier_env(domain: str) -> dict:
|
||||
return dict(os.environ, CCCI_APP_DOMAIN=domain, CCCI_BASE_URL=f"https://{domain}")
|
||||
|
||||
|
||||
def _skip_generic(op: str, meta: dict) -> bool:
|
||||
def _skip_generic(op: str, meta) -> bool:
|
||||
"""Whether the generic assertion for `op` is opted out (Phase 1e HC3). Default: run (additive).
|
||||
Opt-out, any of: env CCCI_SKIP_GENERIC (all ops), env CCCI_SKIP_GENERIC_<OP>, or the recipe's
|
||||
declarative recipe_meta.SKIP_GENERIC list (op name, or "all"/"*")."""
|
||||
@@ -288,11 +262,11 @@ def _skip_generic(op: str, meta: dict) -> bool:
|
||||
return True
|
||||
if _truthy(os.environ.get(f"CCCI_SKIP_GENERIC_{op.upper()}")):
|
||||
return True
|
||||
sg = [str(s).lower() for s in (meta.get("SKIP_GENERIC") or [])]
|
||||
sg = [str(s).lower() for s in (meta.SKIP_GENERIC or [])]
|
||||
return "all" in sg or "*" in sg or op in sg
|
||||
|
||||
|
||||
def _run_pre_hook(recipe: str, op: str, repo_local: str | None, domain: str, meta: dict) -> None:
|
||||
def _run_pre_hook(recipe: str, op: str, repo_local: str | None, domain: str, meta) -> None:
|
||||
"""Run the optional pre-op seed hook (recipe ops.py `pre_<op>`) BEFORE the harness performs the
|
||||
op (HC3 op/assertion split): overlays seed data-continuity markers / the backup→restore mutation
|
||||
here, then assert post-op in test_<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,
|
||||
op_state: dict,
|
||||
deploy_timeout: int = 900,
|
||||
meta: dict | None = None,
|
||||
meta=None,
|
||||
) -> None:
|
||||
"""Perform the single mutating op ONCE (the harness owns the op, HC3). install has no op. Records
|
||||
what the assertions need (pre-upgrade identity, backup snapshot_id) into op_state. None of these
|
||||
@@ -345,7 +319,7 @@ def _perform_op(
|
||||
# verify fails we re-run the WHOLE backup (fresh restic snapshot) with a re-stabilised DB, up to
|
||||
# 3 attempts. Recipes without BACKUP_VERIFY are unaffected (single backup, as before).
|
||||
snap = generic.perform_backup(domain)
|
||||
verify = meta.get("BACKUP_VERIFY") if meta else None
|
||||
verify = meta.BACKUP_VERIFY if meta else None
|
||||
attempt = 1
|
||||
while callable(verify) and not verify(domain) and attempt < 3:
|
||||
attempt += 1
|
||||
@@ -371,7 +345,7 @@ def run_lifecycle_tier(
|
||||
op: str,
|
||||
repo_local: str | None,
|
||||
domain: str,
|
||||
meta: dict,
|
||||
meta,
|
||||
head_ref: str | None,
|
||||
op_state: dict,
|
||||
records: list[dict] | None = None,
|
||||
@@ -411,7 +385,7 @@ def run_lifecycle_tier(
|
||||
recipe,
|
||||
head_ref,
|
||||
op_state,
|
||||
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
|
||||
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
|
||||
meta=meta,
|
||||
)
|
||||
with open(os.environ["CCCI_OP_STATE_FILE"], "w") as f:
|
||||
@@ -523,7 +497,7 @@ def _provision_deps(
|
||||
if wd:
|
||||
print(f" dep: {d} warm provider {wd} not up — cold fallback", flush=True)
|
||||
cold_deps.append(d)
|
||||
dep_metas = {d: _load_meta(d) for d in cold_deps}
|
||||
dep_metas = {d: meta_mod.load(d) for d in cold_deps}
|
||||
deps_list = (
|
||||
deps_mod.deploy_deps(recipe, os.environ.get("PR", "0"), ref, cold_deps, meta_for=dep_metas)
|
||||
if cold_deps
|
||||
@@ -609,7 +583,7 @@ def _wait_undeployed(domain: str, timeout: int = 120) -> None:
|
||||
|
||||
|
||||
def run_quick(
|
||||
recipe: str, ref: str | None, head_ref: str | None, repo_local: str | None, meta: dict
|
||||
recipe: str, ref: str | None, head_ref: str | None, repo_local: str | None, meta
|
||||
) -> int:
|
||||
"""WC4 `--quick` opt-in fast lane (plan §2). Reattach the data-warm canonical (known-good volume)
|
||||
→ upgrade IN PLACE to the PR head (chaos) → assert generic UPGRADE (reconverge+moved+serving) +
|
||||
@@ -645,7 +619,7 @@ def run_quick(
|
||||
|
||||
op_state: dict = {}
|
||||
results: dict[str, str] = {}
|
||||
declared = deps_mod.declared_deps(recipe)
|
||||
declared = list(meta.DEPS)
|
||||
deps_state: dict = {}
|
||||
deps_ready = True
|
||||
deps_not_ready_reason = ""
|
||||
@@ -657,13 +631,13 @@ def run_quick(
|
||||
try:
|
||||
# 1) reattach the canonical (warm boot at the known-good version + retained volume)
|
||||
try:
|
||||
canonical.deploy_canonical(recipe, timeout=int(meta.get("DEPLOY_TIMEOUT", 900)))
|
||||
canonical.deploy_canonical(recipe, timeout=int(meta.DEPLOY_TIMEOUT))
|
||||
lifecycle.wait_healthy(
|
||||
domain,
|
||||
ok_codes=tuple(meta["HEALTH_OK"]),
|
||||
path=meta["HEALTH_PATH"],
|
||||
deploy_timeout=meta["DEPLOY_TIMEOUT"],
|
||||
http_timeout=meta["HTTP_TIMEOUT"],
|
||||
ok_codes=tuple(meta.HEALTH_OK),
|
||||
path=meta.HEALTH_PATH,
|
||||
deploy_timeout=meta.DEPLOY_TIMEOUT,
|
||||
http_timeout=meta.HTTP_TIMEOUT,
|
||||
)
|
||||
warm_ok = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -678,7 +652,7 @@ def run_quick(
|
||||
for d in declared:
|
||||
wd = warm.warm_domain(d)
|
||||
(warm_deps if (wd and warm.is_warm_up(d, wd)) else cold_deps).append(d)
|
||||
dep_metas = {d: _load_meta(d) for d in cold_deps}
|
||||
dep_metas = {d: meta_mod.load(d) for d in cold_deps}
|
||||
deps_list = (
|
||||
deps_mod.deploy_deps(
|
||||
recipe, os.environ.get("PR", "0"), ref, cold_deps, meta_for=dep_metas
|
||||
@@ -848,7 +822,7 @@ def promote_canonical(recipe: str, head_ref: str | None) -> None:
|
||||
if not latest:
|
||||
print(f"WC5 promote: no version tags for {recipe} — skip", flush=True)
|
||||
return
|
||||
meta = _load_meta(recipe)
|
||||
meta = meta_mod.load(recipe)
|
||||
# The cold run's deploy-count was already asserted + the countfile removed; don't perturb it.
|
||||
os.environ.pop("CCCI_DEPLOY_COUNT_FILE", None)
|
||||
print(
|
||||
@@ -860,14 +834,15 @@ def promote_canonical(recipe: str, head_ref: str | None) -> None:
|
||||
domain,
|
||||
version=latest,
|
||||
secrets=True,
|
||||
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
|
||||
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
|
||||
meta=meta,
|
||||
)
|
||||
lifecycle.wait_healthy(
|
||||
domain,
|
||||
ok_codes=tuple(meta["HEALTH_OK"]),
|
||||
path=meta["HEALTH_PATH"],
|
||||
deploy_timeout=meta["DEPLOY_TIMEOUT"],
|
||||
http_timeout=meta["HTTP_TIMEOUT"],
|
||||
ok_codes=tuple(meta.HEALTH_OK),
|
||||
path=meta.HEALTH_PATH,
|
||||
deploy_timeout=meta.DEPLOY_TIMEOUT,
|
||||
http_timeout=meta.HTTP_TIMEOUT,
|
||||
)
|
||||
abra.undeploy(domain)
|
||||
_wait_undeployed(domain)
|
||||
@@ -906,7 +881,7 @@ def main() -> int:
|
||||
# HEAD (the catalogue current) for a non-PR `!testme`. Captured before any version-tag checkout.
|
||||
head_ref = ref or lifecycle.recipe_head_commit(recipe)
|
||||
repo_local = snapshot_recipe_tests(recipe)
|
||||
meta = _load_meta(recipe)
|
||||
meta = meta_mod.load(recipe)
|
||||
|
||||
# WC4/WC7: opt-in `--quick` fast lane. Requires an existing data-warm canonical; if none, fall
|
||||
# back cleanly to the full COLD run below so the PR is still tested (DECISIONS Phase-2w).
|
||||
@@ -929,9 +904,7 @@ def main() -> int:
|
||||
# override must be an exact published version tag (deployed as a pinned base). (Adversary §7.1.)
|
||||
want_upgrade = "upgrade" in stages
|
||||
prev = (
|
||||
(meta.get("UPGRADE_BASE_VERSION") or lifecycle.previous_version(recipe))
|
||||
if want_upgrade
|
||||
else None
|
||||
(meta.UPGRADE_BASE_VERSION or lifecycle.previous_version(recipe)) if want_upgrade else None
|
||||
)
|
||||
base = prev or target
|
||||
backup_cap = generic.backup_capable(recipe, meta)
|
||||
@@ -974,12 +947,12 @@ def main() -> int:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(skipfile)
|
||||
os.environ["CCCI_DEPS_SKIP_REPORT"] = skipfile
|
||||
declared = deps_mod.declared_deps(recipe)
|
||||
declared = list(meta.DEPS)
|
||||
# Q3.2a: a recipe that tolerates OIDC env at first boot AND whose deps are live-warm wires OIDC
|
||||
# at INSTALL time (provision the realm BEFORE the single deploy; install_steps.sh writes the env
|
||||
# into it) instead of the post-deploy in-place `--chaos` redeploy — which is flaky on the heavy
|
||||
# 12-service lasuite-drive stack (collabora WOPI race; see JOURNAL Step 0). Opt-in per recipe.
|
||||
oidc_at_install = bool(meta.get("OIDC_AT_INSTALL")) and bool(declared)
|
||||
oidc_at_install = bool(meta.OIDC_AT_INSTALL) and bool(declared)
|
||||
if declared:
|
||||
when = "BEFORE deploy (install-time OIDC)" if oidc_at_install else "AFTER generic tiers"
|
||||
print(f"\n===== DEPS declared (provision {when}): {declared} =====", flush=True)
|
||||
@@ -1023,18 +996,19 @@ def main() -> int:
|
||||
version=base,
|
||||
secrets=True,
|
||||
install_steps_hook=hook,
|
||||
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
|
||||
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
|
||||
meta=meta,
|
||||
)
|
||||
lifecycle.wait_healthy(
|
||||
domain,
|
||||
ok_codes=tuple(meta["HEALTH_OK"]),
|
||||
path=meta["HEALTH_PATH"],
|
||||
deploy_timeout=meta["DEPLOY_TIMEOUT"],
|
||||
http_timeout=meta["HTTP_TIMEOUT"],
|
||||
ok_codes=tuple(meta.HEALTH_OK),
|
||||
path=meta.HEALTH_PATH,
|
||||
deploy_timeout=meta.DEPLOY_TIMEOUT,
|
||||
http_timeout=meta.HTTP_TIMEOUT,
|
||||
)
|
||||
# Recipe READY_PROBE (e.g. lasuite-drive collabora WOPI discovery) — readiness beyond
|
||||
# replica convergence + app HEALTH_PATH; no-op for recipes without one.
|
||||
lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.get("DEPLOY_TIMEOUT", 900)))
|
||||
lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.DEPLOY_TIMEOUT))
|
||||
deploy_ok = True
|
||||
except Exception as e: # noqa: BLE001 — a failed deploy is a reported INSTALL failure
|
||||
print(f"!! deploy/readiness failed: {e}", flush=True)
|
||||
@@ -1314,7 +1288,7 @@ def main() -> int:
|
||||
no_secret_leak=True, # narrowed below by an actual scan of the serialised artifact
|
||||
screenshot=screenshot_rel, # Phase 3 U1 (R4): relative PNG name iff capture succeeded
|
||||
finished_ts=time.time(),
|
||||
expected_na=meta.get("EXPECTED_NA"), # declared intentional-skip map (recipe_meta)
|
||||
expected_na=meta.EXPECTED_NA, # declared intentional-skip map (recipe_meta)
|
||||
)
|
||||
# Real (if narrow) leak check: no known infra-secret value may appear in the artifact (R7).
|
||||
blob = json.dumps(data)
|
||||
|
||||
Reference in New Issue
Block a user