Files
mesh-catalog/modules/docker/cmd/docker-tools/prune_images_test.go
T
jschoubben 35ff67bda0
mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
Design 47's last milestones: the keeper, self-check, healers, hand-acts, provisioner, vault, artifact sweep, disk readings, credential forms (issues 774, 1952, 1953, 1954, 1955)
One commit with exactly the tree of the reviewed head 35b4509c0a, on main 12c2574c9d, so two compiled programs added and removed on the branch leave no trace in history (issue 1577).
2026-10-30 02:21:51 +01: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-artifact-sweep.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")
}
}