Files
mesh-catalog/modules/docker/cmd/docker-tools/prune_images.go
T

455 lines
14 KiB
Go

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 mesh-artifact-sweep seat'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 mesh-artifact-sweep seat'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 mesh-artifact-sweep seat 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-artifact-sweep.images", map[string]any{"node": c.Node})
if err != nil {
return nil, fmt.Errorf("the mesh-artifact-sweep seat's images verb did not answer: %w", err)
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the mesh-artifact-sweep seat refused images: %s", firstLine(output))
}
var d declaredImages
if err := json.Unmarshal(answer, &d); err != nil {
return nil, fmt.Errorf("the mesh-artifact-sweep seat's images answer is not readable: %v", err)
}
if d.Node != "" && d.Node != c.Node {
return nil, fmt.Errorf("the mesh-artifact-sweep seat 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)
}