Compare commits

..

3 Commits

Author SHA1 Message Date
b5f2b104e6 fix(keycloak): key warm state by stack namespace, not bare recipe (F-redfix-4)
Some checks failed
continuous-integration/drone/push Build is failing
The M2 keycloak enrollment made the canonical collision-free at the DOMAIN layer
(warm-canon-keycloak vs warm-keycloak) but warm STATE stayed keyed by bare recipe:
warmsnap.app_dir("keycloak") resolved both the live-warm reconciler's last_good and
the data-warm canonical's canonical.json + snapshot/ into /var/lib/ci-warm/keycloak/.
snapshot() atomically REPLACES that slot, so the two deployments destroyed each
other's known-good; restore() then raised SnapshotError (fails closed, no cross-stack
data write). Worst case: a sweep promote landing inside the reconciler's
snapshot->wait_healthy window makes its rollback restore() raise after
abra.undeploy(live), leaving the shared OIDC provider undeployed.

Fix: canonical.canonical_ns() is now the single namespace from which BOTH the
canonical's domain and its warm-state slot derive, so they cannot drift apart. A
live-warm provider gets ns "canon-<recipe>": domain warm-canon-keycloak (unchanged)
and slot /var/lib/ci-warm/canon-keycloak/. Every other recipe keeps ns "<recipe>" —
zero on-disk change for the 15 existing canonicals, and no migration on cc-ci
(keycloak's canonical was never seeded: its dir holds only last_good).

- warmsnap: functions take a SLOT, not a recipe; add live_slot(); meta records "slot".
- warmsnap: _assert_slot_not_foreign() refuses to snapshot/restore a slot recorded
  against a different domain -- defence in depth, naming-scheme-independent, fails
  before the destructive swap rather than at the next restore.
- canonical: registry_path/seed_canonical/prune_stale go through canonical_slot().
- prune_stale: the "reconciler dirs are never pruned" invariant is now STRUCTURAL --
  <recipe>/ never gains a canonical.json, so de-enrolling keycloak can no longer
  rmtree the reconciler's last_good (consequence 4).
- warm_reconcile: last_good + snapshot/restore go through warmsnap.live_slot().
- run_recipe_ci: canonical rollback restores from canonical_slot(recipe).
- Correct the two comments that claimed the deployments "can never touch each other".

Tests: 10 new (slot disjointness for every WARM_DOMAINS recipe, slot<->stack 1:1,
registry not in the reconciler dir, prune spares last_good, foreign-slot refusal in
both snapshot and restore). Unit suite 315 -> 325, no regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS8p1esg57UAC69riNvuBX
2026-07-09 00:11:28 +00:00
07fc6d4af5 fix(mumble): widen handshake readiness budget 60s->180s (load flake stabilization)
Some checks failed
continuous-integration/drone/push Build is failing
The TCP READY_PROBE proves 64738 is listening, but the murmur control channel needs more warmup to
complete a full TLS+ServerSync handshake; under concurrent sweep load that exceeded the 60s budget
(green in isolation, red under load). Longer budget absorbs the delay; assertions unchanged (a dead
server still fails after all retries).
2026-06-18 01:58:16 +00:00
61211dba70 fix(keycloak): collision-free canonical domain for live-warm providers; enroll keycloak
canonical_domain() routes any recipe in warm.WARM_DOMAINS (keycloak) to a distinct warm-canon-<recipe>
domain so the data-warm canonical promote can never collide with the live-warm OIDC provider at
warm-keycloak. keycloak WARM_CANONICAL=True (full canonical coverage without risking live SSO).
2026-06-18 01:58:16 +00:00
16 changed files with 296 additions and 796 deletions

View File

@ -54,56 +54,3 @@ hold). Concrete fix designs from M1 evidence:
## Adversary findings
(Adversary-owned — do not edit.)
### [adversary] F-redfix-1 — discourse migration INCOMPLETE: dangling image-less `sidekiq` in compose.smtpauth.yml (R011 lint regression + breaks SMTP-auth deploys) — **CLOSED @2026-06-18T07:06Z**
**CLOSED by Adversary re-test.** Builder fixed in PR #4 @9ff5e19 (force-pushed onto 53ba0910): removed the
orphaned `sidekiq:` block from compose.smtpauth.yml; the `app:` service retains the smtp env + secret (SMTP
auth preserved — official image runs sidekiq internally). My re-verify: (1) exact lint.py repro @9ff5e19
**R011 ✅** (R003/R004 also clean; `grep -c sidekiq compose*.yml` = 0); (2) my own full cold run
`/tmp/adv-discourse-m2v2.log`**level=5 of 5**, all 5 tiers pass, `lint rung: pass`, both overlay tests
(`test_head_runs_official_image_not_bitnamilegacy`, `test_sidekiq_service_dropped_by_head`) still PASS. The
fix is minimal + correct (no test change, smtp preserved). Regression resolved.
**Severity:** blocks M2 (discourse not "verified green"). Fix-introduced regression on a recipe PR meant to be merged.
**What:** The discourse official-image migration (PR #4 @53ba0910) drops the `sidekiq` service from
`compose.yml` (correct — sidekiq is internal to the official image; `test_sidekiq_service_dropped_by_head`
asserts this). BUT it leaves a `sidekiq:` service block in **`compose.smtpauth.yml`** (smtp env +
`smtp_password` secret, **no `image:`**). After the drop, that block is a dangling service with no image:
- The L5 lint rung (`abra recipe lint`, which globs ALL `compose*.yml`) sees the merged
`compose.yml`+`compose.smtpauth.yml` with an image-less `sidekiq` → **R011 "all services have images"
FAILS** (2× `WARN invalid reference format`). Run drops to **level=4 of 5** (the other 5 fixed recipes
all reach level=5).
- Any real deployment that enables SMTP auth (`COMPOSE_FILE` including `compose.smtpauth.yml`) would try to
start a `sidekiq` service with no image → deploy failure.
**Regression proof (introduced by the fix, not pre-existing):**
- Pre-fix published tag `0.8.1+3.5.0`: lint R011 = ✅ — old `compose.yml` had `sidekiq:` WITH
`image: bitnamilegacy/discourse:3.5.0`, so the smtpauth `sidekiq` override merged onto a real image.
- Post-fix head `53ba0910`: lint R011 = ❌ (reproduced via exact `runner/harness/lint.py` flow: clone →
`checkout -B main 53ba0910``ABRA_DIR=scratch abra recipe lint -n discourse`).
- `grep -l sidekiq ~/.abra/recipes/discourse/compose*.yml` @head → ONLY `compose.smtpauth.yml`.
**Why the deploy tiers still pass (so the run verdict is green but level=4):** the discourse canon/CI deploy
uses `COMPOSE_FILE=compose.yml:compose.ccci.yml` (per recipe_meta EXTRA_ENV) — it does NOT include
compose.smtpauth.yml, so the dangling sidekiq isn't deployed; the 5 tiers + the two upgrade-overlay tests
pass. The lint rung (globs all compose files) is what surfaces it. Builder's own run **#849 was ALSO
level=4 / lint=fail / R011 ❌** — so "VERIFIED — run #849 green" is overstated (deploy-green, not L5-green;
masks a fix-introduced regression).
**Repro:**
```
cd ~/.abra/recipes/discourse && git checkout -f 53ba0910
S=$(mktemp -d); LA=$S/abra; mkdir -p $LA/recipes
git clone -q ~/.abra/recipes/discourse $LA/recipes/discourse
git -C $LA/recipes/discourse checkout -f -q -B main 53ba0910
git -C $LA/recipes/discourse remote set-url origin $LA/recipes/discourse
for sh in catalogue servers; do ln -s $(realpath ~/.abra/$sh) $LA/$sh; done
ABRA_DIR=$LA script -qec "abra recipe lint -n discourse" /dev/null # -> R011 X "invalid reference format" x2
# vs the same flow at 0.8.1+3.5.0 -> R011 OK
```
**Proposed remedy (recipe PR #4):** remove the orphaned `sidekiq:` block from `compose.smtpauth.yml` (fold
its `DISCOURSE_SMTP_PASSWORD_FILE` env + `smtp_password` secret into the `app` service, since sidekiq is now
internal). Re-run discourse cold -> EXPECT R011 OK, level=5. Only the Adversary closes this, after re-test.

View File

@ -356,192 +356,3 @@ cold green -> promote -> warm-bluesky-pds 200.
- gitea: fix READY locally (/tmp/redfix-gitea: app.ini->staging + docker-setup seed-once + DOCKER_SETUP_SH_VERSION v2); needs PR push + warm-advance verify.
- keycloak: harness fix (canonical_domain collision-free for WARM_DOMAINS recipes + enroll) NOT STARTED.
- mumble: harness fix (handshake readiness/retry stabilization) NOT STARTED.
## 2026-06-18T02:45Z — M2 progress: gitea PR + harness branch pushed; bluesky pivoted to rename
- **gitea**: opened recipe PR #2 `ci/app-ini-writable` (app.ini->staging + docker-setup seed-once +
DOCKER_SETUP_SH_VERSION v2). Advance-path verification RUNNING (fixed 3.6.0 reattach to idle 3.5.3
canonical; expect no app.ini crash + promote). cold lifecycle green so far (install + cold upgrade
converged).
- **bluesky**: PR #4 updated alias->RENAME service app->pds (abra drops aliases). 3-line recipe diff,
validates. Coupled cc-ci exec-ref change on branch.
- **cc-ci harness branch `redfix-m2-harness`** pushed (3 commits): keycloak (collision-free
canonical_domain + WARM_CANONICAL=True), mumble (handshake budget 60s->180s), bluesky-pds
(exec_in_app service=pds). Verified via temp-checkout runs (CCCI_REPO=<branch checkout>).
- Verification sequencing (node is single, serial): gitea advance (running) -> bluesky rename promote
(needs branch exec-refs) -> keycloak canonical at warm-canon-keycloak (needs branch) -> mumble.
NOTE: mumble "green under load" is hard to reproduce deterministically; plan = show branch run still
green + reason about the budget (or construct concurrent load).
## 2026-06-18T03:00Z — M2 gitea fix v1 (seed) BROKE the transition — needs rework
gitea advance verification (fixed 3.6.0): install tier PASSED FULLY (fresh 3.6.0 + my fix: API 200,
admin auth OK — so the seed works for a FRESH deploy), but upgrade/backup/restore/custom ALL FAILED:
`READY_PROBE not ready: /api/v1/version (last status 404) within 600s` after the 3.5.3->3.6.0 chaos
redeploy → gitea came up in INSTALL-WIZARD mode (serves 200 but no API/admin = no valid app.ini).
The LFS custom test's repo-create also 404'd (same wizard-mode cause).
So my seed-once fix is fine for fresh install but FAILS the 3.5.3->3.6.0 transition — exactly the path
the canon fix needs. Likely cause: on the chaos redeploy from a 3.5.3 stack (docker_setup_sh_v1, no
seed) the docker-setup config didn't update to my v2 (seed) while compose moved app.ini to the staging
path → /etc/gitea/app.ini empty → wizard. (To confirm: reproduce + inspect the post-redeploy container
— is docker_setup_sh_v2 mounted? does /etc/gitea/app.ini exist? gitea log.) Reverted the fix from
cc-ci's gitea clone; warm-gitea intact (idle 3.5.3, promote didn't fire on the red cold run). gitea
recipe PR #2 stands but the fix needs a rework (likely: a more robust seed that runs regardless of
config version, OR provide a 1.24-valid oauth2 JWT secret so gitea never rewrites app.ini — investigate
WHY 1.24 regenerates it). Deferring gitea; proceeding to bluesky-rename / keycloak / mumble verifies.
## 2026-06-18T03:30Z — M2 bluesky verification BLOCKED by abra non-chaos tag-revert; keycloak/mumble next
Root cause of the bluesky rename verify failure: the deployed service was `..._app` (not `pds`).
`run_recipe_ci` CCCI_SKIP_FETCH copies my renamed clone to the per-run tree, BUT abra's NON-CHAOS
pinned deploy (bluesky's tag 0.3.0+v0.4.219 is ANNOTATED) does `git checkout <tag>` in the per-run
tree, REVERTING my rename to the tag's `app:`. So the renamed recipe never deployed; the branch
harness then execs `service=pds` -> "no running container <stack>_pds" -> backup/restore/custom red.
(This also re-explains the earlier "abra dropped the alias" — it was the same tag-revert, not a drop.)
gitea's tag is lightweight -> deploy_app uses chaos -> my gitea fix DID deploy (install passed); its
failure is a real transition issue, not a revert.
IMPLICATION: verifying a RECIPE fix (bluesky, gitea) via CCCI_SKIP_FETCH needs a CHAOS deploy (uses the
checkout, not the tag). HARNESS fixes (keycloak canonical_domain, mumble retry) are runner/test code
from the branch checkout — NO tag-revert — so they verify cleanly. Doing keycloak + mumble next.
For bluesky: force chaos (deploy_app does chaos when has_ccci_overlay) OR reconsider a cc-ci-side
overlay fix (alias + caddyfile override) — both verifiable; recipe PR #4 (rename) stays as the ideal
upstream fix. gitea: rework + reproduce-with-inspection.
## 2026-06-18T03:40Z — M2 keycloak FIXED + VERIFIED (collision-free canonical)
Ran keycloak cold-on-latest from branch checkout /tmp/cc-ci-m2run (harness fix: canonical_domain ->
warm-canon-keycloak for WARM_DOMAINS recipes; WARM_CANONICAL=True). RESULT: all cold tiers PASS
(install/upgrade/backup/restore/custom), and WC5 promote SUCCEEDED:
canonical keycloak @ 10.8.0+26.6.3, domain="warm-canon-keycloak.ci.commoninternet.net", idle, volume retained.
- Promoted at the COLLISION-FREE domain warm-canon-keycloak (not warm-keycloak). ✓
- Live warm-keycloak (shared OIDC provider) = 200 THROUGHOUT — undisturbed. ✓
- warm-canon-keycloak = 404 now = CORRECT idle state (data-warm canonical undeployed, volume kept).
So keycloak is now a full data-warm canonical with zero risk to the live SSO. **FIXED + verified.**
3/6 verified: mattermost-lts, discourse, keycloak. Doing mumble next (harness, tractable).
## 2026-06-18T03:50Z — M2 mumble VERIFIED (stabilization); 4/6 done
Ran mumble from branch checkout (handshake budget attempts=36/180s). ALL tiers PASS incl
test_handshake_completes_with_channel_presence; promote succeeded (canonical 1.0.0+v1.6.870-0 idle).
The longer budget is active + non-regressing. NOTE: mumble is green in isolation regardless of budget
(the 60s sufficed in isolation); the budget matters UNDER LOAD, which is hard to reproduce
deterministically — so this verifies the stabilization is applied + sound + non-weakening, not a literal
load-flake repro. (M1 already established green-isolation/red-under-canon-load; the fix gives the
handshake 3x the readiness window.) **Stabilization fix verified.** 4/6: mattermost, discourse,
keycloak, mumble. Remaining: bluesky (force-chaos verify of the rename), gitea (rework).
## 2026-06-18T03:52Z — M2 bluesky force-chaos verification approach
bluesky's rename can't deploy via the normal path (annotated tag -> non-chaos -> abra checks out the
tag, reverting the rename). In PRODUCTION post-merge the new tag would carry the rename (non-chaos
deploys it fine). For PRE-merge verification I force chaos via a temporary tests/bluesky-pds/
compose.ccci.yml scaffold on the branch (has_ccci_overlay -> deploy_app uses chaos -> deploys my
renamed checkout). Then cold goes green (service pds + branch exec-refs) and the promote deploys the
renamed recipe at warm-bluesky-pds via chaos -> caddy resolves the unique `pds` -> expect 200 (vs M1
000). The overlay is a verification scaffold (NOT part of recipe PR #4); removed after.
## 2026-06-18T04:05Z — M2 bluesky verification: STRUCTURAL blocker (pre-merge warm-promote)
bluesky rename verification keeps deploying the TAG's `app:` (not my rename), even with: tag moved to
the rename commit AND a force-chaos overlay. Root: the warm-promote/cold-on-latest path resolves the
recipe at the UPSTREAM annotated tag (deploy_app recipe_checkout(tag) reverts unmerged content; the
chaos+overlay path STILL recipe_checkout's the pinned version). Unlike gitea (lightweight tag -> the
upgrade-tier chaos_redeploy uses the CHECKOUT, so the gitea fix deployed), bluesky has NO upgrade tier
(EXPECTED_NA) -> no chaos_redeploy path -> the rename never deploys on the promote path.
CONSEQUENCE: an unmerged RECIPE fix whose failure is WARM-PROMOTE-ONLY (bluesky 000) cannot be
end-to-end-verified via the standard harness pre-merge. mattermost/discourse were verifiable because
their failures are COLD tiers (restore/upgrade-overlay) reachable by !testme on the PR head.
bluesky fix correctness is nonetheless ESTABLISHED by: (1) M1 root cause (Adversary-confirmed): bare
`app` collides on the shared proxy; (2) docker test (proven): a unique service name/alias resolves to
the local service (no collision). Renaming app->pds (PR #4) gives a unique name -> caddy resolves THIS
PDS -> cert issued -> 200. End-to-end warm-200 needs either a DIRECT abra chaos deploy at
warm-bluesky-pds (manual app+secrets+PLC-key setup; next iteration) or operator post-merge verify.
Restored the bluesky tag; node clean; warm-keycloak 200.
## M2 STATUS (2026-06-18T04:05Z) — 4/6 verified
- mattermost-lts: VERIFIED (PR #1 ci/pg-restore, !testme run #901 all-green incl restore).
- discourse: VERIFIED (PR #4 discourse-official-image, !testme run #849 green).
- keycloak: VERIFIED (branch redfix-m2-harness; canonical promotes at warm-canon-keycloak, live warm-keycloak undisturbed 200).
- mumble: VERIFIED-stabilization (branch; green + budget 180s active; load-flake not deterministically reproducible).
- bluesky-pds: fix correct (PR #4 rename) + mechanically proven; end-to-end warm verify structurally blocked pre-merge -> direct-deploy or operator post-merge.
- gitea: PR #2 seed fix BROKE 3.5.3->3.6.0 transition (wizard mode); testable via chaos; NEEDS REWORK (reproduce+inspect).
NOT claiming M2 — bluesky end-to-end + gitea rework outstanding.
## 2026-06-18T05:53Z — M2 gitea VERIFIED (v3 seed) + bluesky VERIFIED (${STACK_NAME}_app); 6/6
**gitea — rework was already done (v3, a0f2db8) but unverified; verified it.** The clone's HEAD
a0f2db8 ("fix v2 -s seed, v3") already addressed the v1 wizard-mode bug: docker-setup seeds app.ini
into the writable /etc/gitea volume `if [ ! -s /etc/gitea/app.ini ]` (seed-on-EMPTY, not -f
seed-on-missing — a 3.5.3-old-recipe canonical leaves a 0-byte app.ini placeholder in the config
volume, which -f wrongly treats as present). Also bumps DOCKER_SETUP_SH_VERSION v1->v3 (config names
are immutable; forces swarm to re-mount the new docker-setup) + app.ini config target ->
/etc/gitea/app.ini.init (staging). Pushed v3 to PR #2 (force-replaced the broken v1 d4145266).
VERIFICATION (direct chaos-deploy onto the REAL idle 3.5.3 canonical volumes; /tmp/redfix-gitea-m2-directproof.log):
reattached the retained config volume (0-byte app.ini = genuine pre-fix M1 state) with the v3 recipe.
Result: app.ini seeded 0->1862 bytes, INSTALL_LOCK=true (not wizard), service 1/1, /api/v1/version
-> 200 {"version":"1.24.2"}, /api/healthz 200, retained 3.5.3 data adopted (data dirs dated
2026-06-17T08:39 = canonical seed time, not fresh), **0 read-only-app.ini crashes** (M1 crashed here).
WHY NOT the harness WC5 promote: it is STRUCTURALLY merge-gated. run_recipe_ci.py:373 force-fetches
`refs/tags/*` from upstream even under CCCI_SKIP_FETCH, and abra itself force-fetches tags on deploy
(abra.py:135 documents this) — so a LOCAL tag-move to the fix commit is always reverted to the
published 357926f. promote_canonical does recipe_checkout(tag)+non-chaos deploy -> deploys the
PUBLISHED release, which pre-merge lacks the fix. Confirmed empirically: a full harness run's WC5
promote deployed 357926f (caddyfile/app.ini OLD) -> crashed exactly like M1. So end-to-end
canonical-advance needs the operator to merge PR #2 + re-cut 3.6.0; the direct chaos-deploy is the
maximal+faithful pre-merge proof (chaos deploys the working-tree checkout = the PR fix). Node left
clean: warm-gitea undeployed (idle 3.5.3, volumes retained), app.ini reset to 0-byte for re-verify,
canonical.json UNCHANGED (3.5.3 idle e6a1cc79), recipe tag restored to upstream 357926f.
**bluesky — operator directive (2026-06-18): NO rename; use ${STACK_NAME}_app.** Replaced the rename
(PR #4) with the minimal prefix fix: Caddyfile `ask http://{$APP_HOST}:3000/tls-check` +
`reverse_proxy {$APP_HOST}:3000` (caddy native {$ENV}, already used for {$DOMAIN}); compose caddy
service `- APP_HOST=${STACK_NAME}_app`; CADDYFILE_VERSION v1->v2. Service stays `app` -> NO coupled
cc-ci exec-ref change (reverted/dropped b96b8a4 from branch redfix-m2-harness; that branch is now
mumble+keycloak only). 3-file recipe-PR-only diff. Pushed to PR #4 ci/warm-routing-alias (4987ba9,
force-replaced the rename). Pattern per matrix-synapse/mailu/mumble.
VERIFICATION (direct chaos-deploy at warm-bluesky-pds with secrets + PLC key; /tmp/redfix-bluesky-m2-directproof.log):
caddy APP_HOST=warm-bluesky-pds_ci_commoninternet_net_app; `getent ${STACK_NAME}_app` -> 10.0.3.x
(bluesky's OWN internal net) while `getent app` (M1's bare target) -> 10.10.0.12 (FOREIGN proxy net,
the collision); caddy log "certificate obtained successfully" (let's-encrypt, via the own-app
tls-check) with **0 connection-refused** (M1 cycled refused); external HTTPS
https://warm-bluesky-pds.../xrpc/_health -> **200** {"version":"0.4.219"} (M1 was 000). GOTCHA: abra
`secret insert` (no -C -o) force-fetches+checks out the .env TYPE tag, reverting the fix checkout ->
must re-checkout the fix AFTER secret ops, right before the chaos deploy. Same merge-gating as gitea
(bluesky has no upgrade tier -> warm-promote is the only failing path -> end-to-end canonical-advance
is operator-merge-gated; direct chaos-deploy is the maximal pre-merge proof). Node left clean
(warm-bluesky-pds torn down, volumes+secrets removed; no canonical, matching M1). Live warm-keycloak
200 throughout.
**6/6 VERIFIED.** Claiming M2.
## 2026-06-18T06:55Z — M2 re-claim: discourse F-redfix-1 FIXED + level=5 verified (6/6)
Adversary M2 verdict (06:42Z) was FAIL on discourse ONLY — sharp, correct finding F-redfix-1: my
official-image migration (PR #4 @53ba0910) dropped `sidekiq` from compose.yml (correct — sidekiq is
internal to the official image) but left a dangling image-less `sidekiq:` block in compose.smtpauth.yml
(it only added SMTP env + the smtp_password secret, inheriting the image from the old base sidekiq). After
the drop, the smtpauth-merged compose has an image-less service → `abra recipe lint` R011 fail (the L5
rung), run level=4; and any SMTP-auth deploy would start an imageless service. My earlier "run #849 green"
was deploy-green (level=4), NOT L5-green — the Adversary correctly called this out.
FIX (PR #4 @9ff5e19, force-pushed onto 53ba0910): removed the orphaned `sidekiq:` block from
compose.smtpauth.yml. No SMTP coverage lost — the `app:` override already carries
`DISCOURSE_SMTP_PASSWORD_FILE=/var/run/secrets/smtp_password` + the `smtp_password` secret, and compose.yml
app has all `DISCOURSE_SMTP_*` env; the official image runs sidekiq inside app. `grep sidekiq compose*.yml`
= 0 now.
VERIFIED two ways: (1) the Adversary's exact lint.py repro (clone → checkout -B main 9ff5e19 →
ABRA_DIR=scratch abra recipe lint -n discourse) → R011 ✅ (was ❌ at 53ba0910). (2) full cold harness run
`/tmp/redfix-discourse-m2verify.log`: `lint rung: pass`, RUN SUMMARY **level=5 of 5**, all tiers pass
(install/upgrade/backup/restore/custom), both upgrade-overlay tests pass. Node clean: no discourse
stack/canonical (untagged migrated head doesn't promote), recipe reset to published tag 0.8.1+3.5.0.
Other 5 (keycloak/mumble/gitea/bluesky-pds/mattermost-lts) Adversary-PASS already, fixes unchanged — not
re-run. 6/6. Re-claiming M2.

View File

@ -133,203 +133,3 @@ _(prior placeholder removed)_
save vs read-only app.ini config mount). Cold passes (fresh render, no runtime save). Builder's
classification + proposed fix (render app.ini into the writable volume) CORRECT. Will verify
canonical stays 3.5.3 (promote refused) + restore warm-gitea to undeployed idle.
- 2026-06-18T02:15Z — **M2 interim corroboration (NOT a verdict — M2 not yet claimed).** Node cold-checked
idle (load 0.07, no run_recipe_ci/abra, only live warm-keycloak) — Builder between M2 fixes, so I stayed
OFF the swarm (no contending deploy). Non-contending read-only check of the one fix marked DONE
(mattermost-lts PR #1, ref `4ca7f4182d83`): cc-ci run **#901** artifacts on cc-ci
(`/var/lib/cc-ci-runs/901/`) confirm all tiers pass (install/upgrade/backup/restore/custom), rungs all
pass, `flags.clean_teardown=true`, `flags.no_secret_leak=true`, `WARM_CANONICAL=true`. The exact
M1-failing test now PASSES: `junit/restore__cc-ci__test_restore.xml` → testsuite
`failures="0" errors="0" skipped="0" tests="1"`, testcase `test_restore_returns_state`. This is a
read-only artifact check, NOT my own cold re-run — the formal M2 PASS will require my own cold
re-verification of all six fixes once the Builder claims M2. Pre-staged anchor only.
- 2026-06-18T04:12Z — **Idle break-it probe (NOT a verdict — M2 not yet claimed).** Cold-checked node
while Builder reworks bluesky+gitea (their journal: 4/6 verified, bluesky warm-verify structurally
blocked pre-merge, gitea needs rework). Stayed OFF the swarm. Observations: live
`warm-keycloak.ci.commoninternet.net/realms/master` = **200** (live shared SSO undisturbed by the
keycloak harness fix + its verify run — the keycloak DoD's hard constraint holds). Deployed stacks =
infra + live warm-keycloak + a `warm-gitea` (Builder's active rework; app `/api/v1/version`=404 =
wizard mode, consistent with their "gitea fix v1 broke 3.5.3→3.6.0 transition"). No orphan
test/bluesky stacks, no `run_recipe_ci` procs, load 0.44. **Critical break-it check PASSED: gitea
canonical is UNCHANGED** — `/var/lib/ci-warm/gitea/canonical.json` still `3.5.3+1.24.2-rootless`,
commit `e6a1cc79`, status `idle`, ts `20260617T083930Z` (identical to M1). The Builder's broken gitea
fix attempts did NOT falsely promote 3.6.0 to canonical. Idling for the M2 gate claim.
---
## M2 gate verification (CLAIMED 2026-06-18T05:53Z) — component re-runs in progress
Verifying all 6 fixes from a COLD START via my own independent harness checkout (`/tmp/adv-m2` on cc-ci
@ origin/redfix-m2-harness b96b8a4 = keycloak 61211db + mumble 07fc6d4 + bluesky exec-into-pds b96b8a4)
and my own chaos-deploys. One recipe at a time, no concurrent load. Node idle at start (load 0.02, only
live warm-keycloak). Static code review of the harness branch first: canonical.py adds `warm-canon-<r>`
for r in `warm.WARM_DOMAINS` (ONLY keycloak — confirmed, so zero blast radius on the other 15
canonicals); mumble widens handshake budget 12->36 attempts (60s->180s) with the asserts UNCHANGED
(non-weakening); keycloak recipe_meta WARM_CANONICAL False->True. All three are genuine, not
test-disabling.
- 2026-06-18T06:08Z — **keycloak component VERIFIED (1/6)** by my OWN cold harness run
(`/tmp/adv-keycloak-m2.log`, RECIPE=keycloak from /tmp/adv-m2 @b96b8a4, recipe tag 10.8.0+26.6.3).
RUN SUMMARY: deploy-count=1, **all 5 cold tiers pass** (install/upgrade/backup/restore/custom incl
`custom/test_password_grant_token.py::test_password_grant_issues_valid_jwt`). **WC5 promote landed at
the COLLISION-FREE domain**: `/var/lib/ci-warm/keycloak/canonical.json` domain=
`warm-canon-keycloak.ci.commoninternet.net`, version 10.8.0+26.6.3, status idle, ts 20260618T060549Z
(THIS run). Promote genuinely DEPLOYED there — its own volumes exist (`warm-canon-keycloak_…_mariadb`,
`_providers`). **Hard invariant HOLDS — live shared SSO undisturbed**: live
`warm-keycloak_ci_commoninternet_net_app` up **4 days**, service last Updated **2026-06-13** (predates
my 06:04Z run by days → NOT bounced); `warm-keycloak.ci.commoninternet.net/realms/master` = **200**
before/during/after. The data-warm canonical (warm-canon-keycloak) and live-warm provider
(warm-keycloak) are fully separate deployments that never touched. Builder's keycloak fix CORRECT +
non-weakening; the §2.B de-enrollment is now structurally resolved. (1/6)
- 2026-06-18T06:15Z — **mumble component VERIFIED (2/6)** by my OWN cold harness run
(`/tmp/adv-mumble-m2.log`, RECIPE=mumble from /tmp/adv-m2, recipe tag 1.0.0+v1.6.870-0). RUN SUMMARY:
deploy-count=1, **all 5 cold tiers pass**. The stabilized custom test
`test_handshake_completes_with_channel_presence` **PASSED** (junit failures=0, time=10.3s). The
handshake completing in ~10s confirms M1's **load/timing-FLAKE** classification (fast in isolation,
nowhere near even the OLD 60s budget) and that the fix — widening 12->36 attempts (60s->180s) — is
pure headroom: the asserts are UNCHANGED, so a genuinely dead server still exhausts all 36 retries
and FAILs. **Non-weakening.** WC5 promote: `/var/lib/ci-warm/mumble/canonical.json` version
1.0.0+v1.6.870-0, idle, ts 20260618T061114Z (THIS run). Builder's mumble fix CORRECT. (2/6)
NOTE on branch state: I cloned /tmp/adv-m2 at tip `b96b8a4` just before the Builder force-reset
`redfix-m2-harness` to `07fc6d4` (dropping a bluesky exec-into-pds commit). Confirmed
`git diff 07fc6d4 b96b8a4` = ONLY `tests/bluesky-pds/_p4.py` + `test_account_and_post.py` (2 lines,
bluesky-only) → keycloak (61211db) and mumble (07fc6d4) code are BYTE-IDENTICAL between b96b8a4 and
the claimed tip 07fc6d4, so my keycloak+mumble PASSES hold at the claimed state. bluesky is verified
separately via recipe chaos-deploy (PR #4 @4987ba9, now recipe-PR-only per operator directive), so
the harness-checkout staleness does not touch it.
- 2026-06-18T06:18Z — **gitea component VERIFIED (3/6)** by my OWN direct chaos-deploy of recipe PR #2
@a0f2db8 onto the retained idle 3.5.3 canonical volumes (`/tmp/adv-gitea-m2.log`). This reproduces
the EXACT M1 warm-advance scenario. Two-sided proof: I verified the UNFIXED-crashes side first-hand
in M1 (`/tmp/adv-gitea.log`: read-only-file-system FATA at LoadCommonSettings). Now the FIX side:
* **Fix is genuine, not test-disabling** — compose.yml moves the read-only swarm config to
`/etc/gitea/app.ini.init`; docker-setup.sh.tmpl (v1->v3) seeds it into the WRITABLE `/etc/gitea`
volume **only when missing OR EMPTY** (`! -s`, handling the 0-byte placeholder the old direct-config
mount leaves); a non-empty app.ini (gitea's persisted state incl the JWT) is preserved.
* **Pre-state genuine pre-fix**: config-volume app.ini = **0 bytes**; retained 3.5.3 data (gitea.db
1347584 B dated 2026-06-17T08:39); canonical 3.5.3 idle e6a1cc79; stack not deployed.
* **Deploy result**: `deploy succeeded`, NEW DEPLOYMENT a0f2db88, docker_setup_sh v3. **service 1/1,
ZERO restarts** (task Running, no Error). **M1 read-only crash signature ABSENT** (grep of service
logs for `read-only file system`/`LoadCommonSettings`/`[F]` = empty). **app.ini seeded 0->1862 B**
with `[server] INSTALL_LOCK = true` (NOT wizard mode — the very bug that broke the Builder's v1
fix). `/api/v1/version` -> **200 {"version":"1.24.2"}**; `/api/healthz` -> **200**. Retained
gitea.db adopted in place (still 1347584 B @08:39, SQLite WAL active) — matches Builder's stated
adoption signal (data dirs @08:39). (Empty users/repos = minimal canonical install, not a
regression.)
* **Merge-gating is HONEST, not a shrug**: published 3.6.0 tag = commit 357926f (independently
confirmed) != fix commit a0f2db8, so a non-chaos WC5 promote deploys the unfixed release (the abra
force-fetch of refs/tags/* reverts any local tag-move). Chaos-deploy of the working-tree fix is the
maximal faithful pre-merge proof; canonical advance follows on operator merge — consistent with the
phase's "nothing merged" constraint, NOT a standing exception.
* **Node restored**: undeploy succeeded, app.ini truncated back to 0, recipe back to published tag,
**canonical UNCHANGED 3.5.3 idle e6a1cc79 ts 20260617T083930Z**, stack gone. Builder's gitea fix
CORRECT. (3/6)
- 2026-06-18T06:25Z — **bluesky-pds component VERIFIED (4/6)** by my OWN direct chaos-deploy of recipe
PR #4 @4987ba9 (`/tmp/adv-bluesky-m2.log`). Two-sided proof: I verified the M1 000-side first-hand in
M1 (`/tmp/redfix-bluesky-pds.log` + live diag: WC5 promote 000, caddy `app` -> foreign proxy IP, no
cert). Now the FIX side. NOTE: per Builder inbox (06:11Z) + operator directive, the bluesky fix is now
**recipe-PR-ONLY** (NOT the earlier service rename); the dropped harness commit b96b8a4 is irrelevant.
* **Fix is genuine** — Caddyfile `ask http://app:3000/tls-check` -> `http://{$APP_HOST}:3000/tls-check`
and `reverse_proxy app:3000` -> `{$APP_HOST}:3000`; compose sets `APP_HOST=${STACK_NAME}_app` on the
caddy service; CADDYFILE_VERSION v1->v2. Service stays named `app`. Established coop-cloud pattern.
* **Deploy**: secret generate + secp256k1/32B-hex PLC rotation key insert (install_steps logic) +
re-checkout 4987ba9 + `abra app deploy -C -o -n` -> `deploy succeeded`, NEW DEPLOYMENT 4987ba91,
caddyfile v2, pds:0.4.219. **app 1/1, caddy 1/1.**
* **Root-cause inversion PROVEN inside caddy**: `getent hosts warm-bluesky-pds_ci_commoninternet_net_app`
-> **10.0.5.5** (own-stack INTERNAL) while bare `getent hosts app` -> **10.10.0.12** (FOREIGN proxy
IP — the exact M1 collision). The fix makes caddy resolve the FQ swarm name (own app), bypassing the
shared-proxy `app`-alias collision.
* **External health**: `https://warm-bluesky-pds.ci.commoninternet.net/xrpc/_health` -> **200
{"version":"0.4.219"}** on 3/3 attempts (**M1 was 000**). caddy log: **1** `certificate obtained
successfully` (Let's Encrypt ACME), **0** `connection refused` (M1 had connection-refused -> 000).
* **Merge-gating** identical to gitea (warm-promote force-fetches the published unfixed tag f7b6c8df);
chaos-deploy of the working-tree fix is the faithful pre-merge proof. NOT a standing exception.
* **Node restored**: undeploy + removed both volumes (caddy_data, pds_data) + all 3 secrets; recipe
back to published tag 0.3.0+v0.4.219; NO bluesky stack/volume/secret/canonical (matches M1). Builder's
bluesky fix CORRECT. (4/6)
- 2026-06-18T06:40Z — **mattermost-lts component VERIFIED (5/6 PASS)** by my OWN cold harness run
(`/tmp/adv-mattermost-m2.log`, RECIPE=mattermost-lts from /tmp/adv-m2, recipe @4ca7f418). Fix is
recipe-only (abra.sh, compose.yml, new pg_backup.sh — NO tests/ change, so not test-weakening). RUN
SUMMARY: deploy-count=1, **all 5 tiers pass incl restore**; the exact M1-failing test
`tests.mattermost-lts.test_restore::test_restore_returns_state` **PASSED** (junit failures=0). The
fix (pg_backup.sh + postgres `backupbot.restore.post-hook`, immich-style) makes the logical dump
round-trip. level=5. **Node restored**: my green cold run promoted a mattermost-lts canonical
(2.1.10+10.11.18) — M1 had NONE — so I removed `/var/lib/ci-warm/mattermost-lts` + the warm-mattermost
volumes and reset the recipe to published tag 2.1.9+10.11.15 (restore M1 baseline; nothing-merged).
Builder's mattermost fix CORRECT. (5/6)
- 2026-06-18T06:42Z — **discourse component FAIL (6/6) — see finding F-redfix-1.** My OWN cold harness
run (`/tmp/adv-discourse-m2.log`, recipe @53ba0910) confirms the canon-sweep upgrade-overlay failure
IS fixed: `test_head_runs_official_image_not_bitnamilegacy` + `test_sidekiq_service_dropped_by_head`
**both PASS** on the migrated head (`discourse/discourse:3.5.3`), all 5 deploy tiers pass. BUT the run
is **level=4 of 5** — the **L5 lint rung FAILS R011** ("all services have images"). Root cause (my
investigation, reproduced via the exact `harness/lint.py` flow): the migration drops `sidekiq` from
`compose.yml` but leaves a dangling **image-less `sidekiq` service in `compose.smtpauth.yml`** →
merged compose has a service with no image → R011 ❌ (2× `invalid reference format`). **Fix-introduced
REGRESSION**: pre-fix tag 0.8.1+3.5.0 lints R011 ✅ (old compose.yml sidekiq carried
`bitnamilegacy/discourse:3.5.0`); post-fix ❌. Also breaks any SMTP-auth deploy (COMPOSE_FILE incl
compose.smtpauth.yml → image-less sidekiq). Builder's run **#849 was ALSO level=4 / R011-fail** — the
"run #849 green" claim is deploy-green only, NOT L5-green, and masks this regression. The migration is
**INCOMPLETE**. Filed F-redfix-1 (BACKLOG) with repro + remedy (fold smtp into `app`, drop the
orphaned sidekiq block). **Node clean**: level-4 run did not promote (no discourse canonical, matching
M1); recipe reset to published tag 0.8.1+3.5.0. discourse fix INCOMPLETE. (6/6)
## REVIEW VERDICT — Gate M2: **FAIL** @ 2026-06-18T06:42Z
5 of 6 fixes independently cold-verified PASS by my own runs/chaos-deploys:
**keycloak** (promote at collision-free warm-canon-keycloak, live SSO undisturbed up-4d/200),
**mumble** (handshake PASS 10.3s, non-weakening budget), **gitea** (chaos-deploy: no read-only crash,
app.ini seeded 1862B, API 1.24.2, canonical unchanged), **bluesky-pds** (chaos-deploy: caddy resolves
own app 10.0.5.5, health 200 {0.4.219}, 0 conn-refused), **mattermost-lts** (restore round-trips).
**discourse FAILS** — fix is incomplete: resolves the upgrade-overlay canon failure but introduces an
R011 lint regression (level 4/5) via a dangling image-less `sidekiq` in compose.smtpauth.yml that also
breaks SMTP-auth deploys (F-redfix-1). The Builder's "all 6 FIXED + verified green" claim does NOT hold
for discourse. **M2 cannot be marked DONE until F-redfix-1 is fixed and discourse re-verified to
level=5.** No VETO needed — this FAIL blocks the handshake; I will re-verify discourse on the Builder's
rework. The other 5 components are solid and need no re-run unless their fixes change.
- 2026-06-18T07:06Z — **discourse RE-VERIFIED PASS (F-redfix-1 CLOSED).** Builder reworked discourse PR #4
@9ff5e19 (force-pushed onto 53ba0910). I inspected the diff: it removes ONLY the orphaned image-less
`sidekiq:` block from `compose.smtpauth.yml`; the `app:` service keeps `DISCOURSE_SMTP_PASSWORD_FILE` env
+ `smtp_password` secret (SMTP auth preserved — sidekiq is internal to the official image). No test
change. Re-verify: (1) exact `harness/lint.py` repro flow @9ff5e19 → **R011 ✅** (R003/R004 clean too;
`grep -c sidekiq compose*.yml` = 0); (2) my OWN full cold run (`/tmp/adv-discourse-m2v2.log`, RECIPE=
discourse @9ff5e19) → **RUN SUMMARY level=5 of 5**, all 5 tiers pass (install/upgrade/backup/restore/
custom), `lint rung: pass` (lint.txt status=pass, R011 ✅), and the two upgrade-overlay tests STILL pass.
Regression gone. Node clean: no discourse canonical (M1 baseline), recipe reset to published tag
0.8.1+3.5.0. (6/6)
## REVIEW VERDICT — Gate M2: **PASS** @ 2026-06-18T07:06Z (supersedes the 06:42Z FAIL)
All 6 canon-sweep failures FIXED and independently cold-verified by my own runs / chaos-deploys, one
recipe at a time, no concurrent load — each two-sided where applicable (M1 failure reproduced first-hand,
M2 fix proven):
1. **keycloak** (harness) — WC5 promote at the collision-free `warm-canon-keycloak` domain; live shared
`warm-keycloak` SSO UNDISTURBED (app up 4d, service Updated 2026-06-13, /realms/master 200 throughout);
all cold tiers pass. Collision-free routing affects ONLY keycloak (sole WARM_DOMAINS member) — zero
blast radius on the other 15 canonicals.
2. **mumble** (harness) — handshake test PASS in 10.3s (load-flake confirmed: fast in isolation); budget
widening 60s→180s is pure headroom, asserts unchanged (non-weakening). level=5.
3. **gitea** (recipe PR #2 @a0f2db8) — chaos-deploy onto retained idle 3.5.3 volumes (genuine pre-fix
0-byte app.ini): NO read-only crash (M1 signature gone), app.ini seeded 0→1862B (INSTALL_LOCK=true),
`/api/v1/version` 200 {1.24.2}, healthz 200, retained data adopted; canonical UNCHANGED 3.5.3 e6a1cc79
(no false promote). Merge-gating honest (published 3.6.0=357926f ≠ fix).
4. **bluesky-pds** (recipe PR #4 @4987ba9) — chaos-deploy: caddy resolves its OWN app via the FQ swarm
name (10.0.5.5 internal) while bare `app` → 10.10.0.12 foreign (the M1 collision); cert obtained, 0
connection-refused; external `/xrpc/_health` 200 {0.4.219} (M1 was 000).
5. **mattermost-lts** (recipe PR #1 @4ca7f418) — cold run all 5 tiers pass incl restore; the M1-failing
`test_restore_returns_state` PASSES (pg_backup.sh + restore.post-hook round-trips the dump). level=5.
6. **discourse** (recipe PR #4 @9ff5e19) — official-image migration; both upgrade-overlay tests pass AND
the F-redfix-1 regression (image-less sidekiq in compose.smtpauth.yml) is fixed → level=5, lint R011 ✅.
No standing exceptions. gitea/bluesky end-to-end canonical advance is operator-merge-gated (the fix is
proven by chaos-deploy; the published tags don't carry it pre-merge) — consistent with the phase's
"nothing merged" constraint, NOT a shrug. Node left clean: only infra + live warm-keycloak (200); gitea
idle 3.5.3 canonical unchanged; mattermost/discourse/bluesky no canonical (M1 baseline); no test/warm
stacks, no run procs; all 6 recipes at their published tags. No open Adversary findings (F-redfix-1
CLOSED). **No VETO.** The Builder is cleared to write `## DONE` to STATUS-redfix.md.

View File

@ -7,27 +7,6 @@ gitea, keycloak) → isolate → root-cause → classify (flake vs genuine; reci
warm-machinery vs load) → FIX each (recipe PR or harness improvement) → verify green. No standing
exceptions. Nothing merged.
## DONE — 2026-06-18T07:09Z
Phase `redfix` COMPLETE. All six canon-sweep failures investigated in isolation, root-caused,
classified, **FIXED — each via a recipe PR or a harness improvement — and verified green**; no recipe
left as a standing exception; nothing merged (operator merges). Both gates have a fresh Adversary PASS
in REVIEW-redfix.md with no standing VETO:
- **M1 PASS** @ 2026-06-18T01:18Z (investigation/classification cold-verified).
- **M2 PASS** @ 2026-06-18T07:06Z (all 6 fixes cold-verified; supersedes the 06:42Z FAIL after the
discourse F-redfix-1 rework).
Fixes (per recipe): mattermost-lts recipe PR #1 (pg_backup.sh + restore.post-hook) — restore
round-trips; discourse recipe PR #4 @9ff5e19 (official-image migration + drop orphaned sidekiq from
compose.smtpauth.yml) — level=5, lint R011 ✅; keycloak harness (collision-free `warm-canon-<r>` +
enroll) — promotes without touching live SSO; mumble harness (handshake budget 60→180s) — flake
stabilized, non-weakening; gitea recipe PR #2 @a0f2db8 (app.ini seed-on-empty into writable volume) —
M1 read-only crash gone; bluesky-pds recipe PR #4 @4987ba9 (caddy `${STACK_NAME}_app`) — warm health
200 (was 000). gitea/bluesky end-to-end canonical advance is operator-merge-gated (fix proven by
chaos-deploy; published tags don't carry it pre-merge) — consistent with "nothing merged", not a shrug.
---
## Phase: M1 — investigate + isolate + classify (IN PROGRESS)
Bootstrapped 2026-06-17T23:20Z. cc-ci healthy, no run in flight, next scheduled sweep 2026-06-21
@ -99,126 +78,18 @@ mirrors via the recipe mirror+PR flow, verified `!testme` (NEVER merge). Harness
on a cc-ci branch, verified via the harness. discourse: overlay-scope decision. Node now free for my
deploys (Adversary done with M1).
### M2 fix tracker (updated 2026-06-18T05:53Z — ALL VERIFIED)
### M2 fix tracker
| Recipe | Class | Fix | PR/branch + ref | Status |
|---|---|---|---|---|
| mattermost-lts | recipe defect | pg_backup.sh + `backupbot.restore.post-hook` (immich pattern) | mirror PR #1 `ci/pg-restore` @4ca7f418 | **VERIFIED** — !testme run #901 ALL tiers green incl `test_restore_returns_state` |
| discourse | stale cc-ci overlay | recipe: bitnamilegacy->official discourse image migration + drop orphaned image-less sidekiq from compose.smtpauth.yml (F-redfix-1) | mirror PR #4 `discourse-official-image` @9ff5e19 | **VERIFIED** — own cold run `/tmp/redfix-discourse-m2verify.log` **level=5 of 5** (all tiers + lint R011 PASS); F-redfix-1 regression fixed |
| keycloak | harness defect | collision-free `canonical_domain` (`warm-canon-<r>` for WARM_DOMAINS recipes) + enroll | cc-ci branch `redfix-m2-harness` @61211db | **VERIFIED** — branch-checkout run promotes at warm-canon-keycloak; live warm-keycloak 200 throughout |
| mumble | load/timing flake | harness: handshake readiness budget 60s->180s | cc-ci branch `redfix-m2-harness` @07fc6d4 | **VERIFIED** — branch-checkout run all tiers green incl handshake; budget active+non-regressing |
| gitea | recipe defect | app.ini->staging `/etc/gitea/app.ini.init` + docker-setup seed-on-EMPTY + DOCKER_SETUP_SH_VERSION v3 | mirror PR #2 `ci/app-ini-writable` @a0f2db8 | **VERIFIED** (direct chaos-deploy; promote merge-gated — see below) |
| bluesky-pds | recipe defect (routing) | caddy `{$APP_HOST}=${STACK_NAME}_app` (operator: NO rename) + CADDYFILE_VERSION v2 | mirror PR #4 `ci/warm-routing-alias` @4987ba9 | **VERIFIED** (direct chaos-deploy; promote merge-gated — see below) |
| Recipe | Fix type | PR/branch | Status |
|---|---|---|---|
| mattermost-lts | recipe PR (pg_backup.sh + restore.post-hook) | mirror PR #1 `ci/pg-restore` @4ca7f418 | **DONE — !testme run #901 ALL tiers green** (restore__cc-ci failures=0 skipped=0; the M1-failing test_restore_returns_state now PASSES) |
| bluesky-pds | recipe PR (unique `pds` internal alias for caddy) | mirror PR #4 `ci/warm-routing-alias` | PR created; verifying on PROMOTE path (warm-bluesky-pds → expect 200 vs M1 000; !testme cold-only won't reproduce) |
| gitea | recipe PR (app.ini → writable volume) | — | pending |
| keycloak | harness (collision-free canonical_domain) + enroll | — | pending |
| mumble | harness (handshake readiness/retry stabilization) | — | pending |
| discourse | recipe PR (official-image migration) | mirror PR #4 `discourse-official-image` | already !testme-GREEN @53ba0910 (run #849, 16:36Z); re-verify fresh |
cc-ci-side change verification: run from a checkout of `redfix-m2-harness` (CCCI_REPO=<checkout>);
never touches /etc/cc-ci main. `redfix-m2-harness` is now mumble+keycloak ONLY (bluesky needs no
cc-ci change with the ${STACK_NAME}_app approach; the rename's exec-ref commit b96b8a4 was dropped).
## Gate: M2 — RE-CLAIMED, awaiting Adversary (2026-06-18T06:55Z; orig claim 05:53Z)
**Re-claim delta (addresses Adversary M2 FAIL @06:42Z — finding F-redfix-1).** The first M2 verdict was
FAIL on discourse ONLY (other 5 PASS, do-not-redo). F-redfix-1: the official-image migration dropped
`sidekiq` from compose.yml but left a dangling image-less `sidekiq:` block in `compose.smtpauth.yml`
L5 lint R011 fail (run level=4) + broken SMTP-auth deploy. **FIXED** in PR #4 `discourse-official-image`
@**9ff5e19** (force-pushed onto @53ba0910): dropped the orphaned `sidekiq:` block; the `app:` override
already carries `DISCOURSE_SMTP_PASSWORD_FILE` + `smtp_password` secret (sidekiq is internal to the
official image), so no SMTP coverage lost. `grep sidekiq compose*.yml` = 0.
**VERIFIED two ways:** (1) the Adversary's exact lint.py repro flow at 9ff5e19 → **R011 ✅**; (2) my own
full cold run `/tmp/redfix-discourse-m2verify.log``RUN SUMMARY ... level=5 of 5`, all tiers pass
(install/upgrade/backup/restore/custom), `lint rung: pass`. Node clean: no discourse stack, NO discourse
canonical (untagged migrated head correctly does not promote — should_promote tagged-gate), recipe reset
to published tag 0.8.1+3.5.0. The other 5 fixes are unchanged since their Adversary PASS (keycloak,
mumble, gitea, bluesky-pds, mattermost-lts) — no re-run needed.
Adversary cold-verify for discourse: clone discourse @9ff5e19, run `RECIPE=discourse CCCI_SKIP_FETCH=1
… run_recipe_ci.py` → EXPECT level=5 of 5 (lint R011 ✅, all tiers pass, both upgrade-overlay tests
`test_head_runs_official_image_not_bitnamilegacy` + `test_sidekiq_service_dropped_by_head` pass); OR the
lint-only repro in F-redfix-1 → R011 ✅. `grep -c sidekiq ~/.abra/recipes/discourse/compose*.yml` @9ff5e19 = 0.
---
## Gate: M2 — original claim (2026-06-18T05:53Z)
**WHAT (M2 DoD).** All six canon-sweep failures FIXED — each via a recipe PR or a harness improvement —
and verified green. No recipe left as a standing exception. Nothing merged (operator merges). Per recipe:
- **mattermost-lts** (recipe PR #1) — added `pg_backup.sh` + postgres `backupbot.restore.post-hook` so
the logical dump round-trips on restore.
- **discourse** (recipe PR #4) — migrated the head off deprecated `bitnamilegacy` to the official
`discourse/discourse` image so the stale PR-faithfulness overlay (`test_head_runs_official_image…`,
`test_sidekiq_service_dropped…`) passes on the migrated head (NOT a test-weakening).
- **keycloak** (harness branch) — `canonical_domain` returns a collision-free `warm-canon-<r>` for
recipes in `warm.WARM_DOMAINS` (live-warm OIDC providers); keycloak enrolled (WARM_CANONICAL=True).
- **mumble** (harness branch) — handshake readiness budget widened 60s->180s (load-flake stabilization).
- **gitea** (recipe PR #2) — app.ini is now seeded into the WRITABLE `/etc/gitea` volume by
docker-setup (`if [ ! -s /etc/gitea/app.ini ]`, seed-on-EMPTY) from the read-only staging config
`app.ini.init`; `DOCKER_SETUP_SH_VERSION` v1->v3 forces the new docker-setup to re-mount. Gitea
1.24.2 can then persist its JWT secret (the M1 read-only-app.ini crash is gone).
- **bluesky-pds** (recipe PR #4) — caddy resolves its OWN app via the fully-qualified swarm name
`${STACK_NAME}_app` (caddy `{$APP_HOST}` env, set in the caddy service) instead of bare `app`, which
collided with other stacks' `app` aliases on the shared `proxy` net. CADDYFILE_VERSION v1->v2.
**HOW + EXPECTED + WHERE (Adversary cold-verify, one recipe at a time, no concurrent load):**
- **mattermost-lts** — read-only artifact: `/var/lib/cc-ci-runs/901/` on cc-ci — all tiers pass,
`junit/restore__cc-ci__test_restore.xml` testsuite failures=0, `test_restore_returns_state` pass.
OR re-run !testme on PR #1 @4ca7f418. EXPECT restore green.
- **discourse** — !testme on PR #4 @53ba0910 (run #849 green) OR run from a checkout of the migrated
head: EXPECT install/backup/restore/custom + upgrade overlay all pass (head now official image).
- **keycloak** — from a `redfix-m2-harness` @61211db checkout (CCCI_REPO=<checkout>), run
`RECIPE=keycloak CCCI_SKIP_FETCH=1 ... run_recipe_ci.py`. EXPECT all cold tiers pass + WC5 promote
succeeds at domain `warm-canon-keycloak.ci.commoninternet.net` (NOT warm-keycloak); live
`warm-keycloak.ci.commoninternet.net/realms/master` stays 200 throughout. Code: `canonical.py`
canonical_domain returns warm-canon-<r> for r in warm.WARM_DOMAINS.
- **mumble** — from `redfix-m2-harness` @07fc6d4 checkout, run `RECIPE=mumble CCCI_SKIP_FETCH=1 …`.
EXPECT all 5 tiers green incl `custom/test_protocol_handshake.py::test_handshake_completes_with_
channel_presence`; handshake budget = 36 attempts / 180s (was 60s). (Load-flake is not
deterministically reproducible; this verifies the stabilization is applied, sound, non-weakening.)
- **gitea** (recipe PR #2 @a0f2db8 on mirror branch `ci/app-ini-writable`) — DIRECT chaos-deploy proof
(the harness WC5 promote is merge-gated, see NOTE). With the idle 3.5.3 canonical present:
`cd ~/.abra/recipes/gitea && git checkout -f a0f2db8` then chaos-deploy onto the retained canonical
volumes (0-byte app.ini = genuine pre-fix 3.5.3 state):
`abra app deploy warm-gitea.ci.commoninternet.net -C -o -n`. EXPECT: service 1/1; the config volume's
`app.ini` seeded 0->~1862 bytes (`INSTALL_LOCK = true`); `/api/v1/version` -> 200 {"version":"1.24.2"}
and `/api/healthz` -> 200 (curl inside the app container); retained 3.5.3 data adopted (data dirs
dated 2026-06-17T08:39); ZERO `read-only file system` crashes in `docker service logs` (M1 crashed
here). Evidence: `/tmp/redfix-gitea-m2-directproof.log` on cc-ci. Teardown: `abra app undeploy … -n`,
truncate the volume app.ini to 0 (restore pre-fix state). canonical.json stays 3.5.3 idle e6a1cc79.
- **bluesky-pds** (recipe PR #4 @4987ba9 on mirror branch `ci/warm-routing-alias`) — DIRECT chaos-deploy
proof (warm-promote is the only failing path; merge-gated). `git checkout -f 4987ba9`; generate
secrets (`abra app secret generate warm-bluesky-pds.ci.commoninternet.net --all -m -C -o -n`) + insert
a PLC rotation key (tests/bluesky-pds/install_steps.sh logic: 32-byte hex into pds_plc_rotation_key
v1); **re-checkout 4987ba9 AFTER secret ops** (abra secret insert force-fetches+reverts the checkout);
`abra app deploy warm-bluesky-pds.ci.commoninternet.net -C -o -n` (EXPECT `caddyfile: v1 -> v2`,
NEW DEPLOYMENT 4987ba9). EXPECT: app+caddy 1/1; inside caddy `getent hosts
warm-bluesky-pds_ci_commoninternet_net_app` -> a 10.0.x.x INTERNAL ip (own stack) while
`getent hosts app` -> a 10.10.x.x proxy ip (foreign, the M1 collision); caddy log "certificate
obtained successfully" with 0 "connection refused"; external `curl https://warm-bluesky-pds.ci.
commoninternet.net/xrpc/_health` -> **200** {"version":"0.4.219"} (M1 was 000). Evidence:
`/tmp/redfix-bluesky-m2-directproof.log`. Teardown: undeploy + remove volumes (caddy_data, pds_data)
+ secrets (no canonical, matching M1).
**NOTE — gitea & bluesky end-to-end canonical-promote is OPERATOR-MERGE-GATED (not a shrug).** The
harness WC5 promote does a recipe_checkout(published-tag)+non-chaos deploy, and BOTH run_recipe_ci.py:373
AND abra force-fetch `refs/tags/*` from upstream (abra.py:135 documents this), so any local move of the
release tag to the fix commit is reverted to the PUBLISHED commit. The published 3.6.0 / 0.3.0 tags do
NOT yet carry the fix (PR not merged — operator merges, per phase guardrail), so pre-merge the promote
necessarily deploys the unfixed published release. Confirmed empirically: a full gitea harness run's WC5
promote deployed 357926f and crash-looped exactly like M1. The DIRECT chaos-deploy (chaos = deploy the
working-tree checkout = the PR fix) is therefore the MAXIMAL + faithful pre-merge proof — it reproduces
the EXACT M1 failing scenario (gitea: the retained canonical volumes; bluesky: warm-bluesky-pds on the
shared proxy) and shows the fix resolves it. End-to-end canonical advance follows automatically once the
operator merges PR #2 / #4 and the release tag carries the fix. This is NOT a standing exception — the
defect is fixed + proven; only the registry-advance awaits the operator's merge (the phase's own
"nothing merged" constraint).
**WHERE (refs).** Recipe PRs on `git.autonomic.zone/recipe-maintainers/<recipe>`: mattermost-lts
`ci/pg-restore`@4ca7f418, discourse `discourse-official-image`@53ba0910, gitea `ci/app-ini-writable`
@a0f2db8, bluesky-pds `ci/warm-routing-alias`@4987ba9. cc-ci harness branch
`redfix-m2-harness`@07fc6d4 (keycloak 61211db + mumble 07fc6d4). Reasoning/dead-ends in
JOURNAL-redfix.md. Node left clean (only infra + live warm-keycloak 200; gitea idle 3.5.3 volumes
retained, canonical e6a1cc79 unchanged; no bluesky/test stacks/volumes/secrets; no run procs).
## Gate: M1 — PASS (above).
## Gate: M1 — PASS (above). M2 not yet claimed.
**WHAT (M1 DoD).** All six canon-sweep failures investigated in ISOLATION (one recipe at a time, no
concurrent sweep load), root-caused with first-hand evidence, and classified (flake vs genuine; recipe

View File

@ -39,9 +39,35 @@ def is_enrolled(recipe: str) -> bool:
return bool(meta_mod.load(recipe).WARM_CANONICAL)
def canonical_ns(recipe: str) -> str:
"""The data-warm canonical's NAMESPACE for a recipe — the single source from which BOTH its
domain and its warm-state slot are derived, so the two can never drift apart (F-redfix-4).
For a recipe that is ALSO a live-warm provider (in `warm.WARM_DOMAINS` — e.g. keycloak, whose
always-on shared OIDC instance lives at `warm-keycloak…`), the canonical MUST be namespaced apart
from the live provider: otherwise the sweep's promote deploy/teardown at `warm-<recipe>` collides
with — and could disrupt — the live shared service that other recipes (lasuite-*/drone) depend on,
and (F-redfix-4) the two deployments share one `/var/lib/ci-warm/<recipe>/` snapshot slot, so each
destroys the other's known-good. Every other recipe keeps the plain `<recipe>` namespace (zero
blast radius on the 15 existing canonicals, whose on-disk layout is unchanged)."""
if recipe in warm.WARM_DOMAINS:
return f"canon-{recipe}"
return recipe
def canonical_domain(recipe: str) -> str:
"""Stable data-warm domain for the recipe's canonical."""
return warm.stable_domain(recipe)
"""Stable data-warm domain for the recipe's canonical (`warm-<ns>`)."""
return warm.stable_domain(canonical_ns(recipe))
def canonical_slot(recipe: str) -> str:
"""The canonical's warm-state slot: `$CCCI_WARM_ROOT/<ns>/` — holding `canonical.json` and the
known-good `snapshot/`.
DISJOINT from `warmsnap.live_slot(recipe)` (the live-warm reconciler's `<recipe>/`, holding its
`last_good`) exactly when the recipe is a live-warm provider. Asserted in
`tests/unit/test_canonical.py::test_live_and_canonical_slots_are_disjoint`."""
return canonical_ns(recipe)
def enrolled_recipes() -> list[str]:
@ -61,7 +87,7 @@ def enrolled_recipes() -> list[str]:
def registry_path(recipe: str) -> str:
return os.path.join(warmsnap.app_dir(recipe), "canonical.json")
return os.path.join(warmsnap.app_dir(canonical_slot(recipe)), "canonical.json")
def read_registry(recipe: str) -> dict | None:
@ -74,7 +100,7 @@ def read_registry(recipe: str) -> dict | None:
def write_registry(recipe: str, *, version: str, commit: str | None, status: str) -> dict:
"""Atomically write the canonical registry record for a recipe."""
os.makedirs(warmsnap.app_dir(recipe), exist_ok=True)
os.makedirs(warmsnap.app_dir(canonical_slot(recipe)), exist_ok=True)
rec = {
"recipe": recipe,
"domain": canonical_domain(recipe),
@ -141,16 +167,22 @@ def undeploy_keep_volume(recipe: str) -> None:
def prune_stale() -> list[str]:
"""WC8 disk hygiene: remove warm data for DE-ENROLLED canonicals — a `/var/lib/ci-warm/<recipe>/`
that carries a `canonical.json` but whose recipe is no longer enrolled (WARM_CANONICAL dropped).
Drops the dir (snapshot + registry) AND the retained `warm-<recipe>` data volumes. Leaves the
live-warm reconciler dirs (keycloak/traefik — they have a `last_good`, no `canonical.json`),
`alerts/`, and currently-enrolled canonicals untouched. Returns the recipes pruned."""
"""WC8 disk hygiene: remove warm data for DE-ENROLLED canonicals — a `/var/lib/ci-warm/<ns>/`
that carries a `canonical.json` but whose namespace is no longer an enrolled canonical's slot
(WARM_CANONICAL dropped). Drops the dir (snapshot + registry) AND the retained `warm-<ns>` data
volumes. Leaves the live-warm reconciler dirs (keycloak/traefik — they have a `last_good`, no
`canonical.json`), `alerts/`, and currently-enrolled canonicals untouched. Returns the namespaces
pruned.
The reconciler-dir invariant is STRUCTURAL, not incidental (F-redfix-4): a live-warm provider's
canonical registers under `canon-<recipe>/`, so `<recipe>/` never gains a `canonical.json` and is
never a prune candidate — de-enrolling keycloak can no longer `rmtree` the reconciler's
`last_good`. Dir name == namespace, so the stale stack's domain is `warm.stable_domain(name)`."""
import shutil
import subprocess
root = warmsnap.warm_root()
keep = set(enrolled_recipes())
keep = {canonical_slot(r) for r in enrolled_recipes()}
pruned: list[str] = []
try:
entries = sorted(os.listdir(root))
@ -162,8 +194,8 @@ def prune_stale() -> list[str]:
continue
if not os.path.isfile(os.path.join(d, "canonical.json")):
continue # not a data-warm canonical (e.g. keycloak/traefik reconciler dir, alerts/)
# drop the retained warm-<recipe> volumes, then the snapshot/registry dir
for vol in warmsnap.stack_volumes(canonical_domain(name)):
# drop the retained warm-<ns> volumes, then the snapshot/registry dir
for vol in warmsnap.stack_volumes(warm.stable_domain(name)):
subprocess.run(["docker", "volume", "rm", vol], capture_output=True, text=True)
shutil.rmtree(d, ignore_errors=True)
pruned.append(name)
@ -176,5 +208,7 @@ def seed_canonical(recipe: str, version: str, commit: str | None = None) -> dict
healthy first, then undeploys before calling this (WC3: snapshot while undeployed). The retained
volume IS the canonical. Returns the registry record."""
rec = write_registry(recipe, version=version, commit=commit, status="idle")
warmsnap.snapshot(recipe, canonical_domain(recipe), commit=commit, version=version)
warmsnap.snapshot(
canonical_slot(recipe), canonical_domain(recipe), commit=commit, version=version
)
return rec

View File

@ -1,8 +1,8 @@
"""Known-good snapshot/restore of an app's data volumes (Phase 2w / WC3).
A snapshot is a **raw copy of every docker volume belonging to an app's stack, taken while the app is
UNDEPLOYED** (nothing is writing → consistent). Stored under `/var/lib/ci-warm/<recipe>/` as one
last-known-good per app, replaced atomically. Restore clears each volume and untars it back.
UNDEPLOYED** (nothing is writing → consistent). Stored under `/var/lib/ci-warm/<slot>/` as one
last-known-good per slot, replaced atomically. Restore clears each volume and untars it back.
Used by:
- WC1.1 — snapshot keycloak's data volume BEFORE an auto-upgrade; restore on health-gate rollback
@ -12,12 +12,25 @@ Used by:
Warm snapshots are **cache, excluded from the D8 reproducibility closure** (WC8) — re-seeded by cold
runs, not restored on a VM rebuild.
Layout (atomic dir swap of the `snapshot/` subdir; one last-good per app). Sibling per-app state
(e.g. the reconciler's `last_good`) lives in `<recipe>/` and is NOT clobbered by the swap:
$CCCI_WARM_ROOT/<recipe>/
SLOT, not recipe (F-redfix-4). Warm state belongs to a **deployed stack**, not to a recipe name. A
recipe can own TWO warm stacks at once — keycloak is both the live-warm shared OIDC provider
(`warm-keycloak…`) and a data-warm canonical (`warm-canon-keycloak…`) — and they must never share a
snapshot slot: `snapshot()` atomically REPLACES the slot, so a shared slot means each deployment
destroys the other's known-good, and `restore()` (which requires every recorded volume to exist in
the target stack) then raises. Callers therefore pass a **slot key**, not a bare recipe:
- live-warm reconciler → `live_slot(recipe)` (`<recipe>/`)
- data-warm canonical → `canonical.canonical_slot(r)` (`<recipe>/`, or `canon-<recipe>/` when the
recipe is also a live-warm provider)
Every slot key `<ns>` maps 1:1 to the stack at `warm.stable_domain(<ns>)`, so distinct slot ⇔
distinct stack. `canonical.py` derives its domain and its slot from ONE namespace so the two cannot
drift apart.
Layout (atomic dir swap of the `snapshot/` subdir; one last-good per slot). Sibling per-slot state
(e.g. the reconciler's `last_good`) lives in `<slot>/` and is NOT clobbered by the swap:
$CCCI_WARM_ROOT/<slot>/
last_good # (reconciler) the version known healthy — survives snapshot swaps
snapshot/
meta.json # {recipe, domain, commit, version, ts, volumes:[...]}
meta.json # {slot, domain, commit, version, ts, volumes:[...]}
volumes/<volname>.tar # raw tar of the volume root, one per stack volume
Implementation note: volumes are tarred from their host mountpoint
@ -46,38 +59,46 @@ def warm_root() -> str:
return os.environ.get("CCCI_WARM_ROOT", DEFAULT_WARM_ROOT)
def app_dir(recipe: str) -> str:
return os.path.join(warm_root(), recipe)
def live_slot(recipe: str) -> str:
"""Warm-state slot of the LIVE-warm reconciler's deployment (the stack at `warm-<recipe>`).
The canonical's slot is `canonical.canonical_slot()`, which diverges from this one exactly when
the recipe is also a live-warm provider (F-redfix-4)."""
return recipe
def snap_dir(recipe: str) -> str:
def app_dir(slot: str) -> str:
return os.path.join(warm_root(), slot)
def snap_dir(slot: str) -> str:
"""The snapshot subdir — atomically swapped on update. Kept SEPARATE from app_dir so sibling
per-app state (the reconciler's last_good) survives a snapshot swap."""
return os.path.join(app_dir(recipe), "snapshot")
per-slot state (the reconciler's last_good) survives a snapshot swap."""
return os.path.join(app_dir(slot), "snapshot")
def meta_path(recipe: str) -> str:
return os.path.join(snap_dir(recipe), "meta.json")
def meta_path(slot: str) -> str:
return os.path.join(snap_dir(slot), "meta.json")
def volumes_dir(recipe: str) -> str:
return os.path.join(snap_dir(recipe), "volumes")
def volumes_dir(slot: str) -> str:
return os.path.join(snap_dir(slot), "volumes")
def has_snapshot(recipe: str) -> bool:
def has_snapshot(slot: str) -> bool:
"""True iff a complete last-good snapshot (meta + at least its declared volume tars) exists."""
meta = read_meta(recipe)
meta = read_meta(slot)
if not meta:
return False
for v in meta.get("volumes", []):
if not os.path.isfile(os.path.join(volumes_dir(recipe), f"{v}.tar")):
if not os.path.isfile(os.path.join(volumes_dir(slot), f"{v}.tar")):
return False
return True
def read_meta(recipe: str) -> dict | None:
def read_meta(slot: str) -> dict | None:
try:
with open(meta_path(recipe)) as f:
with open(meta_path(slot)) as f:
return json.load(f)
except (OSError, ValueError):
return None
@ -113,18 +134,35 @@ def _assert_undeployed(domain: str) -> None:
)
def snapshot(
recipe: str, domain: str, commit: str | None = None, version: str | None = None
) -> dict:
"""Take a last-known-good snapshot of every data volume of <domain>'s stack. The app MUST be
undeployed. Atomically replaces the prior last-good. Returns the written meta dict."""
def _assert_slot_not_foreign(slot: str, domain: str) -> None:
"""Refuse to touch a slot that already holds another DOMAIN's known-good (F-redfix-4).
Defence in depth behind the slot separation itself: naming-scheme-independent, it catches any
future caller that pairs a slot with the wrong stack. Fails BEFORE the destructive swap rather
than at the next restore. A slot with no snapshot yet is free to claim."""
meta = read_meta(slot)
if meta and meta.get("domain") and meta["domain"] != domain:
raise SnapshotError(
f"warm slot {slot!r} holds the known-good of {meta['domain']!r}, not {domain!r}: "
"refusing to clobber another deployment's snapshot (F-redfix-4). "
"Live-warm and data-warm deployments of one recipe need distinct slots."
)
def snapshot(slot: str, domain: str, commit: str | None = None, version: str | None = None) -> dict:
"""Take a last-known-good snapshot of every data volume of <domain>'s stack into <slot>. The app
MUST be undeployed. Atomically replaces the prior last-good in that slot. Returns the meta dict.
`slot` must be the warm-state slot OWNING `domain` (see module docstring) — passing another
deployment's slot would destroy its known-good, so the pairing is asserted below."""
_assert_slot_not_foreign(slot, domain)
_assert_undeployed(domain)
volumes = stack_volumes(domain)
if not volumes:
raise SnapshotError(f"no volumes found for {domain} — nothing to snapshot")
os.makedirs(app_dir(recipe), exist_ok=True)
staging = os.path.join(app_dir(recipe), ".snapshot.staging")
os.makedirs(app_dir(slot), exist_ok=True)
staging = os.path.join(app_dir(slot), ".snapshot.staging")
shutil.rmtree(staging, ignore_errors=True)
os.makedirs(os.path.join(staging, "volumes"), exist_ok=True)
@ -138,7 +176,7 @@ def snapshot(
raise SnapshotError(f"tar of volume {vol} failed: {r.stderr.strip()}")
meta = {
"recipe": recipe,
"slot": slot,
"domain": domain,
"commit": commit,
"version": version,
@ -149,8 +187,8 @@ def snapshot(
json.dump(meta, f)
# Atomic-ish swap of the snapshot subdir only (sibling state like last_good is untouched).
target = snap_dir(recipe)
old = os.path.join(app_dir(recipe), ".snapshot.old")
target = snap_dir(slot)
old = os.path.join(app_dir(slot), ".snapshot.old")
shutil.rmtree(old, ignore_errors=True)
if os.path.exists(target):
os.rename(target, old)
@ -159,17 +197,19 @@ def snapshot(
return meta
def restore(recipe: str, domain: str) -> dict:
"""Restore the last-known-good snapshot into <domain>'s stack volumes. The app MUST be undeployed.
Clears each volume then untars the snapshot back. Returns the snapshot meta. Raises if no
snapshot exists or a snapshot volume is missing from the current stack."""
def restore(slot: str, domain: str) -> dict:
"""Restore <slot>'s last-known-good snapshot into <domain>'s stack volumes. The app MUST be
undeployed. Clears each volume then untars the snapshot back. Returns the snapshot meta. Raises
if no snapshot exists, the slot belongs to another domain, or a snapshot volume is missing from
the current stack."""
_assert_undeployed(domain)
meta = read_meta(recipe)
if not meta or not has_snapshot(recipe):
raise SnapshotError(f"no complete snapshot for {recipe} to restore")
meta = read_meta(slot)
if not meta or not has_snapshot(slot):
raise SnapshotError(f"no complete snapshot in slot {slot!r} to restore")
_assert_slot_not_foreign(slot, domain)
current = set(stack_volumes(domain))
for vol in meta.get("volumes", []):
tar_path = os.path.join(volumes_dir(recipe), f"{vol}.tar")
tar_path = os.path.join(volumes_dir(slot), f"{vol}.tar")
if vol not in current:
raise SnapshotError(
f"snapshot volume {vol} absent from current stack {sorted(current)}"

View File

@ -893,7 +893,7 @@ def run_quick(
)
abra.undeploy(domain)
_wait_undeployed(domain)
warmsnap.restore(recipe, domain)
warmsnap.restore(canonical.canonical_slot(recipe), domain)
# reset recorded version to the known-good (the failed upgrade set TYPE to the broken
# PR commit) so the idle canonical's .env agrees with the registry + re-warms cleanly.
if reg.get("version"):

View File

@ -384,7 +384,9 @@ def wait_undeployed(domain: str, timeout: int = 120) -> None:
def last_good_path(recipe: str) -> str:
return os.path.join(warmsnap.app_dir(recipe), "last_good")
# live_slot, not the bare recipe: a recipe that is also a data-warm canonical keeps its canonical
# state in a DIFFERENT slot, so the reconciler's last_good is never clobbered (F-redfix-4).
return os.path.join(warmsnap.app_dir(warmsnap.live_slot(recipe)), "last_good")
def read_last_good(recipe: str) -> str | None:
@ -396,7 +398,7 @@ def read_last_good(recipe: str) -> str | None:
def write_last_good(recipe: str, version: str) -> None:
os.makedirs(warmsnap.app_dir(recipe), exist_ok=True)
os.makedirs(warmsnap.app_dir(warmsnap.live_slot(recipe)), exist_ok=True)
tmp = last_good_path(recipe) + ".tmp"
with open(tmp, "w") as f:
f.write(version)
@ -509,7 +511,7 @@ def reconcile(app: str) -> str:
if stateful:
abra.undeploy(domain)
wait_undeployed(domain)
warmsnap.snapshot(recipe, domain, version=last_good)
warmsnap.snapshot(warmsnap.live_slot(recipe), domain, version=last_good)
# snapshot requires undeployed; now bring up latest.
# A broken "latest" can fail in two ways: deploy_version raises (abra converge times out on a
# crash-looping task) OR it deploys but never becomes healthy. BOTH must roll back, so treat a
@ -531,7 +533,7 @@ def reconcile(app: str) -> str:
if stateful:
abra.undeploy(domain)
wait_undeployed(domain)
warmsnap.restore(recipe, domain)
warmsnap.restore(warmsnap.live_slot(recipe), domain)
deploy_version(recipe, domain, last_good, dt)
recovered = wait_healthy(spec)
write_alert(

View File

@ -7,10 +7,14 @@ DEPLOY_TIMEOUT = (
)
HTTP_TIMEOUT = 900
# canon §2.B EXCEPTION (recorded in DECISIONS): keycloak is NOT a data-warm canonical. It is the
# project's LIVE-WARM OIDC dep provider — an always-on shared service at the SAME stable domain a
# data-warm canonical would use (warm-keycloak.ci.commoninternet.net). Enrolling it would make the
# sweep's promote deploy/teardown collide with the live provider that lasuite-*/drone depend on for
# SSO. keycloak is instead kept current by the sweep's roll_warm_infra step (the health-gated
# warm/infra reconciler, WC1.1) — so it never lacks coverage. WARM_CANONICAL stays False.
WARM_CANONICAL = False
# phase redfix: keycloak IS now a data-warm canonical. The original canon §2.B exception de-enrolled
# it because its canonical would have used the SAME domain as the live-warm OIDC provider
# (warm-keycloak.ci.commoninternet.net), so the sweep's promote deploy/teardown would collide with the
# live service lasuite-*/drone depend on. The canonical is now namespaced apart from the live provider
# on BOTH axes it shares with it — `canonical.canonical_ns()` gives any recipe in `warm.WARM_DOMAINS`
# (keycloak) a `canon-<recipe>` namespace, from which BOTH its domain/stack (`warm-canon-keycloak…`)
# and its warm-state slot (`/var/lib/ci-warm/canon-keycloak/`) derive. Domain separation alone was NOT
# enough: warm state was keyed by bare recipe, so both deployments shared one snapshot slot and each
# destroyed the other's known-good (F-redfix-4). keycloak therefore gets full data-warm canonical
# coverage (a real promote on its latest release) without risking the live OIDC service.
WARM_CANONICAL = True

View File

@ -1,20 +0,0 @@
"""libredesk — health check: the app's /health endpoint responds 200 through Traefik.
This is the same endpoint the compose healthcheck hits internally (:9000/health); exercising it via
the public domain confirms the app is bound and the proxy route is wired.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
def test_libredesk_health(live_app):
"""GET /health → 200."""
url = f"https://{live_app}/health"
status, _ = harness_http.retry_http_get(url, expect_status=(200,), max_wait=120, interval=5)
assert status == 200, f"GET {url} HTTP {status} (expected 200)"

View File

@ -1,49 +0,0 @@
"""libredesk — UI probe: the served root HTML is the real LibreDesk app, not a fallback page.
/health passing only proves the process is up. This asserts the front-end SPA is actually bound and
serving its own shell — a wedged backend or a misrouted proxy would 200 with generic/empty content.
"""
from __future__ import annotations
import os
import ssl
import sys
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
_CTX = ssl.create_default_context()
_CTX.check_hostname = False
_CTX.verify_mode = ssl.CERT_NONE
def _get_body(url: str) -> tuple[int, str]:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=15, context=_CTX) as r:
return r.status, r.read().decode(errors="replace")
def test_libredesk_serves_app_shell(live_app):
"""GET /; assert LibreDesk-specific brand or SPA-asset markers in the served HTML."""
url = f"https://{live_app}/"
def _ready():
try:
status, body = _get_body(url)
except Exception: # noqa: BLE001
return None
return body if status in (200, 301, 302) else None
body = harness_http.assert_converges(_ready, f"GET {url}", max_wait=120, interval=5)
lower = body.lower()
# Brand markers: the app title / meta / bundle references carry "libredesk".
brand = [m for m in ("libredesk", "libre desk") if m in lower]
# SPA asset markers: the built front-end serves hashed JS/CSS bundles + a favicon.
assets = [m for m in ("/assets/", "favicon", ".js", ".css", "<div id=\"app\"", "id=app") if m in body]
assert brand or assets, (
f"GET {url} HTML has no LibreDesk brand or SPA-asset markers. Excerpt: {body[:300]!r}"
)

View File

@ -1,49 +0,0 @@
#!/usr/bin/env bash
# libredesk — INSTALL-TIME secret hook.
#
# Runs during the install tier AFTER `abra app new` + EXTRA_ENV + `abra app secret generate --all`
# and BEFORE the single `abra app deploy` (lifecycle.py::_run_install_steps). LibreDesk validates
# two secrets with formats that abra's generic generator does not guarantee, so we insert compliant
# values here (at a bumped version — swarm forbids overwriting a secret at the same version) and
# point the .env at them, so the recipe deploys ONCE, healthy, with no reconverge:
# - enc_key : exactly 32 hex chars (AES-256 encryption_key) → openssl rand -hex 16
# - admin_pwd : 1072 chars incl. upper+lower+number+special (the app rejects weaker "System"
# user passwords, which abra's alphanumeric generator can produce)
# db_password is fine as abra-generated (no special format), so it is left untouched.
#
# Env supplied by the harness:
# CCCI_APP_DOMAIN — the per-run libredesk app domain (== abra app name)
# CCCI_APP_ENV — path to the app's .env (the one `abra app deploy` reads)
set -euo pipefail
: "${CCCI_APP_DOMAIN:?missing}"
ENV_PATH="${CCCI_APP_ENV:?missing}"
# Insert a compliant value for <secret> and repoint SECRET_<VAR>_VERSION at the bumped version.
insert_secret() {
local secret="$1" env_var="$2" value="$3"
local cur new num
cur=$(grep -E "^\s*${env_var}=" "$ENV_PATH" | tail -1 | cut -d= -f2 | tr -d '"\r' || echo "v1")
cur=${cur:-v1}
num=$(( ${cur#v} + 1 )); new="v${num}"
# abra forbids overwriting an existing version; insert at the fresh version. -C creates it, -o
# allows overwrite if a stale one exists, --no-input for non-interactive.
local log
log=$(abra app secret insert "$CCCI_APP_DOMAIN" "$secret" "$new" "$value" --no-input -C -o 2>&1) ||
log=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN $secret $new $value --no-input -C -o" /dev/null 2>&1) ||
{ echo " install_steps: abra app secret insert ${secret}@${new} failed: $log"; exit 1; }
sed -i "s|^\s*${env_var}=.*|${env_var}=${new}|" "$ENV_PATH"
echo " install_steps: ${secret} inserted at ${new} (was ${cur})"
}
# 32 hex chars = 16 bytes (AES-256 key LibreDesk expects for encryption_key).
ENC_KEY=$(openssl rand -hex 16)
# 1072 chars with upper+lower+number+special. Build deterministically-compliant: a fixed
# upper+lower+digit+special prefix plus random hex entropy (lowercase letters + digits).
ADMIN_PWD="Ci$(openssl rand -hex 12)Aa1!"
insert_secret enc_key SECRET_ENC_KEY_VERSION "$ENC_KEY"
insert_secret admin_pwd SECRET_ADMIN_PWD_VERSION "$ADMIN_PWD"
echo " libredesk install_steps: enc_key (32-hex) + admin_pwd (complex) inserted; deploy will use them"

View File

@ -1,32 +0,0 @@
# Per-recipe harness config for libredesk (LibreDesk — open-source, self-hosted customer-support /
# help desk; recipe repo recipe-maintainers/Libre-Desk, abra TYPE=libredesk).
#
# Stack (compose.yml): app (libredesk/libredesk:v2.4.0, serves on :9000 behind Traefik) + db
# (postgres:17-alpine, user/db=libredesk) + redis (redis:7-alpine). abra-entrypoint.sh self-installs
# the schema and runs migrations on boot (`--install --idempotent-install` then `--upgrade`), so the
# stack comes up ready with no post-deploy step.
#
# Secrets have STRICT formats that abra's generic generator does not satisfy, so install_steps.sh
# inserts compliant values before the single deploy (see that file):
# - enc_key must be 32 hex chars (AES-256 key) → openssl rand -hex 16
# - admin_pwd must be 1072 chars, upper+lower+num+sym → the "System" user's password
# - db_password is abra-generated (no special format) and left as-is.
HEALTH_PATH = "/health" # the app's own healthcheck endpoint (compose hits :9000/health)
HEALTH_OK = (200,)
DEPLOY_TIMEOUT = 600 # app + postgres + redis; app runs install+migrate on first boot
HTTP_TIMEOUT = 300
BACKUP_CAPABLE = True # db carries backupbot.backup labels (pg_dump pre-hook / psql restore-hook)
# Only one published version exists so far (0.1.0+v2.4.0), so there is no PRIOR deployable base to
# upgrade FROM — the upgrade rung is intentionally N/A (a declared skip, never promoted to a pass).
# Drop this once a second version is published; the dynamic base then resolves it (last-green warm
# canonical → same-version step-back → main tip).
EXPECTED_NA = {
"upgrade": "only one published version so far (0.1.0+v2.4.0) — no prior deployable base to "
"upgrade FROM yet; drop this when a second version ships",
}
# canon §2.B: enroll as a DATA-WARM canonical (all recipes enrolled — operator 2026-06-17).
# The weekly sweep promotes this recipe's canonical to its latest green RELEASE TAG.
WARM_CANONICAL = True

View File

@ -19,7 +19,14 @@ import _mumble_proto # noqa: E402
def test_handshake_completes_with_channel_presence(live_app):
r = _mumble_proto.retry_handshake(attempts=12, interval=5.0)
# Readiness budget: 36×5s = 180s. The TCP READY_PROBE (recipe_meta) only proves port 64738 is
# LISTENING; the murmur control channel needs additional warmup before it completes a full
# TLS+Version+ServerSync handshake. Under concurrent node load (the canon sweep) that warmup
# exceeded the old 60s budget and flaked this test RED, while it is reliably GREEN in isolation
# (phase redfix M1: 3× isolation green, 0 isolation reds). The longer budget absorbs the
# load-induced readiness delay WITHOUT weakening the assertion — a genuinely non-responsive
# server still exhausts all retries and FAILs (the asserts below are unchanged).
r = _mumble_proto.retry_handshake(attempts=36, interval=5.0)
assert r["tls_connect"], f"TLS connection to 127.0.0.1:64738 failed — {r.get('error')}"
assert r["server_version"] is not None, "server did not send a Version message"

View File

@ -12,7 +12,7 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import canonical, warm # noqa: E402
from harness import canonical, warm, warmsnap # noqa: E402
from harness import meta as harness_meta # noqa: E402
@ -96,3 +96,78 @@ def test_prune_stale_drops_deenrolled_only(tmp_path, monkeypatch):
assert (tmp_path / "keepme").exists()
assert (tmp_path / "keycloak").exists() # no canonical.json → not a canonical → kept
assert (tmp_path / "alerts").exists()
# ------------------------------------------- F-redfix-4: canonical ns/slot vs live-warm provider
def test_canonical_ns_and_domain_for_live_warm_provider():
# keycloak is a live-warm provider → its canonical is namespaced apart on BOTH axes.
assert canonical.canonical_ns("keycloak") == "canon-keycloak"
assert canonical.canonical_domain("keycloak") == "warm-canon-keycloak.ci.commoninternet.net"
assert canonical.canonical_slot("keycloak") == "canon-keycloak"
# ...and an ordinary recipe is untouched (zero blast radius on existing canonicals).
assert canonical.canonical_ns("cryptpad") == "cryptpad"
assert canonical.canonical_domain("cryptpad") == "warm-cryptpad.ci.commoninternet.net"
assert canonical.canonical_slot("cryptpad") == "cryptpad"
def test_live_and_canonical_slots_are_disjoint():
"""F-redfix-4: for EVERY live-warm provider, the reconciler's slot and the canonical's slot must
differ — they are two deployments and each `snapshot()` atomically replaces its slot."""
for recipe in warm.WARM_DOMAINS:
assert canonical.canonical_slot(recipe) != warmsnap.live_slot(recipe)
# every non-provider recipe keeps one slot (there is only one deployment)
assert canonical.canonical_slot("cryptpad") == warmsnap.live_slot("cryptpad")
def test_slot_maps_1to1_to_its_stack():
# The invariant warmsnap relies on: distinct slot ⇔ distinct stack (domain = warm-<ns>).
for recipe in warm.WARM_DOMAINS:
assert warm.stable_domain(canonical.canonical_slot(recipe)) == canonical.canonical_domain(
recipe
)
assert warm.stable_domain(warmsnap.live_slot(recipe)) == warm.WARM_DOMAINS[recipe]
def test_registry_path_of_live_warm_provider_is_not_the_reconciler_dir(tmp_path, monkeypatch):
"""The canonical registry must NOT land in `<recipe>/`, where the reconciler keeps last_good."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
assert canonical.registry_path("keycloak") == str(
tmp_path / "canon-keycloak" / "canonical.json"
)
canonical.write_registry("keycloak", version="10.7.1+26.6.2", commit=None, status="idle")
assert (tmp_path / "canon-keycloak" / "canonical.json").is_file()
assert not (tmp_path / "keycloak").exists() # reconciler dir untouched
def test_prune_stale_keeps_enrolled_provider_canonical_and_reconciler(tmp_path, monkeypatch):
"""Enrolled keycloak: `canon-keycloak/` is its canonical slot (keep), `keycloak/` is the
reconciler's (keep — no canonical.json)."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
monkeypatch.setattr(canonical, "enrolled_recipes", lambda: ["keycloak"])
monkeypatch.setattr(canonical.warmsnap, "stack_volumes", lambda d: [])
(tmp_path / "canon-keycloak").mkdir()
(tmp_path / "canon-keycloak" / "canonical.json").write_text('{"recipe":"keycloak"}')
(tmp_path / "keycloak").mkdir()
(tmp_path / "keycloak" / "last_good").write_text("10.7.1+26.6.2")
assert canonical.prune_stale() == []
assert (tmp_path / "canon-keycloak").exists()
assert (tmp_path / "keycloak" / "last_good").read_text() == "10.7.1+26.6.2"
def test_prune_stale_deenrolled_provider_spares_reconciler_last_good(tmp_path, monkeypatch):
"""F-redfix-4 consequence 4: de-enrolling keycloak must drop `canon-keycloak/` ONLY — never the
reconciler's `keycloak/last_good`. It also targets the canon stack's volumes, not the live ones."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
monkeypatch.setattr(canonical, "enrolled_recipes", lambda: []) # de-enrolled
seen = []
monkeypatch.setattr(canonical.warmsnap, "stack_volumes", lambda d: seen.append(d) or [])
(tmp_path / "canon-keycloak").mkdir()
(tmp_path / "canon-keycloak" / "canonical.json").write_text('{"recipe":"keycloak"}')
(tmp_path / "keycloak").mkdir()
(tmp_path / "keycloak" / "last_good").write_text("10.7.1+26.6.2")
assert canonical.prune_stale() == ["canon-keycloak"]
assert not (tmp_path / "canon-keycloak").exists()
assert (tmp_path / "keycloak" / "last_good").read_text() == "10.7.1+26.6.2"
assert seen == ["warm-canon-keycloak.ci.commoninternet.net"] # never the LIVE provider's stack

View File

@ -68,3 +68,62 @@ def test_has_snapshot_incomplete_missing_tar(monkeypatch, tmp_path):
(snapdir / "meta.json").write_text(json.dumps({"recipe": "keycloak", "volumes": ["a", "b"]}))
(snapdir / "volumes" / "a.tar").write_bytes(b"fake")
assert warmsnap.has_snapshot("keycloak") is False
# --------------------------------------------------------------- F-redfix-4: slot ≠ recipe
def test_live_slot_is_the_recipe():
assert warmsnap.live_slot("keycloak") == "keycloak"
def test_snapshot_refuses_to_clobber_another_domains_slot(monkeypatch, tmp_path):
"""F-redfix-4 regression: a slot holding domain A's known-good must not be overwritten by a
snapshot of domain B. Before the fix this silently destroyed the other deployment's snapshot."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
snapdir = tmp_path / "keycloak" / "snapshot"
(snapdir / "volumes").mkdir(parents=True)
(snapdir / "meta.json").write_text(
json.dumps({"slot": "keycloak", "domain": "warm-keycloak.ci.x", "volumes": []})
)
# Never reaches docker: the foreign-slot guard fires before _assert_undeployed.
try:
warmsnap.snapshot("keycloak", "warm-canon-keycloak.ci.x")
except warmsnap.SnapshotError as e:
assert "warm-keycloak.ci.x" in str(e) and "refusing to clobber" in str(e)
else:
raise AssertionError("snapshot() overwrote a foreign slot")
# the original known-good is intact
assert warmsnap.read_meta("keycloak")["domain"] == "warm-keycloak.ci.x"
def test_snapshot_may_reclaim_its_own_slot(monkeypatch, tmp_path):
# Same domain → not foreign → the guard must not fire (it would break every re-snapshot).
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
snapdir = tmp_path / "keycloak" / "snapshot"
(snapdir / "volumes").mkdir(parents=True)
(snapdir / "meta.json").write_text(
json.dumps({"slot": "keycloak", "domain": "warm-keycloak.ci.x", "volumes": []})
)
warmsnap._assert_slot_not_foreign("keycloak", "warm-keycloak.ci.x") # no raise
warmsnap._assert_slot_not_foreign("fresh-slot", "anything.ci.x") # empty slot is free to claim
def test_restore_refuses_a_foreign_slot(monkeypatch, tmp_path):
"""restore() must reject a slot recorded against another domain BEFORE touching volumes."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
monkeypatch.setattr(warmsnap, "_assert_undeployed", lambda d: None)
_write_snapshot(tmp_path, "keycloak", ["warm-keycloak_ci_x_mariadb"])
snapdir = tmp_path / "keycloak" / "snapshot"
meta = json.loads((snapdir / "meta.json").read_text())
meta["domain"] = "warm-keycloak.ci.x"
(snapdir / "meta.json").write_text(json.dumps(meta))
called = []
monkeypatch.setattr(warmsnap, "stack_volumes", lambda d: called.append(d) or [])
try:
warmsnap.restore("keycloak", "warm-canon-keycloak.ci.x")
except warmsnap.SnapshotError as e:
assert "refusing to clobber" in str(e)
else:
raise AssertionError("restore() accepted a foreign slot")
assert called == [], "restore() must fail before inspecting the target stack's volumes"