diff --git a/CHANGELOG.md b/CHANGELOG.md index e8919fd..15be160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## v0.269.1 — an installed app keeps the image it runs until an Update moves it (2026-09-24 night, Part B live finding) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (as v0.269.0). New strings: none. + +- **Found live on 9202 (Part B, `audits/night-2026-09-24/B/10-floating-tag.*`):** the catalog re-tested + `redis:7-alpine` at a new digest; v0.269.0's sync wrote that digest into the RUNNING app's compose before + anyone pressed Update. The next restart (a backup's stop/start, a power cut, a reboot) would have pulled the + new image with no backup and no undo — the guarded update bypassed. +- **Fix:** `stacks.CarryDigests` — for an INSTALLED app the sync keeps the digest the app's current file names + for each service whose reference did not change, and adds none. A fresh install still takes the ladder's + tested digest; only a guarded update (`advancePinTo`) moves an installed app's digest. The badge is + unchanged: it still reads „Frissítés elérhető" for a newer tested digest. +- Test: `TestDigest_SyncerKeepsTheRunningDigest` (an older digest kept; no digest stays none; the fix still + flows; the stored definition gets the same bytes). Red-proof: the pre-fix call site fails both cases + ("the sync MOVED the running digest"). +- One release per repo was the brief's rule; this is the second controller release tonight, because the first + would have shipped the bypass to the fleet with the floor. Decided by CC unattended — operator may reverse. + ## v0.269.0 — the second drive brings a file app back whole; a crash loop is stopped; exact image fingerprints; each step judged by its own file (2026-09-24 night, `09` §3 decisions 26–28, R-661, R-666, R-667, R-668, R-664, R-665, R-662, §6.4 part 6) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 for `app_stopped_unhealthy` (older hubs answer 400 and the diff --git a/REUSE.md b/REUSE.md index b140aff..961cbb8 100644 --- a/REUSE.md +++ b/REUSE.md @@ -117,7 +117,7 @@ |---|---|---|---|---| | `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure | | `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `_`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only | -| `renderDigests` / `RenderWithLadderDigests` / `StripDigest` (v0.269.0) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update, sync) | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin | +| `renderDigests` / `RenderWithLadderDigests` / `CarryDigests` / `StripDigest` (v0.269.0/.1) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update; the sync for an app NOT yet installed) — an INSTALLED app's sync uses `CarryDigests` (v0.269.1), which keeps the digest the app runs | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin | | `mergeRestoreFiles` / `RestoreTier2Whole` (v0.269.0) | controller/internal/backup/tier2_whole.go | mirror subtree → live subtree, four rules, counts | ANY restore that brings files back over live ones | Never `rsyncMirror` (--delete) in the restore direction; never overwrite a newer live file; a replaced file's old copy stays beside | | `nextLadderStep` / `StepKey` / `StepFile` (v0.268.0) | controller/internal/stacks/ladder.go | `(templateDir, pinned) (LadderStep, error)` | which definition ONE guarded-update press pins (`09` §3 decision 14) | `StepKey` must equal the catalog's `ladder.step_key` (TestLadder_StepKeyMatchesTheCatalog). A missing/wrong step file is an ERROR, never a jump | | `Manager.RedeployFromEnv` | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | Re-up with changed env (migration flip, config edits) | `compose up -d`, never `restart` (restart won't pick up images/env) | diff --git a/controller/README.md b/controller/README.md index e754eb5..a45cbec 100644 --- a/controller/README.md +++ b/controller/README.md @@ -713,7 +713,9 @@ a replaced older one beside as `.felhom-`) and then restores the unit from t `app_stopped_unhealthy`. Start gives one more try; a repeat within 24 h says support is informed. **Exact images (v0.269.0, `09` §6.4 part 6).** The compose that runs pins `tag@sha256` from the ladder entry that -tested it; pins stay plain; a floating tag reads Behind only for a newer TESTED digest. An update judges the new +tested it; pins stay plain; a floating tag reads Behind only for a newer TESTED digest. A fresh install takes the +tested digest; an INSTALLED app keeps the digest it runs until a guarded update moves it — the sync carries it +over and never renders a newer one (v0.269.1, `CarryDigests`). An update judges the new version by its own `.felhom.yml` (the step's, or the catalog's). A stranded app's Remove keeps the data. **Start/restart never answer "completed" (v0.263.0, R-642)** — they answer what was requested and the diff --git a/controller/internal/stacks/digest.go b/controller/internal/stacks/digest.go index d240d83..f020b61 100644 --- a/controller/internal/stacks/digest.go +++ b/controller/internal/stacks/digest.go @@ -103,6 +103,57 @@ func RenderWithLadderDigests(templateDir string, compose []byte) []byte { return renderDigests(compose, e.Digest) } +// composeImageLines returns each service's OWN image reference as written, digest included — the same +// line walk as renderDigests. +func composeImageLines(compose []byte) map[string]string { + out := map[string]string{} + svc, inServices := "", false + for _, l := range strings.Split(string(compose), "\n") { + if strings.HasPrefix(l, "services:") { + inServices = true + continue + } + if l != "" && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "#") { + inServices = false + } + if !inServices { + continue + } + if m := serviceLineRe.FindStringSubmatch(l); m != nil { + svc = m[1] + continue + } + if m := imageLineRe.FindStringSubmatch(l); m != nil && svc != "" { + if _, seen := out[svc]; !seen { + out[svc] = m[2] + } + } + } + return out +} + +// CarryDigests is the syncer's rule for a DEPLOYED app: the catalog compose, with the digest the app's +// CURRENT file already names for each service whose reference did not change — and no other. The digest +// is part of what the app runs, so only a guarded update (advancePinTo) may move it. Rendering the +// ladder's newest digest here instead let a sync change the image under a running app: the next restart +// pulled it with no backup and no undo (MEASURED on 9202, night 2026-09-24 Part B, +// `audits/night-2026-09-24/B/10-floating-tag.*`). Pinned by TestDigest_SyncerKeepsTheRunningDigest. +func CarryDigests(compose, current []byte) []byte { + cur := composeImageLines(current) + next := composeImageLines(compose) + keep := map[string]string{} + for svc, ref := range cur { + at := strings.LastIndex(ref, "@") + if at < 0 || !digestRe.MatchString(ref[at+1:]) { + continue + } + if n, ok := next[svc]; ok && StripDigest(n) == ref[:at] { + keep[svc] = ref[at+1:] + } + } + return renderDigests(compose, keep) +} + // catalogTestedDigests is the badge's input: the tested digest per service of the catalog's current // refs, and when that test ran. Empty when the ladder has no entry for them. func catalogTestedDigests(templateDir string, catalogRefs map[string]string) (map[string]string, time.Time) { diff --git a/controller/internal/sync/digest_render_test.go b/controller/internal/sync/digest_render_test.go index 95d1e33..3b197de 100644 --- a/controller/internal/sync/digest_render_test.go +++ b/controller/internal/sync/digest_render_test.go @@ -8,35 +8,68 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) -// v0.269.0 (`09` §6.4 part 6) — the syncer writes the catalog's compose WITH the tested digests of the -// ladder entry for exactly its refs, for an undeployed app and for a pinned app the catalog still matches; -// and the stored definition is refreshed with the same bytes. +const ( + digA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + digB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +func ladderWithDigest(t *testing.T, catDir, dig string) { + t.Helper() + write(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Nextcloud\nupdate_ladder:\n"+ + ` - {"from": {"web": "nextcloud:30.0.0-apache"}, "to": {"web": "nextcloud:31.0.14-apache"}, "digest": {"web": "`+dig+`"}, "verdict": "proven", "tested_at": "2026-09-24T01:00:00Z"}`+"\n") +} + +// v0.269.0 (`09` §6.4 part 6) — a FRESH INSTALL takes the tested digest: the syncer writes the catalog's +// compose for an undeployed app WITH the digest of the ladder entry for exactly its refs. // // COMPANION RED-PROOF (REPORT): make RenderWithLadderDigests return its input — the image line stays a bare // tag and this test fails at "no digest in the rendered compose". func TestDigest_SyncerRendersTheTestedDigest(t *testing.T) { s, stackDir, catDir := renderFixture(t, tplOld) - write(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Nextcloud\nupdate_ladder:\n"+ - ` - {"from": {"web": "nextcloud:30.0.0-apache"}, "to": {"web": "nextcloud:31.0.14-apache"}, "digest": {"web": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, "verdict": "proven", "tested_at": "2026-09-24T01:00:00Z"}`+"\n") - for _, plan := range []func(string) stacks.RenderPlan{ - func(string) stacks.RenderPlan { return stacks.RenderPlan{} }, // not deployed - func(string) stacks.RenderPlan { - p := pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, false)("") - p.StackDir = stackDir - return p - }, - } { - write(t, filepath.Join(stackDir, "docker-compose.yml"), "services: {}\n") // force a change each pass - s.SetRenderPlanFn(plan) - if _, _, err := s.copyTemplates(); err != nil { - t.Fatal(err) - } - got := readFile(t, filepath.Join(stackDir, "docker-compose.yml")) - if !strings.Contains(got, "image: nextcloud:31.0.14-apache@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") { - t.Fatalf("no digest in the rendered compose:\n%s", got) - } + ladderWithDigest(t, catDir, digA) + s.SetRenderPlanFn(func(string) stacks.RenderPlan { return stacks.RenderPlan{} }) // not deployed + if _, _, err := s.copyTemplates(); err != nil { + t.Fatal(err) } - if !strings.Contains(readFile(t, stacks.AppliedComposePath(stackDir)), "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") { - t.Fatal("the stored definition was not refreshed with the digest") + got := readFile(t, filepath.Join(stackDir, "docker-compose.yml")) + if !strings.Contains(got, "image: nextcloud:31.0.14-apache@"+digA) { + t.Fatalf("no digest in the rendered compose:\n%s", got) + } +} + +// TestDigest_SyncerKeepsTheRunningDigest — a DEPLOYED app keeps the digest it runs; a sync never moves it. +// MEASURED LIVE (night 2026-09-24 Part B): the catalog re-tested redis:7-alpine at a new digest, the sync +// wrote that digest into the RUNNING app's compose before anyone pressed Update, so the next restart would +// have pulled it with no backup and no undo. The fix still flows (the healthcheck line), the stored +// definition is refreshed with the same bytes, and an app that runs NO digest gets none from a sync. +// +// COMPANION RED-PROOF (REPORT): render the ladder's digest for a deployed app again (the pre-fix call site) +// — this test fails at "the sync MOVED the running digest". +func TestDigest_SyncerKeepsTheRunningDigest(t *testing.T) { + for _, tc := range []struct{ name, live, want, notWant string }{ + {"runs an older digest", strings.Replace(tplOld, "nextcloud:31.0.14-apache", "nextcloud:31.0.14-apache@"+digB, 1), "@" + digB, digA}, + {"runs no digest", tplOld, "image: nextcloud:31.0.14-apache\n", digA}, + } { + t.Run(tc.name, func(t *testing.T) { + s, stackDir, catDir := renderFixture(t, tplOldFixed) + ladderWithDigest(t, catDir, digA) + write(t, filepath.Join(stackDir, "docker-compose.yml"), tc.live) + plan := pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, false)("") + plan.StackDir = stackDir + s.SetRenderPlanFn(func(string) stacks.RenderPlan { return plan }) + if _, _, err := s.copyTemplates(); err != nil { + t.Fatal(err) + } + got := readFile(t, filepath.Join(stackDir, "docker-compose.yml")) + if strings.Contains(got, tc.notWant) || !strings.Contains(got, tc.want) { + t.Fatalf("the sync MOVED the running digest (want %q, never %q):\n%s", tc.want, tc.notWant, got) + } + if !strings.Contains(got, "/status.php") { + t.Fatalf("the fix did not flow:\n%s", got) + } + if applied := readFile(t, stacks.AppliedComposePath(stackDir)); applied != got { + t.Fatalf("the stored definition was not refreshed with the same bytes:\n%s", applied) + } + }) } } diff --git a/controller/internal/sync/sync.go b/controller/internal/sync/sync.go index ea62712..74772a1 100644 --- a/controller/internal/sync/sync.go +++ b/controller/internal/sync/sync.go @@ -395,7 +395,14 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error) s.logger.Printf("[WARN] [sync] Failed to read catalog file %s/%s: %v", appName, filename, rerr) continue } - rendered = stacks.RenderWithLadderDigests(srcDir, raw) + if s.renderPlanFn != nil && s.renderPlanFn(appName).Deployed { + // A DEPLOYED app keeps the digest it runs; only a guarded update moves it (night + // 2026-09-24 Part B, live finding). A fresh install takes the ladder's tested digest. + current, _ := os.ReadFile(dst) + rendered = stacks.CarryDigests(raw, current) + } else { + rendered = stacks.RenderWithLadderDigests(srcDir, raw) + } changed, err = writeIfChanged(rendered, dst) } else { changed, err = copyIfChanged(src, dst)