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.
216 lines
8.2 KiB
Go
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")
|
|
}
|
|
}
|