Compare commits

..

6 Commits

Author SHA1 Message Date
ff09c4075b status(rcust): P4 complete on branch (29a28e2) — unit 184 green + lint PASS; starting P5
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-10 17:14:38 +00:00
63befd05b0 note(rcust): interim pre-review of frozen P3 — mechanical migration held (0 changed asserts), HookCtx complete, legacy-sig guard live-probed PASS, coverage diff still 0/21 (NOT M1)
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-10 17:14:37 +00:00
802b2792a7 note(rcust): interim pre-review of frozen P1+P2 — fallout clean, typo gate PASS, coverage diff 0/21 deltas, validation gaps closed (NOT an M1 verdict; M1 unclaimed)
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-10 17:11:41 +00:00
0264af72c7 status(rcust): P3 complete on branch (fd02d9f) — unit 180 green + lint PASS; starting P4
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-10 17:10:45 +00:00
8945d13674 status(rcust): P2 complete on branch (8cd72fd) — unit 175 green + lint PASS; starting P3
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-10 17:01:58 +00:00
f5119a9703 status(rcust): P1 complete on branch (472a68b) — unit 175 green + lint PASS; starting P2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2026-06-10 16:47:35 +00:00
63 changed files with 1021 additions and 1410 deletions

View File

@ -8,3 +8,99 @@ be `restructure/recipe-custom` off main @ 76a4b6b. Starting P1: reading the six
(run_recipe_ci.py::_load_meta, conftest.py::_recipe_meta, lifecycle.py::_recipe_extra_env,
lifecycle.py::_recipe_meta_flag, deps.py::declared_deps, canonical.py::is_canonical_enrolled)
before writing harness/meta.py.
## 2026-06-10 P1 — single loader + registry (branch 472a68b)
Wrote runner/harness/meta.py: KEYS registry (14 keys + CHAOS_BASE_DEPLOY/OIDC_AT_INSTALL/
SKIP_GENERIC kept registered as deprecated=True so P1 lands green before P2 deletes them),
RecipeMeta generated from KEYS via dataclasses.make_dataclass (frozen; field set cannot drift from
the registry), load() = the only exec() of recipe_meta.py, MetaError on unknown ALL-CAPS/type
mismatch/callable-on-data-key, difflib suggestion in the unknown-key message. BACKUP_CAPABLE keeps
its tri-state via default None (None = auto-detect — preserves the old `"BACKUP_CAPABLE" in meta`
semantics in generic.backup_capable).
Migrations: orchestrator loads once + passes meta down (deploy_app/perform_upgrade/_perform_op/
run_lifecycle_tier all take the object); conftest meta fixture returns full RecipeMeta (R3 closed);
lifecycle._recipe_extra_env/_recipe_meta_flag and deps.declared_deps deleted; canonical.is_enrolled
+ enrolled_recipes go through meta.load (tests monkeypatch meta.TESTS_DIR now instead of
canonical.__file__); screenshot._load_screenshot_hook reads the attribute (R2 fixed — unit test
proves SCREENSHOT survives the real orchestrator load path). deploy_app keeps an optional
meta=None fallback (loads via the single loader) for fixture/manual callers — exec still happens
in exactly one function.
Effective-value safety check before committing: dumped non_default() for all 21 recipe dirs through
the new loader — every recipe's customized key set matches its recipe_meta.py source (e.g. mumble:
DEPLOY_TIMEOUT/EXTRA_ENV/HEALTH_OK/READY_PROBE/UPGRADE_EXTRA_ENV). One intentional delta class:
deps.deploy_deps' fallback timeouts for a MISSING dep meta change from literal 900/600 to loading
the dep's real meta (orchestrator path always supplied metas, so CI behavior is identical).
Verified on cc-ci (rsynced working tree before committing):
cc-ci-run -m pytest tests/unit -q -> 175 passed
nix develop .#lint --command scripts/lint.sh -> lint: PASS
Three pre-existing f212 unit tests passed dicts to wait_ready_probes — updated mechanically to
construct RecipeMeta via dataclasses.replace (assertions untouched).
Next: P2a compose.ccci.yml first-class + auto-chaos.
## 2026-06-10 P2 — legacy keys & paths deleted (branch 8cd72fd)
P2a: lifecycle.provide_ccci_overlay copies tests/<recipe>/compose.ccci.yml into the per-run
checkout (after install_steps hook, before prepull/deploy); pinned base deploys auto-chaos on
overlay presence (has_ccci_overlay replaces the meta.CHAOS_BASE_DEPLOY elif). ghost/discourse
install_steps.sh were copy-only -> deleted whole; their metas keep COMPOSE_FILE in EXTRA_ENV
(unchanged wiring, the harness now owns the copy).
P2b: oidc_at_install condition removed — `if declared:` provisions before the single deploy,
legacy post-deploy block + _run_setup_custom_tests_hook deleted. lasuite-docs install_steps.sh is
the meet/drive hook with docs' exact env names (diffed against the deleted setup_custom_tests.sh:
same keys incl. OIDC_OP_DISCOVERY_ENDPOINT + scopes 'openid email profile'; secret-insert bump
identical; only the abra-redeploy step is gone — the single deploy reads the env instead).
lasuite-drive's MinIO bucket one-shot -> ops.py pre_install (runs at install-tier start, post-
deploy; bucket lives in the minio volume so it survives upgrade/restore; same scale --detach +
30x3s poll as the shell version). run_quick: deps still provision (realm/creds), hook call gone —
no quick-enrolled recipe declares DEPS today; noted inline.
P2c: SKIP_GENERIC out of the registry; _skip_generic(op) env-only; skip_generic_env_overrides()
prints a `!!` warning when active under DRONE (P5 will embed in the manifest).
P2d: conftest deps fixture = dict of _DepEntry (dict subclass w/ attribute sugar) — the 6 lasuite
files only ever used deps_creds, renamed param to deps, zero assertion changes. NOTE for Adversary:
some assert MESSAGE strings ('setup_custom_tests should have populated this.' -> 'dep
provisioning...') and docstrings updated — message text only, no assert logic/expected values.
Verified on cc-ci (rsync of working tree): cc-ci-run -m pytest tests/unit -q -> 175 passed;
nix develop .#lint --command scripts/lint.sh -> PASS. Doc table regenerated to the 14-key registry
(doc-sync unit test pins it).
Next: P3 — HookCtx + ctx-hook signatures everywhere.
## 2026-06-10 P3 — uniform ctx hook convention (branch fd02d9f)
HookCtx frozen dataclass + hook_ctx() constructor in harness/meta.py; ctx.deps read straight from
$CCCI_DEPS_FILE (json, both shapes) — meta.py stays import-cycle-free (deps.py imports lifecycle
which imports meta). Registry keys carry hook_params; meta.load() enforces the expected positional
names per hook key (READY_PROBE/BACKUP_VERIFY/EXTRA_ENV/UPGRADE_EXTRA_ENV=(ctx,),
SCREENSHOT=(page, ctx)); _run_pre_hook applies meta.check_hook_signature(fn, ("ctx",)) to ops.py
hooks before calling. Conversion of 17 ops.py + 8 recipe_meta hooks was scripted (def-line regex +
bare `domain` -> `ctx.domain` inside the pre_*/hook function bodies only) and diff-reviewed; the
only manual fixes: keycloak pre_restore passed `meta` -> `ctx.meta`, and two comment lines in
lasuite-drive/-meet metas that the regex over-replaced were restored. wait_ready_probes gained
op= (install/upgrade call sites pass it) so probes can know the phase.
Verified on cc-ci: cc-ci-run -m pytest tests/unit -q -> 180 passed; lint PASS.
Next: P4 — discovery placement rule + op_state/deps fixtures + migrate hand-parsers.
## 2026-06-10 P4 — custom-test ergonomics (branch 29a28e2)
Pre-change sweeps confirmed the plan's zero-users claims: no top-level non-lifecycle test_*.py in
any recipe dir; no recipe test file reads os.environ / CCCI_OP_STATE_FILE directly (the only
op-state consumers are the generic assertions via harness.generic.op_state — harness-side, fine).
So P4 = discovery glob removal + new op_state fixture + pinning tests; no test migrations needed.
test_discovery.py's HC2 gate test moved its repo-local custom fixture under functional/ (the rule);
test_discovery_phase2.py now asserts top-level custom is NOT discovered. op_state fixture skips
(clear reason) when env unset / file missing / unparseable; tested via request.getfixturevalue.
Verified on cc-ci: cc-ci-run -m pytest tests/unit -q -> 184 passed; lint PASS.
Next: P5 — customization manifest (print block + results.json key).

View File

@ -29,6 +29,73 @@ I own this file and the `## Adversary findings` section of BACKLOG-rcust.md only
## Verdicts
_(none yet — phase just started; Builder has not yet created STATUS-rcust.md or branch
`restructure/recipe-custom`. Only the reference spec doc `76a4b6b` has landed. Awaiting first
`claim(rcust): M1` from the Builder.)_
_(no GATE verdict yet — M1 is not claimed. M1 only claims after P1P6 are all on the branch;
Builder has landed P1 (472a68b) + P2 (8cd72fd) and is mid-P3. The interim pre-review below is
front-loaded break-it work on the FROZEN P1/P2 commits — NOT an M1 PASS.)_
### Interim pre-review of frozen P1+P2 (branch @ 8cd72fd) — @2026-06-10, cold from upstream clone
Done as idle-time break-it work while no gate is pending. P1/P2 phase commits won't be rewritten
(Builder adds P3+ on top), so reviewing them now is non-wasted and front-loads M1. Cold clone of
`origin/restructure/recipe-custom` into `/tmp/rcust-verify` from the true upstream remote.
**No defects found so far.** Results:
1. **Deleted-code fallout — CLEAN.** Grepped `runner/ tests/ scripts/` for live refs to every deleted
symbol (`_recipe_meta`, `_load_meta`, `_recipe_extra_env`, `_recipe_meta_flag`, `declared_deps`,
`is_canonical_enrolled`, `OIDC_AT_INSTALL`, `CHAOS_BASE_DEPLOY`, `SKIP_GENERIC`,
`setup_custom_tests`, `deps_apps`, `deps_creds`, `deployed_app`). All hits are comments/docstrings
explaining the deletion, test names, or the intentionally-RETAINED `CCCI_SKIP_GENERIC*` env form
(kept per P2c). Zero live call-sites. `setup_custom_tests.sh` files gone.
2. **All-recipes-load-clean (typo gate) — PASS, independently.** Ran `meta.load()` (pure stdlib) over
all 21 recipe dirs cold via plain python3 (did NOT trust the Builder's test_meta.py). All 21 load;
non-default key sets sane. Every ALL-CAPS key used in any recipe_meta.py is in the 14-key registry.
3. **Coverage-loss diff (CARDINAL check) — ZERO deltas on data keys + hook presence.** Throwaway
harness (`/tmp/diff_meta.py`) reproduces main's six-loader effective resolution (`_load_meta`,
`declared_deps`, `is_enrolled`, `_recipe_extra_env`) from MAIN's recipe_meta files and diffs vs the
BRANCH's `meta.load()` for all 21 recipes. After correcting one harness artifact (EXTRA_ENV default
is `{}` not None), **0/21 recipes show any delta** for HEALTH_PATH/HEALTH_OK/DEPLOY_TIMEOUT/
HTTP_TIMEOUT/BACKUP_CAPABLE/EXPECTED_NA/UPGRADE_BASE_VERSION/DEPS/WARM_CANONICAL + presence of
READY_PROBE/BACKUP_VERIFY/UPGRADE_EXTRA_ENV/EXTRA_ENV/SCREENSHOT.
4. **Validation gaps — CLOSED.** Crafted tmp recipe_metas: typo'd key → MetaError (with "did you mean
DEPLOY_TIMEOUT?"); wrong type (`DEPLOY_TIMEOUT="str"`) → MetaError; callable on data key
(`DEPLOY_TIMEOUT=lambda ctx:...`) → MetaError; `_PRIVATE`/lowercase-helper → loads clean (exemption
works). All four behave per the locked decision.
5. **meta.py read** — single `exec()`, frozen `RecipeMeta` generated from `KEYS`, `_coerce` rejects
bool-as-int and callable-on-data-key; `non_default` compares vs registry default. No issues.
**Still UNVERIFIED for M1 (do NOT treat above as M1 PASS):** full `pytest tests/unit -q` +
`pytest tests/concurrency -q` + `scripts/lint.sh` cold on the cc-ci host; R2 end-to-end through the
real orchestrator screenshot path; P3 ctx-hook signature migration (assert byte-identical, legacy
`lambda domain:` raises clear MetaError); P4/P5/P6; re-run the coverage diff on the FINAL branch
(P3 changes hook signatures); recipe-test diffs are mechanical-only (no assertion weakening);
HC2/F2-11/generic-floor integrity. These wait for the `claim(rcust): M1`.
### Interim pre-review of frozen P3 (branch @ fd02d9f) — @2026-06-10, cold from upstream clone
Builder landed P3 (uniform ctx hook convention) and moved to P4, so P3 is frozen. Pre-reviewed it.
**No defects found.**
1. **Mechanical-migration discipline — HELD (no VETO trigger).** `git diff 8cd72fd..fd02d9f` over
`tests/*/` shows ZERO changed assert/expected literals. Every hook change is purely
`def HOOK(domain[, meta])``def HOOK(ctx)` + `domain``ctx.domain` in the body. Spot-checked
cryptpad/mumble/ghost/lasuite-drive recipe_meta.py + lasuite-drive ops.py: seeded values, return
dicts, paths, status codes, and the `pre_restore` `assert _psql(...) in (...)` are byte-identical
apart from the `ctx.` deref.
2. **HookCtx — present + complete.** `meta.HookCtx` frozen dataclass has all 5 documented fields
(`.domain`, `.base_url`, `.meta`, `.deps`, `.op`); `meta.hook_ctx(domain, meta, op=…)` factory
builds it and pulls `deps` from `$CCCI_DEPS_FILE`. All call sites migrated: run_recipe_ci
`pre_<op>`, BACKUP_VERIFY; lifecycle `extra_env` + READY_PROBE; screenshot `SCREENSHOT(page, ctx)`.
(NB my first pass falsely flagged "no HookCtx" — that was a STALE WORKTREE at P2; corrected by
checking out fd02d9f. Logged here for honesty.)
3. **Legacy-signature guard (P3.4) — PRESENT + works, live-probed.** `meta.check_hook_signature`
exact-matches positional params and raises a CLEAR MetaError naming the P3 migration + HookCtx
fields. Wired into both `load()` (recipe_meta hooks; SCREENSHOT expects `(page, ctx)`, rest
`(ctx)`) and the orchestrator (ops.py `pre_<op>`). Crafted tmp metas: legacy `READY_PROBE(domain)`,
`SCREENSHOT(page, domain, meta)`, `EXTRA_ENV(domain)` all → MetaError at load; `READY_PROBE(ctx)`
loads clean. No silent mid-run TypeError path.
4. **Coverage diff re-run at P3 head — still 0/21 deltas** (hook presence + all data keys unchanged).
Net: P1+P2+P3 all clean under cold adversarial probing. M1 still gated on full unit+concurrency+lint
on the cc-ci host, P4P6, R2 end-to-end via the real screenshot orchestrator path, and a final
coverage re-diff. No findings filed; no VETO.

View File

