package main import ( "context" "encoding/json" "errors" "strings" "testing" ) // The machine's images, at 2026-10-04 12:00 (client's Now): // // img1 store/web line, used by the container mesh-web // img2 postgres:16, used by the stopped container dev-db // web-old store/web@sha256:old, older than img1: the previous, kept // web-older store/web, oldest: removed // app-new store/app@sha256:cur: declared, not running // app-mid local build tag app-build:abc AND store/app@sha256:mid: one image, two names, one line: previous // app-old app-build:old: same line through the local name: removed // fresh other:1, two days old: younger than the floor // gone retired:1, no line in use: removed const imagesInspect = `[ {"Id":"sha256:img1","RepoTags":["store:5000/web/site:latest"],"RepoDigests":["store:5000/web/site@sha256:w1"],"Created":"2026-09-20T00:00:00Z","Size":100}, {"Id":"sha256:img2","RepoTags":["postgres:16"],"RepoDigests":["postgres@sha256:pg"],"Created":"2026-01-01T00:00:00Z","Size":200}, {"Id":"sha256:webold","RepoTags":[],"RepoDigests":["store:5000/web/site@sha256:w0"],"Created":"2026-09-10T00:00:00Z","Size":100}, {"Id":"sha256:webolder","RepoTags":[],"RepoDigests":["store:5000/web/site@sha256:wm"],"Created":"2026-09-01T00:00:00Z","Size":100}, {"Id":"sha256:appnew","RepoTags":[],"RepoDigests":["store:5000/app/server@sha256:cur"],"Created":"2026-09-25T00:00:00Z","Size":50}, {"Id":"sha256:appmid","RepoTags":["app-build:abc"],"RepoDigests":["store:5000/app/server@sha256:mid"],"Created":"2026-09-20T00:00:00Z","Size":50}, {"Id":"sha256:appold","RepoTags":["app-build:old"],"RepoDigests":[],"Created":"2026-09-01T00:00:00Z","Size":50}, {"Id":"sha256:fresh","RepoTags":["other:1"],"RepoDigests":[],"Created":"2026-10-02T00:00:00Z","Size":10}, {"Id":"sha256:gone","RepoTags":["retired:1"],"RepoDigests":[],"Created":"2026-08-01T00:00:00Z","Size":70} ]` const ids = "sha256:img1\nsha256:img2\nsha256:webold\nsha256:webolder\nsha256:appnew\nsha256:appmid\nsha256:appold\nsha256:fresh\nsha256:gone\n" func imagesMachine() *fake { f := machine(). on("docker image ls --quiet --no-trunc", Ran{Stdout: ids}). on("docker image inspect", Ran{Stdout: imagesInspect}). on("docker image rm", Ran{Stdout: "Deleted"}) return f } func declaring(t *testing.T, answer string) Asker { return func(key string, body any) (json.RawMessage, error) { if key != "seat:mesh-controller.images" { t.Errorf("asked %s", key) } if b, _ := body.(map[string]any); b["node"] != "laptop" { t.Errorf("asked for %v", body) } wrapped, _ := json.Marshal(map[string]any{"ok": true, "output": "", "answer": json.RawMessage(answer)}) return wrapped, nil } } const declaredApp = `{"node":"laptop","sent_known":true,"images":[ {"image":"store:5000/app/server@sha256:cur","resources":["app.server"],"in":["declaration"]}, {"image":"docker.io/library/postgres@sha256:pg","resources":["db.server"],"in":["sent"]}]}` func pruned(t *testing.T, out map[string]any) map[string]*PrunedImage { t.Helper() got := map[string]*PrunedImage{} for _, img := range out["images"].([]*PrunedImage) { got[img.ID] = img } return got } func has(why []string, w string) bool { for _, x := range why { if x == w { return true } } return false } func TestImagesAreKeptForEachReasonAndTheRestAreCandidates(t *testing.T) { f := imagesMachine() c := client(f, 1000) c.Node, c.Ask = "laptop", declaring(t, declaredApp) out, err := c.PruneImages(context.Background(), PruneImagesAsk{DryRun: true, OlderThanDays: 7, Limit: 100}) if err != nil { t.Fatal(err) } got := pruned(t, out) want := map[string]string{ "img1": keptUsed, "img2": keptUsed, "webold": keptPrevious, "appnew": keptDeclared, "appmid": keptPrevious, "fresh": keptYoung, } for id, w := range want { if !got[id].Kept || !has(got[id].Why, w) { t.Errorf("%s: kept %v why %v, want %s", id, got[id].Kept, got[id].Why, w) } } if !has(got["img2"].Why, keptDeclared) { t.Errorf("postgres named as docker.io/library/postgres@… was not matched: %v", got["img2"].Why) } for _, id := range []string{"webolder", "appold", "gone"} { if got[id].Kept { t.Errorf("%s kept: %v", id, got[id].Why) } } if got["appmid"].Line != got["appold"].Line || got["appmid"].Line != got["appnew"].Line { t.Errorf("an image with two names did not join its lines: %q %q %q", got["appmid"].Line, got["appold"].Line, got["appnew"].Line) } if out["bytes_candidate"].(int64) != 220 { t.Errorf("bytes %v", out["bytes_candidate"]) } if f.ran("docker image rm") { t.Fatal("a dry run removed an image") } } func TestARealRunRemovesByNameNeverForced(t *testing.T) { f := imagesMachine() c := client(f, 1000) c.Node, c.Ask = "laptop", declaring(t, declaredApp) out, err := c.PruneImages(context.Background(), PruneImagesAsk{DryRun: false, Why: "disk", OlderThanDays: 7, Limit: 100}) if err != nil { t.Fatal(err) } if n := len(out["removed"].([]string)); n != 3 { t.Errorf("removed %v", out["removed"]) } for _, call := range f.calls { line := strings.Join(call.args, " ") if strings.HasPrefix(line, "image rm") { if strings.Contains(line, "-f") || strings.Contains(line, "--force") { t.Errorf("forced: %s", line) } if strings.Contains(line, "sha256:img1") || strings.Contains(line, "web/site@sha256:w0") { t.Errorf("removed a kept image: %s", line) } } } } func TestARefusalIsReportedAndTheRestGoOn(t *testing.T) { f := machine(). on("docker image ls --quiet --no-trunc", Ran{Stdout: ids}). on("docker image inspect", Ran{Stdout: imagesInspect}). on("docker image rm retired:1", Ran{Status: 1, Stderr: "conflict: unable to remove repository reference"}). on("docker image rm", Ran{Stdout: "Deleted"}) c := client(f, 1000) c.Node, c.Ask = "laptop", declaring(t, declaredApp) out, err := c.PruneImages(context.Background(), PruneImagesAsk{DryRun: false, Why: "disk", OlderThanDays: 7}) if err != nil { t.Fatal(err) } if len(out["refused"].([]map[string]string)) != 1 || len(out["removed"].([]string)) != 2 { t.Errorf("removed %v refused %v", out["removed"], out["refused"]) } } func TestNoAnswerFromTheControllerRemovesNothing(t *testing.T) { for name, c := range map[string]*Client{ "error": {Node: "laptop", Ask: func(string, any) (json.RawMessage, error) { return nil, errors.New("no responders") }}, "refused": {Node: "laptop", Ask: func(string, any) (json.RawMessage, error) { return json.RawMessage(`{"ok":false,"output":"no such machine"}`), nil }}, "no node": {Ask: declaring(t, declaredApp)}, } { f := imagesMachine() cl := client(f, 1000) cl.Node, cl.Ask = c.Node, c.Ask out, err := cl.PruneImages(context.Background(), PruneImagesAsk{DryRun: false, Why: "disk", OlderThanDays: 7}) if err != nil { t.Fatal(name, err) } if f.ran("docker image rm") { t.Errorf("%s: removed without knowing the declaration", name) } if out["declaration_known"] != false || out["counts"].(map[string]int)["candidates"] != 0 { t.Errorf("%s: %v", name, out["counts"]) } } } func TestARealRunWithoutWhyIsRefused(t *testing.T) { f := imagesMachine() c := client(f, 1000) c.Node, c.Ask = "laptop", declaring(t, declaredApp) if _, err := c.PruneImages(context.Background(), PruneImagesAsk{DryRun: false, OlderThanDays: 7}); err == nil { t.Fatal("a real run without why was accepted") } if len(f.calls) != 0 { t.Fatal("something was asked of the runtime before refusing") } for _, tool := range tools(c) { if tool.Name == "docker_prune_images" { if _, err := tool.Run(map[string]any{"dry_run": false}); err == nil { t.Fatal("the tool accepted a real run without why") } } } } func TestReferencesAreNormalisedAsTheRuntimeReportsThem(t *testing.T) { for in, want := range map[string]string{ "docker.io/library/redis@sha256:x": "redis@sha256:x", "redis": "redis:latest", "store:5000/a/b": "store:5000/a/b:latest", "store:5000/a/b:1": "store:5000/a/b:1", "ghcr.io/x/y@sha256:z": "ghcr.io/x/y@sha256:z", } { if got := normalRef(in); got != want { t.Errorf("%s: %s, want %s", in, got, want) } } if repositoryOf("store:5000/a/b:1") != "store:5000/a/b" || repositoryOf("store:5000/a/b@sha256:z") != "store:5000/a/b" { t.Error("repository") } }