Files
mesh-catalog/modules/docker/cmd/docker-tools/docker.go
T
jochen 73bb597c16
mesh/merge-gate pass: builds docker, gitea, lab, nftables, slack, systemd → ace, g14, novox, shanks; no bus step; 4 wait(s) for a person; every machine com…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Hold what the tools say to the glossary's retired words (hq ADR 0244)
An agent meets the mesh's words most often in tool descriptions, and
nothing compared them with the glossary: several still said "the host"
for the node-engine and the forge's pull request comment was headed
"Change plan", a word retired twice over. retired-words is the copy of
the words the glossary retires for the tools, and checks/words fails the
repository check when any string a module's code can show, or any
manifest description, uses one. Those found are reworded here.
2026-10-07 20:02:09 +02:00

1154 lines
38 KiB
Go

package main
// The container runtime's own command line, asked by the operator account the node's tool runtime
// runs as (novox/hq ADR 0175 §4). That account is in the docker group on every machine, but a
// process keeps the groups it started with: a runtime started before the account joined the group
// is refused by the daemon's socket. So a refused socket is asked again through `sudo -n`, as the
// service manager's and the packet filter's acts are, and a refusal there is named by how it failed.
//
// A container the mesh holds carries the host's label (MeshLabel); its value names the assignment
// (`<module>.<resource>`). Every answer says which containers are the mesh's, because the host
// restores a held container to what its declaration says at its next apply (novox/hq ADR 0166).
import (
"context"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// MeshLabel is the label the host puts on every container it creates (mesh-host internal/apply).
const MeshLabel = "mesh-host.id"
// DaemonFile is the runtime's configuration file.
const DaemonFile = "/etc/docker/daemon.json"
// Client asks the runtime.
type Client struct {
Run Runner
UID int
ReadFile func(string) ([]byte, error)
Now func() time.Time
}
// NewClient is the client the bundle serves with.
func NewClient() *Client {
return &Client{Run: ExecRunner, UID: os.Getuid(), ReadFile: os.ReadFile, Now: time.Now}
}
var socketRefused = regexp.MustCompile(`(?i)permission denied.*docker.*sock|docker\.sock.*permission denied`)
// docker runs one docker command and answers its stdout, or an error naming what went wrong.
func (c *Client) docker(ctx context.Context, args ...string) (string, error) {
r := c.Run(ctx, "docker", args...)
program := "docker"
if r.Status != 0 && r.Err == "" && c.UID != 0 && socketRefused.MatchString(r.Stderr) {
program = "sudo"
r = c.Run(ctx, "sudo", append([]string{"-n", "docker"}, args...)...)
}
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
return "", failure(args, program, r)
}
func failure(args []string, program string, r Ran) error {
verb := "docker"
if len(args) > 0 {
verb += " " + args[0]
}
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
switch {
case r.Err == "ENOENT" && program == "sudo":
return fmt.Errorf("the runtime's socket refused this account, and sudo is not installed here to escalate with")
case r.Err == "ENOENT":
return fmt.Errorf("docker is not installed on this machine")
case r.Err != "":
return fmt.Errorf("%s did not answer: %s (the daemon may still be doing it)", verb, r.Err)
case program == "sudo" && regexp.MustCompile(`(?m)^sudo:`).MatchString(said):
return fmt.Errorf("the runtime's socket refused this account (not in the docker group, or not since the tool runtime started) and it may not escalate without a prompt: %s", firstLine(said))
case strings.Contains(said, "Cannot connect to the Docker daemon"):
return fmt.Errorf("the docker daemon is not answering on this machine: %s", firstLine(said))
case strings.Contains(said, "No such container") || strings.Contains(said, "No such object"):
return fmt.Errorf("%s", firstLine(said))
}
if l := firstLine(said); l != "" {
return fmt.Errorf("%s failed (%d): %s", verb, r.Status, l)
}
return fmt.Errorf("%s failed with status %d", verb, r.Status)
}
var refPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:/@+-]*$`)
// Ref is a container's, image's, network's or volume's name or id as an argument: never something
// docker would read as an option, which under sudo would be root's option.
func Ref(s string) (string, error) {
s = strings.TrimSpace(s)
if !refPattern.MatchString(s) || len(s) > 256 {
return "", fmt.Errorf("%q is not a name or id docker knows things by", s)
}
return s, nil
}
// lines splits output into its non-empty lines.
func lines(s string) []string {
var out []string
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
// jsonLines decodes one JSON object per line, as `--format '{{json .}}'` prints them.
func jsonLines[T any](s string) ([]T, error) {
out := []T{}
for _, l := range lines(s) {
var v T
if err := json.Unmarshal([]byte(l), &v); err != nil {
return nil, fmt.Errorf("docker answered a line that is not JSON: %.120s", l)
}
out = append(out, v)
}
return out, nil
}
// Bytes reads docker's human sizes ("55.63GB", "33.2MiB", "0B"): decimal units as docker's own
// HumanSize prints them, binary ones where it says so. -1 when it is not a size.
func Bytes(s string) int64 {
s = strings.TrimSpace(s)
if i := strings.Index(s, " ("); i > 0 {
s = s[:i]
}
m := regexp.MustCompile(`^([0-9.]+)\s*([A-Za-z]*)$`).FindStringSubmatch(s)
if m == nil {
return -1
}
n, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return -1
}
units := map[string]float64{"": 1, "B": 1, "kB": 1e3, "KB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12, "PB": 1e15,
"KiB": 1 << 10, "MiB": 1 << 20, "GiB": 1 << 30, "TiB": 1 << 40}
u, ok := units[m[2]]
if !ok {
return -1
}
return int64(n * u)
}
// ---- containers -------------------------------------------------------------------------------
type inspected struct {
ID string `json:"Id"`
Name string
Created string
Image string
Config struct {
Image string
Labels map[string]string
}
State struct {
Status string
Running bool
Restarting bool
OOMKilled bool
ExitCode int
StartedAt string
FinishedAt string
Health *struct{ Status string }
}
RestartCount int
HostConfig struct {
RestartPolicy struct{ Name string }
NetworkMode string
Privileged bool
}
NetworkSettings struct {
Ports map[string][]struct {
HostIP string `json:"HostIp"`
HostPort string
}
}
Mounts []struct {
Type string
Name string
Source string
Destination string
RW bool
}
}
// Mount is one thing a container mounts.
type Mount struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
Destination string `json:"destination"`
ReadOnly bool `json:"read_only,omitempty"`
}
// Container is one container as every tool here answers it.
type Container struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
ImageID string `json:"image_id"`
State string `json:"state"`
Health string `json:"health,omitempty"`
ExitCode int `json:"exit_code"`
OOMKilled bool `json:"oom_killed,omitempty"`
Created string `json:"created"`
StartedAt string `json:"started_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
Restarts int `json:"restarts"`
RestartPolicy string `json:"restart_policy,omitempty"`
Network string `json:"network_mode,omitempty"`
Privileged bool `json:"privileged,omitempty"`
MeshHeld bool `json:"mesh_held"`
HeldBy string `json:"held_by,omitempty"`
Module string `json:"module,omitempty"`
Compose string `json:"compose_project,omitempty"`
ComposeDir string `json:"compose_dir,omitempty"`
Ports []string `json:"ports"`
Mounts []Mount `json:"mounts"`
}
func summary(i inspected) Container {
c := Container{
ID: shortID(i.ID), Name: strings.TrimPrefix(i.Name, "/"), Image: i.Config.Image, ImageID: i.Image,
State: i.State.Status, ExitCode: i.State.ExitCode, OOMKilled: i.State.OOMKilled, Created: i.Created,
Restarts: i.RestartCount, RestartPolicy: i.HostConfig.RestartPolicy.Name, Network: i.HostConfig.NetworkMode,
Privileged: i.HostConfig.Privileged, Ports: []string{}, Mounts: []Mount{},
}
if !zeroTime(i.State.StartedAt) {
c.StartedAt = i.State.StartedAt
}
if !zeroTime(i.State.FinishedAt) && !i.State.Running {
c.FinishedAt = i.State.FinishedAt
}
if i.State.Health != nil {
c.Health = i.State.Health.Status
}
if v, ok := i.Config.Labels[MeshLabel]; ok {
c.MeshHeld, c.HeldBy = true, v
c.Module, _, _ = strings.Cut(v, ".")
}
c.Compose = i.Config.Labels["com.docker.compose.project"]
c.ComposeDir = i.Config.Labels["com.docker.compose.project.working_dir"]
for port, binds := range i.NetworkSettings.Ports {
for _, b := range binds {
c.Ports = append(c.Ports, fmt.Sprintf("%s:%s->%s", b.HostIP, b.HostPort, port))
}
}
sort.Strings(c.Ports)
for _, m := range i.Mounts {
mm := Mount{Type: m.Type, Destination: m.Destination, ReadOnly: !m.RW}
if m.Type == "volume" {
mm.Name = m.Name
} else {
mm.Source = m.Source
}
c.Mounts = append(c.Mounts, mm)
}
return c
}
func zeroTime(s string) bool { return s == "" || strings.HasPrefix(s, "0001-01-01") }
func shortID(id string) string {
id = strings.TrimPrefix(id, "sha256:")
if len(id) > 12 {
return id[:12]
}
return id
}
// inspectAll is every container on the machine, the mesh's and every other.
func (c *Client) inspectAll(ctx context.Context) ([]inspected, error) {
ids, err := c.docker(ctx, "ps", "--all", "--quiet", "--no-trunc")
if err != nil {
return nil, err
}
all := lines(ids)
if len(all) == 0 {
return []inspected{}, nil
}
out, err := c.docker(ctx, append([]string{"container", "inspect"}, all...)...)
if err != nil {
// A container removed between the two calls fails the whole inspect; ask once more.
if strings.Contains(err.Error(), "No such") {
return c.inspectAll(ctx)
}
return nil, err
}
var got []inspected
if err := json.Unmarshal([]byte(out), &got); err != nil {
return nil, fmt.Errorf("docker inspect answered something that is not JSON: %v", err)
}
return got, nil
}
// Containers is every container, narrowed as asked.
func (c *Client) Containers(ctx context.Context, held, state, match string) ([]Container, error) {
all, err := c.inspectAll(ctx)
if err != nil {
return nil, err
}
out := []Container{}
for _, i := range all {
s := summary(i)
switch held {
case "", "all":
case "mesh":
if !s.MeshHeld {
continue
}
case "other":
if s.MeshHeld {
continue
}
default:
return nil, fmt.Errorf("held %q: \"all\", \"mesh\" or \"other\"", held)
}
if state != "" && s.State != state {
continue
}
if match != "" && !strings.Contains(s.Name, match) && !strings.Contains(s.Image, match) {
continue
}
out = append(out, s)
}
sort.Slice(out, func(a, b int) bool { return out[a].Name < out[b].Name })
return out, nil
}
// Inspect is one container whole, as docker inspects it, with every environment variable's value
// left out (names kept): a container's environment is where its passwords are.
func (c *Client) Inspect(ctx context.Context, ref string) (map[string]any, error) {
ref, err := Ref(ref)
if err != nil {
return nil, err
}
out, err := c.docker(ctx, "container", "inspect", ref)
if err != nil {
return nil, err
}
var got []map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil || len(got) != 1 {
return nil, fmt.Errorf("docker inspect answered something that is not one container")
}
obj := got[0]
// A container's command line is shown without the secrets it carries, as an exec's is (novox/hq
// issue 282): known from its environment, and by shape.
var known []knownSecret
if cfg, ok := obj["Config"].(map[string]any); ok {
if env, ok := cfg["Env"].([]any); ok {
list := make([]string, 0, len(env))
for _, e := range env {
list = append(list, fmt.Sprint(e))
}
known = secretsIn(list)
}
for _, k := range []string{"Cmd", "Entrypoint"} {
if words, ok := cfg[k].([]any); ok {
cfg[k] = redactWords(words, known)
}
}
}
if words, ok := obj["Args"].([]any); ok {
path, _ := obj["Path"].(string)
obj["Args"] = redactWords(append([]any{path}, words...), known)[1:]
}
if cfg, ok := obj["Config"].(map[string]any); ok {
if env, ok := cfg["Env"].([]any); ok {
names := []string{}
for _, e := range env {
name, _, _ := strings.Cut(fmt.Sprint(e), "=")
names = append(names, name)
}
cfg["Env"] = names
cfg["EnvValues"] = "left out: a container's environment holds its secrets"
}
if labels, ok := cfg["Labels"].(map[string]any); ok {
if v, ok := labels[MeshLabel]; ok {
obj["mesh_held"], obj["held_by"] = true, v
} else {
obj["mesh_held"] = false
}
}
}
return obj, nil
}
// Logs is the last lines a container wrote, both streams in the order they were written.
func (c *Client) Logs(ctx context.Context, ref string, tail int, since string) (map[string]any, error) {
ref, err := Ref(ref)
if err != nil {
return nil, err
}
args := []string{"logs", "--timestamps", "--tail", strconv.Itoa(tail)}
if since != "" {
if !regexp.MustCompile(`^[0-9]+[smhd]?$|^\d{4}-\d{2}-\d{2}`).MatchString(since) {
return nil, fmt.Errorf("since %q: a duration such as 30m or 2h, or a time such as 2026-10-04T10:00:00", since)
}
args = append(args, "--since", since)
}
args = append(args, ref)
all, err := c.logLines(ctx, args)
if err != nil {
return nil, err
}
if len(all) > tail {
all = all[len(all)-tail:]
}
// What the container printed of the secrets it was given is not shown (novox/hq issue 268): the
// answer of this tool is read by agents and kept in their transcripts, which would make it a
// second copy of the leak. Redacted before the lines are cut, so a cut never splits a value.
answer := map[string]any{"container": ref}
env, envErr := c.envOf(ctx, ref)
known := secretsIn(env)
redacted := 0
for i, l := range all {
var n int
all[i], n = redact(l, known)
redacted += n
}
if envErr != nil {
answer["redaction"] = "only passwords inside URIs: the environment could not be read (" + envErr.Error() + ")"
}
if redacted > 0 {
answer["redacted"] = redacted
answer["leak"] = "this container printed secrets it was given; docker_secrets_in_logs names them (novox/hq issue 268)"
}
const most = 4096
for i, l := range all {
if len(l) > most {
all[i] = l[:most] + "…"
}
}
answer["lines"], answer["count"] = all, len(all)
return answer, nil
}
// logLines runs `docker logs …` and answers both streams' lines merged in the order written.
func (c *Client) logLines(ctx context.Context, args []string) ([]string, error) {
r := c.Run(ctx, "docker", args...)
program := "docker"
if r.Status != 0 && r.Err == "" && c.UID != 0 && socketRefused.MatchString(r.Stderr) {
program = "sudo"
r = c.Run(ctx, "sudo", append([]string{"-n", "docker"}, args...)...)
}
if r.Status != 0 || r.Err != "" {
return nil, failure(args, program, r)
}
// Both streams carry the container's lines, each led by its timestamp, so they merge in order.
all := append(lines(r.Stdout), lines(r.Stderr)...)
sort.SliceStable(all, func(a, b int) bool { return all[a] < all[b] })
return all, nil
}
// Stat is one container's use of the machine now.
type Stat struct {
Name string `json:"name"`
ID string `json:"id"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
MemPerc string `json:"memory_percent"`
MemBytes int64 `json:"memory_bytes"`
NetIO string `json:"net_io"`
BlockIO string `json:"block_io"`
PIDs string `json:"pids"`
}
// Stats is a snapshot of every running container's use, or one's; the heaviest first.
func (c *Client) Stats(ctx context.Context, ref string) ([]Stat, error) {
args := []string{"stats", "--no-stream", "--format", "{{json .}}"}
if ref != "" {
r, err := Ref(ref)
if err != nil {
return nil, err
}
args = append(args, r)
}
out, err := c.docker(ctx, args...)
if err != nil {
return nil, err
}
raw, err := jsonLines[map[string]string](out)
if err != nil {
return nil, err
}
stats := []Stat{}
for _, s := range raw {
used, _, _ := strings.Cut(s["MemUsage"], " / ")
stats = append(stats, Stat{Name: s["Name"], ID: s["ID"], CPU: s["CPUPerc"], Memory: s["MemUsage"], MemPerc: s["MemPerc"],
MemBytes: Bytes(used), NetIO: s["NetIO"], BlockIO: s["BlockIO"], PIDs: s["PIDs"]})
}
sort.Slice(stats, func(a, b int) bool { return stats[a].MemBytes > stats[b].MemBytes })
return stats, nil
}
// Act starts, stops or restarts one container and answers its state after. A container the mesh
// holds is acted on too — the host restores what its declaration says at its next apply, and the
// answer says so (novox/hq ADR 0166).
func (c *Client) Act(ctx context.Context, verb, ref string) (map[string]any, error) {
ref, err := Ref(ref)
if err != nil {
return nil, err
}
args := []string{verb}
switch verb {
case "start":
case "stop", "restart":
// Ten seconds to stop before it is killed: inside the call's own bound.
args = append(args, "--time", "10")
default:
return nil, fmt.Errorf("%q is not start, stop or restart", verb)
}
if _, err := c.docker(ctx, append(args, ref)...); err != nil {
return nil, err
}
out, err := c.docker(ctx, "container", "inspect", ref)
if err != nil {
return nil, err
}
var got []inspected
if err := json.Unmarshal([]byte(out), &got); err != nil || len(got) != 1 {
return nil, fmt.Errorf("docker inspect answered something that is not one container")
}
s := summary(got[0])
answer := map[string]any{"container": s.Name, "verb": verb, "ok": true, "state": s.State, "mesh_held": s.MeshHeld}
if s.MeshHeld {
answer["held_by"] = s.HeldBy
answer["note"] = fmt.Sprintf("the mesh holds this container (%s): the node-engine restores what its declaration says at its next apply", s.HeldBy)
}
return answer, nil
}
// Top is the processes running in one container.
func (c *Client) Top(ctx context.Context, ref string) (map[string]any, error) {
ref, err := Ref(ref)
if err != nil {
return nil, err
}
out, err := c.docker(ctx, "top", ref, "-eo", "pid,user,etime,pcpu,rss,args")
if err != nil {
return nil, err
}
rows := lines(out)
procs := []map[string]string{}
for _, l := range rows[min(1, len(rows)):] {
f := strings.Fields(l)
if len(f) < 6 {
continue
}
procs = append(procs, map[string]string{"pid": f[0], "user": f[1], "elapsed": f[2], "cpu": f[3], "rss_kb": f[4], "command": strings.Join(f[5:], " ")})
}
return map[string]any{"container": ref, "processes": procs}, nil
}
// Problems is every container that is not well: unhealthy, restarting, dead, killed for memory,
// or exited with a failure.
func (c *Client) Problems(ctx context.Context) ([]map[string]any, error) {
all, err := c.Containers(ctx, "", "", "")
if err != nil {
return nil, err
}
out := []map[string]any{}
for _, s := range all {
var why []string
if s.Health == "unhealthy" {
why = append(why, "unhealthy")
}
if s.State == "restarting" {
why = append(why, "restarting")
}
if s.State == "dead" {
why = append(why, "dead")
}
if s.OOMKilled {
why = append(why, "killed for memory")
}
if s.State == "exited" && s.ExitCode != 0 {
why = append(why, fmt.Sprintf("exited %d", s.ExitCode))
}
if s.Restarts >= 5 {
why = append(why, fmt.Sprintf("restarted %d times", s.Restarts))
}
if len(why) == 0 {
continue
}
out = append(out, map[string]any{"name": s.Name, "state": s.State, "health": s.Health, "why": why,
"mesh_held": s.MeshHeld, "held_by": s.HeldBy, "image": s.Image, "finished_at": s.FinishedAt})
}
return out, nil
}
// Ports is every port the containers publish on the machine.
func (c *Client) Ports(ctx context.Context) ([]map[string]any, error) {
all, err := c.Containers(ctx, "", "", "")
if err != nil {
return nil, err
}
out := []map[string]any{}
for _, s := range all {
for _, p := range s.Ports {
out = append(out, map[string]any{"published": p, "container": s.Name, "state": s.State, "mesh_held": s.MeshHeld})
}
if s.Network == "host" && s.State == "running" {
out = append(out, map[string]any{"published": "host network: every port it listens on", "container": s.Name, "state": s.State, "mesh_held": s.MeshHeld})
}
}
return out, nil
}
// Unlabelled is every container the mesh does not hold: the cleanup list, with what each is
// likely to be (a compose project, a one-off).
func (c *Client) Unlabelled(ctx context.Context) (map[string]any, error) {
all, err := c.Containers(ctx, "other", "", "")
if err != nil {
return nil, err
}
running := 0
for _, s := range all {
if s.State == "running" {
running++
}
}
return map[string]any{"count": len(all), "running": running, "containers": all,
"note": "the mesh holds none of these; it never removes them. docker_prune with containers=true removes the stopped ones"}, nil
}
// ---- images ----------------------------------------------------------------------------------
// Image is one image with what uses it.
type Image struct {
ID string `json:"id"`
Repository string `json:"repository"`
Tag string `json:"tag"`
Created string `json:"created"`
Size string `json:"size"`
SizeBytes int64 `json:"size_bytes"`
Dangling bool `json:"dangling"`
UsedBy []string `json:"used_by"`
MeshUsed bool `json:"used_by_mesh"`
}
// Images is the images on the machine, the largest first, with the containers using each.
func (c *Client) Images(ctx context.Context, filter, match string, limit int) (map[string]any, error) {
out, err := c.docker(ctx, "image", "ls", "--no-trunc", "--format", "{{json .}}")
if err != nil {
return nil, err
}
raw, err := jsonLines[map[string]string](out)
if err != nil {
return nil, err
}
containers, err := c.inspectAll(ctx)
if err != nil {
return nil, err
}
users, meshUsers := map[string][]string{}, map[string]bool{}
for _, i := range containers {
s := summary(i)
users[i.Image] = append(users[i.Image], s.Name)
meshUsers[i.Image] = meshUsers[i.Image] || s.MeshHeld
}
images := []Image{}
var total int64
for _, r := range raw {
img := Image{ID: r["ID"], Repository: r["Repository"], Tag: r["Tag"], Created: r["CreatedAt"], Size: r["Size"],
SizeBytes: Bytes(r["Size"]), Dangling: r["Repository"] == "<none>" && r["Tag"] == "<none>"}
img.UsedBy = append([]string{}, users[img.ID]...)
img.MeshUsed = meshUsers[img.ID]
switch filter {
case "", "all":
case "dangling":
if !img.Dangling {
continue
}
case "unused":
if len(img.UsedBy) > 0 {
continue
}
case "used":
if len(img.UsedBy) == 0 {
continue
}
default:
return nil, fmt.Errorf("filter %q: all, dangling, unused or used", filter)
}
if match != "" && !strings.Contains(img.Repository+":"+img.Tag, match) {
continue
}
total += max(img.SizeBytes, 0)
images = append(images, img)
}
sort.Slice(images, func(a, b int) bool { return images[a].SizeBytes > images[b].SizeBytes })
count := len(images)
if len(images) > limit {
images = images[:limit]
}
return map[string]any{"count": count, "shown": len(images), "size_bytes_summed": total,
"note": "sizes are summed per image; images share layers, so the disk they take together is docker_disk_usage's", "images": images}, nil
}
// ---- prune -----------------------------------------------------------------------------------
// PruneAsk is what a prune is asked to take. Volumes are never among them: a volume is data.
type PruneAsk struct {
Images bool
BuildCache bool
Containers bool
OlderThanH int
DryRun bool
}
// Prune removes, or with DryRun only lists, dangling images, unused build cache and — only when
// asked — stopped containers the mesh does not hold. Never a volume, never a container the mesh
// holds, never an image a container uses (the daemon refuses that itself).
func (c *Client) Prune(ctx context.Context, a PruneAsk) (map[string]any, error) {
answer := map[string]any{"dry_run": a.DryRun, "volumes": "never pruned: a volume is data"}
until := []string{}
if a.OlderThanH > 0 {
until = []string{"--filter", fmt.Sprintf("until=%dh", a.OlderThanH)}
answer["older_than_hours"] = a.OlderThanH
}
if a.Images {
args := append([]string{"image", "ls", "--no-trunc", "--filter", "dangling=true", "--format", "{{json .}}"}, until...)
out, err := c.docker(ctx, args...)
if err != nil {
return nil, err
}
raw, err := jsonLines[map[string]string](out)
if err != nil {
return nil, err
}
var sum int64
ids := []string{}
for _, r := range raw {
sum += max(Bytes(r["Size"]), 0)
ids = append(ids, shortID(r["ID"]))
}
if len(ids) > 50 {
ids = ids[:50]
}
section := map[string]any{"dangling": len(raw), "size_bytes_summed": sum, "ids_first_50": ids}
if !a.DryRun && len(raw) > 0 {
out, err := c.docker(ctx, append([]string{"image", "prune", "--force"}, until...)...)
if err != nil {
return nil, err
}
section["reclaimed"] = reclaimed(out)
}
answer["images"] = section
}
if a.BuildCache {
section := map[string]any{}
if a.DryRun {
out, err := c.docker(ctx, "system", "df", "--format", "{{json .}}")
if err != nil {
return nil, err
}
rows, err := jsonLines[map[string]string](out)
if err != nil {
return nil, err
}
for _, r := range rows {
if r["Type"] == "Build Cache" {
section["entries"], section["size"], section["reclaimable"] = r["TotalCount"], r["Size"], r["Reclaimable"]
}
}
section["note"] = "an estimate: a real prune takes the cache nothing refers to, which can be less than reclaimable"
} else {
out, err := c.docker(ctx, append([]string{"builder", "prune", "--force"}, until...)...)
if err != nil {
return nil, err
}
section["reclaimed"] = reclaimed(out)
}
answer["build_cache"] = section
}
if a.Containers {
all, err := c.Containers(ctx, "other", "", "")
if err != nil {
return nil, err
}
cutoff := c.Now().Add(-time.Duration(a.OlderThanH) * time.Hour)
stopped := []string{}
for _, s := range all {
if s.State != "exited" && s.State != "created" && s.State != "dead" {
continue
}
if a.OlderThanH > 0 {
when := s.FinishedAt
if when == "" {
when = s.Created
}
if t, err := time.Parse(time.RFC3339Nano, when); err == nil && t.After(cutoff) {
continue
}
}
stopped = append(stopped, s.Name)
}
section := map[string]any{"stopped_not_held": stopped}
if !a.DryRun && len(stopped) > 0 {
// By name, and without --volumes: what they mounted stays.
if _, err := c.docker(ctx, append([]string{"container", "rm"}, stopped...)...); err != nil {
return nil, err
}
section["removed"] = stopped
}
answer["containers"] = section
}
if a.DryRun {
answer["note"] = "nothing was removed: call again with dry_run false to prune"
}
return answer, nil
}
func reclaimed(out string) string {
for _, l := range lines(out) {
if strings.HasPrefix(l, "Total reclaimed space:") || strings.HasPrefix(l, "Total:") {
return strings.TrimSpace(l[strings.Index(l, ":")+1:])
}
}
return "0B"
}
// ---- disk, networks, volumes, events, daemon ---------------------------------------------------
// DiskUsage is `docker system df -v`, summed per kind, with the largest of each.
func (c *Client) DiskUsage(ctx context.Context, top int) (map[string]any, error) {
out, err := c.docker(ctx, "system", "df", "--format", "{{json .}}")
if err != nil {
return nil, err
}
rows, err := jsonLines[map[string]any](out)
if err != nil {
return nil, err
}
summaryRows := []map[string]any{}
for _, r := range rows {
summaryRows = append(summaryRows, map[string]any{"type": r["Type"], "total": r["TotalCount"], "active": r["Active"],
"size": r["Size"], "size_bytes": Bytes(fmt.Sprint(r["Size"])), "reclaimable": r["Reclaimable"]})
}
verbose, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
if err != nil {
return nil, err
}
var v map[string][]map[string]any
if err := json.Unmarshal([]byte(verbose), &v); err != nil {
return nil, fmt.Errorf("docker system df -v answered something that is not JSON: %v", err)
}
largest := map[string]any{}
for kind, items := range v {
sort.Slice(items, func(a, b int) bool { return Bytes(fmt.Sprint(items[a]["Size"])) > Bytes(fmt.Sprint(items[b]["Size"])) })
if len(items) > top {
items = items[:top]
}
trimmed := []map[string]any{}
for _, it := range items {
t := map[string]any{"size": it["Size"]}
for _, k := range []string{"Repository", "Tag", "ID", "Names", "Name", "Containers", "Links", "Image", "State", "Description", "InUse", "LastUsedSince", "UniqueSize"} {
if val, ok := it[k]; ok && val != nil && val != "" {
t[strings.ToLower(k)] = val
}
}
trimmed = append(trimmed, t)
}
largest[kind] = trimmed
}
return map[string]any{"summary": summaryRows, "largest": largest}, nil
}
// Networks is every network with its addressing and the running containers on it.
func (c *Client) Networks(ctx context.Context) ([]map[string]any, error) {
ids, err := c.docker(ctx, "network", "ls", "--quiet", "--no-trunc")
if err != nil {
return nil, err
}
if len(lines(ids)) == 0 {
return []map[string]any{}, nil
}
out, err := c.docker(ctx, append([]string{"network", "inspect"}, lines(ids)...)...)
if err != nil {
return nil, err
}
var got []struct {
Name, Driver, Scope string
Internal bool
IPAM struct {
Config []struct{ Subnet, Gateway string }
}
Containers map[string]struct{ Name, IPv4Address string }
Labels map[string]string
}
if err := json.Unmarshal([]byte(out), &got); err != nil {
return nil, fmt.Errorf("docker network inspect answered something that is not JSON: %v", err)
}
held := map[string]bool{}
if all, err := c.Containers(ctx, "", "", ""); err == nil {
for _, s := range all {
held[s.Name] = s.MeshHeld
}
}
res := []map[string]any{}
for _, n := range got {
subnets := []string{}
for _, cfg := range n.IPAM.Config {
subnets = append(subnets, strings.TrimSpace(cfg.Subnet+" gw "+cfg.Gateway))
}
members := []map[string]any{}
for _, m := range n.Containers {
members = append(members, map[string]any{"name": m.Name, "address": m.IPv4Address, "mesh_held": held[m.Name]})
}
sort.Slice(members, func(a, b int) bool { return fmt.Sprint(members[a]["name"]) < fmt.Sprint(members[b]["name"]) })
res = append(res, map[string]any{"name": n.Name, "driver": n.Driver, "scope": n.Scope, "internal": n.Internal,
"subnets": subnets, "containers": members, "compose_project": n.Labels["com.docker.compose.project"]})
}
sort.Slice(res, func(a, b int) bool { return fmt.Sprint(res[a]["name"]) < fmt.Sprint(res[b]["name"]) })
return res, nil
}
// Volumes is every volume with the containers mounting it, whether the mesh holds any of them, and
// — when asked, which is slower — its size.
func (c *Client) Volumes(ctx context.Context, unmountedOnly, sizes bool) (map[string]any, error) {
names, err := c.docker(ctx, "volume", "ls", "--quiet")
if err != nil {
return nil, err
}
containers, err := c.Containers(ctx, "", "", "")
if err != nil {
return nil, err
}
mountedBy := map[string][]map[string]any{}
for _, s := range containers {
for _, m := range s.Mounts {
if m.Type == "volume" {
mountedBy[m.Name] = append(mountedBy[m.Name], map[string]any{"container": s.Name, "state": s.State, "mesh_held": s.MeshHeld})
}
}
}
size := map[string]string{}
if sizes {
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
if err != nil {
return nil, err
}
var v struct{ Volumes []map[string]any }
if err := json.Unmarshal([]byte(out), &v); err == nil {
for _, vol := range v.Volumes {
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
}
}
}
vols := []map[string]any{}
if len(lines(names)) > 0 {
out, err := c.docker(ctx, append([]string{"volume", "inspect"}, lines(names)...)...)
if err != nil {
return nil, err
}
var got []struct {
Name, Driver, CreatedAt, Mountpoint string
Labels map[string]string
}
if err := json.Unmarshal([]byte(out), &got); err != nil {
return nil, fmt.Errorf("docker volume inspect answered something that is not JSON: %v", err)
}
for _, v := range got {
by := mountedBy[v.Name]
if unmountedOnly && len(by) > 0 {
continue
}
mesh := false
for _, b := range by {
mesh = mesh || b["mesh_held"].(bool)
}
_, anonymous := v.Labels["com.docker.volume.anonymous"]
entry := map[string]any{"name": v.Name, "driver": v.Driver, "created": v.CreatedAt, "anonymous": anonymous,
"compose_project": v.Labels["com.docker.compose.project"], "mounted_by": append([]map[string]any{}, by...), "mesh_held": mesh}
if sizes {
entry["size"] = size[v.Name]
}
vols = append(vols, entry)
}
}
return map[string]any{"count": len(vols), "volumes": vols,
"note": "mesh_held: a container the mesh holds mounts it. Nothing here removes a volume; docker_prune never does"}, nil
}
// Events is what the runtime did in a window ending now, the latest last.
func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int, execs bool) (map[string]any, error) {
args := []string{"events", "--since", fmt.Sprintf("%dm", minutes), "--until", "0s", "--format", "{{json .}}"}
if kind != "" {
switch kind {
case "container", "image", "network", "volume", "daemon", "plugin", "builder":
default:
return nil, fmt.Errorf("type %q: container, image, network, volume, daemon, plugin or builder", kind)
}
args = append(args, "--filter", "type="+kind)
}
out, err := c.docker(ctx, args...)
if err != nil {
return nil, err
}
raw, err := jsonLines[runtimeEvent](out)
if err != nil {
return nil, err
}
// An exec's command line is shown without the secrets it carried (novox/hq issue 282): this answer
// is read by agents and kept in their transcripts, which would make it a second copy of the leak.
envs := &envCache{c: c, ctx: ctx}
redacted := map[string]bool{}
events := []map[string]any{}
for _, e := range raw {
if !execs && strings.HasPrefix(e.Action, "exec_") {
continue
}
action := e.Action
if verb, command, ok := execCommand(action); ok {
shown, names := redactCommand(command, envs.of(e.Actor.ID))
action = verb + ": " + shown
for _, n := range names {
redacted[n] = true
}
}
_, held := e.Actor.Attributes[MeshLabel]
ev := map[string]any{"time": time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339), "type": e.Type, "action": action,
"id": shortID(e.Actor.ID), "name": e.Actor.Attributes["name"]}
if e.Type == "container" {
ev["mesh_held"] = held
ev["image"] = e.Actor.Attributes["image"]
if code, ok := e.Actor.Attributes["exitCode"]; ok {
ev["exit_code"] = code
}
}
events = append(events, ev)
}
total := len(events)
if len(events) > limit {
events = events[len(events)-limit:]
}
answer := map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}
if len(redacted) > 0 {
answer["redacted"] = sortedSet(redacted)
answer["leak"] = "exec command lines carried secrets, which the runtime keeps in its events; docker_secrets_in_events names them (novox/hq issue 282)"
}
return answer, nil
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// redactWords is a command's words with what redactCommand hides hidden, word by word.
func redactWords(words []any, known []knownSecret) []any {
list := make([]string, len(words))
for i, w := range words {
list[i] = fmt.Sprint(w)
}
shapes := secretsOnCommandLine(list)
out := make([]any, len(list))
for i, w := range list {
for _, s := range shapes {
if strings.Contains(w, s.Value) {
w = strings.ReplaceAll(w, s.Value, "[redacted: "+s.Name+"]")
}
}
w, _ = redact(w, known)
out[i] = w
}
return out
}
// restartOnly are the daemon keys the runtime reads only when it starts: a reload leaves them as
// they were (dockerd's reloadable set is debug, labels, live-restore, registries, max-concurrent-*,
// default-runtime, runtimes, shutdown-timeout and a few more).
var restartOnly = []string{"dns", "dns-opts", "dns-search", "data-root", "storage-driver", "log-driver", "log-opts", "bip",
"default-address-pools", "iptables", "ip6tables", "ipv6", "userns-remap", "exec-opts", "allow-direct-routing", "mtu"}
// DaemonConfig is the runtime's file and what the daemon runs with now, and where the two differ.
func (c *Client) DaemonConfig(ctx context.Context) (map[string]any, error) {
answer := map[string]any{"file": DaemonFile}
var file map[string]any
raw, err := c.ReadFile(DaemonFile)
switch {
case os.IsNotExist(err):
answer["file_state"] = "absent: the daemon runs on its defaults"
case err != nil:
answer["file_state"] = "unreadable: " + err.Error()
default:
if err := json.Unmarshal(raw, &file); err != nil {
answer["file_state"] = "not JSON — the daemon refuses to start with it: " + err.Error()
} else {
answer["file_state"] = "read"
answer["keys"] = file
}
}
out, err := c.docker(ctx, "info", "--format", "{{json .}}")
if err != nil {
return nil, err
}
var info map[string]any
if err := json.Unmarshal([]byte(out), &info); err != nil {
return nil, fmt.Errorf("docker info answered something that is not JSON: %v", err)
}
essentials := map[string]any{}
for _, k := range []string{"ServerVersion", "Driver", "LoggingDriver", "CgroupDriver", "CgroupVersion", "LiveRestoreEnabled",
"DockerRootDir", "Containers", "ContainersRunning", "ContainersPaused", "ContainersStopped", "Images", "KernelVersion",
"OperatingSystem", "NCPU", "MemTotal", "DefaultRuntime", "FirewallBackend", "SecurityOptions", "Warnings", "Debug"} {
if v, ok := info[k]; ok {
essentials[k] = v
}
}
if reg, ok := info["RegistryConfig"].(map[string]any); ok {
insecure := []string{}
if idx, ok := reg["IndexConfigs"].(map[string]any); ok {
for name, cfg := range idx {
if m, ok := cfg.(map[string]any); ok && m["Secure"] == false {
insecure = append(insecure, name)
}
}
}
sort.Strings(insecure)
essentials["InsecureRegistries"] = insecure
essentials["InsecureRegistryCIDRs"] = reg["InsecureRegistryCIDRs"]
}
answer["daemon"] = essentials
// Where the file says one thing and the daemon runs another: a key changed since the last reload,
// or one only a restart takes.
pending := []string{}
if file != nil {
if v, ok := file["live-restore"].(bool); ok && info["LiveRestoreEnabled"] != v {
pending = append(pending, fmt.Sprintf("live-restore is %v in the file and %v in the daemon: a reload takes it", v, info["LiveRestoreEnabled"]))
}
if v, ok := file["log-driver"].(string); ok && info["LoggingDriver"] != v {
pending = append(pending, fmt.Sprintf("log-driver is %s in the file and %v in the daemon: only a restart takes it", v, info["LoggingDriver"]))
}
present := []string{}
for _, k := range restartOnly {
if _, ok := file[k]; ok {
present = append(present, k)
}
}
answer["read_only_at_start"] = present
}
answer["pending"] = pending
answer["note"] = "keys read only at start take effect at the daemon's next restart; with live-restore on, a restart keeps every container running"
return answer, nil
}