Claims node-container-runtime (ADR 0207). Owns the packages, the socket and a weekly prune of dangling images and unused build cache. Serves 18 tools over every container, marking the mesh's. daemon.json, docker.service and the docker group are left to a proposed change: dnsmasq and zsh declare them today, and the controller refuses a second declaration (README).
280 lines
12 KiB
Go
280 lines
12 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() {
|
|
// 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_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).",
|
|
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_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 host 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 host 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_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.",
|
|
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
|
|
}
|