Files
mesh-catalog/modules/docker/cmd/docker-tools/prune_images_test.go
T

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:artifact-store.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")
}
}