@ -6,17 +6,33 @@ Work branch: `restructure/recipe-custom` (one commit per phase P1P6; merged t
## Phase progress
- [ ] P1 — harness/meta.py single loader + key registry + migrate L1L6 + unit tests + doc gen
- [ ] P2 — delete legacy keys/paths (CHAOS_BASE_DEPLOY, OIDC_AT_INSTALL, SKIP_GENERIC meta, conftest cleanup)
- [ ] P3uniform ctx hook convention
- [ ] P4 — custom-test ergonomics (placement rule, op_state/deps fixtures)
- [x] P1 — single loader + key registry + migrate L1L6 + unit tests + doc gen
(branch commit 472a68b)
- [x] P2delete legacy keys/paths: compose.ccci.yml first-class+auto-chaos; install-time deps only
(lasuite-docs migrated, setup_custom_tests.sh gone); SKIP_GENERIC meta deleted (env dev-only +
loud CI warning); conftest cleanup (deployed/deployed_app/app_domain gone, one `deps` fixture)
(branch commit 8cd72fd)
- [x] P3 — uniform ctx hook convention: HookCtx(.domain/.base_url/.meta/.deps/.op); all hooks
take ctx; legacy signatures raise MetaError at load naming the migration (branch fd02d9f)
- [x] P4 — custom-test ergonomics: placement rule (custom under functional/+playwright/ only),
op_state fixture, deps fixture tests (branch 29a28e2)
- [ ] P5 — customization manifest
- [ ] P6 — docs
## P1P4 verification facts (for the eventual M1 cold-verify)
- WHERE: branch `restructure/recipe-custom`, P1=472a68b, P2=8cd72fd, P3=fd02d9f, P4=29a28e2.
- HOW: `cc-ci-run -m pytest tests/unit -q` and `nix develop .#lint --command scripts/lint.sh`
from a clean checkout of the branch.
- EXPECTED: 184 passed; `lint: PASS`.
- New single loader: `runner/harness/meta.py::load()`; all-recipes typo gate + R2 proof in
`tests/unit/test_meta.py`; docs §4 table generated by `scripts/gen-meta-docs.py` (sync pinned
by unit test).
## Gate
(none claimed yet — phase bootstrap)
(none claimed yet — M1 claims only after P1P6 complete on the branch)
## Current
Bootstrapping phase; starting P1.
P1P4 done on the branch; starting P5 (customization manifest).

View File

@ -96,36 +96,6 @@ single loader; six independent code paths each `exec()` the file and pick out th
| L5 | `runner/harness/deps.py:declared_deps` | `DEPS` 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 `(ctx) -> [probe, ...]` 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 `(ctx) -> 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). Dict, or callable `(ctx) -> dict`. |
| `EXTRA_ENV` | `dict_or_hook` | `{}` | 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`). |
| `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 `(page, ctx)` driving Playwright to a safe, credential-free post-login view for the results-card screenshot (default: landing page). |
<!-- META-TABLE-END -->
### 4.1 HTTP / health / timing (base 4 — seen by L1 AND L2)
| Key | Type / default | Meaning | Used by |

View File

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

View File

@ -20,7 +20,7 @@ Per Phase-2 DECISIONS:
Run state:
- `$CCCI_DEPS_FILE` — JSON file written by the orchestrator after each dep deploys; each entry is
`{"recipe": "<dep-recipe>", "domain": "<dep-domain>", "version": null}`. Tests access via the
`deps` pytest fixture defined in `tests/conftest.py`.
`deps_apps` pytest fixture defined in `tests/conftest.py`.
"""
from __future__ import annotations
@ -31,7 +31,19 @@ import os
from collections.abc import Iterable
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:
@ -50,11 +62,11 @@ def write_run_state(deps_state) -> None:
"""Write the deps state file ($CCCI_DEPS_FILE). Two shapes supported (canonical=keyed dict):
1. **Legacy list-of-entries:** `[{"recipe": "<dep>", "domain": "<d>"}, ...]` (Q2.3 original).
Still accepted by `load_run_state` for backwards compat — the `deps` fixture flattens.
Still accepted by `load_run_state` for backwards compat — `deps_apps` fixture flattens.
2. **NEW per-spec dict (operator-2026-05-28 SSO-dep plan §3.2):**
`{"<dep_recipe>": {"recipe": "<dep>", "domain": "<d>", "realm": "...",
"client_id": "...", "client_secret": "...", "admin_user": "...", "admin_password": "..."}}`.
The per-recipe `install_steps.sh` hook reads this via `jq` to wire OIDC env.
The `setup_custom_tests.sh` per-recipe hook reads this via `jq` to wire OIDC env.
No-op if `$CCCI_DEPS_FILE` isn't set."""
path = os.environ.get("CCCI_DEPS_FILE")
@ -69,12 +81,11 @@ def deploy_deps(
pr: str,
ref: str | None,
deps: Iterable[str],
meta_for: dict | None = None,
meta_for: dict[str, dict] | None = None,
) -> list[dict]:
"""Deploy each declared dep, sequentially, at its per-run domain. Returns the list of state
dicts (one per dep). `meta_for` maps dep_recipe -> RecipeMeta (HEALTH_PATH/HEALTH_OK/timeouts)
so the readiness wait uses per-dep config; a missing dep meta is loaded via meta.load()
(defaults: /, 200/301/302, 600s)."""
dicts (one per dep). `meta_for` maps dep_recipe -> meta (HEALTH_PATH/HEALTH_OK/timeouts) so the
readiness wait uses per-dep config; missing dep meta falls back to (/, 200/301/302, 600s)."""
meta_for = meta_for or {}
state: list[dict] = []
for dep in deps:
@ -83,21 +94,20 @@ def deploy_deps(
# 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
# parent + its deps as distinct install events — by design, since each is a separate app.
dm = meta_for.get(dep) or meta_mod.load(dep)
dm = meta_for.get(dep, {})
lifecycle.deploy_app(
dep,
domain,
secrets=True,
deploy_timeout=int(dm.DEPLOY_TIMEOUT),
meta=dm,
deploy_timeout=int(dm.get("DEPLOY_TIMEOUT", 900)),
)
try:
lifecycle.wait_healthy(
domain,
ok_codes=tuple(dm.HEALTH_OK),
path=dm.HEALTH_PATH,
deploy_timeout=int(dm.DEPLOY_TIMEOUT),
http_timeout=int(dm.HTTP_TIMEOUT),
ok_codes=tuple(dm.get("HEALTH_OK", (200, 301, 302))),
path=dm.get("HEALTH_PATH", "/"),
deploy_timeout=int(dm.get("DEPLOY_TIMEOUT", 600)),
http_timeout=int(dm.get("HTTP_TIMEOUT", 600)),
)
except Exception:
# If a dep fails to converge, abort the whole resolve — let the caller teardown
@ -153,7 +163,7 @@ def load_run_state():
def deps_as_dict(state) -> dict[str, dict]:
"""Coerce either shape (legacy list or new dict) into a recipe→entry dict for the `deps`
"""Coerce either shape (legacy list or new dict) into a recipe→entry dict for the deps_apps
fixture + dependent-tests consumption."""
if isinstance(state, dict):
return state

View File

@ -11,8 +11,7 @@ hook; the orchestrator decides additive-vs-skip. Sources, in precedence order
> cc-ci tests/<recipe>/test_<op>.py
(the generic tests/_generic/test_<op>.py is the always-present floor, run separately by default)
custom test_*.py (functional/ + playwright/ ONLY, rcust P4 placement rule) — ALL run,
additively, from BOTH locations (opt-in).
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.
@ -101,22 +100,29 @@ def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, s
def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]:
"""All custom-tier test_*.py from cc-ci's tests/<recipe>/ and (if approved) the recipe's
repo-local tests/. PLACEMENT RULE (rcust P4): custom tests live ONLY under
- functional/ tests/<recipe>/functional/test_*.py (parity ports + recipe-specific)
- playwright/ tests/<recipe>/playwright/test_*.py (UI flows)
A top-level test_*.py is a LIFECYCLE OVERLAY (test_<op>.py) and nothing else — top-level
non-lifecycle files are NOT discovered (zero users at the time of the change; the lifecycle-
name exclusion below stays as a safety net so a misfiled test_<op>.py can never double-run).
Repo-local is consulted only for allowlist-approved recipes (HC2)."""
"""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
@ -138,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>(ctx)` callable, or None. cc-ci's tests/<recipe>/ops.py wins; the repo-local
`pre_<op>(domain, meta)` 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)."""

View File

@ -19,7 +19,6 @@ import ssl
import time
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.
_BACKUPBOT_RE = re.compile(r"backupbot\.backup\b[^\n]*\btrue\b", re.IGNORECASE)
@ -29,14 +28,13 @@ def _recipe_dir(recipe: str) -> str:
return abra.recipe_dir(recipe) # the per-run tree inside a CI run ($ABRA_DIR)
def backup_capable(recipe: str, meta=None) -> bool:
def backup_capable(recipe: str, meta: dict | None = None) -> bool:
"""Whether the harness should run the backup/restore tiers (else they are a clean N/A skip, DG3).
`recipe_meta.BACKUP_CAPABLE` (bool) overrides when explicitly set (RecipeMeta default is None =
unset); otherwise auto-detect by scanning the recipe's compose*.yml for a truthy
`backupbot.backup` label (the Co-op Cloud backup convention)."""
if meta is not None and meta.BACKUP_CAPABLE is not None:
return bool(meta.BACKUP_CAPABLE)
`recipe_meta.BACKUP_CAPABLE` (bool) overrides; otherwise auto-detect by scanning the recipe's
compose*.yml for a truthy `backupbot.backup` label (the Co-op Cloud backup convention)."""
if meta and "BACKUP_CAPABLE" in meta:
return bool(meta["BACKUP_CAPABLE"])
for path in glob.glob(os.path.join(_recipe_dir(recipe), "compose*.yml")):
try:
with open(path) as fh:
@ -77,7 +75,7 @@ def served_cert(domain: str, port: int = 443) -> tuple[bool, str]:
return (True, f"CN={cn} SAN={sans}")
def assert_serving(domain: str, meta) -> None:
def assert_serving(domain: str, meta: dict) -> None:
"""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):
@ -92,14 +90,14 @@ def assert_serving(domain: str, meta) -> None:
Steps 12 are BOUNDED POLLS (no bare sleep), so a state-mutating op (upgrade/restore) that leaves
the app briefly reconverging settles, while a persistent failure still fails within the timeout."""
deadline = time.time() + meta.DEPLOY_TIMEOUT
deadline = time.time() + meta["DEPLOY_TIMEOUT"]
while time.time() < deadline and not lifecycle.services_converged(domain):
time.sleep(5)
assert lifecycle.services_converged(domain), f"{domain}: services did not converge"
path = meta.HEALTH_PATH
ok = tuple(meta.HEALTH_OK)
deadline = time.time() + meta.HTTP_TIMEOUT
path = meta["HEALTH_PATH"]
ok = tuple(meta["HEALTH_OK"])
deadline = time.time() + meta["HTTP_TIMEOUT"]
served = False
status, body = 0, ""
while time.time() < deadline:
@ -143,7 +141,7 @@ def op_state() -> dict:
return {}
def assert_upgraded(domain: str, meta) -> None:
def assert_upgraded(domain: str, meta: dict) -> None:
"""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
the deployment is genuinely the PR-head code under test (HC1) — non-vacuously (guarding F1d-2).
@ -214,7 +212,7 @@ def assert_backup_artifact(domain: str) -> str:
return snap_id
def assert_restore_healthy(domain: str, meta) -> None:
def assert_restore_healthy(domain: str, meta: dict) -> None:
"""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)."""
assert_serving(domain, meta)
@ -228,7 +226,7 @@ def perform_upgrade(
recipe: str,
head_ref: str | None,
deploy_timeout: int = 900,
meta=None,
meta: dict | None = None,
) -> dict[str, str | None]:
"""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`
@ -246,8 +244,7 @@ def perform_upgrade(
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
deadline, not abra's impatient one — and is stronger evidence than abra's monitor."""
if meta is None:
meta = meta_mod.load(recipe)
meta = meta or {}
before = lifecycle.deployed_identity(domain)
if head_ref:
lifecycle.recipe_checkout_ref(recipe, head_ref)
@ -256,7 +253,9 @@ 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, meta_mod.hook_ctx(domain, meta, op="upgrade"))
upgrade_env = meta.get("UPGRADE_EXTRA_ENV") or {}
if callable(upgrade_env):
upgrade_env = upgrade_env(domain) or {}
for k, v in upgrade_env.items():
print(f" upgrade-env: {k}={v}", flush=True)
abra.env_set(domain, k, v)
@ -267,12 +266,14 @@ def perform_upgrade(
# Own the convergence verification (abra's monitor was skipped via -c).
lifecycle.wait_healthy(
domain,
ok_codes=tuple(meta.HEALTH_OK),
path=meta.HEALTH_PATH,
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
http_timeout=int(meta.HTTP_TIMEOUT),
ok_codes=tuple(meta.get("HEALTH_OK", (200, 301, 302))),
path=meta.get("HEALTH_PATH", "/"),
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", deploy_timeout)),
http_timeout=int(meta.get("HTTP_TIMEOUT", 300)),
)
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), 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.

View File

@ -12,7 +12,6 @@ import glob
import json
import os
import re
import shutil
import socket
import ssl
import subprocess
@ -20,7 +19,6 @@ import time
import urllib.request
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)
# A run app domain is "<recipe[:4]>-<6hex>.ci.commoninternet.net" (see DECISIONS.md). Used by the
@ -113,6 +111,37 @@ 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:
"""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."""
@ -126,34 +155,6 @@ def _record_deploy() -> None:
f.write(str(n + 1))
def ccci_overlay_path(recipe: str) -> str:
"""The cc-ci-owned compose overlay for a recipe (rcust P2a: first-class, auto-discovered)."""
return os.path.join(meta_mod.TESTS_DIR, recipe, "compose.ccci.yml")
def has_ccci_overlay(recipe: str) -> bool:
return os.path.isfile(ccci_overlay_path(recipe))
def provide_ccci_overlay(recipe: str) -> None:
"""Copy tests/<recipe>/compose.ccci.yml into THIS run's recipe checkout (ABRA_DIR-aware), so
the recipe's COMPOSE_FILE reference resolves (rcust P2a — the harness owns the copy; recipes
no longer ship install_steps.sh boilerplate for it). No-op for recipes without an overlay."""
src = ccci_overlay_path(recipe)
if not os.path.isfile(src):
return
dest_dir = abra.recipe_dir(recipe)
if not os.path.isdir(dest_dir):
print(f" ccci-overlay: recipe dir {dest_dir} missing — cannot provide overlay", flush=True)
raise RuntimeError(f"recipe checkout missing for {recipe}: {dest_dir}")
shutil.copy(src, os.path.join(dest_dir, "compose.ccci.yml"))
print(
f" ccci-overlay: provided compose.ccci.yml to the {recipe} checkout "
"(first-class overlay; base deploy auto-chaos)",
flush=True,
)
def _run_install_steps(hook: tuple[str, str], recipe: str, domain: str) -> None:
"""Run a recipe's custom install-steps hook (install_steps.sh) during the install tier — after
`abra app new` + env defaults + secret generate, before deploy (Phase 1d DG5). The hook gets the
@ -237,23 +238,15 @@ def deploy_app(
secrets: bool = True,
install_steps_hook: tuple[str, str] | None = None,
deploy_timeout: int = 900,
meta=None,
) -> None:
"""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
per-recipe EXTRA_ENV (recipe_meta.py), the custom install-steps hook (Phase 1d), and the
first-class `tests/<recipe>/compose.ccci.yml` overlay (rcust P2a) before deploy.
`meta` is the recipe's loaded RecipeMeta (EXTRA_ENV); 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.
per-recipe EXTRA_ENV (recipe_meta.py) and the custom install-steps hook (Phase 1d) before deploy.
`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
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."""
if meta is None:
meta = meta_mod.load(recipe)
_record_deploy()
# 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
@ -281,18 +274,16 @@ def deploy_app(
flush=True,
)
chaos = True
# A first-class cc-ci compose overlay (tests/<recipe>/compose.ccci.yml, copied into the
# checkout below — rcust P2a) is an UNTRACKED file in the recipe checkout, which makes
# abra's pinned-deploy clean-tree check FATA ('has locally unstaged changes'). Auto-chaos:
# chaos skips lint + 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. (Replaces the deleted CHAOS_BASE_DEPLOY meta flag — the
# overlay's presence IS the signal, killing the R7 implicit coupling.)
elif has_ccci_overlay(recipe):
# A recipe may force a chaos base deploy via recipe_meta CHAOS_BASE_DEPLOY=True when an
# install_steps hook adds an untracked compose overlay to the recipe checkout (e.g. discourse's
# compose.ccci.yml, provided by install_steps for the pinned base). The untracked file makes
# 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
# recipe_checkout(version) above) — NOT latest. Same mechanism as the lightweight-tag branch.
elif _recipe_meta_flag(recipe, "CHAOS_BASE_DEPLOY"):
print(
f" deploy_app({recipe}@{version}): compose.ccci.yml overlay present → chaos base "
"deploy of the checked-out pinned version (skips clean-tree/lint; deploys version, "
"not LATEST)",
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)",
flush=True,
)
chaos = True
@ -302,18 +293,12 @@ 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, meta_mod.hook_ctx(domain, meta)).items():
for k, v in _recipe_extra_env(recipe, domain).items():
abra.env_set(domain, k, v)
if secrets:
abra.secret_generate(domain)
if install_steps_hook:
_run_install_steps(install_steps_hook, recipe, domain)
# First-class cc-ci compose overlay (rcust P2a): if the recipe ships
# tests/<recipe>/compose.ccci.yml, copy it into THIS run's recipe checkout (ABRA_DIR-aware)
# so the COMPOSE_FILE reference in the recipe's EXTRA_ENV resolves. Untracked, so it persists
# across the later PR-head checkout (idempotent when the head ships the same fix). Replaces
# the per-recipe install_steps.sh copy boilerplate + CHAOS_BASE_DEPLOY flag (auto-chaos above).
provide_ccci_overlay(recipe)
# HQ1: warm the local image store before the (real, unchanged) abra deploy.
prepull_images(recipe, domain)
abra.deploy(domain, chaos=chaos, timeout=deploy_timeout)
@ -525,7 +510,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, op: str | None = None) -> None:
def wait_ready_probes(meta: dict, domain: str, timeout: int = 600) -> 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,)}, ...]`
@ -542,10 +527,10 @@ def wait_ready_probes(meta, domain: str, timeout: int = 600, op: str | None = No
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
the voice port to be stably listening before proceeding closes that window."""
probe_fn = meta.READY_PROBE
probe_fn = meta.get("READY_PROBE")
if not callable(probe_fn):
return
probes = probe_fn(meta_mod.hook_ctx(domain, meta, op=op)) or []
probes = probe_fn(domain) or []
for probe in probes:
if "tcp_port" in probe:
host = probe.get("tcp_host", "127.0.0.1")

