Merge pull request 'docker: remove images no container or declaration uses, keeping the previous (hq ADR 0251 §5)' (#122) from feat/docker-prune-images into main
This commit was merged in pull request #122.
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")
|
||||
}
|
||||
}
|
||||
@@ -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