Files
cc-ci/runner/harness/discovery.py
autonomic-bot fd02d9f4b8
All checks were successful
continuous-integration/drone/push Build is passing
feat(harness): P3 — uniform ctx hook convention (rcust)
harness.meta.HookCtx (frozen): .domain, .base_url, .meta (RecipeMeta), .deps
(provisioned dep creds from $CCCI_DEPS_FILE or None), .op (current lifecycle op
or None); built via meta.hook_ctx() at each hook call site.

All recipe callables now take ctx: EXTRA_ENV(ctx), UPGRADE_EXTRA_ENV(ctx),
READY_PROBE(ctx), BACKUP_VERIFY(ctx), SCREENSHOT(page, ctx), ops.py pre_<op>(ctx).
Dict-valued EXTRA_ENV/UPGRADE_EXTRA_ENV unchanged (only the callable signature
moved). Call sites converted: deploy_app env shaping, perform_upgrade,
wait_ready_probes (gains op=), _perform_op BACKUP_VERIFY, screenshot.capture,
_run_pre_hook.

Legacy signatures fail FAST with a clear migration message: the registry carries
hook_params per hook key, enforced at meta.load() (MetaError names the old vs new
signature); ops.py pre-op hooks get the same check at the orchestrator call site
(meta.check_hook_signature) — no silent TypeError mid-run.

