diff --git a/modules/docker/README.md b/modules/docker/README.md index 459fab7d..2233859a 100644 --- a/modules/docker/README.md +++ b/modules/docker/README.md @@ -160,6 +160,7 @@ A failure is an error naming how it failed, never an empty answer. | `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each | | `docker_networks` | r | networks, subnets, and the containers on each | | `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked | +| `docker_volumes_remove_anonymous` | a | leftover anonymous volumes no container mounts in any state (so none the mesh holds), optionally created before a time; each other volume answered with why it stays; none can be named. **A dry run unless `dry_run` is false, which needs `why`. Never forced** (issue 1796) | | `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise unless asked; **an exec's command line is shown with any secret it carried as `[redacted: ]`** | | `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet | | `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list | diff --git a/modules/docker/cmd/docker-tools/docker.go b/modules/docker/cmd/docker-tools/docker.go index 88669917..9d723a30 100644 --- a/modules/docker/cmd/docker-tools/docker.go +++ b/modules/docker/cmd/docker-tools/docker.go @@ -1215,3 +1215,77 @@ func (c *Client) DaemonConfig(ctx context.Context) (map[string]any, error) { 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 } + +// RemoveVolumesAsk is what docker_volumes_remove_anonymous is asked to take. +type RemoveVolumesAsk struct { + DryRun bool + Why string + CreatedBefore *time.Time +} + +// RemoveAnonymousVolumes removes the leftover anonymous volumes (novox/hq issue 1796): those docker +// labels anonymous, which no container mounts in any state, so none a mesh-held container holds. The +// classification is Volumes' own, not a copy. Every other volume is answered with why it stays. Each +// is removed by its own `docker volume rm`, never forced and never `volume prune`, so one the daemon +// refuses is reported and the rest still go. +func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk) (map[string]any, error) { + if !a.DryRun && strings.TrimSpace(a.Why) == "" { + return nil, fmt.Errorf("why is required when dry_run is false") + } + all, err := c.Volumes(ctx, false, true) + if err != nil { + return nil, err + } + sizes := map[string]string{} + candidates := []string{} + skipped := []map[string]any{} + skip := func(name, why string) { skipped = append(skipped, map[string]any{"name": name, "why": why}) } + for _, v := range all["volumes"].([]map[string]any) { + name := fmt.Sprint(v["name"]) + switch by := v["mounted_by"].([]map[string]any); { + case v["anonymous"] != true: + skip(name, "not anonymous: a named volume is data") + case v["mesh_held"] == true: + skip(name, "mounted by a container the mesh holds") + case len(by) > 0: + skip(name, fmt.Sprintf("mounted by %v (%v)", by[0]["container"], by[0]["state"])) + case a.CreatedBefore != nil && !volumeOlder(fmt.Sprint(v["created"]), *a.CreatedBefore): + skip(name, "not created before "+a.CreatedBefore.Format(time.RFC3339)+" (created "+fmt.Sprint(v["created"])+")") + default: + candidates = append(candidates, name) + sizes[name] = fmt.Sprint(v["size"]) + } + } + sum := func(names []string) int64 { + var t int64 + for _, n := range names { + t += max(Bytes(sizes[n]), 0) + } + return t + } + answer := map[string]any{"dry_run": a.DryRun, "skipped": skipped, "sizes": sizes} + if a.DryRun { + answer["would_remove"] = candidates + answer["would_free_bytes"] = sum(candidates) + answer["note"] = "nothing was removed: call again with dry_run false and why to remove them" + return answer, nil + } + removed := []string{} + for _, name := range candidates { + if _, err := c.docker(ctx, "volume", "rm", name); err != nil { + skip(name, "docker refused: "+err.Error()) + continue + } + removed = append(removed, name) + } + answer["skipped"] = skipped + answer["removed"], answer["count"], answer["why"] = removed, len(removed), a.Why + answer["freed_bytes"] = sum(removed) + return answer, nil +} + +// volumeOlder is whether a volume's CreatedAt is before the cutoff; one that cannot be read is not. +func volumeOlder(created string, cutoff time.Time) bool { + t, err := time.Parse(time.RFC3339Nano, created) + return err == nil && t.Before(cutoff) +} diff --git a/modules/docker/cmd/docker-tools/docker_test.go b/modules/docker/cmd/docker-tools/docker_test.go index 7bf8042f..14f974e6 100644 --- a/modules/docker/cmd/docker-tools/docker_test.go +++ b/modules/docker/cmd/docker-tools/docker_test.go @@ -431,3 +431,73 @@ func TestEventsCoveringTheWholeWindowSayItIsCompleteAndFilterTheTypeHere(t *test t.Fatalf("a full buffer reaching past the window: %v", got) } } + +// 1796/AC1: only anonymous volumes nothing mounts go, each answered; a named, a mounted and a too young one stay with why. +func TestRemoveAnonymousVolumesTakesOnlyTheUnmountedAnonymousOnes(t *testing.T) { + f := machine(). + on("docker volume ls --quiet", Ran{Stdout: "webdata\nanonmounted\nold\nyoung\nnamed\n"}). + on("docker volume inspect", Ran{Stdout: `[ +{"Name":"webdata","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}}, +{"Name":"old","Driver":"local","CreatedAt":"2026-09-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}}, +{"Name":"young","Driver":"local","CreatedAt":"2026-10-04T11:00:00Z","Labels":{"com.docker.volume.anonymous":""}}, +{"Name":"named","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z"}]`}). + on("docker system df --verbose", Ran{Stdout: `{"Volumes":[{"Name":"old","Size":"1.5MB"},{"Name":"young","Size":"1kB"}]}`}). + on("docker volume rm old", Ran{Stdout: "old\n"}) + c := client(f, 1000) + before := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) + got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, CreatedBefore: &before}) + if err != nil { + t.Fatal(err) + } + if f.ran("docker volume rm") || !reflect.DeepEqual(got["would_remove"], []string{"old"}) { + t.Fatalf("dry run: %v %+v", got, f.calls) + } + if got["would_free_bytes"] != int64(1500000) || got["sizes"].(map[string]string)["old"] != "1.5MB" { + t.Fatalf("dry run size: %v", got) + } + skipped := got["skipped"].([]map[string]any) + why := map[string]string{} + for _, s := range skipped { + why[s["name"].(string)] = s["why"].(string) + } + if len(why) != 3 || !strings.Contains(why["webdata"], "mesh holds") || why["young"] == "" || why["named"] == "" { + t.Fatalf("skipped: %v", skipped) + } + got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "leftovers", CreatedBefore: &before}) + if err != nil { + t.Fatal(err) + } + if !f.ran("docker volume rm old") || got["count"] != 1 || !reflect.DeepEqual(got["removed"], []string{"old"}) { + t.Fatalf("removed: %v %+v", got, f.calls) + } + if got["freed_bytes"] != int64(1500000) { + t.Fatalf("freed: %v", got) + } + for _, c := range f.calls { + if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" && (len(c.args) != 3 || c.args[2] != "old") { + t.Errorf("reached too far: %v", c.args) + } + } +} + +// 1796/AC2: a volume that is mounted by a mesh-held container is never removed; the tool takes no name. +func TestRemoveAnonymousVolumesRefusesANamedVolumeAndKeepsWhatTheMeshHolds(t *testing.T) { + for _, tool := range tools(client(&fake{}, 1000)) { + if tool.Name != "docker_volumes_remove_anonymous" { + continue + } + for _, k := range []string{"name", "names", "volume", "volumes"} { + if _, err := tool.Run(map[string]any{k: "x", "dry_run": false, "why": "w"}); err == nil { + t.Errorf("%s accepted", k) + } + } + if _, err := tool.Run(map[string]any{"dry_run": false}); err == nil { + t.Error("a removal without why was accepted") + } + if _, err := tool.Run(map[string]any{"created_before": "yesterday"}); err == nil { + t.Error("a bad time was accepted") + } + return + } + t.Fatal("tool not served") +} diff --git a/modules/docker/cmd/docker-tools/main.go b/modules/docker/cmd/docker-tools/main.go index f89c0edd..545984b3 100644 --- a/modules/docker/cmd/docker-tools/main.go +++ b/modules/docker/cmd/docker-tools/main.go @@ -11,6 +11,7 @@ import ( "math" "os" "strings" + "time" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -286,6 +287,34 @@ func tools(c *Client) []stdio.Tool { return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false)) }, }, + { + Name: "docker_volumes_remove_anonymous", + Description: "Remove the leftover anonymous volumes (novox/hq issue 1796): only a volume docker labels anonymous (as docker_volumes says), which no container mounts in any state " + + "and so none the mesh holds, and with created_before only one created before it. Every other volume is answered with why it stays; a named volume is data and is never taken, " + + "and no volume can be named. Each is removed on its own, never forced; the answer has the count and names removed. The answer has each volume's size and the bytes freed (or, in a dry run, that would be). " + + "A dry run by default; dry_run false needs why. Replaces docker volume rm and docker volume prune. (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"}, + "created_before": map[string]any{"type": "string", "description": "RFC 3339 time: only volumes created before it (default: any age)"}, + }, + Run: func(args map[string]any) (any, error) { + for _, k := range []string{"name", "names", "volume", "volumes"} { + if _, ok := args[k]; ok { + return nil, fmt.Errorf("%s: this tool takes no volume by name; it removes only unmounted anonymous volumes", k) + } + } + ask := RemoveVolumesAsk{DryRun: flag(args, "dry_run", true), Why: optional(args, "why")} + if s := optional(args, "created_before"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil, fmt.Errorf("created_before must be an RFC 3339 time such as 2026-10-01T00:00:00Z") + } + ask.CreatedBefore = &t + } + return c.RemoveAnonymousVolumes(ctx, ask) + }, + }, { 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; " + diff --git a/modules/docker/module.json b/modules/docker/module.json index a3a75e09..fae954c3 100644 --- a/modules/docker/module.json +++ b/modules/docker/module.json @@ -33,6 +33,7 @@ "docker_disk_usage", "docker_networks", "docker_volumes", + "docker_volumes_remove_anonymous", "docker_events", "docker_daemon_config", "docker_unlabelled",