mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
One commit with exactly the tree of the reviewed head35b4509c0a, on main12c2574c9d, so two compiled programs added and removed on the branch leave no trace in history (issue 1577).
455 lines
14 KiB
Go
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)
|
|
}
|