Migrated every in-repo user mechanically (17 ops.py files; cryptpad/lasuite-*/
mailu EXTRA_ENV; mumble+lasuite-drive READY_PROBE; ghost/discourse BACKUP_VERIFY)
— seeded values, probes and assertions byte-identical (domain -> ctx.domain;
keycloak pre_restore's meta arg -> ctx.meta).

Unit tests: hook_ctx field contract, ctx.deps from the run deps file, legacy-
signature MetaError (READY_PROBE/EXTRA_ENV/SCREENSHOT + pre-op checker), ctx
signatures accepted. Docs table regenerated (signature docs in key docs).

Verified on cc-ci: cc-ci-run -m pytest tests/unit -q -> 180 passed; scripts/lint.sh -> PASS.
2026-06-10 17:10:26 +00:00

169 lines
7.9 KiB
Python

"""Overlay / custom-test / install-steps discovery + precedence (Phase 1d/1e, DG4/DG5 + HC2/HC3).
The generic is the default floor for each lifecycle op and, per Phase 1e HC3, runs ADDITIVELY
alongside a recipe overlay by default (the orchestrator owns the op; both assertion sets evaluate the
shared post-op state). Discovery here only locates the candidate assertion files + the install-steps
hook; the orchestrator decides additive-vs-skip. Sources, in precedence order
(machine-docs/DECISIONS.md):
lifecycle op (install/upgrade/backup/restore) — the OVERLAY assertion file, if any:
repo-local tests/test_<op>.py (upstream-authoritative, wins same-name collisions)
> cc-ci tests/<recipe>/test_<op>.py
(the generic tests/_generic/test_<op>.py is the always-present floor, run separately by default)
custom (non-lifecycle) test_*.py — ALL run, additively, from BOTH locations (opt-in).
install-steps hook — install_steps.sh: repo-local > cc-ci, or none.
Repo-local = the recipe repo's own tests/ dir, snapshotted after fetch (it survives abra
re-checking-out the recipe to a version tag — see the run orchestrator). It is PR-author-controlled
code that runs on the CI host with /run/secrets/* present, so per Phase 1e HC2 it is **default-deny**:
the repo-local source is consulted ONLY when the recipe is on the cc-ci approval allowlist
(`tests/repo-local-approved.txt`). Otherwise precedence is cc-ci > generic only.
"""
from __future__ import annotations
import glob
import os
LIFECYCLE_OPS = ("install", "upgrade", "backup", "restore")
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
GENERIC_DIR = os.path.join(ROOT, "tests", "_generic")
APPROVED_FILE = os.path.join(ROOT, "tests", "repo-local-approved.txt")
def cc_ci_dir(recipe: str) -> str:
return os.path.join(ROOT, "tests", recipe)
def approved_file_path() -> str:
"""Location of the repo-local approval allowlist. Overridable via CCCI_REPO_LOCAL_APPROVED_FILE
(used by tests + by the Adversary to demonstrate approved-vs-not without editing the checked-in
file). Default: the git-tracked tests/repo-local-approved.txt."""
return os.environ.get("CCCI_REPO_LOCAL_APPROVED_FILE", APPROVED_FILE)
def approved_recipes() -> set[str]:
"""Recipes whose repo-local (PR-authored) code is trusted to execute (HC2). One name per line in
the allowlist; `#` comments + blank lines ignored. Missing file ⇒ empty set ⇒ default-deny."""
names: set[str] = set()
try:
with open(approved_file_path()) as fh:
for raw in fh:
line = raw.split("#", 1)[0].strip()
if line:
names.add(line)
except OSError:
pass
return names
def repo_local_approved(recipe: str) -> bool:
"""True iff `recipe` is on the cc-ci repo-local approval allowlist (default-deny, HC2)."""
return recipe in approved_recipes()
def _gated(recipe: str, repo_local_dir: str | None) -> str | None:
"""The repo-local dir to actually consult: the given dir if the recipe is approved, else None
(default-deny). Centralizes the HC2 gate so every discovery function honors it identically."""
return repo_local_dir if (repo_local_dir and repo_local_approved(recipe)) else None
def resolve_overlay_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str] | None:
"""Return (source, path) for the OVERLAY assertion file for `op` (repo-local > cc-ci), or None if
the recipe ships no overlay for it. The generic floor is handled separately by the orchestrator
(HC3 additive). Repo-local is consulted only for allowlist-approved recipes (HC2)."""
fname = f"test_{op}.py"
rl = _gated(recipe, repo_local_dir)
if rl:
p = os.path.join(rl, fname)
if os.path.isfile(p):
return ("repo-local", p)
p = os.path.join(cc_ci_dir(recipe), fname)
if os.path.isfile(p):
return ("cc-ci", p)
return None
def generic_op(op: str) -> tuple[str, str]:
"""The always-present generic assertion file for `op` (the floor, HC3)."""
return ("generic", os.path.join(GENERIC_DIR, f"test_{op}.py"))
def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str]:
"""Back-compat single-file resolver (override semantics): overlay if present, else generic.
Phase-1e orchestration uses resolve_overlay_op + generic_op (additive); this remains for unit
tests and any caller wanting the legacy "one file wins" view. HC2 gate still applies."""
return resolve_overlay_op(recipe, op, repo_local_dir) or generic_op(op)
def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]:
"""All non-lifecycle test_*.py from cc-ci's tests/<recipe>/ and (if approved) the recipe's
repo-local tests/. Discovered locations (Phase 2 §4.1):
- the top-level dir tests/<recipe>/test_*.py (legacy + cross-cutting)
- functional/ tests/<recipe>/functional/test_*.py (parity ports + recipe-specific)
- playwright/ tests/<recipe>/playwright/test_*.py (UI flows P6)
Files named `test_<op>.py` (lifecycle ops) are excluded from this list — the orchestrator runs
those in their lifecycle tier, not the custom one. Repo-local is consulted only for
allowlist-approved recipes (HC2)."""
lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS}
subdirs = ("functional", "playwright")
found: list[tuple[str, str]] = []
for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))):
if not d or not os.path.isdir(d):
continue
# top-level (legacy / cross-cutting tests not under functional/playwright)
for p in sorted(glob.glob(os.path.join(d, "test_*.py"))):
if os.path.basename(p) not in lifecycle_names:
found.append((source, p))
# functional/ and playwright/ subdirs (Phase 2 §4.1)
for sub in subdirs:
for p in sorted(glob.glob(os.path.join(d, sub, "test_*.py"))):
# Phase-2 layout: lifecycle ops never live under functional/playwright, but be
# explicit so a misfiled file doesn't silently get double-run.
if os.path.basename(p) not in lifecycle_names:
found.append((source, p))
return found
def install_steps(recipe: str, repo_local_dir: str | None) -> tuple[str, str] | None:
"""The custom install-steps hook (install_steps.sh) for a recipe, or None. repo-local > cc-ci.
Repo-local is consulted only for allowlist-approved recipes (HC2)."""
rl = _gated(recipe, repo_local_dir)
if rl:
p = os.path.join(rl, "install_steps.sh")
if os.path.isfile(p):
return ("repo-local", p)
p = os.path.join(cc_ci_dir(recipe), "install_steps.sh")
if os.path.isfile(p):
return ("cc-ci", p)
return None
def pre_op_hook(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str] | None:
"""The pre-op seed hook for `op`: the path to a recipe `ops.py` module that defines a
`pre_<op>(ctx)` callable, or None. cc-ci's tests/<recipe>/ops.py wins; the repo-local
ops.py is consulted only for allowlist-approved recipes (HC2). The orchestrator imports the
module and calls pre_<op> BEFORE performing the op (HC3 op/assertion split — overlays seed
pre-op state here, then assert post-op in test_<op>.py)."""
fn = f"pre_{op}"
for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))):
if not d:
continue
p = os.path.join(d, "ops.py")
if os.path.isfile(p) and _module_defines(p, fn):
return (source, p)
return None
def _module_defines(path: str, name: str) -> bool:
"""Cheap source scan for a top-level `def <name>(` — avoids importing the module just to check."""
try:
with open(path) as fh:
src = fh.read()
except OSError:
return False
return f"def {name}(" in src