v0.269.1: an installed app keeps the image digest it runs until a guarded Update moves it
gates / gates (push) Successful in 26s
gates / gates (push) Successful in 26s
Found live on 9202 (night 2026-09-24 Part B): the sync rendered the ladder's newest tested digest into a RUNNING app's compose, so the next restart would pull a new image with no backup and no undo. stacks.CarryDigests keeps the running digest for an installed app; a fresh install still takes the tested digest. Red-proofed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user