Files
mesh-catalog/modules/docker/cmd/docker-tools/prune_images_test.go
T
jochen 465707d41d
mesh/merge-gate pass: builds docker → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer delivery to the same trunk took over its walk
docker: remove images no container or declaration uses, keeping the previous of each line (hq ADR 0251)
Images pulled by digest are not dangling, so the weekly prune never takes an old version
and every machine keeps every image it ever ran. docker_prune_images takes them, keeps what
the declaration names (asked of the controller; no answer, nothing removed) and the one before,
and is a dry run unless asked with a why.
2026-10-08 11:56:43 +02:00

216 lines
8.2 KiB
Go

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")
}
}