Files
felhom-controller/controller/internal/stacks/controller_image_retention_test.go
T

186 lines
7.7 KiB
Go

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: "<none>", 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)
}
}