package stacks import ( "fmt" "os" "strings" "testing" ) // R-745 (decision 56): a box keeps the controller image it runs and the one before it; older controller images go, // by exact name/ID, after the in-use check; nothing goes while the controller swaps itself. Docker is the imageDocker // seam (fakeImages, image_retention_test.go) — nothing here reaches a daemon. const ctlRepo = "gitea.dooplex.hu/admin/felhom-controller" func ctlImages() *fakeImages { return &fakeImages{ imgs: []localImage{ {ID: "sha256:C285", Repo: ctlRepo, Tag: "0.285.0", Size: "409MB"}, {ID: "sha256:C2842", Repo: ctlRepo, Tag: "0.284.2", Size: "409MB"}, {ID: "sha256:C2841", Repo: ctlRepo, Tag: "0.284.1", Size: "409MB"}, {ID: "sha256:C2831", Repo: ctlRepo, Tag: "0.283.1", Size: "409MB"}, {ID: "sha256:CNONE", Repo: ctlRepo, Tag: "", Size: "422MB"}, {ID: "sha256:CRC", Repo: ctlRepo, Tag: "0.273.0-rc1", Size: "400MB"}, {ID: "sha256:W3", Repo: "acme/web", Tag: "3", Size: "100MB"}, }, containers: map[string]string{"c-ctl": "sha256:C285"}, } } func rmiSet(f *fakeImages) string { return "," + strings.Join(f.rmi, ",") + "," } // The running one and the one the swap record names stay; an older one and an untagged one go; another repository's // image and a non-version tag are never touched. // COMPANION RED-PROOF: drop the `prev` case from the keep switch → "the previous controller (0.284.2) was deleted". func TestControllerRetention_KeepsRunningAndPreviousDeletesOlder(t *testing.T) { m := retentionManager(t) f := ctlImages() withFakeImages(t, f) got, err := m.RetainControllerImages(ControllerImageRecord{Repo: ctlRepo, Running: "0.285.0", Previous: ctlRepo + ":0.284.2"}) if err != nil { t.Fatal(err) } s := rmiSet(f) if strings.Contains(s, ":0.285.0,") || strings.Contains(s, "sha256:C285,") { t.Fatalf("the RUNNING controller was deleted: %v", f.rmi) } if strings.Contains(s, ":0.284.2,") { t.Fatalf("the previous controller (0.284.2) was deleted: %v", f.rmi) } for _, want := range []string{ctlRepo + ":0.284.1", ctlRepo + ":0.283.1", "sha256:CNONE"} { if !strings.Contains(s, ","+want+",") { t.Fatalf("%s (older than the previous / untagged) was not deleted: rmi=%v", want, f.rmi) } } if strings.Contains(s, "0.273.0-rc1") || strings.Contains(s, "acme/web") || strings.Contains(s, "W3") { t.Fatalf("a non-version tag or another repository's image was touched: %v", f.rmi) } if len(got) != 3 { t.Fatalf("deleted %d, want 3: %v", len(got), got) } } // The swap record wins over version order: a box rolled back by hand runs 0.285.0 with 0.283.1 as its recorded // previous — 0.284.x (newer by sort order) go, the recorded one stays. // COMPANION RED-PROOF: ignore rec.Previous (version order only) → "the recorded previous (0.283.1) was deleted". func TestControllerRetention_RecordBeatsVersionOrder(t *testing.T) { m := retentionManager(t) f := ctlImages() withFakeImages(t, f) if _, err := m.RetainControllerImages(ControllerImageRecord{Repo: ctlRepo, Running: "0.285.0", Previous: ctlRepo + ":0.283.1"}); err != nil { t.Fatal(err) } s := rmiSet(f) if strings.Contains(s, ":0.283.1,") { t.Fatalf("the recorded previous (0.283.1) was deleted: %v", f.rmi) } // 0.284.x are NEWER than the recorded previous: kept (only versions below the previous are candidates). if strings.Contains(s, ":0.284.2,") || strings.Contains(s, ":0.284.1,") { t.Fatalf("a version between the previous and the running one was deleted: %v", f.rmi) } } // No record (a box set up by hand, like 9202): the highest version below the running one is the previous. func TestControllerRetention_NoRecordFallsBackToVersionOrder(t *testing.T) { m := retentionManager(t) f := ctlImages() withFakeImages(t, f) if _, err := m.RetainControllerImages(ControllerImageRecord{Repo: ctlRepo, Running: "0.285.0"}); err != nil { t.Fatal(err) } s := rmiSet(f) if strings.Contains(s, ":0.284.2,") { t.Fatalf("with no record, the highest older version (0.284.2) must stay: %v", f.rmi) } if !strings.Contains(s, ":0.284.1,") { t.Fatalf("0.284.1 should go: %v", f.rmi) } } // A version ABOVE the running one (a target the self-update pulled, not swapped to yet) is never deleted, and while // the controller swaps itself NOTHING is deleted. // COMPANION RED-PROOF: remove the selfUpdatingNow() check → "deleted while the controller is swapping itself". func TestControllerRetention_NothingWhileSwappingAndNewerKept(t *testing.T) { m := retentionManager(t) f := ctlImages() f.containers = map[string]string{"c-ctl": "sha256:C2842"} // running 0.284.2, 0.285.0 pulled withFakeImages(t, f) m.SetSelfUpdatingCheck(func() bool { return true }) if _, err := m.RetainControllerImages(ControllerImageRecord{Repo: ctlRepo, Running: "0.284.2", Previous: ctlRepo + ":0.284.1"}); err != nil { t.Fatal(err) } if len(f.rmi) != 0 { t.Fatalf("deleted while the controller is swapping itself: %v", f.rmi) } m.SetSelfUpdatingCheck(func() bool { return false }) if _, err := m.RetainControllerImages(ControllerImageRecord{Repo: ctlRepo, Running: "0.284.2", Previous: ctlRepo + ":0.284.1"}); err != nil { t.Fatal(err) } s := rmiSet(f) if strings.Contains(s, ":0.285.0,") { t.Fatalf("the pulled target (0.285.0, above the running one) was deleted: %v", f.rmi) } if strings.Contains(s, ":0.284.1,") || !strings.Contains(s, ":0.283.1,") { t.Fatalf("want 0.284.1 kept (previous) and 0.283.1 deleted: %v", f.rmi) } } // A container that still uses an OLD controller image keeps it (the in-use rule), and a container list that cannot // be read deletes nothing (fail closed). func TestControllerRetention_InUseAndFailClosed(t *testing.T) { m := retentionManager(t) f := ctlImages() f.containers["c-old"] = "sha256:C2831" withFakeImages(t, f) if _, err := m.RetainControllerImages(ControllerImageRecord{Repo: ctlRepo, Running: "0.285.0", Previous: ctlRepo + ":0.284.2"}); err != nil { t.Fatal(err) } if strings.Contains(rmiSet(f), ":0.283.1,") { t.Fatalf("an image a container uses was deleted: %v", f.rmi) } f2 := ctlImages() withFakeImages(t, &fakeImages{imgs: f2.imgs, containers: f2.containers}) prev := imageDocker imageDocker = func(args ...string) (string, error) { if args[0] == "inspect" { return "", fmt.Errorf("no such container") } return prev(args...) } t.Cleanup(func() { imageDocker = prev }) if _, err := m.RetainControllerImages(ControllerImageRecord{Repo: ctlRepo, Running: "0.285.0"}); err == nil { t.Fatal("an unreadable container list must be an error (and delete nothing)") } } // A dev build (no release version) does nothing. func TestControllerRetention_DevBuildSkips(t *testing.T) { m := retentionManager(t) f := ctlImages() withFakeImages(t, f) if _, err := m.RetainControllerImages(ControllerImageRecord{Repo: ctlRepo, Running: "dev"}); err != nil { t.Fatal(err) } if len(f.rmi) != 0 { t.Fatalf("a dev build deleted images: %v", f.rmi) } } // R-751: the retention after an update runs in a goroutine; when the app is gone by the time it re-reads the stack // (removed right after the update, or a rescan that no longer finds it), it must return — it dereferenced a nil stack // and the panic took the whole controller down (seen 2026-10-01 in the full suite: a test's temp dir removed under it). // COMPANION RED-PROOF: without the `!ok` check after the rescan → panic "invalid memory address". func TestRetainImagesAfterUpdate_AppGoneDoesNotPanic(t *testing.T) { m := retentionManager(t) f := baseImages() withFakeImages(t, f) st, _ := m.GetStack("web") must(t, os.Remove(st.ComposePath)) // the rescan inside RetainImagesAfterUpdate no longer finds "web" m.RetainImagesAfterUpdate("web", map[string]InstalledImage{"web": {Ref: "acme/web:2", Digest: "sha256:w2"}}) if len(f.rmi) != 0 { t.Fatalf("deleted images for an app that is gone: %v", f.rmi) } }