Files
felhom-controller/controller/internal/stacks/image_retention_test.go
T
admin a70c398dee
gates / gates (push) Successful in 26s
v0.284.2 — the image clean-up sees digest-pulled (untagged) images (decision 53, R-736)
Found live on 9202: the product pins tag@digest, and such images are stored untagged (repo:<none>);
`docker image ls` without -a did not list them, so the retention saw almost no app image. Now `image ls -a`;
an anonymous <none>:<none> entry is never a candidate; the one-time marker is v2 so the corrected sweep runs
once everywhere. Test TestImageRetention_SeesUntaggedDigestPulledImages, red-proofed. 0.284.0/0.284.1 were
never floored.

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 23:03:32 +02:00

321 lines
13 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":
all := false
for _, a := range args {
all = all || a == "-a"
}
var b strings.Builder
for _, im := range f.imgs {
if im.Tag == "<none>" && !all {
continue // measured on 9202: `docker image ls` without -a hides untagged (digest-pulled) images
}
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")
}
}
// The product pins tag@digest, so app images sit UNTAGGED (`repo:<none>`): the pass must see them.
// COMPANION RED-PROOF: drop "-a" from listLocalImages → "an untagged old image was not deleted".
func TestImageRetention_SeesUntaggedDigestPulledImages(t *testing.T) {
m := retentionManager(t)
f := baseImages()
f.imgs = append(f.imgs, localImage{ID: "sha256:W0", Repo: "acme/web", Tag: "<none>", Digest: "sha256:w0", Size: "100MB"},
localImage{ID: "sha256:ANON", Repo: "<none>", Tag: "<none>", Digest: "<none>", Size: "1MB"})
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:W0") {
t.Fatalf("an untagged old image was not deleted: %s", got)
}
if strings.Contains(got, "sha256:ANON") {
t.Fatalf("an anonymous <none>:<none> entry was touched: %s", got)
}
}