diff --git a/modules/docker/README.md b/modules/docker/README.md index c3425720..416c72e0 100644 --- a/modules/docker/README.md +++ b/modules/docker/README.md @@ -135,6 +135,7 @@ A failure is an error naming how it failed, never an empty answer. | `docker_top` | r | the processes inside one container | | `docker_images` | r | images, largest first, with the containers using each; `dangling`, `unused` or `used` | | `docker_prune` | a | dangling images and build cache, and stopped containers the mesh does not hold if `containers` is true. **A dry run unless `dry_run` is false. Never a volume** | +| `docker_prune_images` | a | named images no container and no declaration uses, keeping the previous version of each line (below). **A dry run unless `dry_run` is false, which needs `why`. Never forced** | | `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each | | `docker_networks` | r | networks, subnets, and the containers on each | | `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked | @@ -198,6 +199,29 @@ transcript that already copied it. `docker_inspect` shows a container's own command line (`Path`/`Args`, `Cmd`, `Entrypoint`) redacted the same way. +## Removing the images nothing uses (hq ADR 0251 §5) + +The weekly prune takes dangling images only, and an image pulled by digest is not dangling, so every +version of every module a machine ever ran stays on it. `docker_prune_images` takes those, and keeps: + +- every image a container on the machine uses, in any state; +- every image the machine's declaration names — what the mesh would send it now and what it was last + sent — asked of the controller's `images` verb (the manifest's `invokes`). **No answer, an error, or + no machine name (`MESH_NODE`) means nothing is removed**: every image is answered as kept, + `declaration-unknown`; +- every image younger than `older_than_days` (seven by default, the weekly prune's week); +- in each **line** holding an image kept for one of the first two reasons, the newest other image: the + previous version, so going back needs no pull. A line is the images sharing a repository name, + joined across names when one image carries several (a build's local tag and the store's name). + +A declared reference is matched against the runtime's own names for an image (`docker.io/` and +`library/` left out, a bare name tagged `latest`), by the whole reference and then by its digest. + +Dangling images are not taken here; they stay the weekly prune's. A real run removes each image by +every name it carries, one image at a time and never with force, so the runtime refuses an image a +container uses; a refusal is reported for that image and the rest go on. The bytes it states are each +image's size summed, an upper bound, because images share layers. + ## Tests ``` @@ -213,6 +237,7 @@ The tests run against a fake runner and cover: - the environment left out of `inspect`; - the restore note on a mesh-held act; - prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`; +- image pruning: each reason an image is kept, a two-name image being one line, the previous being the newest other image, nothing removed without the controller's answer or in a dry run, removal never forced, a real run refused without `why`; - the log merge; - a printed secret found by name and never answered by value, in the scan and in `docker_logs`; - size parsing; diff --git a/modules/docker/cmd/docker-tools/docker.go b/modules/docker/cmd/docker-tools/docker.go index 9872778d..872b8f3d 100644 --- a/modules/docker/cmd/docker-tools/docker.go +++ b/modules/docker/cmd/docker-tools/docker.go @@ -20,6 +20,8 @@ import ( "strconv" "strings" "time" + + stdio "git.novox.be/novox/mesh-sdk/go" ) // MeshLabel is the label the host puts on every container it creates (mesh-host internal/apply). @@ -34,11 +36,14 @@ type Client struct { UID int ReadFile func(string) ([]byte, error) Now func() time.Time + // Ask asks the mesh (the SDK's stdio.Ask); Node is this machine's name (MESH_NODE). + Ask Asker + Node string } // NewClient is the client the bundle serves with. func NewClient() *Client { - return &Client{Run: ExecRunner, UID: os.Getuid(), ReadFile: os.ReadFile, Now: time.Now} + return &Client{Run: ExecRunner, UID: os.Getuid(), ReadFile: os.ReadFile, Now: time.Now, Ask: stdio.Ask, Node: os.Getenv("MESH_NODE")} } var socketRefused = regexp.MustCompile(`(?i)permission denied.*docker.*sock|docker\.sock.*permission denied`) diff --git a/modules/docker/cmd/docker-tools/main.go b/modules/docker/cmd/docker-tools/main.go index a6228200..f5c812ea 100644 --- a/modules/docker/cmd/docker-tools/main.go +++ b/modules/docker/cmd/docker-tools/main.go @@ -198,6 +198,39 @@ func tools(c *Client) []stdio.Tool { }) }, }, + { + Name: "docker_prune_images", + Description: "Remove the images no container and no declaration on this machine uses (novox/hq ADR 0251 §5). Kept: every image a " + + "container uses, in any state; every image this machine's declaration names, now and as last sent, asked of the " + + "controller (no answer, nothing removed); every image younger than older_than_days (default 7); and in each line of " + + "images (one repository name, joined across names one image carries) the newest image besides those, so the previous " + + "version stays for going back. Each image is answered with whether it is kept and why. Never forced; dangling images " + + "are the weekly prune's. A dry run by default; dry_run false needs why. Replaces docker image prune -a and docker rmi. (a)", + Input: map[string]any{ + "dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"}, + "why": map[string]any{"type": "string", "description": "why: required when dry_run is false"}, + "older_than_days": map[string]any{"type": "integer", "description": "keep every image younger than this many days (default 7, 0 for none)"}, + "match": map[string]any{"type": "string", "description": "only images whose repository or name contains this"}, + "limit": map[string]any{"type": "integer", "description": "how many images to list (default 100, at most 2000); counts cover all"}, + }, + Run: func(args map[string]any) (any, error) { + days := 7 + if v, ok := args["older_than_days"]; ok && v != nil { + f, isNum := v.(float64) + if !isNum || f != math.Trunc(f) || f < 0 || f > 3650 { + return nil, fmt.Errorf("older_than_days must be a whole number from 0 to 3650") + } + days = int(f) + } + limit, err := bounded(args, "limit", 100, 2000) + if err != nil { + return nil, err + } + why, _ := args["why"].(string) + return c.PruneImages(ctx, PruneImagesAsk{DryRun: flag(args, "dry_run", true), Why: why, OlderThanDays: days, + Match: optional(args, "match"), Limit: limit}) + }, + }, { Name: "docker_disk_usage", Description: "What the runtime takes on disk (docker system df -v): per kind — images, containers, volumes, build cache — the total, the active and the reclaimable, and the largest of each.", diff --git a/modules/docker/cmd/docker-tools/prune_images.go b/modules/docker/cmd/docker-tools/prune_images.go new file mode 100644 index 00000000..49f75436 --- /dev/null +++ b/modules/docker/cmd/docker-tools/prune_images.go @@ -0,0 +1,454 @@ +package main + +// Removing the images no declaration uses, keeping the one each module runs and the one before +// (novox/hq ADR 0251 §5, to-be 51 "A machine's images"). +// +// The weekly prune takes dangling images only, and an image a machine pulled by digest is not +// dangling: every version of every module a machine ever ran stays on it. This takes them, and +// keeps four things: +// +// - every image a container on this machine uses, in any state; +// - every image this machine's declaration names — what the mesh would send it now and what it was +// last sent — as the controller's `images` verb answers. **No answer, no removal**: what the +// declaration names is the one thing that cannot be guessed; +// - every image younger than the floor (seven days, the weekly prune's week); +// - in each line of images holding one of those, the newest image besides them: the previous +// version, so going back needs no pull. +// +// A line is the images sharing a repository name, joined across names when one image carries more +// than one (a build's local tag and the store's name for the same image). +// +// Removal is by every name an image carries, one image at a time, never forced: the runtime itself refuses an image a container +// uses, and a refusal is reported for that image while the rest go on. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +// Asker asks a seat's verb or a module's tool on the mesh (the SDK's stdio.Ask). +type Asker func(key string, body any) (json.RawMessage, error) + +// PruneImagesAsk is what docker_prune_images is asked. +type PruneImagesAsk struct { + DryRun bool + Why string + OlderThanDays int + Match string + Limit int +} + +// declaredImages is the controller's `images` answer (novox/hq ADR 0251 §5). +type declaredImages struct { + Node string `json:"node"` + Images []struct { + Image string `json:"image"` + Resources []string `json:"resources"` + In []string `json:"in"` + } `json:"images"` + SentKnown bool `json:"sent_known"` +} + +// imageInspected is the part of `docker image inspect` this reads. +type imageInspected struct { + ID string `json:"Id"` + RepoTags []string `json:"RepoTags"` + RepoDigests []string `json:"RepoDigests"` + Created string `json:"Created"` + Size int64 `json:"Size"` +} + +// PrunedImage is one image as the answer says it. +type PrunedImage struct { + ID string `json:"id"` + Names []string `json:"names"` + Created string `json:"created"` + Size int64 `json:"size_bytes"` + Kept bool `json:"kept"` + Why []string `json:"why,omitempty"` + Line string `json:"line"` + Declared []string `json:"declared_by,omitempty"` + UsedBy []string `json:"used_by,omitempty"` + created time.Time + fullID string +} + +const ( + keptUsed = "used-by-container" + keptDeclared = "declared" + keptPrevious = "previous" + keptYoung = "younger-than-floor" + keptUnknown = "declaration-unknown" +) + +// normalRef is an image reference as the runtime reports it: the default registry and its library +// namespace left out, and a bare name tagged latest. +func normalRef(ref string) string { + ref = strings.TrimSpace(ref) + for _, p := range []string{"docker.io/", "index.docker.io/", "registry-1.docker.io/"} { + ref = strings.TrimPrefix(ref, p) + } + ref = strings.TrimPrefix(ref, "library/") + if !strings.Contains(ref, "@") { + name, tag := splitTag(ref) + if tag == "" { + ref = name + ":latest" + } + } + return ref +} + +// splitTag splits name:tag, minding a registry's port. +func splitTag(ref string) (string, string) { + i := strings.LastIndex(ref, ":") + if i < 0 || strings.Contains(ref[i:], "/") { + return ref, "" + } + return ref[:i], ref[i+1:] +} + +// repositoryOf is a reference without its tag or digest. +func repositoryOf(ref string) string { + ref = normalRef(ref) + if name, _, ok := strings.Cut(ref, "@"); ok { + return name + } + name, _ := splitTag(ref) + return name +} + +func digestOf(ref string) string { + if _, d, ok := strings.Cut(ref, "@"); ok { + return d + } + return "" +} + +// askDeclared asks the controller what this machine's declarations name. An error means unknown. +func (c *Client) askDeclared() (*declaredImages, error) { + if c.Node == "" { + return nil, errors.New("the runtime did not say which machine this is (MESH_NODE is empty)") + } + if c.Ask == nil { + return nil, errors.New("this bundle cannot ask the mesh") + } + raw, err := c.Ask("seat:mesh-controller.images", map[string]any{"node": c.Node}) + if err != nil { + return nil, fmt.Errorf("the controller's images verb did not answer: %w", err) + } + answer, output, ok := answerOf(raw) + if !ok { + return nil, fmt.Errorf("the controller refused images: %s", firstLine(output)) + } + var d declaredImages + if err := json.Unmarshal(answer, &d); err != nil { + return nil, fmt.Errorf("the controller's images answer is not readable: %v", err) + } + if d.Node != "" && d.Node != c.Node { + return nil, fmt.Errorf("the controller answered for %q, not for this machine %q", d.Node, c.Node) + } + return &d, nil +} + +// answerOf reads a verb's answer whatever wraps it: the controller's {output, ok, answer}, a text the +// runtime handed over, or the protocol's content list (as mesh-delivery reads it). +func answerOf(raw json.RawMessage) (json.RawMessage, string, bool) { + var s string + if json.Unmarshal(raw, &s) == nil { + return answerOf(json.RawMessage(s)) + } + var m map[string]json.RawMessage + if json.Unmarshal(raw, &m) != nil { + return raw, string(raw), true + } + if content, has := m["content"]; has { + var items []struct { + Text string `json:"text"` + } + var e struct { + IsError bool `json:"isError"` + } + _ = json.Unmarshal(raw, &e) + if json.Unmarshal(content, &items) == nil && len(items) > 0 { + inner, out, ok := answerOf(json.RawMessage(items[0].Text)) + return inner, out, ok && !e.IsError + } + } + if okRaw, has := m["ok"]; has { + var ok bool + _ = json.Unmarshal(okRaw, &ok) + var output string + _ = json.Unmarshal(m["output"], &output) + if answer, has := m["answer"]; has && ok { + return answer, output, true + } + return nil, output, ok + } + return raw, string(raw), true +} + +// images is every image that has a name, inspected. Dangling ones are the weekly prune's. +func (c *Client) namedImages(ctx context.Context) ([]imageInspected, error) { + out, err := c.docker(ctx, "image", "ls", "--quiet", "--no-trunc") + if err != nil { + return nil, err + } + seen, ids := map[string]bool{}, []string{} + for _, id := range lines(out) { + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil, nil + } + out, err = c.docker(ctx, append([]string{"image", "inspect"}, ids...)...) + if err != nil { + if strings.Contains(err.Error(), "No such") { + return c.namedImages(ctx) // one went between the two calls: ask once more + } + return nil, err + } + var got []imageInspected + if err := json.Unmarshal([]byte(out), &got); err != nil { + return nil, fmt.Errorf("docker image inspect answered something that is not JSON: %v", err) + } + named := got[:0] + for _, i := range got { + if len(i.RepoTags)+len(i.RepoDigests) > 0 { + named = append(named, i) + } + } + return named, nil +} + +// PruneImages removes, or with DryRun only lists, the images no container and no declaration uses. +func (c *Client) PruneImages(ctx context.Context, a PruneImagesAsk) (map[string]any, error) { + if !a.DryRun && strings.TrimSpace(a.Why) == "" { + return nil, errors.New("a real run needs why: nothing was removed") + } + if a.OlderThanDays < 0 { + return nil, errors.New("older_than_days is at least 0") + } + if a.Limit <= 0 { + a.Limit = 100 + } + raw, err := c.namedImages(ctx) + if err != nil { + return nil, err + } + containers, err := c.inspectAll(ctx) + if err != nil { + return nil, err + } + users := map[string][]string{} + for _, ct := range containers { + users[ct.Image] = append(users[ct.Image], strings.TrimPrefix(ct.Name, "/")) + } + declared, askErr := c.askDeclared() + + // What the declarations name, by full reference and by digest. + byRef, byDigest := map[string][]string{}, map[string][]string{} + if declared != nil { + for _, d := range declared.Images { + who := strings.Join(d.Resources, ",") + if who == "" { + who = d.Image + } + ref := normalRef(d.Image) + byRef[ref] = append(byRef[ref], who) + if dg := digestOf(ref); dg != "" { + byDigest[dg] = append(byDigest[dg], who) + } + } + } + + now := c.Now() + floor := now.Add(-time.Duration(a.OlderThanDays) * 24 * time.Hour) + images := make([]*PrunedImage, 0, len(raw)) + parent := map[string]string{} + var find func(string) string + find = func(x string) string { + if parent[x] == "" || parent[x] == x { + parent[x] = x + return x + } + parent[x] = find(parent[x]) + return parent[x] + } + for _, r := range raw { + img := &PrunedImage{ID: shortID(r.ID), fullID: r.ID, Size: r.Size, Created: r.Created} + img.created, _ = time.Parse(time.RFC3339Nano, r.Created) + img.Names = append(append([]string{}, r.RepoTags...), r.RepoDigests...) + img.UsedBy = users[r.ID] + repos := []string{} + for _, n := range img.Names { + ref := normalRef(n) + repos = append(repos, repositoryOf(ref)) + who := byRef[ref] + if dg := digestOf(ref); dg != "" && len(who) == 0 { + who = byDigest[dg] + } + img.Declared = append(img.Declared, who...) + } + sort.Strings(repos) + for _, rp := range repos[1:] { + parent[find(rp)] = find(repos[0]) + } + find(repos[0]) + img.Line = repos[0] + images = append(images, img) + } + for _, img := range images { + img.Line = find(img.Line) + if len(img.UsedBy) > 0 { + img.Why = append(img.Why, keptUsed) + } + if len(img.Declared) > 0 { + img.Why = append(img.Why, keptDeclared) + } + } + // The previous version: in each line holding an image in use or declared, the newest one besides. + lines := map[string][]*PrunedImage{} + for _, img := range images { + lines[img.Line] = append(lines[img.Line], img) + } + for _, members := range lines { + current := false + for _, m := range members { + current = current || len(m.Why) > 0 + } + if !current { + continue + } + var newest *PrunedImage + for _, m := range members { + if len(m.Why) == 0 && (newest == nil || m.created.After(newest.created)) { + newest = m + } + } + if newest != nil { + newest.Why = append(newest.Why, keptPrevious) + } + } + for _, img := range images { + if img.created.IsZero() || img.created.After(floor) { + img.Why = append(img.Why, keptYoung) + } + if declared == nil { + img.Why = append(img.Why, keptUnknown) + } + img.Kept = len(img.Why) > 0 + } + + counts := map[string]int{"images": len(images)} + var candidates []*PrunedImage + var candidateBytes int64 + for _, img := range images { + for _, w := range img.Why { + counts["kept_"+strings.ReplaceAll(w, "-", "_")]++ + } + if img.Kept { + counts["kept"]++ + continue + } + if a.Match != "" && !strings.Contains(img.Line+" "+strings.Join(img.Names, " "), a.Match) { + continue + } + candidates = append(candidates, img) + candidateBytes += img.Size + } + counts["candidates"] = len(candidates) + + answer := map[string]any{ + "dry_run": a.DryRun, "node": c.Node, "declaration_known": declared != nil, + "older_than_days": a.OlderThanDays, "counts": counts, + "bytes_candidate": candidateBytes, + "note": "bytes_candidate sums each image's size, an upper bound: images share layers, so less is freed. " + + "Dangling images are not taken here; the weekly prune takes them.", + } + if declared != nil { + answer["sent_known"] = declared.SentKnown + } else { + answer["sent_known"] = false + answer["declaration_unknown"] = askErr.Error() + } + if a.Match != "" { + answer["match"] = a.Match + } + + sort.Slice(images, func(i, j int) bool { + if images[i].Kept != images[j].Kept { + return !images[i].Kept + } + return images[i].Size > images[j].Size + }) + shown := images + if a.Match != "" { + shown = shown[:0:0] + for _, img := range images { + if strings.Contains(img.Line+" "+strings.Join(img.Names, " "), a.Match) { + shown = append(shown, img) + } + } + } + answer["shown"] = min(len(shown), a.Limit) + answer["images"] = shown[:min(len(shown), a.Limit)] + + if a.DryRun { + answer["said"] = fmt.Sprintf("dry run: %d of %d images would be removed (at most %s); nothing was removed. "+ + "Call again with dry_run false and why to remove them.", len(candidates), len(images), human(candidateBytes)) + return answer, nil + } + if declared == nil { + answer["said"] = "nothing was removed: what this machine's declaration names is not known (" + askErr.Error() + ")" + return answer, nil + } + removed, refused := []string{}, []map[string]string{} + var freed int64 + for _, img := range candidates { + // By every name it carries, never forced: removing an image's last name removes the image, and the + // runtime refuses one a container uses. (By id, an image with two names needs force, which this + // never uses.) + args := []string{"image", "rm"} + var bad error + for _, n := range img.Names { + ref, err := Ref(n) + if err != nil { + bad = err + break + } + args = append(args, ref) + } + if bad != nil { + refused = append(refused, map[string]string{"id": img.ID, "why": bad.Error()}) + continue + } + if _, err := c.docker(ctx, args...); err != nil { + refused = append(refused, map[string]string{"id": img.ID, "why": err.Error()}) + continue + } + removed = append(removed, img.ID) + freed += img.Size + } + answer["removed"], answer["refused"], answer["bytes_removed"] = removed, refused, freed + answer["why"] = a.Why + answer["said"] = fmt.Sprintf("removed %d image(s) (at most %s), %d refused by the runtime", len(removed), human(freed), len(refused)) + return answer, nil +} + +func human(b int64) string { + switch { + case b >= 1<<30: + return fmt.Sprintf("%.1f GiB", float64(b)/(1<<30)) + case b >= 1<<20: + return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) + } + return fmt.Sprintf("%d B", b) +} diff --git a/modules/docker/cmd/docker-tools/prune_images_test.go b/modules/docker/cmd/docker-tools/prune_images_test.go new file mode 100644 index 00000000..62a5d6c7 --- /dev/null +++ b/modules/docker/cmd/docker-tools/prune_images_test.go @@ -0,0 +1,215 @@ +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") + } +} diff --git a/modules/docker/docker-tools b/modules/docker/docker-tools new file mode 100755 index 00000000..f2465f85 Binary files /dev/null and b/modules/docker/docker-tools differ diff --git a/modules/docker/module.json b/modules/docker/module.json index b041705d..b7fc0726 100644 --- a/modules/docker/module.json +++ b/modules/docker/module.json @@ -6,6 +6,9 @@ "service-manager", "privileged" ], + "invokes": [ + "seat:mesh-controller.images" + ], "claims": [ { "name": "node-container-runtime", @@ -25,6 +28,7 @@ "docker_top", "docker_images", "docker_prune", + "docker_prune_images", "docker_disk_usage", "docker_networks", "docker_volumes",