Files
admin 5b1b191ffc
gates / gates (push) Successful in 26s
v0.269.1: an installed app keeps the image digest it runs until a guarded Update moves it
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
2026-09-24 13:01:31 +02:00

76 lines
3.5 KiB
Go

package sync
import (
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
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)
ladderWithDigest(t, catDir, digA)
s.SetRenderPlanFn(func(string) stacks.RenderPlan { return stacks.RenderPlan{} }) // not deployed
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@"+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)
}
})
}
}