Files
felhom-controller/controller/internal/stacks/image_retention_test.go
T
admin 00fddd3816
gates / gates (push) Successful in 26s
v0.284.1 — the image clean-up runs on the household's Remove button too; one summary line per pass (decision 53, R-736)
Found live on 9202: v0.284.0 wired the remove half into DeleteStack only; the app page's Remove runs RemoveStack.
Now RemoveStack reads the app's image repositories before its compose down and runs the retention after. Every
retention pass logs one line (images seen, candidates, deleted), so a pass that kept everything is visible.
Tests TestImageRetention_TheRemoveButtonRunsIt / ADoneUpdateRunsItWithThePrevious, red-proofed. v0.284.0 was never
floored (scratch 9202 only).

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 22:54:15 +02:00

293 lines
12 KiB
Go

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)
}
}
// The household's Remove button runs RemoveStack — the retention must run there (v0.284.0 wired only DeleteStack;
// found live on 9202 2026-09-30).
// COMPANION RED-PROOF: drop the retainAfterRemoveFn call in RemoveStack → "RemoveStack did not run the retention".
func TestImageRetention_TheRemoveButtonRunsIt(t *testing.T) {
drive := t.TempDir()
m, _, _ := newR442Manager(t, "app", ssdCompose, driveAppYAML(drive), drive)
var got string
var repos map[string]bool
prev := retainAfterRemoveFn
retainAfterRemoveFn = func(_ *Manager, name string, r map[string]bool) { got, repos = name, r }
defer func() { retainAfterRemoveFn = prev }()
if _, err := m.RemoveStack("app", false, nil); err != nil {
t.Fatalf("RemoveStack: %v", err)
}
if got != "app" || len(repos) == 0 {
t.Fatalf("RemoveStack did not run the retention with the app's repos (got %q, %v)", got, repos)
}
}
// A guarded Update that ends done runs the retention with what the app ran BEFORE (the previous image to keep).
// COMPANION RED-PROOF: drop the retainAfterUpdate call at the end of verifyAndConclude → "the done update did not run it".
func TestImageRetention_ADoneUpdateRunsItWithThePrevious(t *testing.T) {
m, dir, _, _, _ := ladderManager(t, true)
cfg := LoadAppConfig(dir)
cfg.InstalledImages = map[string]InstalledImage{"web": {Ref: ladderA, Digest: dA, At: "2026-09-20T00:00:00Z"}}
must(t, SaveAppConfig(dir, cfg, m.encKey, nil))
must(t, m.ScanStacks())
ch := make(chan map[string]InstalledImage, 1)
prev := retainAfterUpdateFn
retainAfterUpdateFn = func(_ *Manager, name string, p map[string]InstalledImage) { ch <- p }
defer func() { retainAfterUpdateFn = prev }()
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
st := waitUpdateDone(t, m, "nextcloud")
if st.UpdatePhase != UpdatePhaseDone {
t.Fatalf("the update ended %q (%s)", st.UpdatePhase, st.UpdateError)
}
select {
case p := <-ch:
if p["web"].Ref != ladderA {
t.Fatalf("the previous image handed on is %+v, want %s", p, ladderA)
}
default:
t.Fatal("the done update did not run the retention")
}
}