2a56f557d0
gates / gates (push) Successful in 14s
Found by the LIVE validation on demo-hp, not by review. Scenario A passed - a non-image catalog change reached the pinned app on the real 15-minute cycle - and that is exactly what exposed the gap: the stored applied-compose.yml is written when the PIN is written, so the fix landed in the live compose file and not in the store. The first time the catalog then moved a version, the freeze would have rendered the pre-fix definition and reverted every fix delivered since - silently undoing the half of the operator's ruling that says fixes keep flowing. The equal-images branch now refreshes the store as it delivers. The images cannot move in that branch by construction, so no version moves and no intent is rewritten. RenderPlan gains StackDir so the syncer can write it. TestFixRefreshesTheStoredDefinition asserts both halves: the fix reaches the store, and it survives the freeze that follows.
317 lines
12 KiB
Go
317 lines
12 KiB
Go
package sync
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// Slice 3 (v0.235.0) — "freeze the version, keep the fixes flowing" (operator ruling, 2026-09-06).
|
|
//
|
|
// Every assertion reads the rendered docker-compose.yml BACK OFF DISK. "copyTemplates returned nil"
|
|
// is hollow: the whole feature is which bytes end up in that file.
|
|
|
|
const (
|
|
tplOld = `services:
|
|
web:
|
|
image: nextcloud:31.0.14-apache
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:80"]
|
|
`
|
|
// Same version, a FIX to the healthcheck — Scenario A's input.
|
|
tplOldFixed = `services:
|
|
web:
|
|
image: nextcloud:31.0.14-apache
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:80/status.php"]
|
|
`
|
|
// A new VERSION, and a template shaped for it — Scenario B's input.
|
|
tplNew = `services:
|
|
web:
|
|
image: nextcloud:34.0.1-apache
|
|
environment:
|
|
- NEXTCLOUD_TRUSTED_DOMAINS=example
|
|
`
|
|
)
|
|
|
|
// renderFixture builds a syncer over a temp root with one catalog template and one stack dir.
|
|
func renderFixture(t *testing.T, catalogCompose string) (*Syncer, string, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = filepath.Join(root, "data")
|
|
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
|
|
catDir := filepath.Join(cfg.Paths.DataDir, "catalog-cache", "templates", "nextcloud")
|
|
stackDir := filepath.Join(cfg.Paths.StacksDir, "nextcloud")
|
|
for _, d := range []string{catDir, stackDir} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write(t, filepath.Join(catDir, "docker-compose.yml"), catalogCompose)
|
|
write(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Nextcloud\ncatalog_since: \"2026-07-18\"\n")
|
|
s := New(cfg, log.New(io.Discard, "", 0), func() error { return nil }, nil)
|
|
return s, stackDir, catDir
|
|
}
|
|
|
|
func write(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func readFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// pinnedPlan is the seam's answer for a deployed, pinned app with a stored definition.
|
|
func pinnedPlan(stackDir string, pin map[string]string, applied bool) func(string) stacks.RenderPlan {
|
|
return func(string) stacks.RenderPlan {
|
|
p := stacks.RenderPlan{Deployed: true, Pinned: pin}
|
|
if applied {
|
|
p.AppliedPath = stacks.AppliedComposePath(stackDir)
|
|
}
|
|
return p
|
|
}
|
|
}
|
|
|
|
// --- GROUP A: the catalog still offers the pinned version → FIXES FLOW ---
|
|
|
|
// TestGroupA_FixFlowsToAPinnedMatchingApp is the half the operator explicitly chose to KEEP.
|
|
// Freezing everything would have been far simpler and would have broken this.
|
|
func TestGroupA_FixFlowsToAPinnedMatchingApp(t *testing.T) {
|
|
s, stackDir, _ := renderFixture(t, tplOldFixed)
|
|
live := filepath.Join(stackDir, "docker-compose.yml")
|
|
write(t, live, tplOld)
|
|
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
|
s.SetRenderPlanFn(pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, true))
|
|
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readFile(t, live)
|
|
if !strings.Contains(got, "status.php") {
|
|
t.Fatalf("the healthcheck FIX must reach a pinned app whose version the catalog still offers.\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "31.0.14-apache") {
|
|
t.Errorf("the version must not have moved: %s", got)
|
|
}
|
|
}
|
|
|
|
// --- GROUP B: the catalog moved → the app FREEZES, WHOLE ---
|
|
|
|
// TestGroupB_CatalogMoveFreezesTheAppWhole.
|
|
//
|
|
// COMPANION RED-PROOF 1 (run 2026-09-06): make renderSource return the catalog template on the
|
|
// moved branch (i.e. the "substitute the refs" shortcut, or simply forgetting the branch). This test
|
|
// then fails on the version assertion. Reverted.
|
|
func TestGroupB_CatalogMoveFreezesTheAppWhole(t *testing.T) {
|
|
s, stackDir, _ := renderFixture(t, tplNew)
|
|
live := filepath.Join(stackDir, "docker-compose.yml")
|
|
write(t, live, tplOld)
|
|
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
|
s.SetRenderPlanFn(pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, true))
|
|
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readFile(t, live)
|
|
if strings.Contains(got, "34.0.1-apache") {
|
|
t.Fatalf("a pinned app must NOT receive the catalog's new version:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "31.0.14-apache") {
|
|
t.Fatalf("the frozen version must still be named:\n%s", got)
|
|
}
|
|
// THE WHOLE definition, never a substitution. The new template's env belongs to the new
|
|
// version; an old image under a new template is a third state nobody chose (`wger 2.6`).
|
|
if strings.Contains(got, "NEXTCLOUD_TRUSTED_DOMAINS") {
|
|
t.Fatalf("the NEW template's body leaked into a frozen app — the whole stored definition must be used:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "healthcheck") {
|
|
t.Errorf("the stored definition should have been written verbatim:\n%s", got)
|
|
}
|
|
// `.felhom.yml` is ALWAYS copied — it carries catalog_since, which the badge needs.
|
|
if !strings.Contains(readFile(t, filepath.Join(stackDir, ".felhom.yml")), "catalog_since") {
|
|
t.Error(".felhom.yml must flow even to a frozen app")
|
|
}
|
|
}
|
|
|
|
// --- GROUP C: self-healing, in BOTH branches ---
|
|
|
|
// TestGroupC_SelfHealingSurvivesInBothBranches. A hand-broken compose file repairing itself within
|
|
// 15 minutes was MEASURED in SPIKE-app-update-2026-09-01 §3, and is a property this task must keep.
|
|
func TestGroupC_SelfHealingSurvivesInBothBranches(t *testing.T) {
|
|
t.Run("catalog still offers the pin — heals to the catalog", func(t *testing.T) {
|
|
s, stackDir, _ := renderFixture(t, tplOld)
|
|
live := filepath.Join(stackDir, "docker-compose.yml")
|
|
write(t, live, "services:\n web:\n image: alpine:3.20 # hand-broken\n")
|
|
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
|
s.SetRenderPlanFn(pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, true))
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := readFile(t, live); !strings.Contains(got, "31.0.14-apache") || strings.Contains(got, "alpine") {
|
|
t.Fatalf("a corrupted file must heal from the catalog:\n%s", got)
|
|
}
|
|
})
|
|
t.Run("catalog has moved — heals to the STORED definition", func(t *testing.T) {
|
|
s, stackDir, _ := renderFixture(t, tplNew)
|
|
live := filepath.Join(stackDir, "docker-compose.yml")
|
|
write(t, live, "services:\n web:\n image: alpine:3.20 # hand-broken\n")
|
|
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
|
s.SetRenderPlanFn(pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, true))
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readFile(t, live)
|
|
if strings.Contains(got, "alpine") {
|
|
t.Fatalf("a corrupted file must heal even while frozen:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "31.0.14-apache") || strings.Contains(got, "34.0.1") {
|
|
t.Fatalf("it must heal to the STORED definition, not the catalog's new one:\n%s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- the render table's remaining rows ---
|
|
|
|
func TestRenderTable_UnpinnedAndUndeployedAndMissingStore(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
plan stacks.RenderPlan
|
|
applied bool
|
|
wantNew bool // does the catalog's NEW version land in the live file?
|
|
wantSkip bool
|
|
}{
|
|
{name: "not deployed", plan: stacks.RenderPlan{}, wantNew: true},
|
|
{name: "protected", plan: stacks.RenderPlan{Deployed: true, Protected: true}, wantNew: true},
|
|
{name: "deployed but UNPINNED", plan: stacks.RenderPlan{Deployed: true}, wantNew: true},
|
|
{name: "mid-deploy — skipped", plan: stacks.RenderPlan{Deployed: true, Deploying: true,
|
|
Pinned: map[string]string{"web": "nextcloud:31.0.14-apache"}}, wantSkip: true},
|
|
{name: "pinned, moved, NO stored definition", plan: stacks.RenderPlan{Deployed: true,
|
|
Pinned: map[string]string{"web": "nextcloud:31.0.14-apache"}}, wantNew: true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
s, stackDir, _ := renderFixture(t, tplNew)
|
|
live := filepath.Join(stackDir, "docker-compose.yml")
|
|
write(t, live, tplOld)
|
|
plan := c.plan
|
|
if c.applied {
|
|
plan.AppliedPath = stacks.AppliedComposePath(stackDir)
|
|
}
|
|
s.SetRenderPlanFn(func(string) stacks.RenderPlan { return plan })
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readFile(t, live)
|
|
switch {
|
|
case c.wantSkip:
|
|
if got != tplOld {
|
|
t.Fatalf("a mid-deploy app's compose file must be left ALONE:\n%s", got)
|
|
}
|
|
case c.wantNew:
|
|
if !strings.Contains(got, "34.0.1-apache") {
|
|
t.Fatalf("want the catalog copied verbatim (pre-v0.235.0 behaviour):\n%s", got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRenderTable_NilSeamIsExactlyTheOldBehaviour — the nil case is the safety net: a wiring mistake
|
|
// must degrade to the previous product, not to a broken one.
|
|
func TestRenderTable_NilSeamIsExactlyTheOldBehaviour(t *testing.T) {
|
|
s, stackDir, _ := renderFixture(t, tplNew)
|
|
live := filepath.Join(stackDir, "docker-compose.yml")
|
|
write(t, live, tplOld)
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(readFile(t, live), "34.0.1-apache") {
|
|
t.Fatal("with no seam the syncer must copy verbatim, exactly as before v0.235.0")
|
|
}
|
|
}
|
|
|
|
// TestRenderTable_EmptyStoredDefinitionIsTreatedAsAbsent — never write an empty compose file over a
|
|
// live app. An empty applied file is "absent", which copies the catalog and WARNs.
|
|
func TestRenderTable_EmptyStoredDefinitionIsTreatedAsAbsent(t *testing.T) {
|
|
s, stackDir, _ := renderFixture(t, tplNew)
|
|
live := filepath.Join(stackDir, "docker-compose.yml")
|
|
write(t, live, tplOld)
|
|
write(t, stacks.AppliedComposePath(stackDir), " \n")
|
|
s.SetRenderPlanFn(func(string) stacks.RenderPlan {
|
|
// The manager's own RenderPlanFor would report "" here; this asserts the syncer is not
|
|
// relying on that alone — an empty file must never be rendered even if a path arrives.
|
|
return stacks.RenderPlan{Deployed: true, Pinned: map[string]string{"web": "nextcloud:31.0.14-apache"},
|
|
AppliedPath: stacks.AppliedComposePath(stackDir)}
|
|
})
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := readFile(t, live); strings.TrimSpace(got) == "" {
|
|
t.Fatal("an empty compose file was written over a live app")
|
|
}
|
|
}
|
|
|
|
// TestFixRefreshesTheStoredDefinition — found by the LIVE validation of v0.235.0, not by review.
|
|
//
|
|
// The stored definition is written when the pin is written. A fix delivered afterwards lands in the
|
|
// live compose file but NOT in the stored one — so the first time the catalog moves a version, the
|
|
// freeze reverts every fix delivered since, silently undoing the half of the ruling that says fixes
|
|
// keep flowing. The equal-images branch therefore refreshes the store as it delivers.
|
|
//
|
|
// The IMAGES cannot move here by construction: this branch only runs when the catalog's images equal
|
|
// the pin. No version moves and no intent is rewritten.
|
|
func TestFixRefreshesTheStoredDefinition(t *testing.T) {
|
|
s, stackDir, _ := renderFixture(t, tplOldFixed) // same version, fixed healthcheck
|
|
live := filepath.Join(stackDir, "docker-compose.yml")
|
|
write(t, live, tplOld)
|
|
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
|
s.SetRenderPlanFn(func(string) stacks.RenderPlan {
|
|
return stacks.RenderPlan{Deployed: true, StackDir: stackDir,
|
|
Pinned: map[string]string{"web": "nextcloud:31.0.14-apache"},
|
|
AppliedPath: stacks.AppliedComposePath(stackDir)}
|
|
})
|
|
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stored, err := stacks.LoadAppliedDefinition(stackDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(stored), "status.php") {
|
|
t.Fatalf("the delivered fix must also become part of what the app is pinned TO:\n%s", stored)
|
|
}
|
|
if !strings.Contains(string(stored), "31.0.14-apache") {
|
|
t.Fatalf("refreshing the store must NOT move the version:\n%s", stored)
|
|
}
|
|
|
|
// And now the catalog moves: the freeze must keep the fix, not revert to the pre-fix definition.
|
|
catDir := filepath.Join(filepath.Dir(filepath.Dir(stackDir)), "data", "catalog-cache", "templates", "nextcloud")
|
|
write(t, filepath.Join(catDir, "docker-compose.yml"), tplNew)
|
|
if _, _, err := s.copyTemplates(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readFile(t, live)
|
|
if strings.Contains(got, "34.0.1") {
|
|
t.Fatalf("the version must be frozen:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "status.php") {
|
|
t.Fatalf("the freeze must keep the fix that was delivered while the versions matched:\n%s", got)
|
|
}
|
|
}
|