package stacks import ( "fmt" "os" "path/filepath" "sort" "strings" "testing" ) // R-736 (decision 53): the box keeps each app service's running image and the one before it; it deletes older // images of that app; it never deletes an image a container or an installed compose names. Docker is the // imageDocker seam — nothing here reaches a daemon (and dockerexec refuses one under go test anyway, R-650). type fakeImages struct { imgs []localImage containers map[string]string // container id -> image id rmi []string } func (f *fakeImages) run(args ...string) (string, error) { switch { case args[0] == "image" && args[1] == "ls": var b strings.Builder for _, im := range f.imgs { fmt.Fprintf(&b, "%s\t%s\t%s\t%s\t%s\n", im.ID, im.Repo, im.Tag, im.Digest, im.Size) } return b.String(), nil case args[0] == "ps": var ids []string for c := range f.containers { ids = append(ids, c) } sort.Strings(ids) return strings.Join(ids, "\n"), nil case args[0] == "inspect": var out []string for _, c := range args[3:] { out = append(out, f.containers[c]) } return strings.Join(out, "\n"), nil case args[0] == "rmi": f.rmi = append(f.rmi, args[1]) var keep []localImage for _, im := range f.imgs { if im.ID != args[1] { keep = append(keep, im) } } f.imgs = keep return "Deleted", nil case args[0] == "system": return "Images 1GB 0B", nil } return "", fmt.Errorf("unexpected docker %v", args) } func withFakeImages(t *testing.T, f *fakeImages) { t.Helper() prev := imageDocker imageDocker = f.run t.Cleanup(func() { imageDocker = prev }) } // retentionManager: two installed apps sharing postgres:18-alpine; app "web" at web:3 (previous web:2), web:1 older. func retentionManager(t *testing.T) *Manager { t.Helper() m := gateManager(t, "display_name: G\n") m.cfg.Paths.DataDir = filepath.Join(t.TempDir(), "data") // never the package folder root := m.cfg.Paths.StacksDir write := func(app, compose, appYaml string) { d := filepath.Join(root, app) must(t, os.MkdirAll(d, 0o755)) must(t, os.WriteFile(filepath.Join(d, "docker-compose.yml"), []byte(compose), 0o644)) must(t, os.WriteFile(filepath.Join(d, "app.yaml"), []byte(appYaml), 0o644)) } write("web", "services:\n web:\n image: acme/web:3\n web-db:\n image: postgres:18-alpine\n", "deployed: true\nenv: {}\ninstalled_images:\n web:\n ref: acme/web:3\n digest: sha256:w3\n at: \"2026-09-30T00:00:00Z\"\n web-db:\n ref: postgres:18-alpine\n digest: sha256:p18\n at: \"2026-09-30T00:00:00Z\"\nprevious_images:\n web:\n ref: acme/web:2\n digest: sha256:w2\n at: \"2026-09-20T00:00:00Z\"\n") write("docs", "services:\n docs:\n image: acme/docs:1\n docs-db:\n image: postgres:18-alpine\n", "deployed: true\nenv: {}\ninstalled_images:\n docs:\n ref: acme/docs:1\n digest: sha256:d1\n at: \"2026-09-30T00:00:00Z\"\n") must(t, m.ScanStacks()) return m } func baseImages() *fakeImages { return &fakeImages{ imgs: []localImage{ {ID: "sha256:W3", Repo: "acme/web", Tag: "3", Digest: "sha256:w3", Size: "100MB"}, {ID: "sha256:W2", Repo: "acme/web", Tag: "2", Digest: "sha256:w2", Size: "100MB"}, {ID: "sha256:W1", Repo: "acme/web", Tag: "1", Digest: "sha256:w1", Size: "100MB"}, {ID: "sha256:P18", Repo: "postgres", Tag: "18-alpine", Digest: "sha256:p18", Size: "300MB"}, {ID: "sha256:P16", Repo: "postgres", Tag: "16-alpine", Digest: "sha256:p16", Size: "290MB"}, {ID: "sha256:D1", Repo: "acme/docs", Tag: "1", Digest: "sha256:d1", Size: "50MB"}, {ID: "sha256:CTL", Repo: "gitea.dooplex.hu/admin/felhom-controller", Tag: "0.283.0", Digest: "", Size: "400MB"}, }, containers: map[string]string{"c-web": "sha256:W3", "c-webdb": "sha256:P18", "c-docs": "sha256:D1", "c-docsdb": "sha256:P18"}, } } // After an update: the running image and the one before it stay; the older one goes; the shared engine stays. // COMPANION RED-PROOF: drop previous_images from imageKeepSet → "the undo's image (web:2) was deleted". func TestImageRetention_KeepsRunningAndPreviousDeletesOlder(t *testing.T) { m := retentionManager(t) f := baseImages() withFakeImages(t, f) st, _ := m.GetStack("web") if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); err != nil { t.Fatal(err) } got := strings.Join(f.rmi, ",") if strings.Contains(got, "sha256:W2") { t.Fatal("the undo's image (web:2) was deleted") } if strings.Contains(got, "sha256:W3") || strings.Contains(got, "sha256:P18") { t.Fatalf("a running image was deleted: %s", got) } if !strings.Contains(got, "sha256:W1") { t.Fatalf("the older web:1 was not deleted: %s", got) } if !strings.Contains(got, "sha256:P16") { t.Fatalf("postgres:16-alpine is this app's repo and nothing keeps it — expected deleted: %s", got) } if strings.Contains(got, "sha256:CTL") || strings.Contains(got, "sha256:D1") { t.Fatalf("another app's or the controller's image was touched: %s", got) } } // A shared image survives the remove of one of its apps (another app's container and compose name it). // COMPANION RED-PROOF: drop the container half of the keep set (return an empty map from imagesUsedByContainers) // AND the compose half → "the shared postgres:18-alpine was deleted". func TestImageRetention_ASharedImageSurvivesTheRemoveOfOneApp(t *testing.T) { m := retentionManager(t) f := baseImages() withFakeImages(t, f) st, _ := m.GetStack("web") repos := appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig) // the remove took web's containers away delete(f.containers, "c-web") delete(f.containers, "c-webdb") m.mu.Lock() m.stacks["web"].Deployed = false m.mu.Unlock() m.RetainImagesAfterRemove("web", repos) got := strings.Join(f.rmi, ",") if strings.Contains(got, "sha256:P18") { t.Fatal("the shared postgres:18-alpine was deleted while docs still runs it") } for _, id := range []string{"sha256:W3", "sha256:W2", "sha256:W1"} { if !strings.Contains(got, id) { t.Fatalf("the removed app's image %s was kept: %s", id, got) } } } // The keep set is checked from the compose too, not only containers: an installed app whose containers are down // (stopped by the household, or mid-restart) keeps its images. // COMPANION RED-PROOF: drop the ParseComposeImages loop from imageKeepSet → docs' image goes. func TestImageRetention_AStoppedAppsComposeKeepsItsImage(t *testing.T) { m := retentionManager(t) f := baseImages() f.containers = map[string]string{} // nothing running anywhere withFakeImages(t, f) m.RunImageRetentionOnce() // catalog-cache is absent → skipped, no marker if len(f.rmi) != 0 { t.Fatalf("the one-time sweep ran without a catalog: %v", f.rmi) } st, _ := m.GetStack("docs") m.mu.Lock() m.stacks["docs"].AppConfig.InstalledImages = nil // only the compose names it now m.mu.Unlock() if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), nil), ""); err != nil { t.Fatal(err) } if strings.Contains(strings.Join(f.rmi, ","), "sha256:D1") { t.Fatal("a stopped app's image was deleted although its compose names it") } } // A keep set that cannot be read deletes NOTHING (fail closed). func TestImageRetention_UnreadableKeepSetDeletesNothing(t *testing.T) { m := retentionManager(t) f := baseImages() withFakeImages(t, f) prev := imageDocker imageDocker = func(args ...string) (string, error) { if args[0] == "ps" { return "", fmt.Errorf("daemon hiccup") } return f.run(args...) } t.Cleanup(func() { imageDocker = prev }) if _, err := m.deleteUnkeptImages("test", map[string]bool{"acme/web": true, "postgres": true}, ""); err == nil { t.Fatal("no error from an unreadable keep set") } if len(f.rmi) != 0 { t.Fatalf("deleted with no keep set: %v", f.rmi) } } // The one-time sweep reaches only images the catalog names (app images) — never the controller's. func TestImageRetention_OneTimeSweepOnlyCatalogRepos(t *testing.T) { m := retentionManager(t) f := baseImages() f.imgs = append(f.imgs, localImage{ID: "sha256:OLD", Repo: "acme/gone", Tag: "5", Digest: "sha256:g5", Size: "70MB"}) withFakeImages(t, f) cat := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates", "gone") must(t, os.MkdirAll(cat, 0o755)) must(t, os.WriteFile(filepath.Join(cat, "docker-compose.yml"), []byte("services:\n gone:\n image: acme/gone:6\n"), 0o644)) deleted := m.RunImageRetentionOnce() got := strings.Join(f.rmi, ",") if !strings.Contains(got, "sha256:OLD") { t.Fatalf("an earlier-removed app's image was not swept: %v", deleted) } if strings.Contains(got, "sha256:CTL") || strings.Contains(got, "sha256:W1") { t.Fatalf("the sweep reached beyond the catalog's repos: %s", got) } if _, err := os.Stat(m.imageRetentionMarker()); err != nil { t.Fatal("no marker — the sweep would run at every start") } f.rmi = nil m.RunImageRetentionOnce() if len(f.rmi) != 0 { t.Fatal("the one-time sweep ran twice") } } // An update in flight pauses every pass: its undo's image is named by nothing the keep set reads. // COMPANION RED-PROOF: drop the busy check in deleteUnkeptImages → "a pass ran while docs was updating". func TestImageRetention_NoPassWhileAnUpdateRuns(t *testing.T) { m := retentionManager(t) f := baseImages() withFakeImages(t, f) m.mu.Lock() m.stacks["docs"].Updating = true m.mu.Unlock() st, _ := m.GetStack("web") if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); err != nil { t.Fatal(err) } if len(f.rmi) != 0 { t.Fatalf("a pass ran while docs was updating: %v", f.rmi) } }