View File

@ -1,320 +0,0 @@
"""Single recipe-meta loader + declarative key registry (recipe-custom restructure P1; spec
docs/recipe-customization.md §8 R1).
THE one place `tests/<recipe>/recipe_meta.py` is `exec()`d. Every consumer (orchestrator, pytest
`meta` fixture, deploy env shaping, deps, warm-canonical enrollment, screenshot) reads the ONE
loaded `RecipeMeta` object instead of re-exec'ing the file and cherry-picking keys — that drift
(six divergent loaders, spec §4 L1L6) is what made `SCREENSHOT` an unreachable knob (R2) and let
key typos silently disable coverage (R6).
Validation (locked decision, recipe-custom-restructure-full-plan.md):
- unknown ALL-CAPS top-level name → MetaError (hard error, fails fast at load; the all-recipes
unit test catches it at PR time). Underscore-prefixed names (`_FOO`) are recipe-private and
exempt; lowercase names (helper functions/imports) are ignored.
- type mismatch → MetaError. Callables are accepted ONLY for hook-typed keys.
The KEYS registry is the single source of truth for the key set: it drives validation, the
RecipeMeta dataclass fields, and the generated reference table in docs/recipe-customization.md §4
(scripts/gen-meta-docs.py; a unit test asserts the committed table matches).
"""
from __future__ import annotations
import copy
import dataclasses
import difflib
import inspect
import json
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
# 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, ...] = (
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 `(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",
"str",
None,
"Exact published tag overriding the upgrade tier's base (default: `recipe_versions[-2]`).",
),
Key(
"BACKUP_VERIFY",
"hook",
None,
"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). 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). Dict, or callable `(ctx) -> dict` deriving values from the per-run domain (`ctx.domain`).",
hook_params=("ctx",),
),
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 `(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;
# the generic floor is suppressible only via the dev-only CCCI_SKIP_GENERIC* env form.)
)
_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 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."""
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.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)
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
@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(ctx)
return {str(k): str(v) for k, v in (value or {}).items()}
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, 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)

View File

@ -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, ctx) -> None` that drives Playwright to a
`SCREENSHOT` hook: a callable `screenshot(page, domain, meta) -> 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,7 +21,6 @@ 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}
@ -34,19 +33,12 @@ def screenshot_path(run_artifact_dir: str) -> str:
return os.path.join(run_artifact_dir, "screenshot.png")
def _load_screenshot_hook(recipe_meta):
def _load_screenshot_hook(recipe_meta: dict | 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.
`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:
The hook drives Playwright to a safe post-login view; default is the landing page."""
if not recipe_meta:
return None
if isinstance(recipe_meta, dict):
hook = recipe_meta.get("SCREENSHOT")
else:
hook = getattr(recipe_meta, "SCREENSHOT", None)
hook = recipe_meta.get("SCREENSHOT")
return hook if callable(hook) else None
@ -75,9 +67,8 @@ 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. SCREENSHOT(page, ctx) —
# the uniform ctx convention (rcust P3).
hook(page, meta_mod.hook_ctx(domain, recipe_meta))
# it doesn't, we still snap the resulting page below.
hook(page, domain, recipe_meta)
if not os.path.exists(out_path):
page.screenshot(path=out_path, full_page=False)
else:

View File

