From 07b3c5e77a195bf7fc31ef2ac04d09cc1d325b50 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 23:46:46 +0200 Subject: [PATCH] Report each anonymous volume's size and the space freed (issue 1796) --- modules/docker/cmd/docker-tools/docker.go | 16 +++++++++++++--- modules/docker/cmd/docker-tools/docker_test.go | 9 ++++++++- modules/docker/cmd/docker-tools/main.go | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/modules/docker/cmd/docker-tools/docker.go b/modules/docker/cmd/docker-tools/docker.go index c2b9cfbb..9d723a30 100644 --- a/modules/docker/cmd/docker-tools/docker.go +++ b/modules/docker/cmd/docker-tools/docker.go @@ -1232,10 +1232,11 @@ func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk) 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, 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}) } @@ -1252,12 +1253,20 @@ func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk) 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"]) } } - answer := map[string]any{"dry_run": a.DryRun, "skipped": skipped, - "space_freed": "not reported: docker volume rm names what it removed; docker_disk_usage shows the space"} + 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 } @@ -1271,6 +1280,7 @@ func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk) } answer["skipped"] = skipped answer["removed"], answer["count"], answer["why"] = removed, len(removed), a.Why + answer["freed_bytes"] = sum(removed) return answer, nil } diff --git a/modules/docker/cmd/docker-tools/docker_test.go b/modules/docker/cmd/docker-tools/docker_test.go index d116cc64..14f974e6 100644 --- a/modules/docker/cmd/docker-tools/docker_test.go +++ b/modules/docker/cmd/docker-tools/docker_test.go @@ -441,6 +441,7 @@ func TestRemoveAnonymousVolumesTakesOnlyTheUnmountedAnonymousOnes(t *testing.T) {"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) @@ -451,12 +452,15 @@ func TestRemoveAnonymousVolumesTakesOnlyTheUnmountedAnonymousOnes(t *testing.T) 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 || why["webdata"] == "" || why["young"] == "" || why["named"] == "" { + 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}) @@ -466,6 +470,9 @@ func TestRemoveAnonymousVolumesTakesOnlyTheUnmountedAnonymousOnes(t *testing.T) 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) diff --git a/modules/docker/cmd/docker-tools/main.go b/modules/docker/cmd/docker-tools/main.go index cd73ec8c..545984b3 100644 --- a/modules/docker/cmd/docker-tools/main.go +++ b/modules/docker/cmd/docker-tools/main.go @@ -291,7 +291,7 @@ func tools(c *Client) []stdio.Tool { 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. Docker does not report the space a removed volume freed. " + + "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)"},