plan: recipe-customization restructure — full builder/adversary plan (P1-P6 + real-CI regression sweep gate)

This commit is contained in:
autonomic-bot
2026-06-10 16:28:09 +00:00
parent a6e177e286
commit aefaf17757

View File

@ -0,0 +1,256 @@
# Recipe-customization restructure — full implementation plan
Operator-approved direction (chat 2026-06-10). Reference spec of the CURRENT system:
cc-ci `docs/recipe-customization.md` (commit 76a4b6b) — read it FIRST; its §8 R1R9 are the
defects this plan fixes. Goals: one coherent customization system, one place/way to configure a
recipe, easy custom tests, fix broken knobs, delete legacy paths, zero behavior regression for
currently-working recipes.
## Decisions locked (operator + orchestrator)
| Question | Decision |
|---|---|
| Loaders | ONE loader `runner/harness/meta.py::load(recipe) -> RecipeMeta` backed by a declarative KEY registry. All six current loaders (spec §4 L1L6) migrate to it; `exec()` of recipe_meta.py happens in exactly one function. |
| Validation strictness | Unknown ALL-CAPS top-level name or type mismatch = **hard error** (run fails fast at load; unit test catches at PR time). Underscore-prefixed names (`_FOO`) are recipe-private and exempt. |
| `SCREENSHOT` | KEEP and FIX (it is currently dead — spec §8 R2). Reachable once the registry replaces the allowlist. |
| `CHAOS_BASE_DEPLOY` | DELETE. `tests/<recipe>/compose.ccci.yml` becomes first-class: harness copies it into the checkout + auto-uses `--chaos` for the base deploy. Both users (ghost, discourse) use the flag only for this. |
| `OIDC_AT_INSTALL` | DELETE. Install-time deps provisioning becomes the ONLY mode when `DEPS` is set; legacy post-deploy provisioning + `setup_custom_tests.sh` redeploy path removed. Migrate lasuite-docs. |
| `SKIP_GENERIC` (meta key) | DELETE (zero users). Env form `CCCI_SKIP_GENERIC*` stays as a documented LOCAL-DEV-ONLY escape hatch, loudly surfaced in the run manifest. |
| Hook signatures | All recipe callables take a single `ctx` (HookCtx) — `EXTRA_ENV(ctx)`, `UPGRADE_EXTRA_ENV(ctx)`, `READY_PROBE(ctx)`, `BACKUP_VERIFY(ctx)`, `SCREENSHOT(page, ctx)`, `pre_<op>(ctx)`. All users are in-repo; migrate them, no compat shim. |
| Fixtures | Single `tests/conftest.py`. Final surface: `recipe`, `meta` (full RecipeMeta), `live_app`, `op_state` (NEW), `deps` (NEW — consolidates `deps_apps`+`deps_creds`). DELETE dead pre-deploy-once fixtures `deployed`/`deployed_app` (zero users) and `app_domain` if nothing else uses it (builder: grep). |
| Custom-test placement | `test_<op>.py` top-level = lifecycle overlay; ALL custom tests under `functional/` or `playwright/`. Discovery of top-level non-lifecycle `test_*.py` in recipe dirs is REMOVED (zero users today). |
| Docs | Key reference table is GENERATED from the registry; a unit test asserts docs ⊆ registry sync. |
| recipe_meta stays Python | NO data/hooks file split (spec §8 R9 considered, rejected — registry validation gets the value at lower cost). One file per recipe remains the single config place. |
| Landing | one branch `restructure/recipe-custom`, one commit per phase, merged to main only after Adversary M1 PASS + lint green; real-CI regression sweep (M2) after merge. |
## Target shape (end state)
```
tests/<recipe>/
├── recipe_meta.py # THE config: registry-validated keys + ctx-hooks (+ _private consts)
├── test_<op>.py # lifecycle overlay asserts (op_state/live_app/meta fixtures)
├── ops.py # pre_<op>(ctx) seed hooks
├── functional/ playwright/ # ALL custom tests
├── install_steps.sh # pre-deploy shell hook (the only shell hook)
├── compose.ccci.yml # first-class CI overlay (auto-copied, auto-chaos)
└── PARITY.md
```
One loader, one hook convention, one fixture file, one shell hook, one generated reference.
---
## P1 — `harness/meta.py`: single loader + key registry
1. New `runner/harness/meta.py`:
- `KEYS`: registry of every key — `name, type ("int"|"str"|"tuple[int]"|"bool"|"dict_or_hook"|
"hook"|"list[str]"|"dict"), default, doc` (one line each), optional `validate(value)`.
Final key set (14): HEALTH_PATH, HEALTH_OK, DEPLOY_TIMEOUT, HTTP_TIMEOUT, BACKUP_CAPABLE,
EXPECTED_NA, READY_PROBE, UPGRADE_BASE_VERSION, BACKUP_VERIFY, UPGRADE_EXTRA_ENV, EXTRA_ENV,
DEPS, WARM_CANONICAL, SCREENSHOT. (CHAOS_BASE_DEPLOY / OIDC_AT_INSTALL / SKIP_GENERIC are
deleted in P2 — during P1 keep them registered with `deprecated=True` so P1 lands green
before P2 removes them.)
- `load(recipe) -> RecipeMeta` (frozen dataclass, attribute access): the ONLY `exec()` of
`tests/<recipe>/recipe_meta.py`. Missing file → all defaults. Validation per the locked
decision: unknown non-underscore ALL-CAPS name → `MetaError` listing the unknown name and
nearest registered key; type mismatch → `MetaError`. Callables accepted only for hook-typed
keys.
2. Migrate ALL readers (spec §4 L1L6 + §9 index has exact locations):
- `run_recipe_ci.py::_load_meta` → `meta.load()`; orchestrator loads ONCE and passes the
object down (functions grow a `meta` param; stop re-exec'ing per call).
- `tests/conftest.py::_recipe_meta` → `meta.load()` (fixture now returns full RecipeMeta).
- `lifecycle.py::_recipe_extra_env`, `lifecycle.py::_recipe_meta_flag` → deleted; callers take meta.
- `deps.py::declared_deps` → `meta.DEPS`; `canonical.py::is_canonical_enrolled` → `meta.WARM_CANONICAL`.
- `screenshot.py` consumer unchanged — but now the dict/object it receives actually contains
SCREENSHOT. **This is the R2 fix; prove it with a unit test** (meta with SCREENSHOT hook →
`_load_screenshot_hook` returns it through the real orchestrator load path).
3. Mumble private constants `WELCOME_TEXT_MARKER`, `MAX_USERS` → `_WELCOME_TEXT_MARKER`,
`_MAX_USERS` (fix their importers; builder: grep how mumble tests consume them).
4. New unit tests `tests/unit/test_meta.py`:
- every `tests/*/recipe_meta.py` in the repo loads clean through the registry (the typo gate);
- unknown-key and wrong-type files (tmp fixtures) raise MetaError;
- defaults match spec §2 baseline; underscore exemption; callable-on-data-key rejected.
5. Doc generation: `scripts/gen-meta-docs.py` renders the registry to a markdown table between
`<!-- META-TABLE-START/END -->` markers in `docs/recipe-customization.md` §4; unit test asserts
the committed table == rendered output (drift fails CI).
## P2 — Delete legacy keys & paths
a. **compose.ccci.yml first-class.** In the deploy path (after `install_steps.sh`, before base
deploy): if `tests/<recipe>/compose.ccci.yml` exists, copy it into the recipe checkout
(ABRA_DIR-aware) and use `--chaos` for the base deploy. Remove the copy boilerplate from
ghost/discourse `install_steps.sh` (delete the hook file entirely if copying was all it did —
builder: read both) and `CHAOS_BASE_DEPLOY = True` from both metas. Delete `_recipe_meta_flag`
remnants. Keep the policy note (overlays = minimal justified fallback) in docs.
b. **Install-time deps only.** Make the `OIDC_AT_INSTALL=True` code path the unconditional
behavior for recipes with DEPS; delete the legacy post-deploy provisioning branch, the
`setup_custom_tests.sh` invocation machinery, and the deploy-count exception for the legacy
redeploy. Migrate `lasuite-docs` to install-time wiring (mirror what lasuite-drive/meet do in
`install_steps.sh` reading `$CCCI_DEPS_FILE`). NOTE: lasuite-drive ALSO ships
`setup_custom_tests.sh` despite OIDC_AT_INSTALL=True — builder must read both scripts and
determine what they still do (realm/user provisioning is harness-side via `harness.sso`; env
wiring belongs in install_steps.sh). Whatever remains necessary moves into
`install_steps.sh`; then delete both `setup_custom_tests.sh` files and the key.
c. **SKIP_GENERIC meta key deleted**; env `CCCI_SKIP_GENERIC*` documented dev-only; if set in a
CI (drone) run, print a loud `!!` warning + record in the P5 manifest.
d. **Conftest cleanup**: delete `deployed`, `deployed_app` (dead, zero users — verified), and
`app_domain` if unused after their removal. Consolidate `deps_apps` + `deps_creds` into one
`deps` fixture (entries expose `.domain` plus full creds; dict-style access fine). Migrate the
6 lasuite test files that use the old pair. Keep `requires_deps` marker + skip-report plumbing
unchanged (F2-11 gate — do NOT weaken).
## P3 — Uniform `ctx` hook convention
1. `harness/meta.py::HookCtx` (frozen dataclass): `.domain`, `.base_url`, `.meta` (RecipeMeta),
`.deps` (creds dict or None), `.op` (current lifecycle op or None). One constructor helper in
the orchestrator; harness builds it at each hook call site.
2. Convert call sites: `EXTRA_ENV(ctx)`, `UPGRADE_EXTRA_ENV(ctx)`, `READY_PROBE(ctx)`,
`BACKUP_VERIFY(ctx)`, `SCREENSHOT(page, ctx)`, `ops.py pre_<op>(ctx)`. Dict-valued
EXTRA_ENV/UPGRADE_EXTRA_ENV (non-callable) still allowed — only the callable signature changes.
3. Migrate every in-repo user (spec §4 lists them per key; ops.py exists in ghost, discourse,
immich, lasuite-*, mumble, others — builder: glob). Mechanical change; assertions and seeded
values must remain byte-identical.
4. Unit tests: hook invocation passes a ctx with correct fields; a legacy-signature callable
(`lambda domain: ...`) raises a CLEAR MetaError naming the migration (no silent TypeError
mid-run).
## P4 — Custom-test ergonomics
1. `discovery.py::custom_tests`: drop the top-level `test_*.py` glob for recipe dirs (keep
`functional/` + `playwright/`); lifecycle-name exclusion logic stays for safety. (Zero current
users of top-level custom tests — verified; the change is doc'd as a placement RULE.)
2. New fixtures in `tests/conftest.py`:
- `op_state` — parse `$CCCI_OP_STATE_FILE` (skip with clear reason if unset/absent);
- `deps` — from P2d.
Migrate overlay tests that hand-parse `CCCI_OP_STATE_FILE` / env (builder: grep
`CCCI_OP_STATE_FILE|os.environ` under `tests/*/test_*.py` + `functional/`) to fixtures. Tests
should not read `os.environ` directly except via fixtures after this phase (grep-clean,
excluding conftest itself).
3. `harness` import surface: ensure `from harness import lifecycle, sso, browser` is the
documented toolbox; no new module needed — docs job (P6).
## P5 — Customization manifest
At run start (after meta load + discovery), print ONE block and embed the same dict in
`results.json` under `"customization"`:
```
===== customization manifest: <recipe> =====
meta (non-default): DEPLOY_TIMEOUT=1500 HTTP_TIMEOUT=600 DEPS=['keycloak'] ...
hooks: ops.py[pre_upgrade,pre_backup,pre_restore](cc-ci) install_steps.sh(cc-ci) compose.ccci.yml(cc-ci)
overlays: test_backup.py(cc-ci) test_restore.py(repo-local)
custom tests: functional/=5 playwright/=2 (cc-ci) functional/=1 (repo-local)
env overrides: CCCI_SKIP_GENERIC_BACKUP=1 !! dev-only override active in CI
```
Pure presentation + one results.json key — MUST NOT influence any verdict. Unit test: manifest for
a synthetic recipe dir is complete + deterministic; results schema test updated.
## P6 — Docs
- `docs/recipe-customization.md`: §4 table generated (P1.5); §8 rewritten — R1/R2/R3/R6/R7/R8
resolved (say how), R4 mitigated by manifest, R9 rejected-by-decision; §3/§5 updated to the
end-state shape (no setup_custom_tests.sh, placement rule, ctx hooks, fixtures incl. op_state/deps).
- `docs/testing.md` + `docs/enroll-recipe.md`: remove their partial key lists (point at the
generated table), update hook signatures, fixture names, lasuite-docs worked example, local-run
instructions.
- Keep all three docs' scope split: concepts (testing.md) / how-to (enroll-recipe.md) /
reference+structure (recipe-customization.md).
---
## Test suite additions (run inside `pytest tests/unit -q` — these are cheap/pure)
- `tests/unit/test_meta.py` (P1.4) — registry, validation, all-recipes-load-clean, R2 proof.
- ctx-hook tests (P3.4).
- discovery placement tests (P4.1) — top-level custom no longer discovered; functional/playwright are.
- manifest tests (P5).
- doc-sync test (P1.5).
- KEEP every existing unit test green; where a unit test pins old behavior being deleted
(allowlist loaders, setup_custom_tests, deployed fixtures, top-level discovery), update it to
pin the NEW behavior — never delete a test without a replacement that covers the successor path.
## Roles, gates & Definition of Done (loop protocol — plan.md §6.1 applies)
Builder/Adversary loops, phase-namespaced state files: STATUS-rcust.md, REVIEW-rcust.md,
BACKLOG-rcust.md, JOURNAL-rcust.md.
**Builder** (fable): implement P1→P6 on branch `restructure/recipe-custom` in YOUR clone; one
commit per phase; before each commit ALL green: `pytest tests/unit -q`, `scripts/lint.sh`
(tests/concurrency NOT required — untouched by this plan; run it once before M1 to prove that).
Push the branch (NOT main — merge gated below). Claim gates via `claim(rcust): ...` commits +
STATUS-rcust.md.
**Adversary** (opus): cold-verify from your own clone. For M1: check out the branch, run
`pytest tests/unit -q` + `pytest tests/concurrency -q` + lint yourself, then adversarial review of
the FULL diff. Hunt specifically:
- **coverage loss** — the cardinal risk of this restructure. For EVERY migrated recipe, diff the
resolved customization (old loaders' effective values vs new `meta.load()`) — write a throwaway
script that computes both for all 21 recipe dirs and diffs; any delta is a finding.
- assertion weakening in `tests/<recipe>/` diffs: migrations must be mechanical (signatures,
fixture names, underscore renames); any changed assert/expected-value = VETO.
- deleted-code fallout: grep for dangling refs to `_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`.
- validation gaps: craft a recipe_meta with a typo'd key / wrong type / callable-on-data-key —
must MetaError, not silently pass.
- R2 actually fixed end-to-end (orchestrator path delivers SCREENSHOT to screenshot.py).
- HC2 gate integrity: repo-local default-deny unchanged; `requires_deps` skip-report (F2-11)
unchanged; generic floor semantics unchanged.
Findings to REVIEW-rcust.md; Adversary owns VETO.
**Gates:**
- **M1 — implementation verified.** Branch complete (P1P6 + tests), unit+concurrency+lint green
on the Adversary's cold clone, resolved-customization diff clean for all 21 recipes,
adversarial diff review PASS in REVIEW-rcust.md.
- **M2 — merged + REAL-CI REGRESSION SWEEP (operator-required).** After M1 PASS only, Builder
merges to main (merge commit, never force) and confirms the push build green. Then EVERY
enrolled recipe is re-run through the real harness and must match its pre-change baseline:
1. **Baseline matrix first** (build it BEFORE merging, during M1): for each of the 21 recipe
dirs record expected outcome from the most recent known-good evidence (dashboard/results
history; the bad-canary recipes custom-html-bkp-bad/rst-bad are EXPECTED to fail at their
designed tier — record the tier). Commit the matrix to STATUS-rcust.md.
2. **Canary suite** on the merged main: `cc-ci-run python -m pytest tests/regression/ -m canary -v`
— all seven canaries (green canaries pass, RED canaries still caught at the intended tier;
this is the false-green detector).
3. **Per-recipe full runs** on the merged main, every enrolled recipe:
`RECIPE=<r> PR=<n> REF=<sha> SRC=recipe-maintainers/<r> STAGES=install,upgrade,backup,restore,custom cc-ci-run runner/run_recipe_ci.py`
using each mirror's current default-branch head as REF (or the open PR head where one exists —
immich#2, plausible#3 may also be exercised via `!testme` through drone for at least TWO
recipes so the drone→harness path is covered too). Max 23 concurrent (respect runner
capacity); teardown verified after each (zero leaked apps — janitor sweep clean).
4. **Verdict + evidence**: per-recipe result level == baseline matrix. ALSO spot-grep run logs
to prove customizations actually executed (no silent loss): mumble READY_PROBE tcp lines,
cryptpad SANDBOX_DOMAIN in env, ghost/discourse BACKUP_VERIFY lines + overlay copy + chaos
base deploy, lasuite-* deps provisioning + OIDC tests ran (skip-count 0), immich ops.py
seeds, manifest block present in every log, screenshot.png present where capture succeeded.
Evidence (run ids, log excerpts) in STATUS-rcust.md; Adversary independently re-checks a
sample of ≥5 recipes' logs + ALL mismatches.
Any regression vs baseline → fix-forward on main only for trivial breakage with Adversary
approval; otherwise revert the merge (rollback below) and return to M1.
**## DONE in STATUS-rcust.md** only when M1 and M2 both show a fresh Adversary PASS in
REVIEW-rcust.md.
## Guardrails (builder + adversary MUST honor)
- This plan DOES touch `tests/<recipe>/` — but ONLY mechanically (signatures, fixture/key renames,
underscore prefixes, install_steps consolidation). NEVER change an assertion, expected value,
seeded marker, or test semantics. NEVER weaken recipe-test gates, the generic floor, HC2, or
the F2-11 skip-report.
- cc-ci main is touched ONLY by the M2 merge after M1 PASS (and gated fix-forwards in M2). Never
force-push. NEVER merge or push recipe-mirror repos — `!testme` comments only.
- No secrets in commits; reference `.testenv` / `/run/secrets` locations only.
- Teardown all dev/test deploys on every exit path; M2 sweep must leave zero apps behind.
- Do NOT touch concurrency machinery (lifetime.py, app locks, janitor, ABRA_DIR) beyond
pass-through `meta` params.
- Match repo commit style (`feat(harness)`/`fix(...)`/`test(...)`/`docs:`).
- Stuck >2 cycles on the same defect → write it to BACKLOG-rcust.md + JOURNAL-rcust.md and ping
the orchestrator via INBOX rather than thrashing.
## Rollback
Single revert of the merge commit restores the six-loader world; recipe_meta.py edits ride in the
same merge so the revert is self-consistent. No persistent state involved (config is all
in-repo); re-running the M2 sweep after a revert re-validates the old world.