feat(harness): P3 — uniform ctx hook convention (rcust)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
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.
This commit is contained in:
@ -144,7 +144,7 @@ def install_steps(recipe: str, repo_local_dir: str | None) -> tuple[str, str] |
|
||||
|
||||
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>(domain, meta)` callable, or None. cc-ci's tests/<recipe>/ops.py wins; the repo-local
|
||||
`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)."""
|
||||
|
||||
@ -256,7 +256,7 @@ def perform_upgrade(
|
||||
# (target) version, so the base deploys minimally WITHOUT it and the upgrade adds it to COMPOSE_FILE
|
||||
# here, after the PR-head checkout (which ships the overlay) and before the chaos redeploy that
|
||||
# picks up the new .env. Dict or callable(domain)->dict. No-op for recipes without it.
|
||||
upgrade_env = meta_mod.upgrade_extra_env(meta, domain)
|
||||
upgrade_env = meta_mod.upgrade_extra_env(meta, meta_mod.hook_ctx(domain, meta, op="upgrade"))
|
||||
for k, v in upgrade_env.items():
|
||||
print(f" upgrade-env: {k}={v}", flush=True)
|
||||
abra.env_set(domain, k, v)
|
||||
@ -272,7 +272,7 @@ def perform_upgrade(
|
||||
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
|
||||
http_timeout=int(meta.HTTP_TIMEOUT),
|
||||
)
|
||||
lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.DEPLOY_TIMEOUT))
|
||||
lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.DEPLOY_TIMEOUT), op="upgrade")
|
||||
after = lifecycle.deployed_identity(domain)
|
||||
# Evidence (HC1): the chaos-version label = the deployed recipe commit; it should match the
|
||||
# PR-head we checked out — proving the upgrade deployed the code under test, not a published tag.
|
||||
|
||||
@ -302,7 +302,7 @@ def deploy_app(
|
||||
# it ourselves is recipe-agnostic and canonical (the run domain IS the app's domain).
|
||||
abra.env_set(domain, "DOMAIN", domain)
|
||||
abra.env_set(domain, "LETS_ENCRYPT_ENV", "")
|
||||
for k, v in meta_mod.extra_env(meta, domain).items():
|
||||
for k, v in meta_mod.extra_env(meta, meta_mod.hook_ctx(domain, meta)).items():
|
||||
abra.env_set(domain, k, v)
|
||||
if secrets:
|
||||
abra.secret_generate(domain)
|
||||
@ -525,7 +525,7 @@ def chaos_redeploy(
|
||||
abra.deploy(domain, chaos=True, timeout=deploy_timeout, no_converge_checks=no_converge_checks)
|
||||
|
||||
|
||||
def wait_ready_probes(meta, domain: str, timeout: int = 600) -> None:
|
||||
def wait_ready_probes(meta, domain: str, timeout: int = 600, op: str | None = None) -> None:
|
||||
"""Poll a recipe's optional READY_PROBE endpoints until each returns an accepted status, or raise.
|
||||
|
||||
A recipe_meta may define `READY_PROBE(domain) -> [{"host":..., "path":..., "ok":(200,)}, ...]`
|
||||
@ -545,7 +545,7 @@ def wait_ready_probes(meta, domain: str, timeout: int = 600) -> None:
|
||||
probe_fn = meta.READY_PROBE
|
||||
if not callable(probe_fn):
|
||||
return
|
||||
probes = probe_fn(domain) or []
|
||||
probes = probe_fn(meta_mod.hook_ctx(domain, meta, op=op)) or []
|
||||
for probe in probes:
|
||||
if "tcp_port" in probe:
|
||||
host = probe.get("tcp_host", "127.0.0.1")
|
||||
|
||||
@ -23,6 +23,8 @@ from __future__ import annotations
|
||||
import copy
|
||||
import dataclasses
|
||||
import difflib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
@ -48,6 +50,10 @@ class Key:
|
||||
doc: str
|
||||
validate: Callable[[object], None] | None = None
|
||||
deprecated: bool = False
|
||||
# Expected positional-parameter names for a callable value (rcust P3 uniform ctx convention).
|
||||
# Enforced at load so a legacy-signature hook (e.g. `def READY_PROBE(domain)`) fails with a
|
||||
# CLEAR MetaError naming the migration — never a silent TypeError mid-run.
|
||||
hook_params: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
KEYS: tuple[Key, ...] = (
|
||||
@ -76,7 +82,8 @@ KEYS: tuple[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}`.",
|
||||
"Callable `(ctx) -> [probe, ...]` returning extra readiness probes, run after install AND after upgrade: HTTP `{host, path, ok}` or TCP `{tcp_host, tcp_port, stable}`.",
|
||||
hook_params=("ctx",),
|
||||
),
|
||||
Key(
|
||||
"UPGRADE_BASE_VERSION",
|
||||
@ -88,19 +95,22 @@ KEYS: tuple[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.",
|
||||
"Callable `(ctx) -> bool` post-backup data-capture check; `False` re-runs the backup (truncated-dump race guard), retried up to 3 attempts.",
|
||||
hook_params=("ctx",),
|
||||
),
|
||||
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).",
|
||||
"Extra `.env` keys applied after the PR-head checkout, before the chaos redeploy (env that exists only at head). Dict, or callable `(ctx) -> dict`.",
|
||||
hook_params=("ctx",),
|
||||
),
|
||||
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.",
|
||||
"Extra `.env` keys applied at EVERY deploy (base install AND upgrade old-app). Dict, or callable `(ctx) -> dict` deriving values from the per-run domain (`ctx.domain`).",
|
||||
hook_params=("ctx",),
|
||||
),
|
||||
Key(
|
||||
"DEPS",
|
||||
@ -118,7 +128,8 @@ KEYS: tuple[Key, ...] = (
|
||||
"SCREENSHOT",
|
||||
"hook",
|
||||
None,
|
||||
"Callable driving Playwright to a safe, credential-free post-login view for the results-card screenshot (default: landing page).",
|
||||
"Callable `(page, ctx)` driving Playwright to a safe, credential-free post-login view for the results-card screenshot (default: landing page).",
|
||||
hook_params=("page", "ctx"),
|
||||
),
|
||||
# (CHAOS_BASE_DEPLOY, OIDC_AT_INSTALL and SKIP_GENERIC were deleted in restructure P2:
|
||||
# compose.ccci.yml is first-class + auto-chaos; install-time deps wiring is the only mode;
|
||||
@ -145,6 +156,28 @@ def meta_path(recipe: str, tests_dir: str | None = None) -> str:
|
||||
return os.path.join(tests_dir or TESTS_DIR, recipe, "recipe_meta.py")
|
||||
|
||||
|
||||
def check_hook_signature(fn, expected: tuple[str, ...], where: str) -> None:
|
||||
"""Enforce the uniform ctx hook convention (rcust P3): a hook callable's positional parameters
|
||||
must be exactly `expected` (e.g. ("ctx",) or ("page", "ctx")). A legacy-signature hook (the
|
||||
pre-restructure `(domain)` / `(domain, meta)` / `(page, domain, meta)` forms) raises a CLEAR
|
||||
MetaError naming the migration — never a silent TypeError mid-run."""
|
||||
try:
|
||||
params = [
|
||||
p.name
|
||||
for p in inspect.signature(fn).parameters.values()
|
||||
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
except (TypeError, ValueError): # builtins/odd callables — let the call site surface it
|
||||
return
|
||||
if tuple(params) != expected:
|
||||
raise MetaError(
|
||||
f"{where}: hook signature is ({', '.join(params)}) — the recipe-customization "
|
||||
f"restructure (P3) changed ALL recipe hook signatures to ({', '.join(expected)}); "
|
||||
f"read fields off the HookCtx (ctx.domain, ctx.base_url, ctx.meta, ctx.deps, ctx.op). "
|
||||
f"See docs/recipe-customization.md §5."
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
@ -209,6 +242,8 @@ def load(recipe: str, tests_dir: str | None = None):
|
||||
f"underscore-prefixed (e.g. _{name})."
|
||||
)
|
||||
values[name] = _coerce(key, ns[name], path)
|
||||
if key.hook_params and callable(values[name]):
|
||||
check_hook_signature(values[name], key.hook_params, f"{path}: {name}")
|
||||
if key.validate:
|
||||
key.validate(values[name])
|
||||
return RecipeMeta(**values)
|
||||
@ -231,17 +266,55 @@ def non_default(meta) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _env_map(value, domain: str) -> dict[str, str]:
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HookCtx:
|
||||
"""The single argument every recipe hook receives (rcust P3 uniform ctx convention):
|
||||
`EXTRA_ENV(ctx)`, `UPGRADE_EXTRA_ENV(ctx)`, `READY_PROBE(ctx)`, `BACKUP_VERIFY(ctx)`,
|
||||
`SCREENSHOT(page, ctx)`, ops.py `pre_<op>(ctx)`."""
|
||||
|
||||
domain: str # the app's per-run domain
|
||||
base_url: str # https://<domain>
|
||||
meta: object # the recipe's full RecipeMeta
|
||||
deps: dict | None # provisioned dep creds ({dep_recipe: entry}) or None if absent/empty
|
||||
op: str | None # current lifecycle op (install|upgrade|backup|restore) or None
|
||||
|
||||
|
||||
def _run_deps() -> dict | None:
|
||||
"""The current run's provisioned dep creds from $CCCI_DEPS_FILE (either shape), or None.
|
||||
Read directly (not via harness.deps) to keep meta.py import-cycle-free."""
|
||||
path = os.environ.get("CCCI_DEPS_FILE")
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
return data or None
|
||||
if isinstance(data, list):
|
||||
out = {e["recipe"]: e for e in data if isinstance(e, dict) and e.get("recipe")}
|
||||
return out or None
|
||||
return None
|
||||
|
||||
|
||||
def hook_ctx(domain: str, meta, *, op: str | None = None) -> HookCtx:
|
||||
"""Build the HookCtx for a hook call site. Dep creds are picked up from the run's
|
||||
$CCCI_DEPS_FILE when present (None otherwise)."""
|
||||
return HookCtx(domain=domain, base_url=f"https://{domain}", meta=meta, deps=_run_deps(), op=op)
|
||||
|
||||
|
||||
def _env_map(value, ctx: HookCtx) -> dict[str, str]:
|
||||
if callable(value):
|
||||
value = value(domain)
|
||||
value = value(ctx)
|
||||
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 extra_env(meta, ctx: HookCtx) -> dict[str, str]:
|
||||
"""Resolve EXTRA_ENV (dict or callable(ctx)->dict) to the concrete per-run env map."""
|
||||
return _env_map(meta.EXTRA_ENV, ctx)
|
||||
|
||||
|
||||
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)
|
||||
def upgrade_extra_env(meta, ctx: HookCtx) -> dict[str, str]:
|
||||
"""Resolve UPGRADE_EXTRA_ENV (dict or callable(ctx)->dict) to the concrete env map."""
|
||||
return _env_map(meta.UPGRADE_EXTRA_ENV, ctx)
|
||||
|
||||
@ -8,7 +8,7 @@ Secret-safety (R7, the cardinal screenshot guardrail): the screenshot step must
|
||||
that displays generated credentials (an install wizard showing the initial admin password, a secrets
|
||||
page, etc.). The DEFAULT capture is the app's **landing page** (a login form shows fields, not the
|
||||
password) — safe for every recipe. A recipe that needs a post-login view opts in via a recipe-meta
|
||||
`SCREENSHOT` hook: a callable `screenshot(page, domain, meta) -> None` that drives Playwright to a
|
||||
`SCREENSHOT` hook: a callable `SCREENSHOT(page, ctx) -> None` that drives Playwright to a
|
||||
safe, credential-free view and is responsible for not landing on a secrets page. The harness never
|
||||
auto-fills a wizard.
|
||||
|
||||
@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from . import browser as harness_browser
|
||||
from . import meta as meta_mod
|
||||
|
||||
# Default viewport for the captured screenshot — a desktop-ish frame that crops well into the card.
|
||||
VIEWPORT = {"width": 1280, "height": 800}
|
||||
@ -74,8 +75,9 @@ def capture(domain: str, out_path: str, *, recipe_meta: dict | None = None) -> s
|
||||
if hook is not None:
|
||||
# Recipe-specific safe view (post-login etc.). The hook owns navigation +
|
||||
# the no-secret-page guarantee; it should call page.screenshot itself, but if
|
||||
# it doesn't, we still snap the resulting page below.
|
||||
hook(page, domain, recipe_meta)
|
||||
# it doesn't, we still snap the resulting page below. SCREENSHOT(page, ctx) —
|
||||
# the uniform ctx convention (rcust P3).
|
||||
hook(page, meta_mod.hook_ctx(domain, recipe_meta))
|
||||
if not os.path.exists(out_path):
|
||||
page.screenshot(path=out_path, full_page=False)
|
||||
else:
|
||||
|
||||
@ -289,7 +289,11 @@ def _run_pre_hook(recipe: str, op: str, repo_local: str | None, domain: str, met
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
print(f" pre-op seed ({source}): {os.path.relpath(path, ROOT)}::pre_{op}", flush=True)
|
||||
getattr(mod, f"pre_{op}")(domain, meta)
|
||||
fn = getattr(mod, f"pre_{op}")
|
||||
# Uniform ctx convention (rcust P3): pre_<op>(ctx). A legacy (domain, meta) hook fails
|
||||
# HERE with a clear migration message, not a TypeError mid-call.
|
||||
meta_mod.check_hook_signature(fn, ("ctx",), f"{os.path.relpath(path, ROOT)}::pre_{op}")
|
||||
fn(meta_mod.hook_ctx(domain, meta, op=op))
|
||||
finally:
|
||||
if d in sys.path:
|
||||
sys.path.remove(d)
|
||||
@ -326,8 +330,9 @@ def _perform_op(
|
||||
# 3 attempts. Recipes without BACKUP_VERIFY are unaffected (single backup, as before).
|
||||
snap = generic.perform_backup(domain)
|
||||
verify = meta.BACKUP_VERIFY if meta else None
|
||||
verify_ctx = meta_mod.hook_ctx(domain, meta, op="backup") if meta else None
|
||||
attempt = 1
|
||||
while callable(verify) and not verify(domain) and attempt < 3:
|
||||
while callable(verify) and not verify(verify_ctx) and attempt < 3:
|
||||
attempt += 1
|
||||
print(
|
||||
f" backup-verify FAILED (attempt {attempt - 1}/3) — backup did not capture the "
|
||||
@ -335,7 +340,7 @@ def _perform_op(
|
||||
flush=True,
|
||||
)
|
||||
snap = generic.perform_backup(domain)
|
||||
if callable(verify) and not verify(domain):
|
||||
if callable(verify) and not verify(verify_ctx):
|
||||
print(
|
||||
f" !! backup-verify still FAILED after {attempt} attempts — backup is incomplete",
|
||||
flush=True,
|
||||
@ -992,7 +997,9 @@ def main() -> int:
|
||||
)
|
||||
# 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.DEPLOY_TIMEOUT))
|
||||
lifecycle.wait_ready_probes(
|
||||
meta, domain, timeout=int(meta.DEPLOY_TIMEOUT), op="install"
|
||||
)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user