Files
mesh-catalog/modules/docker/cmd/docker-tools/main.go
T
jschoubben 9a1a97cc43
mesh/merge-gate pass: builds docker → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
docker: make log rotation a preference, 100m x 10 by default, judged by a step before daemon.json takes it (novox/hq issue 452)
The controller pastes a setting into daemon.json as text and the runtime is its
only reader, so a bad size would surface as a runtime that does not start and a
quote could add any key. A step refuses the value first and gates the module.
The defaults are the one hand-set machine's, so it does not change; nothing
restarts the runtime, which takes the keys at its next start.
2026-10-11 04:43:14 +02:00

380 lines
18 KiB
Go

// docker's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's tool runtime launches
// and speaks MCP over stdio to, through the Go SDK. It answers for every container on this machine —
// the mesh's and every other — and for the runtime's images, networks, volumes, events and
// configuration. It runs as the operator account (ADR 0175 §4); docker.go says how it reaches the
// daemon's socket. The host applies the module's resources; these tools answer about the runtime.
package main
import (
"context"
"fmt"
"math"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// Started as `docker-tools check-log-rotation` it is the module's step that judges the log rotation before
// daemon.json takes it (logrotation.go, novox/hq issue 452): run as root by the host, reading the file
// DOCKER_LOG_ROTATION names. With no argument, the runtime's tools bundle.
if len(os.Args) > 1 {
if os.Args[1] != "check-log-rotation" || len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: docker-tools [check-log-rotation]")
os.Exit(2)
}
if err := checkLogRotationFile(os.Getenv("DOCKER_LOG_ROTATION")); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): docker.
if err := stdio.Serve("", tools(NewClient())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var containerArg = map[string]any{"type": "string", "description": "the container's name or id"}
func tools(c *Client) []stdio.Tool {
ctx := context.Background()
act := func(verb, description string) stdio.Tool {
return stdio.Tool{
Name: "docker_" + verb, Description: description,
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Act(ctx, verb, ref)
},
}
}
return []stdio.Tool{
{
Name: "docker_list",
Description: "Every container on this machine — the mesh's and every other — with its image, state, health, restarts, " +
"published ports, mounts, compose project, and mesh_held/held_by (the assignment that holds it).",
Input: map[string]any{
"held": map[string]any{"type": "string", "enum": []string{"all", "mesh", "other"}, "description": "whose: all (default), the mesh's, or the others"},
"state": map[string]any{"type": "string", "description": "only containers in this state (running, exited, created, restarting, paused, dead)"},
"match": map[string]any{"type": "string", "description": "only containers whose name or image contains this"},
},
Run: func(args map[string]any) (any, error) {
list, err := c.Containers(ctx, optional(args, "held"), optional(args, "state"), optional(args, "match"))
if err != nil {
return nil, err
}
return map[string]any{"count": len(list), "containers": list}, nil
},
},
{
Name: "docker_inspect",
Description: "One container whole, as docker inspects it, with mesh_held; its environment's values are left out (names kept), because that is where a container's secrets are.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Inspect(ctx, ref)
},
},
{
Name: "docker_resolv_conf",
Description: "The resolver file one container really uses (/etc/resolv.conf as it sees it, read through the runtime's archive, no exec): " +
"its nameservers, search and options and the raw text, beside the machine's own nameservers and whether they are the same.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.ResolvConfOf(ctx, ref)
},
},
{
Name: "docker_logs",
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB). " +
"A secret the container was given that it printed is shown as [redacted: <name>], and so is a password inside a URI.",
Input: map[string]any{
"container": containerArg,
"lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"},
"since": map[string]any{"type": "string", "description": "only lines since then: a duration such as 30m or 2h, or a time"},
},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
n, err := bounded(args, "lines", 200, 2000)
if err != nil {
return nil, err
}
return c.Logs(ctx, ref, n, optional(args, "since"))
},
},
{
Name: "docker_secrets_in_logs",
Description: "Which containers printed a secret they were given into their own log — by container, module and the secret's name, never its value: " +
"each one's recent lines compared with the values of its environment named like a secret and the passwords in its URIs, and any URI carrying a password. " +
"A finding is a secret to rotate once the program stops printing it (novox/hq issue 268).",
Input: map[string]any{
"held": map[string]any{"type": "string", "enum": []string{"all", "mesh", "other"}, "description": "whose: the mesh's (default), every container, or the others"},
"lines": map[string]any{"type": "integer", "description": "how many lines from the end of each log (default 5000, at most 50000)"},
},
Run: func(args map[string]any) (any, error) {
n, err := bounded(args, "lines", 5000, 50000)
if err != nil {
return nil, err
}
held := optional(args, "held")
if held == "" {
held = "mesh"
}
return c.SecretsInLogs(ctx, held, n)
},
},
{
Name: "docker_secrets_in_events",
Description: "Which secrets exec command lines carried in a window ending now (default the last 60 minutes, at most 24 hours) — the runtime records every exec's command line in its events, " +
"so a password passed as an argument is kept there for anyone who may ask it. By container, module and the secret's name, never its value. " +
"A finding is code to change (hand the secret over as a file or on stdin) and then a secret to rotate (novox/hq issue 282).",
Input: map[string]any{
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
},
Run: func(args map[string]any) (any, error) {
minutes, err := bounded(args, "minutes", 60, 1440)
if err != nil {
return nil, err
}
return c.SecretsInEvents(ctx, minutes)
},
},
{
Name: "docker_stats",
Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.",
Input: map[string]any{"container": map[string]any{"type": "string", "description": "one container (optional)"}},
Run: func(args map[string]any) (any, error) {
stats, err := c.Stats(ctx, optional(args, "container"))
if err != nil {
return nil, err
}
return map[string]any{"count": len(stats), "containers": stats}, nil
},
},
act("start", "Start one container. A container the mesh holds is started too, and the answer says the node-engine restores what its declaration says at its next apply."),
act("stop", "Stop one container (ten seconds, then killed). For a container the mesh holds, the answer says the node-engine will start it again at its next apply if its declaration says running."),
act("restart", "Restart one container (ten seconds to stop, then killed); the answer says whether the mesh holds it."),
{
Name: "docker_top",
Description: "The processes running inside one container: pid, user, elapsed time, CPU, resident memory and command.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Top(ctx, ref)
},
},
{
Name: "docker_images",
Description: "The images on this machine, the largest first, each with its size and the containers using it (and whether one of them is the mesh's). " +
"filter: all, dangling, unused or used.",
Input: map[string]any{
"filter": map[string]any{"type": "string", "enum": []string{"all", "dangling", "unused", "used"}, "description": "which images (default all)"},
"match": map[string]any{"type": "string", "description": "only images whose repository:tag contains this"},
"limit": map[string]any{"type": "integer", "description": "how many to show (default 100, at most 1000); count says how many matched"},
},
Run: func(args map[string]any) (any, error) {
n, err := bounded(args, "limit", 100, 1000)
if err != nil {
return nil, err
}
return c.Images(ctx, optional(args, "filter"), optional(args, "match"), n)
},
},
{
Name: "docker_prune",
Description: "Reclaim space: dangling images and unused build cache, and — only when containers is true — stopped containers the mesh does not hold. " +
"Never a volume, never a container the mesh holds, never an image a container uses. A dry run by default: it lists what would go; dry_run false removes it.",
Input: map[string]any{
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},
"images": map[string]any{"type": "boolean", "description": "dangling images (default true)"},
"build_cache": map[string]any{"type": "boolean", "description": "build cache nothing refers to (default true)"},
"containers": map[string]any{"type": "boolean", "description": "stopped containers the mesh does not hold (default false); what they mounted is kept"},
"older_than_hours": map[string]any{"type": "integer", "description": "only what is older than this many hours (default 0: any age)"},
},
Run: func(args map[string]any) (any, error) {
older := 0
if v, ok := args["older_than_hours"]; ok && v != nil && v != float64(0) {
n, err := bounded(args, "older_than_hours", 0, 24*365)
if err != nil {
return nil, err
}
older = n
}
return c.Prune(ctx, PruneAsk{
DryRun: flag(args, "dry_run", true), Images: flag(args, "images", true), BuildCache: flag(args, "build_cache", true),
Containers: flag(args, "containers", false), OlderThanH: older,
})
},
},
{
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.",
Input: map[string]any{"top": map[string]any{"type": "integer", "description": "how many of the largest per kind (default 10, at most 100)"}},
Run: func(args map[string]any) (any, error) {
n, err := bounded(args, "top", 10, 100)
if err != nil {
return nil, err
}
return c.DiskUsage(ctx, n)
},
},
{
Name: "docker_networks",
Description: "Every network the runtime has: driver, scope, subnets and gateway, and the running containers on it with their addresses and whether the mesh holds them.",
Run: func(map[string]any) (any, error) { return c.Networks(ctx) },
},
{
Name: "docker_volumes",
Description: "Every volume with the containers mounting it, whether the mesh holds any of them, whether it is anonymous, its compose project, and — when sizes is true (slower) — its size.",
Input: map[string]any{
"unmounted": map[string]any{"type": "boolean", "description": "only volumes no container mounts (default false)"},
"sizes": map[string]any{"type": "boolean", "description": "measure each volume (default false: it walks every volume)"},
},
Run: func(args map[string]any) (any, error) {
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
},
},
{
Name: "docker_events",
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
"an exec's command line is shown with any secret it carried as [redacted: <what it was>] — a value of the container's environment named like a secret, a password in a URI, the word after a password flag. " +
"The runtime keeps only its last ~256 events in memory, which health-check execs fill in minutes: complete=false and history say where what it holds begins, and an empty list then means gone, not quiet.",
Input: map[string]any{
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
"type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"},
"limit": map[string]any{"type": "integer", "description": "the latest this many (default 200, at most 2000)"},
"execs": map[string]any{"type": "boolean", "description": "include exec_* events (default false: health checks make many)"},
},
Run: func(args map[string]any) (any, error) {
minutes, err := bounded(args, "minutes", 60, 1440)
if err != nil {
return nil, err
}
limit, err := bounded(args, "limit", 200, 2000)
if err != nil {
return nil, err
}
return c.Events(ctx, minutes, optional(args, "type"), limit, flag(args, "execs", false))
},
},
{
Name: "docker_daemon_config",
Description: "The runtime's configuration: /etc/docker/daemon.json as it is on disk, the daemon's essentials as it runs now (docker info: version, storage and logging drivers, " +
"live restore, root directory, insecure registries, warnings), and where the two differ — keys a reload or only a restart would take.",
Run: func(map[string]any) (any, error) { return c.DaemonConfig(ctx) },
},
{
Name: "docker_unlabelled",
Description: "The containers the mesh does not hold — the cleanup list — each with its image, state, compose project and directory, ports and mounts.",
Run: func(map[string]any) (any, error) { return c.Unlabelled(ctx) },
},
{
Name: "docker_problems",
Description: "Every container that is not well: unhealthy, restarting, dead, killed for memory, exited with a failure, or restarted five times or more — with whether the mesh holds it.",
Run: func(map[string]any) (any, error) {
p, err := c.Problems(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(p), "containers": p}, nil
},
},
{
Name: "docker_ports",
Description: "Every port the containers publish on this machine (address:port -> container port), and the containers on the host's network, which publish whatever they listen on.",
Run: func(map[string]any) (any, error) {
p, err := c.Ports(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(p), "ports": p}, nil
},
},
}
}
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
if s = strings.TrimSpace(s); s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
func optional(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
func flag(args map[string]any, key string, def bool) bool {
if b, ok := args[key].(bool); ok {
return b
}
return def
}
// bounded is a whole number argument, defaulted when absent and held to a ceiling.
func bounded(args map[string]any, key string, def, most int) (int, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok || f != math.Trunc(f) || f < 1 {
return 0, fmt.Errorf("%s must be a whole number of at least 1", key)
}
return int(math.Min(f, float64(most))), nil
}