Compare commits
3 Commits
feat/libre
...
redfix-m2-
| Author | SHA1 | Date | |
|---|---|---|---|
| b5f2b104e6 | |||
| 07fc6d4af5 | |||
| 61211dba70 |
@ -54,56 +54,3 @@ hold). Concrete fix designs from M1 evidence:
|
|||||||
## Adversary findings
|
## Adversary findings
|
||||||
|
|
||||||
(Adversary-owned — do not edit.)
|
(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.
|
|
||||||
|
|||||||
@ -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.
|
- 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.
|
- keycloak: harness fix (canonical_domain collision-free for WARM_DOMAINS recipes + enroll) NOT STARTED.
|
||||||
- mumble: harness fix (handshake readiness/retry stabilization) 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.
|
|
||||||
|
|||||||
@ -133,203 +133,3 @@ _(prior placeholder removed)_
|
|||||||
save vs read-only app.ini config mount). Cold passes (fresh render, no runtime save). Builder's
|
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
|
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.
|
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.
|
|
||||||
|
|||||||
@ -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
|
warm-machinery vs load) → FIX each (recipe PR or harness improvement) → verify green. No standing
|
||||||
exceptions. Nothing merged.
|
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)
|
## 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
|
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
|
on a cc-ci branch, verified via the harness. discourse: overlay-scope decision. Node now free for my
|
||||||
deploys (Adversary done with M1).
|
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 |
|
| Recipe | Fix type | PR/branch | 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` |
|
| 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) |
|
||||||
| 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 |
|
| 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) |
|
||||||
| 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 |
|
| gitea | recipe PR (app.ini → writable volume) | — | pending |
|
||||||
| 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 |
|
| keycloak | harness (collision-free canonical_domain) + enroll | — | pending |
|
||||||
| 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) |
|
| mumble | harness (handshake readiness/retry stabilization) | — | pending |
|
||||||
| 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) |
|
| 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>);
|
## Gate: M1 — PASS (above). M2 not yet claimed.
|
||||||
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).
|
|
||||||
|
|
||||||
**WHAT (M1 DoD).** All six canon-sweep failures investigated in ISOLATION (one recipe at a time, no
|
**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
|
concurrent sweep load), root-caused with first-hand evidence, and classified (flake vs genuine; recipe
|
||||||
|
|||||||
@ -39,9 +39,35 @@ def is_enrolled(recipe: str) -> bool:
|
|||||||
return bool(meta_mod.load(recipe).WARM_CANONICAL)
|
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:
|
def canonical_domain(recipe: str) -> str:
|
||||||
"""Stable data-warm domain for the recipe's canonical."""
|
"""Stable data-warm domain for the recipe's canonical (`warm-<ns>`)."""
|
||||||
return warm.stable_domain(recipe)
|
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]:
|
def enrolled_recipes() -> list[str]:
|
||||||
@ -61,7 +87,7 @@ def enrolled_recipes() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def registry_path(recipe: str) -> 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:
|
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:
|
def write_registry(recipe: str, *, version: str, commit: str | None, status: str) -> dict:
|
||||||
"""Atomically write the canonical registry record for a recipe."""
|
"""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 = {
|
rec = {
|
||||||
"recipe": recipe,
|
"recipe": recipe,
|
||||||
"domain": canonical_domain(recipe),
|
"domain": canonical_domain(recipe),
|
||||||
@ -141,16 +167,22 @@ def undeploy_keep_volume(recipe: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def prune_stale() -> list[str]:
|
def prune_stale() -> list[str]:
|
||||||
"""WC8 disk hygiene: remove warm data for DE-ENROLLED canonicals — a `/var/lib/ci-warm/<recipe>/`
|
"""WC8 disk hygiene: remove warm data for DE-ENROLLED canonicals — a `/var/lib/ci-warm/<ns>/`
|
||||||
that carries a `canonical.json` but whose recipe is no longer enrolled (WARM_CANONICAL dropped).
|
that carries a `canonical.json` but whose namespace is no longer an enrolled canonical's slot
|
||||||
Drops the dir (snapshot + registry) AND the retained `warm-<recipe>` data volumes. Leaves the
|
(WARM_CANONICAL dropped). Drops the dir (snapshot + registry) AND the retained `warm-<ns>` data
|
||||||
live-warm reconciler dirs (keycloak/traefik — they have a `last_good`, no `canonical.json`),
|
volumes. Leaves the live-warm reconciler dirs (keycloak/traefik — they have a `last_good`, no
|
||||||
`alerts/`, and currently-enrolled canonicals untouched. Returns the recipes pruned."""
|
`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 shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
root = warmsnap.warm_root()
|
root = warmsnap.warm_root()
|
||||||
keep = set(enrolled_recipes())
|
keep = {canonical_slot(r) for r in enrolled_recipes()}
|
||||||
pruned: list[str] = []
|
pruned: list[str] = []
|
||||||
try:
|
try:
|
||||||
entries = sorted(os.listdir(root))
|
entries = sorted(os.listdir(root))
|
||||||
@ -162,8 +194,8 @@ def prune_stale() -> list[str]:
|
|||||||
continue
|
continue
|
||||||
if not os.path.isfile(os.path.join(d, "canonical.json")):
|
if not os.path.isfile(os.path.join(d, "canonical.json")):
|
||||||
continue # not a data-warm canonical (e.g. keycloak/traefik reconciler dir, alerts/)
|
continue # not a data-warm canonical (e.g. keycloak/traefik reconciler dir, alerts/)
|
||||||
# drop the retained warm-<recipe> volumes, then the snapshot/registry dir
|
# drop the retained warm-<ns> volumes, then the snapshot/registry dir
|
||||||
for vol in warmsnap.stack_volumes(canonical_domain(name)):
|
for vol in warmsnap.stack_volumes(warm.stable_domain(name)):
|
||||||
subprocess.run(["docker", "volume", "rm", vol], capture_output=True, text=True)
|
subprocess.run(["docker", "volume", "rm", vol], capture_output=True, text=True)
|
||||||
shutil.rmtree(d, ignore_errors=True)
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
pruned.append(name)
|
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
|
healthy first, then undeploys before calling this (WC3: snapshot while undeployed). The retained
|
||||||
volume IS the canonical. Returns the registry record."""
|
volume IS the canonical. Returns the registry record."""
|
||||||
rec = write_registry(recipe, version=version, commit=commit, status="idle")
|
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
|
return rec
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"""Known-good snapshot/restore of an app's data volumes (Phase 2w / WC3).
|
"""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
|
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
|
UNDEPLOYED** (nothing is writing → consistent). Stored under `/var/lib/ci-warm/<slot>/` as one
|
||||||
last-known-good per app, replaced atomically. Restore clears each volume and untars it back.
|
last-known-good per slot, replaced atomically. Restore clears each volume and untars it back.
|
||||||
|
|
||||||
Used by:
|
Used by:
|
||||||
- WC1.1 — snapshot keycloak's data volume BEFORE an auto-upgrade; restore on health-gate rollback
|
- 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
|
Warm snapshots are **cache, excluded from the D8 reproducibility closure** (WC8) — re-seeded by cold
|
||||||
runs, not restored on a VM rebuild.
|
runs, not restored on a VM rebuild.
|
||||||
|
|
||||||
Layout (atomic dir swap of the `snapshot/` subdir; one last-good per app). Sibling per-app state
|
SLOT, not recipe (F-redfix-4). Warm state belongs to a **deployed stack**, not to a recipe name. A
|
||||||
(e.g. the reconciler's `last_good`) lives in `<recipe>/` and is NOT clobbered by the swap:
|
recipe can own TWO warm stacks at once — keycloak is both the live-warm shared OIDC provider
|
||||||
$CCCI_WARM_ROOT/<recipe>/
|
(`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
|
last_good # (reconciler) the version known healthy — survives snapshot swaps
|
||||||
snapshot/
|
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
|
volumes/<volname>.tar # raw tar of the volume root, one per stack volume
|
||||||
|
|
||||||
Implementation note: volumes are tarred from their host mountpoint
|
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)
|
return os.environ.get("CCCI_WARM_ROOT", DEFAULT_WARM_ROOT)
|
||||||
|
|
||||||
|
|
||||||
def app_dir(recipe: str) -> str:
|
def live_slot(recipe: str) -> str:
|
||||||
return os.path.join(warm_root(), recipe)
|
"""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
|
"""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."""
|
per-slot state (the reconciler's last_good) survives a snapshot swap."""
|
||||||
return os.path.join(app_dir(recipe), "snapshot")
|
return os.path.join(app_dir(slot), "snapshot")
|
||||||
|
|
||||||
|
|
||||||
def meta_path(recipe: str) -> str:
|
def meta_path(slot: str) -> str:
|
||||||
return os.path.join(snap_dir(recipe), "meta.json")
|
return os.path.join(snap_dir(slot), "meta.json")
|
||||||
|
|
||||||
|
|
||||||
def volumes_dir(recipe: str) -> str:
|
def volumes_dir(slot: str) -> str:
|
||||||
return os.path.join(snap_dir(recipe), "volumes")
|
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."""
|
"""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:
|
if not meta:
|
||||||
return False
|
return False
|
||||||
for v in meta.get("volumes", []):
|
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 False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def read_meta(recipe: str) -> dict | None:
|
def read_meta(slot: str) -> dict | None:
|
||||||
try:
|
try:
|
||||||
with open(meta_path(recipe)) as f:
|
with open(meta_path(slot)) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return None
|
return None
|
||||||
@ -113,18 +134,35 @@ def _assert_undeployed(domain: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def snapshot(
|
def _assert_slot_not_foreign(slot: str, domain: str) -> None:
|
||||||
recipe: str, domain: str, commit: str | None = None, version: str | None = None
|
"""Refuse to touch a slot that already holds another DOMAIN's known-good (F-redfix-4).
|
||||||
) -> dict:
|
|
||||||
"""Take a last-known-good snapshot of every data volume of <domain>'s stack. The app MUST be
|
Defence in depth behind the slot separation itself: naming-scheme-independent, it catches any
|
||||||
undeployed. Atomically replaces the prior last-good. Returns the written meta dict."""
|
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)
|
_assert_undeployed(domain)
|
||||||
volumes = stack_volumes(domain)
|
volumes = stack_volumes(domain)
|
||||||
if not volumes:
|
if not volumes:
|
||||||
raise SnapshotError(f"no volumes found for {domain} — nothing to snapshot")
|
raise SnapshotError(f"no volumes found for {domain} — nothing to snapshot")
|
||||||
|
|
||||||
os.makedirs(app_dir(recipe), exist_ok=True)
|
os.makedirs(app_dir(slot), exist_ok=True)
|
||||||
staging = os.path.join(app_dir(recipe), ".snapshot.staging")
|
staging = os.path.join(app_dir(slot), ".snapshot.staging")
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
os.makedirs(os.path.join(staging, "volumes"), exist_ok=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()}")
|
raise SnapshotError(f"tar of volume {vol} failed: {r.stderr.strip()}")
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
"recipe": recipe,
|
"slot": slot,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"commit": commit,
|
"commit": commit,
|
||||||
"version": version,
|
"version": version,
|
||||||
@ -149,8 +187,8 @@ def snapshot(
|
|||||||
json.dump(meta, f)
|
json.dump(meta, f)
|
||||||
|
|
||||||
# Atomic-ish swap of the snapshot subdir only (sibling state like last_good is untouched).
|
# Atomic-ish swap of the snapshot subdir only (sibling state like last_good is untouched).
|
||||||
target = snap_dir(recipe)
|
target = snap_dir(slot)
|
||||||
old = os.path.join(app_dir(recipe), ".snapshot.old")
|
old = os.path.join(app_dir(slot), ".snapshot.old")
|
||||||
shutil.rmtree(old, ignore_errors=True)
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
if os.path.exists(target):
|
if os.path.exists(target):
|
||||||
os.rename(target, old)
|
os.rename(target, old)
|
||||||
@ -159,17 +197,19 @@ def snapshot(
|
|||||||
return meta
|
return meta
|
||||||
|
|
||||||
|
|
||||||
def restore(recipe: str, domain: str) -> dict:
|
def restore(slot: str, domain: str) -> dict:
|
||||||
"""Restore the last-known-good snapshot into <domain>'s stack volumes. The app MUST be undeployed.
|
"""Restore <slot>'s last-known-good snapshot into <domain>'s stack volumes. The app MUST be
|
||||||
Clears each volume then untars the snapshot back. Returns the snapshot meta. Raises if no
|
undeployed. Clears each volume then untars the snapshot back. Returns the snapshot meta. Raises
|
||||||
snapshot exists or a snapshot volume is missing from the current stack."""
|
if no snapshot exists, the slot belongs to another domain, or a snapshot volume is missing from
|
||||||
|
the current stack."""
|
||||||
_assert_undeployed(domain)
|
_assert_undeployed(domain)
|
||||||
meta = read_meta(recipe)
|
meta = read_meta(slot)
|
||||||
if not meta or not has_snapshot(recipe):
|
if not meta or not has_snapshot(slot):
|
||||||
raise SnapshotError(f"no complete snapshot for {recipe} to restore")
|
raise SnapshotError(f"no complete snapshot in slot {slot!r} to restore")
|
||||||
|
_assert_slot_not_foreign(slot, domain)
|
||||||
current = set(stack_volumes(domain))
|
current = set(stack_volumes(domain))
|
||||||
for vol in meta.get("volumes", []):
|
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:
|
if vol not in current:
|
||||||
raise SnapshotError(
|
raise SnapshotError(
|
||||||
f"snapshot volume {vol} absent from current stack {sorted(current)}"
|
f"snapshot volume {vol} absent from current stack {sorted(current)}"
|
||||||
|
|||||||
@ -893,7 +893,7 @@ def run_quick(
|
|||||||
)
|
)
|
||||||
abra.undeploy(domain)
|
abra.undeploy(domain)
|
||||||
_wait_undeployed(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
|
# 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.
|
# PR commit) so the idle canonical's .env agrees with the registry + re-warms cleanly.
|
||||||
if reg.get("version"):
|
if reg.get("version"):
|
||||||
|
|||||||
@ -384,7 +384,9 @@ def wait_undeployed(domain: str, timeout: int = 120) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def last_good_path(recipe: str) -> str:
|
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:
|
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:
|
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"
|
tmp = last_good_path(recipe) + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
f.write(version)
|
f.write(version)
|
||||||
@ -509,7 +511,7 @@ def reconcile(app: str) -> str:
|
|||||||
if stateful:
|
if stateful:
|
||||||
abra.undeploy(domain)
|
abra.undeploy(domain)
|
||||||
wait_undeployed(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.
|
# snapshot requires undeployed; now bring up latest.
|
||||||
# A broken "latest" can fail in two ways: deploy_version raises (abra converge times out on a
|
# 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
|
# 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:
|
if stateful:
|
||||||
abra.undeploy(domain)
|
abra.undeploy(domain)
|
||||||
wait_undeployed(domain)
|
wait_undeployed(domain)
|
||||||
warmsnap.restore(recipe, domain)
|
warmsnap.restore(warmsnap.live_slot(recipe), domain)
|
||||||
deploy_version(recipe, domain, last_good, dt)
|
deploy_version(recipe, domain, last_good, dt)
|
||||||
recovered = wait_healthy(spec)
|
recovered = wait_healthy(spec)
|
||||||
write_alert(
|
write_alert(
|
||||||
|
|||||||
@ -7,10 +7,14 @@ DEPLOY_TIMEOUT = (
|
|||||||
)
|
)
|
||||||
HTTP_TIMEOUT = 900
|
HTTP_TIMEOUT = 900
|
||||||
|
|
||||||
# canon §2.B EXCEPTION (recorded in DECISIONS): keycloak is NOT a data-warm canonical. It is the
|
# phase redfix: keycloak IS now a data-warm canonical. The original canon §2.B exception de-enrolled
|
||||||
# project's LIVE-WARM OIDC dep provider — an always-on shared service at the SAME stable domain a
|
# it because its canonical would have used the SAME domain as the live-warm OIDC provider
|
||||||
# data-warm canonical would use (warm-keycloak.ci.commoninternet.net). Enrolling it would make the
|
# (warm-keycloak.ci.commoninternet.net), so the sweep's promote deploy/teardown would collide with the
|
||||||
# sweep's promote deploy/teardown collide with the live provider that lasuite-*/drone depend on for
|
# live service lasuite-*/drone depend on. The canonical is now namespaced apart from the live provider
|
||||||
# SSO. keycloak is instead kept current by the sweep's roll_warm_infra step (the health-gated
|
# on BOTH axes it shares with it — `canonical.canonical_ns()` gives any recipe in `warm.WARM_DOMAINS`
|
||||||
# warm/infra reconciler, WC1.1) — so it never lacks coverage. WARM_CANONICAL stays False.
|
# (keycloak) a `canon-<recipe>` namespace, from which BOTH its domain/stack (`warm-canon-keycloak…`)
|
||||||
WARM_CANONICAL = False
|
# 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
|
||||||
|
|||||||
@ -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)"
|
|
||||||
@ -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}"
|
|
||||||
)
|
|
||||||
@ -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 : 10–72 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)
|
|
||||||
|
|
||||||
# 10–72 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"
|
|
||||||
@ -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 10–72 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
|
|
||||||
@ -19,7 +19,14 @@ import _mumble_proto # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
def test_handshake_completes_with_channel_presence(live_app):
|
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["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"
|
assert r["server_version"] is not None, "server did not send a Version message"
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
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
|
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 / "keepme").exists()
|
||||||
assert (tmp_path / "keycloak").exists() # no canonical.json → not a canonical → kept
|
assert (tmp_path / "keycloak").exists() # no canonical.json → not a canonical → kept
|
||||||
assert (tmp_path / "alerts").exists()
|
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
|
||||||
|
|||||||
@ -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 / "meta.json").write_text(json.dumps({"recipe": "keycloak", "volumes": ["a", "b"]}))
|
||||||
(snapdir / "volumes" / "a.tar").write_bytes(b"fake")
|
(snapdir / "volumes" / "a.tar").write_bytes(b"fake")
|
||||||
assert warmsnap.has_snapshot("keycloak") is False
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user