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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user