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) } }