00fddd3816
gates / gates (push) Successful in 26s
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
293 lines
12 KiB
Go
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")
|
|
}
|
|
}
|