@ -58,9 +58,6 @@ 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,
)
@ -73,7 +70,7 @@ ALL_STAGES = ("install", "upgrade", "backup", "restore", "custom")
def sso_dep_unverified(declared, deps_ready: bool, requires_deps_skipped: int) -> bool:
"""F2-11 gate predicate (pure, unit-tested). True when a recipe declares DEPS but its
dep provisioning failed (deps not ready) AND that caused ≥1 `requires_deps` (SSO/OIDC) test
setup_custom_tests failed (deps not ready) AND that caused ≥1 `requires_deps` (SSO/OIDC) test
to SKIP. In that case the recipe's characteristic SSO claim was NOT verified, so the run must
NOT report GREEN — even though a skip-only pytest file exits 0 and leaves every tier 'pass'.
Generic-tier failure-isolation is preserved (those results stand); only the green SIGNAL is
@ -250,29 +247,52 @@ 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_env_overrides() -> list[str]:
"""Active CCCI_SKIP_GENERIC* env overrides (rcust P2c: the meta key is deleted; the env form
is a documented LOCAL-DEV-ONLY escape hatch). Surfaced loudly when set in a CI (drone) run —
it reduces generic-floor coverage and must never silently ride a CI verdict."""
return sorted(
k for k in os.environ if k.startswith("CCCI_SKIP_GENERIC") and _truthy(os.environ.get(k))
)
def _skip_generic(op: str) -> bool:
def _skip_generic(op: str, meta: dict) -> bool:
"""Whether the generic assertion for `op` is opted out (Phase 1e HC3). Default: run (additive).
Opt-out via env only (dev-only escape hatch, P2c): CCCI_SKIP_GENERIC (all ops) or
CCCI_SKIP_GENERIC_<OP>. The recipe_meta SKIP_GENERIC key is deleted (zero users)."""
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"/"*")."""
if _truthy(os.environ.get("CCCI_SKIP_GENERIC")):
return True
return _truthy(os.environ.get(f"CCCI_SKIP_GENERIC_{op.upper()}"))
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 [])]
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) -> None:
def _run_pre_hook(recipe: str, op: str, repo_local: str | None, domain: str, meta: dict) -> 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
@ -289,11 +309,7 @@ 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)
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))
getattr(mod, f"pre_{op}")(domain, meta)
finally:
if d in sys.path:
sys.path.remove(d)
@ -306,7 +322,7 @@ def _perform_op(
head_ref: str | None,
op_state: dict,
deploy_timeout: int = 900,
meta=None,
meta: dict | None = 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
@ -329,10 +345,9 @@ 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.BACKUP_VERIFY if meta else None
verify_ctx = meta_mod.hook_ctx(domain, meta, op="backup") if meta else None
verify = meta.get("BACKUP_VERIFY") if meta else None
attempt = 1
while callable(verify) and not verify(verify_ctx) and attempt < 3:
while callable(verify) and not verify(domain) and attempt < 3:
attempt += 1
print(
f" backup-verify FAILED (attempt {attempt - 1}/3) — backup did not capture the "
@ -340,7 +355,7 @@ def _perform_op(
flush=True,
)
snap = generic.perform_backup(domain)
if callable(verify) and not verify(verify_ctx):
if callable(verify) and not verify(domain):
print(
f" !! backup-verify still FAILED after {attempt} attempts — backup is incomplete",
flush=True,
@ -356,7 +371,7 @@ def run_lifecycle_tier(
op: str,
repo_local: str | None,
domain: str,
meta,
meta: dict,
head_ref: str | None,
op_state: dict,
records: list[dict] | None = None,
@ -371,7 +386,7 @@ def run_lifecycle_tier(
a {tier,source,file,rc,junit} record appended, so the run can assemble per-stage/per-test
results.json + the level afterwards. Purely additive — does not change the verdict."""
overlay = discovery.resolve_overlay_op(recipe, op, repo_local)
skip_gen = _skip_generic(op)
skip_gen = _skip_generic(op, meta)
files: list[tuple[str, str]] = []
if not skip_gen:
files.append(discovery.generic_op(op))
@ -396,7 +411,7 @@ def run_lifecycle_tier(
recipe,
head_ref,
op_state,
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
meta=meta,
)
with open(os.environ["CCCI_OP_STATE_FILE"], "w") as f:
@ -434,7 +449,7 @@ def run_lifecycle_tier(
def _enrich_deps_with_sso(parent_recipe: str, parent_domain: str, deps_list) -> dict[str, dict]:
"""For each dep, set up a fresh realm/client + test user via the harness's provider-specific
setup function, then return a recipe→entry dict carrying domain + admin + realm/client/user
info — the shape the `install_steps.sh` hook (and dependent tests) read.
info — the shape the `setup_custom_tests.sh` hook (and dependent tests) read.
Provider routing: today only `keycloak` is supported. authentik will need a parallel
`setup_authentik_realm` when an authentik-dep recipe enrolls (DEFERRED.md #9).
@ -448,7 +463,7 @@ def _enrich_deps_with_sso(parent_recipe: str, parent_domain: str, deps_list) ->
if not dep_recipe or not dep_domain:
continue
if dep_recipe != "keycloak":
# Provider not yet supported — record bare entry; install_steps.sh / tests will
# Provider not yet supported — record bare entry; setup_custom_tests.sh / tests will
# raise if they need realm/client info they don't see.
out[dep_recipe] = entry
continue
@ -492,10 +507,12 @@ def _provision_deps(
Splits deps into live-warm (shared provider at a stable domain + a per-run realm) vs cold
(co-deployed per run), provisions each dep's SSO realm/client/user, and persists the enriched
dict the `install_steps.sh` hooks + dependent tests read. Raises on any failure (the caller
marks deps-not-ready). Install-time wiring is the ONLY mode (rcust P2b): provision BEFORE the
single deploy so the install-tier `install_steps.sh` hook wires OIDC env into that one deploy —
no reconverge, no post-deploy `setup_custom_tests.sh` machinery.
dict the `setup_custom_tests.sh`/`install_steps.sh` hooks + dependent tests read. Raises on any
failure (the caller marks deps-not-ready). Used by BOTH wiring paths:
- post-deploy (legacy): provision AFTER generic tiers, then `setup_custom_tests.sh` does an
in-place OIDC redeploy.
- install-time (`OIDC_AT_INSTALL`, Q3.2a): provision BEFORE the single deploy so the
install-tier `install_steps.sh` hook wires OIDC env into that one deploy — no reconverge.
"""
warm_deps, cold_deps = [], []
for d in declared:
@ -506,7 +523,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: meta_mod.load(d) for d in cold_deps}
dep_metas = {d: _load_meta(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
@ -524,6 +541,32 @@ def _provision_deps(
return deps_state
def _run_setup_custom_tests_hook(recipe: str, domain: str, deps_file: str) -> None:
"""Run `tests/<recipe>/setup_custom_tests.sh` if present (operator-2026-05-28 SSO-dep plan
§3.2). The hook reads `$CCCI_DEPS_FILE`, sets OIDC env via `abra app config set` + secret
insert, and triggers an in-place `abra app deploy --force --chaos`. Failure here propagates
to mark deps-not-ready (caught in main())."""
path = os.path.join(ROOT, "tests", recipe, "setup_custom_tests.sh")
if not os.path.isfile(path):
# No hook = recipe doesn't need post-deps wiring; deps are deployed + creds available
# via deps_apps fixture as-is.
print(
f" setup_custom_tests: no hook at {os.path.relpath(path, ROOT)} (deps creds ready in $CCCI_DEPS_FILE)",
flush=True,
)
return
print(f" setup_custom_tests hook: {os.path.relpath(path, ROOT)}", flush=True)
rc = subprocess.run(
["bash", path],
check=False,
env=dict(os.environ, CCCI_APP_DOMAIN=domain, CCCI_RECIPE=recipe, CCCI_DEPS_FILE=deps_file),
)
if rc.returncode != 0:
raise RuntimeError(
f"setup_custom_tests.sh exited {rc.returncode} (deps env not wired into parent)"
)
def run_custom(
recipe: str,
repo_local: str | None,
@ -566,7 +609,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
recipe: str, ref: str | None, head_ref: str | None, repo_local: str | None, meta: dict
) -> 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) +
@ -602,7 +645,7 @@ def run_quick(
op_state: dict = {}
results: dict[str, str] = {}
declared = list(meta.DEPS)
declared = deps_mod.declared_deps(recipe)
deps_state: dict = {}
deps_ready = True
deps_not_ready_reason = ""
@ -614,32 +657,28 @@ 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.DEPLOY_TIMEOUT))
canonical.deploy_canonical(recipe, timeout=int(meta.get("DEPLOY_TIMEOUT", 900)))
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
print(f"!! canonical reattach/readiness failed: {_scrub(str(e))}", flush=True)
if warm_ok:
# 2) deps (warm keycloak + per-run realm) — mirrors main()'s warm/cold split. NB
# (rcust P2b): deps are provisioned (realm/creds in $CCCI_DEPS_FILE) but quick mode
# cannot do install-time OIDC env wiring — the canonical app pre-exists its per-run
# realm. No quick-enrolled recipe declares DEPS today; if one ever does, its
# requires_deps tests will exercise creds-only flows or skip (F2-11 keeps the signal).
# 2) deps (warm keycloak + per-run realm) — mirrors main()'s warm/cold split
if declared:
print(f"\n===== deps (quick): {declared} =====", flush=True)
print(f"\n===== setup_custom_tests (quick): deps {declared} =====", flush=True)
try:
warm_deps, cold_deps = [], []
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: meta_mod.load(d) for d in cold_deps}
dep_metas = {d: _load_meta(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
@ -654,11 +693,12 @@ def run_quick(
print(f" dep: using live-warm {d} @ {wd} (per-run realm)", flush=True)
deps_state = _enrich_deps_with_sso(recipe, domain, deps_list)
deps_mod.write_run_state(deps_state)
_run_setup_custom_tests_hook(recipe, domain, depsfile)
except Exception as e: # noqa: BLE001
deps_ready = False
deps_not_ready_reason = _scrub(str(e))[:300]
print(
f"!! dep provisioning failed (deps-not-ready): {deps_not_ready_reason}",
f"!! setup_custom_tests failed (deps-not-ready): {deps_not_ready_reason}",
flush=True,
)
@ -773,7 +813,7 @@ def run_quick(
overall = 1
if sso_unverified:
print(
f"!! DEPS={declared} but dep provisioning failed and {requires_deps_skipped} "
f"!! DEPS={declared} but setup_custom_tests failed and {requires_deps_skipped} "
"requires_deps SKIPPED — SSO NOT verified (F2-11)",
file=sys.stderr,
)
@ -808,7 +848,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 = meta_mod.load(recipe)
meta = _load_meta(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(
@ -820,15 +860,14 @@ def promote_canonical(recipe: str, head_ref: str | None) -> None:
domain,
version=latest,
secrets=True,
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
meta=meta,
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
)
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)
@ -857,17 +896,6 @@ def main() -> int:
print(
f"== cc-ci run: recipe={recipe} ref={ref} pr={os.environ.get('PR', '0')} stages={sorted(stages)}"
)
# P2c: the CCCI_SKIP_GENERIC* env escape hatch is LOCAL-DEV-ONLY. If it rides a CI (drone)
# run, shout — generic-floor coverage is reduced and the verdict must not look routine.
for ov in skip_generic_env_overrides():
if os.environ.get("DRONE"):
print(
f"!! {ov}=1 — dev-only generic-floor override ACTIVE IN A CI RUN; generic "
"assertions are suppressed for the affected op(s). This must never gate a merge.",
flush=True,
)
else:
print(f"== {ov}=1 (dev-only generic-floor override active)", flush=True)
# Concurrent-run safety is structural: this run's recipe trees live in its own ABRA_DIR
# (exported here, before ANY abra call), so no recipe-tree lock exists; same-DOMAIN runs
# serialise on the app-domain flock taken in deploy_app (see docs/concurrency.md).
@ -878,7 +906,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 = meta_mod.load(recipe)
meta = _load_meta(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).
@ -901,7 +929,9 @@ 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.UPGRADE_BASE_VERSION or lifecycle.previous_version(recipe)) if want_upgrade else None
(meta.get("UPGRADE_BASE_VERSION") or lifecycle.previous_version(recipe))
if want_upgrade
else None
)
base = prev or target
backup_cap = generic.backup_capable(recipe, meta)
@ -930,8 +960,10 @@ def main() -> int:
os.environ["CCCI_OP_STATE_FILE"] = statefile
op_state: dict = {}
# Run-scoped dep state (Phase 2 Q2.3; install-time-only since rcust P2b): deps are provisioned
# BEFORE the single deploy so install_steps.sh wires OIDC env into that one deploy.
# Run-scoped dep state (Phase 2 Q2.3, refined per operator-2026-05-28 SSO-dep plan §1):
# deps now deploy AFTER generic tiers (between RESTORE and CUSTOM) so a failed dep deploy
# cannot break the generic-tier signal. The `setup_custom_tests` step deploys each dep + runs
# `tests/<recipe>/setup_custom_tests.sh` to wire OIDC env via in-place redeploy.
# `$CCCI_DEPS_FILE` is written with the full creds dict the hook script needs (jq-readable).
depsfile = _run_state_path("deps") + ".json"
with open(depsfile, "w") as f:
@ -942,9 +974,15 @@ def main() -> int:
with contextlib.suppress(OSError):
os.remove(skipfile)
os.environ["CCCI_DEPS_SKIP_REPORT"] = skipfile
declared = list(meta.DEPS)
declared = deps_mod.declared_deps(recipe)
# 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)
if declared:
print(f"\n===== DEPS declared (provision BEFORE deploy): {declared} =====", flush=True)
when = "BEFORE deploy (install-time OIDC)" if oidc_at_install else "AFTER generic tiers"
print(f"\n===== DEPS declared (provision {when}): {declared} =====", flush=True)
deps_state: dict[str, dict] = {} # new shape: recipe→entry dict (sso-dep plan §1)
deps_ready = True
deps_not_ready_reason: str = ""
@ -958,7 +996,7 @@ def main() -> int:
# install_steps.sh can read $CCCI_DEPS_FILE and wire the OIDC env into that one deploy. On
# failure we mark deps-not-ready but STILL deploy the recipe alone (install_steps.sh no-ops
# on an empty deps file) so the generic tiers run; the OIDC custom test then skips → F2-11. ----
if declared:
if oidc_at_install:
print(
f"\n===== install-time OIDC: provisioning deps {declared} BEFORE deploy =====",
flush=True,
@ -985,21 +1023,18 @@ def main() -> int:
version=base,
secrets=True,
install_steps_hook=hook,
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
meta=meta,
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
)
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.DEPLOY_TIMEOUT), op="install"
)
lifecycle.wait_ready_probes(meta, domain, timeout=int(meta.get("DEPLOY_TIMEOUT", 900)))
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)
@ -1096,11 +1131,41 @@ def main() -> int:
if backup_cap
else "skip"
)
# (rcust P2b: install-time deps wiring is the ONLY mode — deps were provisioned BEFORE
# the single deploy and install_steps.sh wired the OIDC env into it. The legacy
# post-deploy provisioning + setup_custom_tests.sh redeploy machinery is deleted; a
# recipe's post-deploy seeding belongs in ops.py pre_install, e.g. lasuite-drive's
# MinIO bucket one-shot.)
# ---- setup_custom_tests step (NEW, operator-2026-05-28 SSO-dep plan §3.2) ----
# Deploy each declared dep + wire OIDC env into the parent app via the per-recipe
# setup_custom_tests.sh hook + in-place redeploy. Failure here marks deps-not-ready
# but does NOT abort the run — @pytest.mark.requires_deps tests skip with reason;
# non-deps custom tests still run normally.
if declared and not oidc_at_install:
# LEGACY post-deploy path: provision deps AFTER generic tiers, then wire OIDC env
# into the parent via the setup_custom_tests.sh hook + an in-place `--chaos` redeploy.
print("\n===== setup_custom_tests: deps + OIDC wiring =====", flush=True)
try:
deps_state = _provision_deps(recipe, domain, ref, declared)
# Run the per-recipe post-deps hook (jq-driven OIDC wiring + in-place redeploy)
_run_setup_custom_tests_hook(recipe, domain, depsfile)
except Exception as e: # noqa: BLE001 — setup failure is ISOLATED to dep-marked tests
deps_ready = False
deps_not_ready_reason = _scrub(str(e))[:300]
print(
f"!! setup_custom_tests failed (deps-not-ready): {deps_not_ready_reason}",
flush=True,
)
elif declared and oidc_at_install and deps_ready:
# INSTALL-TIME path (Q3.2a): deps were provisioned BEFORE the single deploy and the
# install-tier install_steps.sh hook already wired OIDC env into that one deploy —
# so NO re-provision, NO reconverge here. Run only the post-deploy setup hook
# (e.g. lasuite-drive's minio-createbuckets one-shot), which needs the live stack.
print("\n===== post-deploy setup (OIDC already wired at install) =====", flush=True)
try:
_run_setup_custom_tests_hook(recipe, domain, depsfile)
except Exception as e: # noqa: BLE001 — isolated to dep-marked / state-dependent tests
deps_ready = False
deps_not_ready_reason = _scrub(str(e))[:300]
print(
f"!! post-deploy setup failed: {deps_not_ready_reason}",
flush=True,
)
# ---- CUSTOM tier ----
if "custom" in stages:
@ -1175,7 +1240,8 @@ def main() -> int:
# ---- per-op summary (DG6 feed) ----
# SSO-dep plan §1: DG4.1 generalised — one `abra app new` per app in the run (recipe + each
# COLD dep). Chaos redeploys are NOT a fresh `app_new` and do NOT increment the count.
# COLD dep). In-place reconfigure-and-redeploy (the setup_custom_tests step's
# `abra app deploy --force --chaos`) is NOT a fresh `app_new` and does NOT increment the count.
# WC1: a live-warm dep (keycloak) is NOT deployed by the run — it only gets a per-run realm — so
# warm deps contribute 0. So expected = 1 + (number of COLD deps that actually got deployed).
_dep_entries = deps_state.values() if isinstance(deps_state, dict) else (deps_state or [])
@ -1216,12 +1282,12 @@ def main() -> int:
overall = 1
if any(v == "fail" for v in results.values()):
overall = 1
# F2-11: a deps-declaring recipe whose dep provisioning failed has NOT verified its SSO/OIDC
# F2-11: a deps-declaring recipe whose setup_custom_tests failed has NOT verified its SSO/OIDC
# claim — its requires_deps tests SKIPPED (a skip-only file exits 0, so without this the run
# would report GREEN). Fail the run for that recipe; generic-tier results above are untouched.
if sso_dep_unverified(declared, deps_ready, requires_deps_skipped):
print(
f"!! recipe declares DEPS={declared} but dep provisioning failed and "
f"!! recipe declares DEPS={declared} but setup_custom_tests failed and "
f"{requires_deps_skipped} requires_deps (SSO) test(s) were SKIPPED — SSO claim NOT "
f"verified; failing run (F2-11). deps-not-ready: {deps_not_ready_reason}",
file=sys.stderr,
@ -1248,7 +1314,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.EXPECTED_NA, # declared intentional-skip map (recipe_meta)
expected_na=meta.get("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)

View File

@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""Render the harness.meta KEYS registry to the markdown key-reference table in
docs/recipe-customization.md §4 (rcust P1.5; kills the R5 doc-drift class).
Usage:
python3 scripts/gen-meta-docs.py # rewrite the table in-place between the markers
python3 scripts/gen-meta-docs.py --print # print the rendered table to stdout (used by the
# doc-sync unit test, tests/unit/test_meta.py)
The table lives between `<!-- META-TABLE-START -->` / `<!-- META-TABLE-END -->` markers; a unit
test asserts the committed table equals this rendering, so editing it by hand fails CI.
"""
from __future__ import annotations
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "runner"))
from harness.meta import KEYS # noqa: E402
DOC = os.path.join(ROOT, "docs", "recipe-customization.md")
START = "<!-- META-TABLE-START -->"
END = "<!-- META-TABLE-END -->"
def _default_repr(v) -> str:
if v is None:
return "`None`"
return f"`{v!r}`"
def render() -> str:
lines = [
START,
"",
"_This table is GENERATED from the `runner/harness/meta.py` KEYS registry by"
" `scripts/gen-meta-docs.py` — do not edit by hand (a unit test pins the sync)._",
"",
"| Key | Type | Default | Meaning |",
"|---|---|---|---|",
]
for k in KEYS:
doc = k.doc.replace("|", "\\|")
name = f"`{k.name}`" + (" **(deprecated)**" if k.deprecated else "")
lines.append(f"| {name} | `{k.type}` | {_default_repr(k.default)} | {doc} |")
lines += ["", END]
return "\n".join(lines)
def main() -> int:
table = render()
if "--print" in sys.argv:
print(table)
return 0
with open(DOC) as f:
text = f.read()
if START not in text or END not in text:
print(f"{DOC}: missing {START}/{END} markers", file=sys.stderr)
return 1
head, _, rest = text.partition(START)
_, _, tail = rest.partition(END)
with open(DOC, "w") as f:
f.write(head + table + tail)
print(f"{DOC}: key table rewritten from the registry ({len(KEYS)} keys)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -9,14 +9,14 @@ sys.path.insert(0, os.path.dirname(__file__))
import _p4 # noqa: E402
def pre_upgrade(ctx):
_p4.create_account(ctx.domain)
def pre_upgrade(domain, meta):
_p4.create_account(domain)
def pre_backup(ctx):
_p4.create_account(ctx.domain)
def pre_backup(domain, meta):
_p4.create_account(domain)
def pre_restore(ctx):
_p4.delete_account(ctx.domain)
assert not _p4.account_exists(ctx.domain), "marker account delete did not take (pre_restore)"
def pre_restore(domain, meta):
_p4.delete_account(domain)
assert not _p4.account_exists(domain), "marker account delete did not take (pre_restore)"

View File

@ -14,7 +14,32 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "runner"))
from harness import deps as deps_mod # noqa: E402
from harness import meta as meta_mod # noqa: E402
from harness import lifecycle, naming
def _short(s: str, n: int = 8) -> str:
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")
@ -23,10 +48,18 @@ def recipe() -> str:
@pytest.fixture(scope="session")
def 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)
def app_domain(recipe) -> str:
# Docker swarm config/secret names = <stackname>_<res>_<ver> must be <= 64 chars, and
# stackname is the sanitized domain. ".ci.commoninternet.net" alone is 22 chars, so the
# subdomain label must stay short. Use <recipe[:4]>-<6hex(recipe|pr|ref)> — unique per run,
# collision-safe across recipes (full recipe in the hash), readable context lives in the
# Drone build params + PR comment. (Deviation from plan §4.0 long name; see DECISIONS.md.)
return naming.app_domain(recipe, os.environ.get("PR", "0"), os.environ.get("REF"))
@pytest.fixture(scope="session")
def meta(recipe) -> dict:
return _recipe_meta(recipe)
@pytest.fixture(scope="session")
@ -40,55 +73,32 @@ def live_app() -> str:
return domain
@pytest.fixture
def op_state() -> dict:
"""The orchestrator's run-scoped op context (rcust P4): versions, artifact paths — written to
`$CCCI_OP_STATE_FILE` after each lifecycle op (e.g. `{"upgrade": {"before": {...},
"head_ref": ...}, "backup": {"snapshot_id": ...}}`). Overlay tests read op facts from here
instead of hand-parsing env/JSON. Skips with a clear reason outside an orchestrator run."""
import json
@pytest.fixture(scope="session")
def deps_apps() -> dict[str, str]:
"""Phase 2 Q2.3 dependency-resolver contract (refined operator-2026-05-28 SSO-dep plan §1):
when a recipe declares `DEPS = [...]` in its `recipe_meta.py`, the orchestrator deploys each
dep AFTER the generic tiers (between RESTORE and CUSTOM) and persists their per-run identity
+ SSO creds to `$CCCI_DEPS_FILE`. Tests access the dep's per-run domain via this fixture.
For full SSO creds (realm/client/secret/admin) use the `deps_creds` fixture instead.
path = os.environ.get("CCCI_OP_STATE_FILE")
if not path:
pytest.skip(
"CCCI_OP_STATE_FILE not set — op_state is only available under the orchestrator"
)
if not os.path.exists(path):
pytest.skip(f"op-state file missing ({path}) — orchestrator has not performed an op yet")
try:
with open(path) as f:
return json.load(f)
except ValueError:
pytest.skip(f"op-state file unreadable/not JSON ({path})")
class _DepEntry(dict):
"""One provisioned dep (full creds dict) with attribute sugar: `entry.domain`, `entry.realm`,
`entry.client_secret`, ... — dict-style access works too (rcust P2d)."""
def __getattr__(self, name):
try:
return self[name]
except KeyError as e:
raise AttributeError(name) from e
Returns `{dep_recipe: domain}` (str→str). Empty when no deps declared OR deps-not-ready."""
state = deps_mod.deps_as_dict(deps_mod.load_run_state())
return {r: e["domain"] for r, e in state.items() if e.get("domain")}
@pytest.fixture(scope="session")
def deps() -> dict[str, _DepEntry]:
"""The recipe's provisioned deps (rcust P2d — consolidates the old `deps_apps`+`deps_creds`
pair). When a recipe declares `DEPS = [...]` in its `recipe_meta.py`, the orchestrator
provisions each dep BEFORE the single deploy and persists per-run identity + SSO creds to
`$CCCI_DEPS_FILE`. `deps["keycloak"]` carries domain/realm/client_id/client_secret/user/
password/email/admin_user/admin_password/discovery_url/token_url/... (`.domain` etc. work as
attributes). Empty when no deps declared OR deps-not-ready — pair with
`@pytest.mark.requires_deps` so the F2-11 skip-report keeps the green signal honest."""
state = deps_mod.deps_as_dict(deps_mod.load_run_state())
return {r: _DepEntry(e) for r, e in state.items()}
def deps_creds() -> dict[str, dict]:
"""Full SSO-creds dict for each declared dep (operator-2026-05-28 SSO-dep plan §1).
`deps_creds["keycloak"]` returns the entry written by setup_custom_tests with keys
domain/realm/client_id/client_secret/user/password/email/admin_user/admin_password/
discovery_url/token_url/.... Use this in `@pytest.mark.requires_deps` tests that need to
authenticate via OIDC."""
return deps_mod.deps_as_dict(deps_mod.load_run_state())
def pytest_collection_modifyitems(config, items):
"""SSO-dep plan §4: tests marked `@pytest.mark.requires_deps` are skipped with reason
`deps-not-ready: <captured-err>` when the orchestrator's dep provisioning failed
`deps-not-ready: <captured-err>` when the orchestrator's setup_custom_tests step failed
(orchestrator sets CCCI_DEPS_READY=0 in env). Non-deps custom tests are unaffected.
This is failure-isolation per plan §1 — generic tiers cannot break the SSO-marked tests'
@ -121,5 +131,40 @@ def pytest_configure(config):
"""Register the `requires_deps` marker so pytest doesn't warn about it."""
config.addinivalue_line(
"markers",
"requires_deps: test requires DEPS-declared services + dep provisioning success.",
"requires_deps: test requires DEPS-declared services + setup_custom_tests success.",
)
def _wait_healthy(domain, 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"],
)
@pytest.fixture
def deployed(recipe, app_domain, meta, request):
"""Function-scoped: deploy the current/$REF version healthy, guaranteed teardown after.
Used by stages that start from current (install/backup)."""
version = os.environ.get("VERSION") or None
lifecycle.janitor()
request.addfinalizer(lambda: lifecycle.teardown_app(app_domain))
lifecycle.deploy_app(recipe, app_domain, version=version)
_wait_healthy(app_domain, meta)
return app_domain
@pytest.fixture(scope="session")
def deployed_app(recipe, app_domain, meta):
"""Install stage: deploy the recipe and wait until healthy; tear down at session end."""
version = os.environ.get("VERSION") or None
lifecycle.janitor() # sweep orphans from crashed runs first
try:
lifecycle.deploy_app(recipe, app_domain, version=version, secrets=True)
_wait_healthy(app_domain, meta)
yield app_domain
finally:
lifecycle.teardown_app(app_domain)

View File

@ -15,13 +15,13 @@ def _write(domain, val):
lifecycle.exec_in_app(domain, ["sh", "-c", f"echo {val} > {MARKER}"])
def pre_upgrade(ctx):
_write(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_write(domain, "upgrade-survives")
def pre_backup(ctx):
_write(ctx.domain, "original")
def pre_backup(domain, meta):
_write(domain, "original")
def pre_restore(ctx):
_write(ctx.domain, "mutated") # diverge so a successful restore is observable
def pre_restore(domain, meta):
_write(domain, "mutated") # diverge so a successful restore is observable

View File

@ -7,9 +7,9 @@ DEPLOY_TIMEOUT = 600
HTTP_TIMEOUT = 600
def EXTRA_ENV(ctx):
def EXTRA_ENV(domain):
"""cryptpad needs a SANDBOX_DOMAIN distinct from the main DOMAIN (it serves user content from a
separate origin; the web router routes both). Derive a sibling subdomain under the same wildcard
(covered by the wildcard cert, so no cert work)."""
label, _, rest = ctx.domain.partition(".")
label, _, rest = domain.partition(".")
return {"SANDBOX_DOMAIN": f"{label}-sb.{rest}"}

View File

@ -12,8 +12,8 @@ from harness import lifecycle
MARKER_PATH = "/usr/share/nginx/html/ci-marker.txt"
def pre_restore(ctx) -> None:
def pre_restore(domain: str, meta: dict) -> None:
"""Write 'mutated' to the marker before restore runs. If restore brings back the
snapshot (which has no marker — never seeded by pre_backup), the marker ends up
MISSING or 'mutated' after restore → test_restore_returns_state FAILS → restore=RED."""
lifecycle.exec_in_app(ctx.domain, ["sh", "-c", f"echo mutated > {MARKER_PATH}"])
lifecycle.exec_in_app(domain, ["sh", "-c", f"echo mutated > {MARKER_PATH}"])

View File

@ -11,5 +11,5 @@ from harness import lifecycle
MARKER_PATH = "/usr/share/nginx/html/ci-marker.txt"
def pre_restore(ctx) -> None:
lifecycle.exec_in_app(ctx.domain, ["sh", "-c", f"echo mutated > {MARKER_PATH}"])
def pre_restore(domain: str, meta: dict) -> None:
lifecycle.exec_in_app(domain, ["sh", "-c", f"echo mutated > {MARKER_PATH}"])

View File

@ -1,4 +1,4 @@
"""custom-html — pre-op seed hooks (Phase 1e HC3). The orchestrator runs `pre_<op>(ctx)`
"""custom-html — pre-op seed hooks (Phase 1e HC3). The orchestrator runs `pre_<op>(domain, meta)`
BEFORE it performs the op; the matching test_<op>.py asserts the post-op state (assertion-only).
nginx serves the volume at /usr/share/nginx/html, so the marker file survives an upgrade / a
@ -17,16 +17,16 @@ def _write(domain: str, val: str) -> None:
lifecycle.exec_in_app(domain, ["sh", "-c", f"echo {val} > {MARKER_PATH}"])
def pre_upgrade(ctx):
def pre_upgrade(domain, meta):
# seed a marker before the upgrade so the overlay can prove the data survives it
_write(ctx.domain, "upgrade-survives")
_write(domain, "upgrade-survives")
def pre_backup(ctx):
def pre_backup(domain, meta):
# establish a known original state before the backup op captures it
_write(ctx.domain, "original")
_write(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# diverge from the backed-up state so a successful restore (back to "original") is observable
_write(ctx.domain, "mutated")
_write(domain, "mutated")

View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# discourse — INSTALL-TIME hook (Phase 2 Q4.6). Runs during the install tier AFTER `abra app new` +
# EXTRA_ENV + `abra app secret generate` and BEFORE the single `abra app deploy`
# (lifecycle.py::_run_install_steps), with CCCI_RECIPE / CCCI_APP_DOMAIN in env.
#
# Purpose: provide the cc-ci re-pin+grace overlay (compose.ccci.yml) to the recipe checkout so the
# UPGRADE-tier BASE deploy (published 0.7.0+3.3.1, whose compose pins the Docker-Hub-removed
# `bitnami/discourse:3.3.1` and ships a too-tight 5m start_period) is deployable and can survive the
# 15-25min Rails cold boot — so upgrade-to-latest can run. See compose.ccci.yml's header for the full
# rationale. The overlay is referenced by recipe_meta COMPOSE_FILE; it is a cc-ci file (not part of the
# recipe), so copying it here makes it resolvable. It persists across the later `git checkout <head>`
# (untracked) so the head deploy also merges it (idempotent — the PR head already re-pins + ships 20m).
# CHAOS_BASE_DEPLOY=True is set so abra's pinned-deploy clean-tree check doesn't FATA on the overlay.
set -euo pipefail
: "${CCCI_RECIPE:?missing CCCI_RECIPE}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Resolve the recipe tree the way abra does: $ABRA_DIR (the per-run tree inside a CI run) else
# the canonical ~/.abra — the overlay must land in the tree this run actually deploys from.
RECIPE_DIR="${ABRA_DIR:-${HOME}/.abra}/recipes/${CCCI_RECIPE}"
if [ ! -d "$RECIPE_DIR" ]; then
echo " discourse install_steps: recipe dir $RECIPE_DIR missing — cannot provide compose.ccci.yml" >&2
exit 1
fi
cp "$SCRIPT_DIR/compose.ccci.yml" "$RECIPE_DIR/compose.ccci.yml"
echo " discourse install_steps: provided compose.ccci.yml (bitnamilegacy re-pin + 20m start_period grace) to recipe checkout (${CCCI_RECIPE})"

View File

@ -30,18 +30,18 @@ def _seed(domain, value):
assert got == value, f"seed did not commit (read back {got!r}, expected {value!r})"
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# diverge from the backup so a successful restore is observable
_psql(ctx.domain, "DROP TABLE IF EXISTS ci_marker;")
assert _psql(ctx.domain, "SELECT to_regclass('public.ci_marker');") in (
_psql(domain, "DROP TABLE IF EXISTS ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -29,11 +29,11 @@ HTTP_TIMEOUT = 1200
# (1) it pins the Docker-Hub-removed `bitnami/discourse:3.3.1` (404) → overlay re-pins app+sidekiq to
# `bitnamilegacy/discourse:3.3.1` (namespace-only, identical image), the same re-pin the PR makes;
# (2) its 5m start_period is too tight for the 15-25min Rails boot → overlay widens it to 20m (grace).
# The harness auto-provides the overlay to the checkout and auto-chaoses the base deploy
# (first-class compose.ccci.yml, rcust P2a); it persists across the head checkout (idempotent — the
# PR head already re-pins + ships 20m).
# install_steps.sh provides the overlay; CHAOS_BASE_DEPLOY skips the clean-tree gate on the untracked
# overlay; it persists across the head checkout (idempotent — the PR head already re-pins + ships 20m).
# Upgrade crossover: 0.7.0 (re-pinned base) → PR head; full assertions run on the HEAD. The 0.7.0
# *custom* tests are not separately run (custom tier runs once, on the head — policy §1 allows skip+record).
CHAOS_BASE_DEPLOY = True
UPGRADE_BASE_VERSION = "0.7.0+3.3.1"
EXTRA_ENV = {
"TIMEOUT": "3600", # abra's internal convergence wait; matches DEPLOY_TIMEOUT (slow Rails boot headroom)
@ -41,7 +41,7 @@ EXTRA_ENV = {
}
def BACKUP_VERIFY(ctx):
def BACKUP_VERIFY(domain):
"""Post-backup integrity check (Q4.6, same race ghost F2-14b hit). The recipe's backupbot db
pre-hook (`/pg_backup.sh backup`) dumps the discourse postgres DB to `/var/lib/postgresql/data/
backup.sql` (gzip), then restic captures that path. On the loaded single CI node the db container
@ -60,7 +60,7 @@ def BACKUP_VERIFY(ctx):
try:
out = lifecycle.exec_in_app(
ctx.domain,
domain,
[
"sh",
"-c",

28
tests/ghost/install_steps.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# ghost — INSTALL-TIME hook (Phase 2 F2-14b). Runs during the install tier AFTER `abra app new` +
# EXTRA_ENV + `abra app secret generate` and BEFORE the single `abra app deploy`
# (lifecycle.py::_run_install_steps), with CCCI_RECIPE / CCCI_APP_DOMAIN in env.
#
# Purpose: provide the cc-ci start_period-grace overlay (compose.ccci.yml) to the recipe checkout so
# the UPGRADE-tier BASE deploy (a previous published version whose app healthcheck still ships the
# too-tight 1m start_period) can survive ghost's ~6-9min fresh-DB migration and converge. See
# compose.ccci.yml's header for the full rationale. The overlay is referenced by recipe_meta
# COMPOSE_FILE; copying it here (it is a cc-ci file, not part of the recipe) makes it resolvable.
# It persists across the later `git checkout <head>` (untracked) so the head deploy also merges it
# (idempotent — the PR head already ships 15m). CHAOS_BASE_DEPLOY=True is set so abra's pinned-deploy
# clean-tree check doesn't FATA on the untracked overlay.
set -euo pipefail
: "${CCCI_RECIPE:?missing CCCI_RECIPE}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Resolve the recipe tree the way abra does: $ABRA_DIR (the per-run tree inside a CI run) else
# the canonical ~/.abra — the overlay must land in the tree this run actually deploys from.
RECIPE_DIR="${ABRA_DIR:-${HOME}/.abra}/recipes/${CCCI_RECIPE}"
if [ ! -d "$RECIPE_DIR" ]; then
echo " ghost install_steps: recipe dir $RECIPE_DIR missing — cannot provide compose.ccci.yml" >&2
exit 1
fi
cp "$SCRIPT_DIR/compose.ccci.yml" "$RECIPE_DIR/compose.ccci.yml"
echo " ghost install_steps: provided compose.ccci.yml (app start_period grace) to recipe checkout (${CCCI_RECIPE})"

View File

@ -36,19 +36,19 @@ def _seed(domain, value):
assert got == value, f"seed did not commit (read back {got!r}, expected {value!r})"
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# diverge from the backup so a successful restore is observable: drop the marker table.
_mysql(ctx.domain, "DROP TABLE IF EXISTS ci_marker;")
_mysql(domain, "DROP TABLE IF EXISTS ci_marker;")
got = _mysql(
ctx.domain,
domain,
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema='ghost' AND table_name='ci_marker';",
)

View File

@ -31,22 +31,23 @@ HTTP_TIMEOUT = 900
# (plan-ccci-compose-overlay-policy.md §1), so the harness base-deploys the previous PUBLISHED version
# (1.1.1+6-alpine) — which predates the PR and still ships the too-tight 1m start_period → it would
# deadlock on the same migration kill. compose.ccci.yml re-applies the 15m grace to the BASE so the
# from-version is deployable; the harness auto-provides it to the checkout and auto-chaoses the base
# deploy (first-class compose.ccci.yml, rcust P2a). It persists across the head checkout (idempotent —
# the PR head already ships 15m). This is the policy-blessed "minimal overlay on the from-version so
# from-version is deployable; install_steps.sh provides it to the checkout; CHAOS_BASE_DEPLOY skips the
# clean-tree gate on that untracked overlay. It persists across the head checkout (idempotent — the PR
# head already ships 15m). This is the policy-blessed "minimal overlay on the from-version so
# upgrade-to-latest can run" — grace-only, masks no defect, weakens no test.
# TIMEOUT/DEPLOY_TIMEOUT 2400s: the BASE cold boot's wall-time is mysql fresh-dir init (~6min, during
# which the app crash-loops harmlessly on `ECONNREFUSED 3306` until mysql accepts connections — no
# migration progress lost, it hasn't started) PLUS the ~9-15min schema migration (round-trip-bound,
# slower under host load). 1200s was too tight (full4 killed at the near-final `email_recipients`
# tables while still 0/1); 2400s gives headroom while still bounding a genuine hang (matches discourse).
CHAOS_BASE_DEPLOY = True
EXTRA_ENV = {
"TIMEOUT": "2400",
"COMPOSE_FILE": "compose.yml:compose.ccci.yml",
}
def BACKUP_VERIFY(ctx):
def BACKUP_VERIFY(domain):
"""Post-backup integrity check (F2-14b). The recipe's backupbot db pre-hook dumps the ghost MySQL
DB to `/var/lib/mysql/backup.sql.gz` (then restic captures that path). On the loaded single CI node
the db container intermittently CYCLES mid-dump (observed: full5/6/7 RED, full8 green — pure race;
@ -61,7 +62,7 @@ def BACKUP_VERIFY(ctx):
try:
out = lifecycle.exec_in_app(
ctx.domain,
domain,
[
"sh",
"-c",

View File

@ -25,17 +25,17 @@ def _seed(domain, value):
assert _psql(domain, "SELECT v FROM ci_marker;") == value
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
_psql(ctx.domain, "DROP TABLE ci_marker;")
assert _psql(ctx.domain, "SELECT to_regclass('public.ci_marker');") in (
def pre_restore(domain, meta):
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -14,20 +14,20 @@ def _token(domain):
return kc_admin.admin_token(domain, kc_admin.admin_password(domain))
def pre_upgrade(ctx):
def pre_upgrade(domain, meta):
# create the marker realm (DB data) before the upgrade so the overlay can prove it survives
assert kc_admin.create_marker_realm(ctx.domain, _token(ctx.domain)) in (201, 409)
assert kc_admin.create_marker_realm(domain, _token(domain)) in (201, 409)
def pre_backup(ctx):
def pre_backup(domain, meta):
# establish the marker realm before the backup op captures mariadb
assert kc_admin.create_marker_realm(ctx.domain, _token(ctx.domain)) in (201, 409)
assert kc_admin.create_marker_realm(domain, _token(domain)) in (201, 409)
def pre_restore(ctx):
def pre_restore(domain, meta):
# backup-bot-two cycles the keycloak container during backup → wait for serving, re-auth, then
# delete the realm (diverge from the backup) so a successful restore is observable
generic.assert_serving(ctx.domain, ctx.meta)
tok = _token(ctx.domain)
assert kc_admin.delete_marker_realm(ctx.domain, tok) in (204, 200)
assert not kc_admin.marker_realm_exists(ctx.domain, tok), "delete did not take"
generic.assert_serving(domain, meta)
tok = _token(domain)
assert kc_admin.delete_marker_realm(domain, tok) in (204, 200)
assert not kc_admin.marker_realm_exists(domain, tok), "delete did not take"

View File

@ -5,7 +5,7 @@ persistence". This is the canonical create-an-object + read-it-back for lasuite-
Flow (uses an OIDC token from the dep keycloak):
1. Obtain a JWT via OIDC password grant against the dep keycloak (the test user is provisioned
by the orchestrator's dep-provisioning step).
by the orchestrator's setup_custom_tests step).
2. POST `/api/v1.0/documents/` with `Authorization: Bearer <jwt>` to create a new doc with a
unique title; capture the returned `id`.
3. GET `/api/v1.0/documents/<id>/` with the same Bearer token; assert the returned title and
@ -15,7 +15,7 @@ Non-vacuous: a misconfigured OIDC, broken backend, or missing endpoint fails at
broken. The marker-in-the-title + id round-trip proves the doc actually persisted in lasuite-
docs's database after going through the recipe's nginx → backend → postgres path.
Marked @pytest.mark.requires_deps — skips with `deps-not-ready` if dep provisioning failed.
Marked @pytest.mark.requires_deps — skips with `deps-not-ready` if setup_custom_tests failed.
"""
from __future__ import annotations
@ -32,9 +32,9 @@ from harness import sso
@pytest.mark.requires_deps
def test_create_doc_and_read_back(live_app, deps):
def test_create_doc_and_read_back(live_app, deps_creds):
"""Create a doc via the authenticated API; fetch it back; assert round-trip."""
kc = deps["keycloak"]
kc = deps_creds["keycloak"]
# Obtain a JWT via OIDC password grant
access_token = sso.oidc_password_grant(

View File

@ -5,13 +5,13 @@ SOURCE: references/recipe-maintainer/recipe-info/lasuite-docs/tests/oidc_login.p
End-to-end flow:
1. GET `/api/v1.0/users/me/` without auth → asserts the response REDIRECTS to the dep
keycloak's realm auth endpoint (the recipe is correctly configured to challenge
unauthenticated callers — wired via install_steps.sh).
unauthenticated callers — wired via setup_custom_tests.sh).
2. Obtain an OIDC token from the dep keycloak via password grant
(the test user provisioned by the orchestrator's realm setup).
3. Call `/api/v1.0/users/me/` with `Authorization: Bearer <jwt>` → asserts 200 and the
returned user's email matches the provisioned test user.
Marked @pytest.mark.requires_deps — skips with `deps-not-ready` if dep provisioning failed.
Marked @pytest.mark.requires_deps — skips with `deps-not-ready` if setup_custom_tests failed.
"""
from __future__ import annotations
@ -51,9 +51,9 @@ def _get_no_redirect(url: str) -> tuple[int, str]:
@pytest.mark.requires_deps
def test_oidc_login_via_keycloak(live_app, deps):
def test_oidc_login_via_keycloak(live_app, deps_creds):
"""Anonymous → redirect to keycloak; password-grant token → 200 from /api/v1.0/users/me/."""
kc = deps["keycloak"]
kc = deps_creds["keycloak"]
# Step 1: unauthenticated GET → 302 to keycloak realm's auth endpoint
status, redirect = _get_no_redirect(f"https://{live_app}/api/v1.0/users/me/")

View File

@ -3,10 +3,10 @@
Refactored to the refined SSO-dep model:
- The orchestrator deploys a per-run keycloak dep AFTER generic tiers and provisions a fresh
realm/client/user via `harness.sso.setup_keycloak_realm`. The creds are written to
`$CCCI_DEPS_FILE` (read here via the `deps` fixture).
`$CCCI_DEPS_FILE` (read here via the `deps_creds` fixture).
- This test no longer calls `setup_keycloak_realm` itself — that's the orchestrator's job in
the dep-provisioning step. We just consume the credentials and exercise the OIDC flow.
- Marked `@pytest.mark.requires_deps` so if dep provisioning failed, this test SKIPs with a
the setup_custom_tests step. We just consume the credentials and exercise the OIDC flow.
- Marked `@pytest.mark.requires_deps` so if setup_custom_tests failed, this test SKIPs with a
clear `deps-not-ready` reason rather than red-flagging a non-recipe failure.
"""
@ -31,13 +31,13 @@ def _b64url_decode(seg: str) -> bytes:
@pytest.mark.requires_deps
def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
"""The dep keycloak issues a JWT for the pre-provisioned test user via OIDC password grant."""
assert "keycloak" in deps, (
f"keycloak creds not in deps; got {list(deps.keys())}. "
"dep provisioning should have populated this."
assert "keycloak" in deps_creds, (
f"keycloak creds not in deps_creds; got {list(deps_creds.keys())}. "
"setup_custom_tests should have populated this."
)
kc = deps["keycloak"]
kc = deps_creds["keycloak"]
# Sanity-check the creds shape — orchestrator-written
assert kc["domain"]

View File

@ -1,74 +0,0 @@
#!/usr/bin/env bash
# lasuite-docs — INSTALL-TIME OIDC wiring hook (rcust P2b; migrated from the deleted
# setup_custom_tests.sh post-deploy path — sibling of lasuite-drive/-meet's hooks).
#
# Runs during the install tier AFTER `abra app new` + EXTRA_ENV + `abra app secret generate`, and
# BEFORE the single `abra app deploy` (lifecycle.py::_run_install_steps). Writing OIDC env + the
# real client secret HERE means the recipe deploys ONCE with OIDC already wired — no post-deploy
# reconverge. The orchestrator provisions the per-run realm/client on the (live-warm) keycloak
# BEFORE this hook and writes $CCCI_DEPS_FILE (the recipe→creds dict). docs' OIDC settings are
# config-only (validated by `manage.py check`, not fetched at boot), so the stack boots healthy
# with the env set. Env names per lasuite-docs's .env.sample (same values the old post-deploy
# hook wrote — byte-identical wiring, only the timing moved).
#
# Env supplied by the harness:
# CCCI_APP_DOMAIN — the per-run lasuite-docs app domain
# CCCI_APP_ENV — path to the app's .env (the one `abra app deploy` reads)
# CCCI_DEPS_FILE — JSON {keycloak: {domain, realm, client_id, client_secret, ...}} (may be empty)
set -euo pipefail
: "${CCCI_APP_DOMAIN:?missing}"
ENV_PATH="${CCCI_APP_ENV:?missing}"
# No deps file / no keycloak entry → install-time provisioning failed or was skipped. NO-OP so the
# recipe still boots; the @requires_deps OIDC custom test then SKIPs and F2-11 flips the run RED.
if [ -z "${CCCI_DEPS_FILE:-}" ] || [ ! -s "${CCCI_DEPS_FILE}" ]; then
echo " install_steps: no deps file — skipping OIDC wiring (recipe boots without OIDC)"
exit 0
fi
KC_DOMAIN=$(jq -r '.keycloak.domain // empty' "$CCCI_DEPS_FILE")
KC_REALM=$(jq -r '.keycloak.realm // empty' "$CCCI_DEPS_FILE")
KC_CLIENT=$(jq -r '.keycloak.client_id // empty' "$CCCI_DEPS_FILE")
KC_SECRET=$(jq -r '.keycloak.client_secret // empty' "$CCCI_DEPS_FILE")
if [ -z "$KC_DOMAIN" ] || [ -z "$KC_SECRET" ]; then
echo " install_steps: deps file has no keycloak domain/secret — skipping OIDC wiring"
exit 0
fi
echo " lasuite-docs install_steps: wiring OIDC at install against keycloak ${KC_DOMAIN}"
# 1) Insert the OIDC client secret at a bumped version (abra already generated oidc_rpcs:v1; swarm
# forbids overwriting a secret at the same version). The app is not deployed yet — a swarm secret
# can be created independently — so the single deploy below picks up v2.
CUR_VER=$(grep -E '^\s*SECRET_OIDC_RPCS_VERSION=' "$ENV_PATH" | tail -1 | cut -d= -f2 | tr -d '"\r' || echo "v1")
NEW_NUM=$((${CUR_VER#v} + 1))
NEW_VER="v${NEW_NUM}"
INSERT_LOG=$(abra app secret insert "$CCCI_APP_DOMAIN" oidc_rpcs "$NEW_VER" "$KC_SECRET" --no-input -C -o 2>&1) ||
INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o" /dev/null 2>&1) ||
{
echo " install_steps: abra app secret insert oidc_rpcs@$NEW_VER failed: $INSERT_LOG"
exit 1
}
sed -i "s|^\s*SECRET_OIDC_RPCS_VERSION=.*|SECRET_OIDC_RPCS_VERSION=$NEW_VER|" "$ENV_PATH"
echo " install_steps: oidc_rpcs secret inserted at $NEW_VER (was $CUR_VER)"
# 2) Write OIDC env vars to the app's .env (names per lasuite-docs's .env.sample). Ensure a
# trailing newline first so appends never concatenate onto the last line.
write_env() {
local key="$1" val="$2"
sed -i "/^\s*#\?\s*${key}=/d" "$ENV_PATH"
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >>"$ENV_PATH"
printf '%s=%s\n' "$key" "$val" >>"$ENV_PATH"
}
write_env OIDC_REALM "$KC_REALM"
write_env OIDC_OP_DISCOVERY_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/.well-known/openid-configuration"
write_env OIDC_OP_AUTHORIZATION_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/auth"
write_env OIDC_OP_TOKEN_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/token"
write_env OIDC_OP_USER_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/userinfo"
write_env OIDC_OP_LOGOUT_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/logout"
write_env OIDC_OP_JWKS_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/certs"
write_env OIDC_RP_CLIENT_ID "$KC_CLIENT"
write_env OIDC_RP_SIGN_ALGO "RS256"
write_env OIDC_RP_SCOPES "openid email profile"
echo " lasuite-docs install_steps: OIDC env wired into .env (deploy will pick it up, no reconverge)"

View File

@ -24,18 +24,18 @@ def _seed(domain, value):
assert _psql(domain, "SELECT v FROM ci_marker;") == value
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# drop the marker table (diverge from the backup) so a successful restore is observable
_psql(ctx.domain, "DROP TABLE ci_marker;")
assert _psql(ctx.domain, "SELECT to_regclass('public.ci_marker');") in (
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -15,7 +15,7 @@ HTTP_TIMEOUT = 600
DEPS = ["keycloak"]
def EXTRA_ENV(ctx):
def EXTRA_ENV(domain):
# abra's internal per-deploy convergence timeout (the recipe's TIMEOUT env, default 300s) is too
# short for this 9-service stack on a COLD image cache (~9 large images: impress frontend/backend,
# minio, postgres18, redis, docspec, y-provider). Cold pulls exceed 300s -> "deploy timed out 🟠".

View File

@ -0,0 +1,91 @@
#!/usr/bin/env bash
# lasuite-docs — post-deps setup hook (operator-2026-05-28 SSO-dep plan §3.2).
#
# Runs AFTER the generic tiers (install/upgrade/backup/restore) and AFTER each declared dep is
# deployed + provisioned with realm/client via the harness. The orchestrator has written
# $CCCI_DEPS_FILE with the keycloak dep's domain + realm + client_secret + admin creds.
#
# This hook:
# 1. Reads the dep's connection info from $CCCI_DEPS_FILE.
# 2. Inserts the OIDC client secret as an abra app secret (recipe-conventional name oidc_rpcs).
# 3. Writes the OIDC env vars to the running app's .env via `abra app config set`.
# 4. Triggers an in-place `abra app deploy --force --chaos` so the new env takes effect.
# THIS IS NOT a fresh `abra app new` — the deploy-count guard (DG4.1, generalised) still
# sees one app_new per app.
#
# Env supplied by the orchestrator:
# CCCI_APP_DOMAIN — the running per-run lasuite-docs app domain
# CCCI_RECIPE — "lasuite-docs"
# CCCI_DEPS_FILE — JSON file (dict shape: {dep_recipe: {domain, realm, client_id, ...}, ...})
set -euo pipefail
: "${CCCI_APP_DOMAIN:?missing}"
: "${CCCI_DEPS_FILE:?missing}"
test -s "$CCCI_DEPS_FILE" || {
echo " setup_custom_tests: deps file empty"
exit 1
}
# Read keycloak dep info via jq
KC_DOMAIN=$(jq -r '.keycloak.domain' "$CCCI_DEPS_FILE")
KC_REALM=$(jq -r '.keycloak.realm' "$CCCI_DEPS_FILE")
KC_CLIENT=$(jq -r '.keycloak.client_id' "$CCCI_DEPS_FILE")
KC_SECRET=$(jq -r '.keycloak.client_secret' "$CCCI_DEPS_FILE")
if [ -z "$KC_DOMAIN" ] || [ "$KC_DOMAIN" = "null" ]; then
echo " setup_custom_tests: no keycloak.domain in deps"
exit 1
fi
if [ -z "$KC_SECRET" ] || [ "$KC_SECRET" = "null" ]; then
echo " setup_custom_tests: no keycloak.client_secret"
exit 1
fi
echo " lasuite-docs setup_custom_tests: wiring OIDC against keycloak dep ${KC_DOMAIN}"
# 1) Insert the OIDC client secret AT A BUMPED VERSION (the recipe-maintainer pattern).
# `abra app new -S` already generated `oidc_rpcs:v1` (random) — Docker Swarm forbids overwriting
# a secret at the same version, so we bump the version (v2), insert our value there, then
# update SECRET_OIDC_RPCS_VERSION in the .env to point at the new one.
ENV_PATH="$HOME/.abra/servers/default/${CCCI_APP_DOMAIN}.env"
CUR_VER=$(grep -E '^\s*SECRET_OIDC_RPCS_VERSION=' "$ENV_PATH" | tail -1 | cut -d= -f2 | tr -d '"\r' || echo "v1")
NEW_NUM=$((${CUR_VER#v} + 1))
NEW_VER="v${NEW_NUM}"
INSERT_LOG=$(abra app secret insert "$CCCI_APP_DOMAIN" oidc_rpcs "$NEW_VER" "$KC_SECRET" --no-input -C -o 2>&1) ||
INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o" /dev/null 2>&1) ||
{
echo " setup_custom_tests: abra app secret insert oidc_rpcs@$NEW_VER failed: $INSERT_LOG"
exit 1
}
# Repoint the env var to the new version
sed -i "s|^\s*SECRET_OIDC_RPCS_VERSION=.*|SECRET_OIDC_RPCS_VERSION=$NEW_VER|" "$ENV_PATH"
echo " setup_custom_tests: oidc_rpcs secret inserted at $NEW_VER (was $CUR_VER)"
# 2) Write OIDC env vars to the app's .env (names per lasuite-docs's .env.sample).
# Ensure the file ends with a newline FIRST so our appends don't concatenate onto the last line
# (we saw `TIMEOUT=900OIDC_REALM=...` malformed by a missing-trailing-newline file).
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >>"$ENV_PATH"
write_env() {
local key="$1" val="$2"
# remove any existing key (commented or live) then append the live key=val
sed -i "/^\s*#\?\s*${key}=/d" "$ENV_PATH"
# Re-ensure trailing newline after each delete (sed may leave the file without one)
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >>"$ENV_PATH"
printf '%s=%s\n' "$key" "$val" >>"$ENV_PATH"
}
write_env OIDC_REALM "$KC_REALM"
write_env OIDC_OP_DISCOVERY_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/.well-known/openid-configuration"
write_env OIDC_OP_AUTHORIZATION_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/auth"
write_env OIDC_OP_TOKEN_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/token"
write_env OIDC_OP_USER_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/userinfo"
write_env OIDC_OP_LOGOUT_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/logout"
write_env OIDC_OP_JWKS_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/certs"
write_env OIDC_RP_CLIENT_ID "$KC_CLIENT"
write_env OIDC_RP_SIGN_ALGO "RS256"
write_env OIDC_RP_SCOPES "openid email profile"
# 3) Trigger an in-place redeploy so the env update takes effect. --force re-deploys even when
# the recipe hasn't changed; --chaos avoids the chaos prompt; --no-input non-interactive.
abra app deploy "$CCCI_APP_DOMAIN" --force --chaos --no-input 2>&1 | tail -10
echo " lasuite-docs setup_custom_tests: OIDC wired + redeployed"

View File

@ -3,12 +3,12 @@
Drive (La Suite Drive) is OIDC-required: login is gated by an external OpenID Connect provider.
Mirrors the proven lasuite-docs SSO model:
- The orchestrator deploys a per-run keycloak dep AFTER the generic tiers and provisions a fresh
realm/client/user via `harness.sso.setup_keycloak_realm`; `install_steps.sh` then wires the
realm/client/user via `harness.sso.setup_keycloak_realm`; `setup_custom_tests.sh` then wires the
OIDC env + client secret into the running drive app and redeploys. Creds land in `$CCCI_DEPS_FILE`
(read here via the `deps` fixture).
(read here via the `deps_creds` fixture).
- This test consumes those creds and exercises the real OIDC flow against the dep keycloak: discovery
endpoint advertises the realm, and a password grant yields a valid JWT with the expected claims.
- Marked `@pytest.mark.requires_deps` so if dep provisioning failed the test SKIPs with a clear
- Marked `@pytest.mark.requires_deps` so if setup_custom_tests failed the test SKIPs with a clear
`deps-not-ready` reason — and (per F2-11) the orchestrator then fails the run rather than going
green on a skipped SSO test.
@ -36,13 +36,13 @@ def _b64url_decode(seg: str) -> bytes:
@pytest.mark.requires_deps
def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
"""The dep keycloak issues a JWT for the pre-provisioned test user via OIDC password grant."""
assert "keycloak" in deps, (
f"keycloak creds not in deps; got {list(deps.keys())}. "
"dep provisioning should have populated this."
assert "keycloak" in deps_creds, (
f"keycloak creds not in deps_creds; got {list(deps_creds.keys())}. "
"setup_custom_tests should have populated this."
)
kc = deps["keycloak"]
kc = deps_creds["keycloak"]
# Creds shape. WC1: realm is per-run namespaced "<parent>-<6hex>"; client_id stays the parent.
assert kc["domain"]

View File

@ -6,7 +6,7 @@
# BEFORE the single `abra app deploy` (runner/harness/lifecycle.py::_run_install_steps). By writing
# the OIDC env + the real client secret into the app's `.env` HERE, the recipe deploys ONCE with
# OIDC already wired — eliminating the flaky post-deploy in-place `--force --chaos` 12-service
# post-deploy reconverge (collabora WOPI-discovery race; see JOURNAL
# reconverge that the old setup_custom_tests.sh did (collabora WOPI-discovery race; see JOURNAL
# Step 0). The orchestrator provisions the per-run realm/client on the live-warm keycloak BEFORE
# this hook and writes $CCCI_DEPS_FILE (the recipe→creds dict).
#

View File

@ -5,7 +5,6 @@ in the `db` service. The backup path exercises the recipe's pg_backup.sh DB-dump
backupbot-labelled)."""
import os
import subprocess
import sys
import time
@ -13,47 +12,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")
from harness import lifecycle # noqa: E402
def pre_install(ctx):
"""Post-deploy seed for the custom tier (the former setup_custom_tests.sh, moved here in rcust
P2b — install_steps.sh runs PRE-deploy and cannot touch the live stack). The deploy alone does
NOT create the MinIO bucket: `minio-createbuckets` is a `replicas:0` one-shot (restart_policy:
none) that must be triggered. The MinIO storage test asserts the bucket exists, so trigger it
here and poll. `--detach` is REQUIRED: the job creates the bucket then EXITS 0, so it never
holds a steady 1/1 replica — a blocking scale would wait forever."""
stack = ctx.domain.replace(".", "_")
print(" pre_install: creating MinIO bucket via the minio-createbuckets one-shot", flush=True)
subprocess.run(
["docker", "service", "scale", "--detach", f"{stack}_minio-createbuckets=1"],
capture_output=True,
check=False,
)
check = (
'mc alias set _c http://localhost:9000 "$(cat /run/secrets/minio_ru)" '
'"$(cat /run/secrets/minio_rp)" >/dev/null 2>&1 && '
"mc ls _c/drive-media-storage >/dev/null 2>&1"
)
for i in range(30):
cid = subprocess.run(
["docker", "ps", "-q", "-f", f"name={stack}_minio.1"],
capture_output=True,
text=True,
check=False,
).stdout.split()
if cid and (
subprocess.run(
["docker", "exec", cid[0], "sh", "-c", check], capture_output=True, check=False
).returncode
== 0
):
print(
f" pre_install: bucket drive-media-storage present after {i + 1} poll(s)",
flush=True,
)
return
time.sleep(3)
raise AssertionError("minio-createbuckets one-shot did not create drive-media-storage in 90s")
def _wait_collabora_ready(domain, timeout=420):
"""Gate the upgrade op on collabora being FULLY ready (WOPI discovery endpoint → 200), not just
container 1/1 'running'. coolwsd takes ~2min to boot (pre-reads 1300+ l10n files + RSA keygen);
@ -91,21 +49,21 @@ def _seed(domain, value):
assert _psql(domain, "SELECT v FROM ci_marker;") == value
def pre_upgrade(ctx):
def pre_upgrade(domain, meta):
# Gate the chaos redeploy on a fully-ready collabora (else it kills a still-booting coolwsd and
# abra aborts the upgrade deploy — Q3.2a run 1). Then seed the data-integrity marker.
_wait_collabora_ready(ctx.domain)
_seed(ctx.domain, "upgrade-survives")
_wait_collabora_ready(domain)
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# drop the marker table (diverge from the backup) so a successful restore is observable
_psql(ctx.domain, "DROP TABLE ci_marker;")
assert _psql(ctx.domain, "SELECT to_regclass('public.ci_marker');") in (
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -18,31 +18,34 @@ DEPLOY_TIMEOUT = 1800
HTTP_TIMEOUT = 900
# Base deploy/lifecycle proven cold-green @2026-05-28 (install: pass; 12 services incl.
# onlyoffice+collabora) once the Docker Hub rate limit was fixed. Declaring DEPS makes the
# orchestrator provision keycloak (realm/client/user) BEFORE the single deploy;
# functional/test_oidc_with_keycloak.py then exercises the SSO flow.
# onlyoffice+collabora) once the Docker Hub rate limit was fixed. The keycloak SSO dep is now
# enabled: declaring DEPS triggers the orchestrator's setup_custom_tests step (deploy keycloak +
# provision realm/client/user + run tests/lasuite-drive/setup_custom_tests.sh to wire OIDC env +
# in-place redeploy). functional/test_oidc_with_keycloak.py then exercises the SSO flow.
DEPS = ["keycloak"]
# OIDC is wired at INSTALL time (the only deps mode since rcust P2b; Q3.2a pioneered it here):
# the orchestrator provisions the per-run realm on the live-warm keycloak BEFORE the single
# `abra app deploy`, and tests/lasuite-drive/install_steps.sh writes the OIDC env + client secret
# into the .env that one deploy reads. No post-deploy reconverge (the flaky 12-service collabora
# WOPI race is structurally gone). The post-deploy MinIO bucket one-shot lives in ops.py
# pre_install (the former setup_custom_tests.sh, deleted in P2b).
# Q3.2a (plan-lasuite-drive-oidc-robustness.md Part A): wire OIDC at INSTALL time, not via a
# post-deploy in-place `--chaos` redeploy. The orchestrator provisions the per-run realm on the
# live-warm keycloak BEFORE the single `abra app deploy`, and tests/lasuite-drive/install_steps.sh
# writes the OIDC env + client secret into the .env that one deploy reads. This eliminates the flaky
# 12-service reconverge (collabora WOPI-discovery race; JOURNAL Step 0). Drive boots fine with OIDC
# env set because keycloak is live-warm (discovery reachable at boot). setup_custom_tests.sh now
# only triggers the post-deploy MinIO bucket one-shot.
OIDC_AT_INSTALL = True
def READY_PROBE(ctx):
def READY_PROBE(domain):
"""Readiness signals beyond replica-convergence + the app HEALTH_PATH (Q3.2/F2-12). collabora's
coolwsd reports its container 1/1 'running' while still doing jail/config init, and its WOPI
discovery endpoint 404s until ready — so the harness waits for `/hosting/discovery` → 200 on the
collabora sibling host after the install deploy AND after the upgrade chaos redeploy. This is what
makes the heavy prev→PR-head crossover reliably green (the new collabora 25.04.9.x finishes init
within swarm's healthcheck retries; abra's own converge monitor was too impatient — F2-12)."""
label, _, rest = ctx.domain.partition(".")
return [{"host": f"collabora-{ctx.domain}", "path": "/hosting/discovery", "ok": (200,)}]
label, _, rest = domain.partition(".")
return [{"host": f"collabora-{domain}", "path": "/hosting/discovery", "ok": (200,)}]
def EXTRA_ENV(ctx):
def EXTRA_ENV(domain):
# Two of lasuite-drive's services route on DOMAIN-DERIVED **nested** subdomains —
# `MINIO_DOMAIN="minio.${DOMAIN}"` and `COLLABORA_DOMAIN="collabora.${DOMAIN}"`. The cc-ci
# wildcard TLS cert is `*.ci.commoninternet.net` (single label only), so a 2-label name like
@ -52,8 +55,8 @@ def EXTRA_ENV(ctx):
# no cert/gateway change. See DECISIONS.md "Phase 2 — nested DOMAIN-derived subdomains".
# `AWS_S3_DOMAIN_REPLACE` derives from MINIO_DOMAIN in-compose, so setting MINIO_DOMAIN is enough.
return {
"MINIO_DOMAIN": f"minio-{ctx.domain}",
"COLLABORA_DOMAIN": f"collabora-{ctx.domain}",
"MINIO_DOMAIN": f"minio-{domain}",
"COLLABORA_DOMAIN": f"collabora-{domain}",
# abra's internal per-deploy convergence timeout (recipe TIMEOUT env, default 300s) is too
# short for this 12-service stack on a cold image cache (impress frontend/backend, minio,
# postgres, redis, collabora ~1GB, onlyoffice ~2GB). Bump so abra waits long enough for

View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# lasuite-drive — POST-DEPLOY setup hook (Phase 2 Q3.2a).
#
# As of Q3.2a (plan-lasuite-drive-oidc-robustness.md Part A) OIDC is wired at INSTALL time by
# tests/lasuite-drive/install_steps.sh (before the single `abra app deploy`), so this hook NO LONGER
# does any OIDC env wiring or in-place redeploy — that eliminated the flaky 12-service reconverge
# (collabora WOPI race; see JOURNAL Step 0). What remains here is the ONE post-deploy step that
# genuinely needs the live stack: triggering the MinIO bucket-creation one-shot. The orchestrator
# runs this only on the install-time path AFTER the deploy is healthy (deps already provisioned).
#
# Env supplied by the orchestrator:
# CCCI_APP_DOMAIN — the running per-run lasuite-drive app domain
# CCCI_DEPS_FILE — JSON deps creds dict (unused here now; OIDC handled at install)
set -euo pipefail
: "${CCCI_APP_DOMAIN:?missing}"
# The deploy alone does NOT create the MinIO bucket — `minio-createbuckets` is a `replicas:0`
# one-shot (restart_policy: none) that must be triggered. The MinIO storage test asserts the bucket
# exists, so create it here. `--detach` is REQUIRED: the job creates the bucket then EXITS 0, so it
# never holds a steady 1/1 replica; a blocking `docker service scale ...=1` would wait forever and
# hang the run. With `--detach` the scale just submits the one-run and returns; the poll loop below
# confirms the bucket was actually created.
STACK=$(printf '%s' "$CCCI_APP_DOMAIN" | tr '.' '_')
echo " setup: creating MinIO bucket via the minio-createbuckets one-shot (scale 0->1)"
docker service scale --detach "${STACK}_minio-createbuckets=1" >/dev/null 2>&1 || true
# Wait up to 90s for the one-shot to create the bucket (mc mb drive/drive-media-storage; exit 0).
# Poll by checking the bucket directly from the running minio replica container.
for i in $(seq 1 30); do
MC_CID=$(docker ps -q -f "name=${STACK}_minio.1" | head -1)
if [ -n "$MC_CID" ] && docker exec "$MC_CID" sh -c \
'mc alias set _c http://localhost:9000 "$(cat /run/secrets/minio_ru)" "$(cat /run/secrets/minio_rp)" >/dev/null 2>&1 && mc ls _c/drive-media-storage >/dev/null 2>&1'; then
echo " setup: bucket drive-media-storage present after ${i} poll(s)"
break
fi
sleep 3
done
echo " lasuite-drive setup_custom_tests: post-deploy MinIO bucket step complete (OIDC wired at install)"

View File

@ -36,8 +36,8 @@ def _b64url(seg: str) -> bytes:
return base64.urlsafe_b64decode(seg + "=" * ((4 - len(seg) % 4) % 4))
def _creds(deps: dict) -> dict:
kc = deps["keycloak"]
def _creds(deps_creds: dict) -> dict:
kc = deps_creds["keycloak"]
return {
"provider": "keycloak",
"provider_domain": kc["domain"],
@ -55,10 +55,10 @@ def _creds(deps: dict) -> dict:
@pytest.mark.requires_deps
def test_create_room_get_livekit_token_and_read_back(live_app, deps):
assert "keycloak" in deps, f"keycloak creds missing; got {list(deps.keys())}"
def test_create_room_get_livekit_token_and_read_back(live_app, deps_creds):
assert "keycloak" in deps_creds, f"keycloak creds missing; got {list(deps_creds.keys())}"
base = f"https://{live_app}"
token = sso.oidc_password_grant(_creds(deps))
token = sso.oidc_password_grant(_creds(deps_creds))
assert isinstance(token, str) and token.count(".") == 2, "OIDC access token is not a JWT"
auth = {"Authorization": f"Bearer {token}"}

View File

@ -3,12 +3,12 @@
Meet (La Suite Meet) is OIDC-required: login is gated by an external OpenID Connect provider.
Mirrors the proven lasuite-docs SSO model:
- The orchestrator deploys a per-run keycloak dep AFTER the generic tiers and provisions a fresh
realm/client/user via `harness.sso.setup_keycloak_realm`; `install_steps.sh` then wires the
realm/client/user via `harness.sso.setup_keycloak_realm`; `setup_custom_tests.sh` then wires the
OIDC env + client secret into the running drive app and redeploys. Creds land in `$CCCI_DEPS_FILE`
(read here via the `deps` fixture).
(read here via the `deps_creds` fixture).
- This test consumes those creds and exercises the real OIDC flow against the dep keycloak: discovery
endpoint advertises the realm, and a password grant yields a valid JWT with the expected claims.
- Marked `@pytest.mark.requires_deps` so if dep provisioning failed the test SKIPs with a clear
- Marked `@pytest.mark.requires_deps` so if setup_custom_tests failed the test SKIPs with a clear
`deps-not-ready` reason — and (per F2-11) the orchestrator then fails the run rather than going
green on a skipped SSO test.
@ -36,13 +36,13 @@ def _b64url_decode(seg: str) -> bytes:
@pytest.mark.requires_deps
def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
"""The dep keycloak issues a JWT for the pre-provisioned test user via OIDC password grant."""
assert "keycloak" in deps, (
f"keycloak creds not in deps; got {list(deps.keys())}. "
"dep provisioning should have populated this."
assert "keycloak" in deps_creds, (
f"keycloak creds not in deps_creds; got {list(deps_creds.keys())}. "
"setup_custom_tests should have populated this."
)
kc = deps["keycloak"]
kc = deps_creds["keycloak"]
# Creds shape. WC1: realm is per-run namespaced "<parent>-<6hex>"; client_id stays the parent.
assert kc["domain"]

View File

@ -4,8 +4,7 @@
# Runs during the install tier AFTER `abra app new` + EXTRA_ENV + `abra app secret generate`, and
# BEFORE the single `abra app deploy` (lifecycle.py::_run_install_steps). Writing OIDC env + the real
# client secret HERE means the recipe deploys ONCE with OIDC already wired — no post-deploy reconverge
# (install-time deps wiring — the only mode since rcust P2b). The orchestrator provisions the
# per-run realm/client on the live-warm keycloak
# (OIDC_AT_INSTALL). The orchestrator provisions the per-run realm/client on the live-warm keycloak
# BEFORE this hook and writes $CCCI_DEPS_FILE (the recipe→creds dict).
#
# Meet's OIDC is REQUIRED (recipe README). Same La Suite/impress env contract as drive, with meet's

View File

@ -27,18 +27,18 @@ def _seed(domain, value):
assert _psql(domain, "SELECT v FROM ci_marker;") == value
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# drop the marker table (diverge from the backup) so a successful restore is observable
_psql(ctx.domain, "DROP TABLE ci_marker;")
assert _psql(ctx.domain, "SELECT to_regclass('public.ci_marker');") in (
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -13,15 +13,16 @@ HEALTH_OK = (200, 301, 302)
DEPLOY_TIMEOUT = 1200
HTTP_TIMEOUT = 600
# SSO-dependent (recipe.toml requires=["keycloak"], [sso] provider=keycloak). OIDC is wired at
# INSTALL time (the only deps mode since rcust P2b) against the live-warm keycloak: the
# orchestrator provisions the per-run realm BEFORE the single `abra app deploy`, and
# tests/lasuite-meet/install_steps.sh writes the OIDC env + client secret into that one deploy
# (no post-deploy reconverge). Meet boots fine with OIDC env set because keycloak is live-warm.
# SSO-dependent (recipe.toml requires=["keycloak"], [sso] provider=keycloak). Wire OIDC at INSTALL
# time against the live-warm keycloak — same machinery as lasuite-drive (Q3.2a): the orchestrator
# provisions the per-run realm BEFORE the single `abra app deploy`, and tests/lasuite-meet/
# install_steps.sh writes the OIDC env + client secret into that one deploy (no post-deploy
# reconverge). Meet boots fine with OIDC env set because keycloak is live-warm.
DEPS = ["keycloak"]
OIDC_AT_INSTALL = True
def EXTRA_ENV(ctx):
def EXTRA_ENV(domain):
# lasuite-meet routes LiveKit's WebSocket signaling on a DOMAIN-derived **nested** subdomain
# `LIVEKIT_DOMAIN="livekit.${DOMAIN}"`. The cc-ci wildcard TLS cert is `*.ci.commoninternet.net`
# (single label only), so a 2-label name like `livekit.lasuite-meet-pr0-abc.ci.commoninternet.net`
@ -30,7 +31,7 @@ def EXTRA_ENV(ctx):
# no cert/gateway change. Same fix as lasuite-drive's minio/collabora siblings (DECISIONS.md
# "Phase 2 — nested DOMAIN-derived subdomains").
return {
"LIVEKIT_DOMAIN": f"livekit-{ctx.domain}",
"LIVEKIT_DOMAIN": f"livekit-{domain}",
# abra's internal per-deploy convergence TIMEOUT (default 300s) is too short for this stack on
# a cold image cache; bump it (kept under DEPLOY_TIMEOUT so Python never kills abra mid-wait).
"TIMEOUT": "1000",

View File

@ -21,10 +21,10 @@ DEPLOY_TIMEOUT = 900
HTTP_TIMEOUT = 600
def EXTRA_ENV(ctx):
def EXTRA_ENV(domain):
return {
"MAIL_DOMAIN": ctx.domain,
"HOSTNAMES": ctx.domain,
"MAIL_DOMAIN": domain,
"HOSTNAMES": domain,
"TRAEFIK_STACK_NAME": "traefik_ci_commoninternet_net",
"TLS_FLAVOR": "notls",
"SITENAME": "ccci-mail",

View File

@ -24,18 +24,18 @@ def _seed(domain, value):
assert _psql(domain, "SELECT v FROM ci_marker;") == value
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# drop the marker table (diverge from the backup) so a successful restore is observable
_psql(ctx.domain, "DROP TABLE ci_marker;")
assert _psql(ctx.domain, "SELECT to_regclass('public.ci_marker');") in (
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -29,18 +29,18 @@ def _seed(domain, value):
assert _psql(domain, "SELECT v FROM ci_marker;") == value
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# drop the marker table (diverge from the backup) so a successful restore is observable
_psql(ctx.domain, "DROP TABLE ci_marker;")
assert _psql(ctx.domain, "SELECT to_regclass('public.ci_marker');") in (
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -26,9 +26,9 @@ def test_configured_max_users_surfaces_in_serverconfig(live_app):
assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}"
cfg = r["server_config"]
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"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
# is the real ServerConfig (not an empty/garbled decode).

View File

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

View File

@ -38,18 +38,16 @@ def _seed(domain, value):
assert got == value, f"seed did not commit (read back {got!r}, expected {value!r})"
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
def pre_restore(domain, meta):
# diverge from the backup so a successful restore is observable: drop the marker table.
_sqlite(ctx.domain, "DROP TABLE IF EXISTS ci_marker;")
got = _sqlite(
ctx.domain, "SELECT name FROM sqlite_master WHERE type='table' AND name='ci_marker';"
)
_sqlite(domain, "DROP TABLE IF EXISTS ci_marker;")
got = _sqlite(domain, "SELECT name FROM sqlite_master WHERE type='table' AND name='ci_marker';")
assert got == "", f"drop did not take (sqlite_master still lists ci_marker: {got!r})"

View File

@ -31,19 +31,18 @@ HEALTH_OK = (200,)
DEPLOY_TIMEOUT = 900 # two images to pull (mumble-server + mumble-web) on a cold node
HTTP_TIMEOUT = 300
# A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol
# (underscore prefix = recipe-private constant, exempt from registry validation — rcust P1).
_WELCOME_TEXT_MARKER = "cc-ci-mumble-welcome-7f3a9c"
# A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol.
WELCOME_TEXT_MARKER = "cc-ci-mumble-welcome-7f3a9c"
# 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
# set here and persists across the upgrade so it takes effect on the latest (where the custom config
# round-trip tests assert it).
EXTRA_ENV = {
"COMPOSE_FILE": "compose.yml:compose.mumbleweb.yml",
"WELCOME_TEXT": _WELCOME_TEXT_MARKER,
"USERS": str(_MAX_USERS),
"WELCOME_TEXT": WELCOME_TEXT_MARKER,
"USERS": str(MAX_USERS),
}
# UPGRADE-target deploy (latest 1.0.0+): add the NATIVE compose.host-ports.yml so 64738 is
@ -53,7 +52,7 @@ UPGRADE_EXTRA_ENV = {
}
def READY_PROBE(ctx):
def READY_PROBE(domain):
# The voice server on 64738 is testable on-host ONLY when compose.host-ports.yml is active — i.e.
# the post-upgrade LATEST, not the minimal 0.2.0 base. Read the live COMPOSE_FILE to decide, so the
# SAME probe fn is correct in both phases: the post-install probe (base, no host-ports) returns []
@ -64,7 +63,7 @@ def READY_PROBE(ctx):
# backup-bot would then exec into a not-running app container -> 409).
from harness import abra # lazy: recipe_meta is exec'd with `harness` importable at call time
cf = abra.env_get(ctx.domain, "COMPOSE_FILE") or ""
cf = abra.env_get(domain, "COMPOSE_FILE") or ""
if "compose.host-ports.yml" in cf:
return [{"tcp_host": "127.0.0.1", "tcp_port": 64738, "stable": 3}]
return []

View File

@ -15,13 +15,13 @@ def _write(domain, val):
lifecycle.exec_in_app(domain, ["sh", "-c", f"echo {val} > {MARKER}"])
def pre_upgrade(ctx):
_write(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_write(domain, "upgrade-survives")
def pre_backup(ctx):
_write(ctx.domain, "original")
def pre_backup(domain, meta):
_write(domain, "original")
def pre_restore(ctx):
_write(ctx.domain, "mutated") # diverge so a successful restore is observable
def pre_restore(domain, meta):
_write(domain, "mutated") # diverge so a successful restore is observable

View File

@ -24,17 +24,17 @@ def _seed(domain, value):
assert _psql(domain, "SELECT v FROM ci_marker;") == value
def pre_upgrade(ctx):
_seed(ctx.domain, "upgrade-survives")
def pre_upgrade(domain, meta):
_seed(domain, "upgrade-survives")
def pre_backup(ctx):
_seed(ctx.domain, "original")
def pre_backup(domain, meta):
_seed(domain, "original")
def pre_restore(ctx):
_psql(ctx.domain, "DROP TABLE ci_marker;")
assert _psql(ctx.domain, "SELECT to_regclass('public.ci_marker');") in (
def pre_restore(domain, meta):
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

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

View File

@ -1,48 +0,0 @@
"""Unit tests for the shared conftest fixtures added/reshaped by the rcust restructure (P2d/P4):
`op_state` (run-scoped op context from $CCCI_OP_STATE_FILE) and `deps` (consolidated dep creds
with attribute sugar). Pure — exercised via request.getfixturevalue with env monkeypatched."""
from __future__ import annotations
import json
import pytest
def test_op_state_fixture_reads_file(tmp_path, monkeypatch, request):
f = tmp_path / "op.json"
f.write_text(json.dumps({"backup": {"snapshot_id": "abc123"}, "upgrade": {"head_ref": "h"}}))
monkeypatch.setenv("CCCI_OP_STATE_FILE", str(f))
st = request.getfixturevalue("op_state")
assert st["backup"]["snapshot_id"] == "abc123"
assert st["upgrade"]["head_ref"] == "h"
def test_op_state_fixture_skips_without_env(monkeypatch, request):
monkeypatch.delenv("CCCI_OP_STATE_FILE", raising=False)
with pytest.raises(pytest.skip.Exception, match="orchestrator"):
request.getfixturevalue("op_state")
def test_op_state_fixture_skips_on_missing_file(tmp_path, monkeypatch, request):
monkeypatch.setenv("CCCI_OP_STATE_FILE", str(tmp_path / "nope.json"))
with pytest.raises(pytest.skip.Exception, match="missing"):
request.getfixturevalue("op_state")
def test_deps_fixture_entries_expose_attributes(tmp_path, monkeypatch, request):
"""`deps` (session-scoped) coerces the run deps file into entries with .domain/.realm/...
attribute sugar while keeping dict-style access (rcust P2d). Single test for the session-
cached fixture (one instantiation)."""
f = tmp_path / "deps.json"
f.write_text(
json.dumps(
{"keycloak": {"recipe": "keycloak", "domain": "kc.x", "client_secret": "s3cret"}}
)
)
monkeypatch.setenv("CCCI_DEPS_FILE", str(f))
deps = request.getfixturevalue("deps")
assert deps["keycloak"].domain == "kc.x"
assert deps["keycloak"]["client_secret"] == "s3cret"
with pytest.raises(AttributeError):
_ = deps["keycloak"].not_a_field

View File

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

View File

@ -71,18 +71,17 @@ def test_repo_local_wins_when_approved(tmp_path):
def test_custom_tests_repo_local_gated(tmp_path, monkeypatch):
# custom test_*.py from repo-local only count for approved recipes (HC2); placement rule
# (rcust P4): custom tests live under functional/ (or playwright/) — top-level files are
# lifecycle overlays only, so the repo-local custom here sits in functional/.
# non-lifecycle test_*.py from repo-local only count for approved recipes; lifecycle names excluded
# Use a synthetic recipe name + monkeypatched cc_ci_dir so this is independent of what
# tests/<real-recipe>/ ships (F2-1).
# tests/<real-recipe>/ ships (Phase-2 custom-html now also ships functional/ + playwright/,
# which would legitimately appear in custom_tests for "custom-html" — F2-1).
fake_recipe = "ccci-hc2-fixture"
monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(tmp_path / "cc-ci" / r))
(tmp_path / "cc-ci" / fake_recipe).mkdir(parents=True)
rl = tmp_path / "repo"
(rl / "functional").mkdir(parents=True)
(rl / "functional" / "test_sso.py").write_text("# repo-local custom\n")
(rl / "functional" / "test_install.py").write_text("# lifecycle name -> excluded from custom\n")
rl.mkdir()
(rl / "test_sso.py").write_text("# repo-local custom\n")
(rl / "test_install.py").write_text("# lifecycle name -> excluded from custom\n")
_approve(tmp_path) # not approved -> repo-local custom ignored
assert discovery.custom_tests(fake_recipe, str(rl)) == []

View File

@ -1,6 +1,6 @@
"""Unit tests for Phase-2 discovery additions (plan §4.1).
Proves the `custom_tests` discovery covers exactly the per-recipe `functional/` + `playwright/`
Proves the `custom_tests` discovery recurses into the per-recipe `functional/` + `playwright/`
subdirs as well as the top-level dir, while still excluding lifecycle `test_<op>.py` names and
honouring the HC2 repo-local approval gate.
@ -27,16 +27,16 @@ def teardown_function():
os.environ.pop("CCCI_REPO_LOCAL_APPROVED_FILE", None)
def test_custom_tests_placement_rule_functional_playwright_only(tmp_path, monkeypatch):
"""Placement rule (rcust P4): custom tests are discovered ONLY under functional/ +
playwright/. A top-level non-lifecycle test_*.py is NOT discovered (top level is reserved
for lifecycle overlays); lifecycle names inside the subdirs stay excluded (defensive)."""
def test_custom_tests_recurses_functional_and_playwright(tmp_path, monkeypatch):
"""A Phase-2 cc-ci recipe layout: functional/test_*.py + playwright/test_*.py + top-level
test_*.py — all are discovered as custom tests; the lifecycle names are excluded."""
# Point cc-ci's per-recipe dir at a fake recipe in tmp_path
fake_recipe = "ccci-phase2-fixture"
fake_dir = tmp_path / "tests" / fake_recipe
(fake_dir / "functional").mkdir(parents=True)
(fake_dir / "playwright").mkdir()
(fake_dir / "test_sso_smoke.py").write_text("# top-level — NOT discovered since P4\n")
# legitimate custom tests at multiple levels
(fake_dir / "test_sso_smoke.py").write_text("# top-level cross-cutting\n")
(fake_dir / "functional" / "test_health_check.py").write_text("# parity port\n")
(fake_dir / "functional" / "test_content_roundtrip.py").write_text("# recipe-specific\n")
(fake_dir / "playwright" / "test_login_flow.py").write_text("# UI flow\n")
@ -49,11 +49,11 @@ def test_custom_tests_placement_rule_functional_playwright_only(tmp_path, monkey
customs = discovery.custom_tests(fake_recipe, None)
names = sorted((src, os.path.basename(p)) for src, p in customs)
# functional/ + playwright/ discovered; top-level custom + lifecycle name are NOT
# Top-level + functional/ + playwright/ all discovered; lifecycle name excluded
assert ("cc-ci", "test_sso_smoke.py") in names
assert ("cc-ci", "test_health_check.py") in names
assert ("cc-ci", "test_content_roundtrip.py") in names
assert ("cc-ci", "test_login_flow.py") in names
assert ("cc-ci", "test_sso_smoke.py") not in names
assert ("cc-ci", "test_install.py") not in names

View File

@ -30,7 +30,7 @@ def test_sso_dep_unverified_true_when_declared_notready_and_skipped():
def test_sso_dep_unverified_false_when_deps_ready():
"""deps ready (dep provisioning succeeded) → SSO tests actually ran → not a failure."""
"""deps ready (setup_custom_tests succeeded) → SSO tests actually ran → not a failure."""
assert not run_recipe_ci.sso_dep_unverified(
["keycloak"], deps_ready=True, requires_deps_skipped=0
)

View File

@ -14,7 +14,6 @@ So `-c` + owned-wait is non-vacuous: a genuinely-broken upgrade stays RED.
from __future__ import annotations
import dataclasses
import os
import sys
@ -22,7 +21,6 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import lifecycle as lc # noqa: E402
from harness import meta as harness_meta # noqa: E402
def _fake_clock(monkeypatch):
@ -33,15 +31,11 @@ def _fake_clock(monkeypatch):
return state
# RecipeMeta (rcust P1: wait_ready_probes reads meta.READY_PROBE off the loaded object); defaults
# + the drive-style probe hook (P3 ctx signature: the probe receives a HookCtx).
_DRIVE_META = dataclasses.replace(
harness_meta.load("ccci-no-such-recipe"),
READY_PROBE=lambda ctx: [
{"host": f"collabora-{ctx.domain}", "path": "/hosting/discovery", "ok": (200,)}
],
)
_NO_PROBE_META = harness_meta.load("ccci-no-such-recipe")
_DRIVE_META = {
"READY_PROBE": lambda d: [
{"host": f"collabora-{d}", "path": "/hosting/discovery", "ok": (200,)}
]
}
def test_wait_ready_probes_raises_when_never_ready(monkeypatch):
@ -63,7 +57,7 @@ def test_wait_ready_probes_returns_when_ready(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)."""
monkeypatch.setattr(lc, "http_get", lambda *a, **k: 599) # would fail if it were consulted
lc.wait_ready_probes(_NO_PROBE_META, "x.ci.commoninternet.net", timeout=1) # no raise, no call
lc.wait_ready_probes({}, "x.ci.commoninternet.net", timeout=1) # no raise, no call
def test_wait_healthy_raises_when_services_never_converge(monkeypatch):

View File

@ -1,276 +0,0 @@
"""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, no more (the 3 P2-deleted legacy keys are gone from the registry,
# so any recipe_meta still setting them hard-fails the typo gate)
assert len(KEYS) == 14
assert not [k for k in KEYS if k.deprecated]
for gone in ("CHAOS_BASE_DEPLOY", "OIDC_AT_INSTALL", "SKIP_GENERIC"):
assert gone not in {k.name for k in KEYS}
# ---- 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(ctx):\n return _helper(ctx.domain)\n",
)
meta = meta_mod.load(r, tests_dir=str(tmp_path))
ctx = meta_mod.hook_ctx("x.example", meta)
assert meta_mod.extra_env(meta, ctx) == {"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, meta_mod.hook_ctx("d", meta)) == {"A": "1"} # stringified
r2 = _write_meta(
tmp_path, "UPGRADE_EXTRA_ENV = lambda ctx: {'COMPOSE_FILE': ctx.domain}\n", recipe="r2"
)
meta2 = meta_mod.load(r2, tests_dir=str(tmp_path))
ctx2 = meta_mod.hook_ctx("dom.x", meta2, op="upgrade")
assert meta_mod.upgrade_extra_env(meta2, ctx2) == {"COMPOSE_FILE": "dom.x"}
assert meta_mod.extra_env(meta2, ctx2) == {} # unset EXTRA_ENV resolves to {}
# ---- P3: uniform ctx hook convention -------------------------------------------------------------
def test_hook_ctx_fields(tmp_path):
meta = meta_mod.load("no-such", tests_dir=str(tmp_path))
ctx = meta_mod.hook_ctx("app.ci.example", meta, op="backup")
assert ctx.domain == "app.ci.example"
assert ctx.base_url == "https://app.ci.example"
assert ctx.meta is meta
assert ctx.op == "backup"
assert meta_mod.hook_ctx("d", meta).op is None
def test_hook_ctx_deps_from_run_file(tmp_path, monkeypatch):
import json
meta = meta_mod.load("no-such", tests_dir=str(tmp_path))
monkeypatch.delenv("CCCI_DEPS_FILE", raising=False)
assert meta_mod.hook_ctx("d", meta).deps is None
f = tmp_path / "deps.json"
f.write_text(json.dumps({"keycloak": {"recipe": "keycloak", "domain": "kc.x"}}))
monkeypatch.setenv("CCCI_DEPS_FILE", str(f))
deps = meta_mod.hook_ctx("d", meta).deps
assert deps["keycloak"]["domain"] == "kc.x"
f.write_text("{}") # empty dict -> None (deps declared but not provisioned)
assert meta_mod.hook_ctx("d", meta).deps is None
def test_legacy_hook_signature_raises_clear_meta_error(tmp_path):
"""A pre-restructure hook signature must fail AT LOAD with a migration message — never a
silent TypeError mid-run (P3.4)."""
r = _write_meta(tmp_path, "def READY_PROBE(domain):\n return []\n")
with pytest.raises(MetaError, match="ctx"):
meta_mod.load(r, tests_dir=str(tmp_path))
r2 = _write_meta(tmp_path, "EXTRA_ENV = lambda domain: {}\n", recipe="r2")
with pytest.raises(MetaError, match="restructure"):
meta_mod.load(r2, tests_dir=str(tmp_path))
r3 = _write_meta(
tmp_path, "def SCREENSHOT(page, domain, meta):\n return None\n", recipe="r3"
)
with pytest.raises(MetaError, match="page, ctx"):
meta_mod.load(r3, tests_dir=str(tmp_path))
def test_ctx_hook_signatures_accepted(tmp_path):
r = _write_meta(
tmp_path,
"def READY_PROBE(ctx):\n return []\n"
"def BACKUP_VERIFY(ctx):\n return True\n"
"def SCREENSHOT(page, ctx):\n return None\n"
"def EXTRA_ENV(ctx):\n return {}\n",
)
meta = meta_mod.load(r, tests_dir=str(tmp_path))
assert callable(meta.READY_PROBE) and callable(meta.SCREENSHOT)
def test_check_hook_signature_for_pre_op_hooks():
"""The orchestrator validates ops.py pre_<op> hooks with the same checker (legacy
(domain, meta) form names the migration)."""
def legacy(domain, meta):
pass
def new(ctx):
pass
with pytest.raises(MetaError, match="ctx"):
meta_mod.check_hook_signature(legacy, ("ctx",), "tests/x/ops.py::pre_upgrade")
meta_mod.check_hook_signature(new, ("ctx",), "tests/x/ops.py::pre_upgrade") # no raise
def test_non_default_reports_only_customized_keys(tmp_path):
r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = 1500\nDEPS = ['keycloak']\n")
nd = meta_mod.non_default(meta_mod.load(r, tests_dir=str(tmp_path)))
assert nd == {"DEPLOY_TIMEOUT": 1500, "DEPS": ["keycloak"]}
def test_meta_is_frozen():
import dataclasses
meta = meta_mod.load("custom-html")
with pytest.raises(dataclasses.FrozenInstanceError):
meta.DEPLOY_TIMEOUT = 1
# ---- doc generation sync (P1.5: the committed §4 table == the registry rendering) ---------------
def test_generated_doc_table_in_sync():
"""docs/recipe-customization.md's key reference table is GENERATED from the registry
(scripts/gen-meta-docs.py). If this fails: re-run `python3 scripts/gen-meta-docs.py` and
commit the result — the table must never drift from the registry (R5)."""
gen = os.path.join(ROOT, "scripts", "gen-meta-docs.py")
doc = os.path.join(ROOT, "docs", "recipe-customization.md")
rendered = subprocess.run(
[sys.executable, gen, "--print"], capture_output=True, text=True, check=True
).stdout
with open(doc) as f:
committed = f.read()
assert rendered.strip() in committed, (
"docs/recipe-customization.md key table is out of sync with the harness.meta registry — "
"run `python3 scripts/gen-meta-docs.py` and commit"
)

View File

@ -11,7 +11,6 @@ import os
import sys
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
@ -30,19 +29,3 @@ def test_hook_returned_when_callable():
pass
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