Report each anonymous volume's size and the space freed (issue 1796)
mesh/merge-gate head 07b3c5e77a19 merged into trunk f3100d111633 — pass: builds docker → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head 07b3c5e77a19 merged into trunk f3100d111633 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered

This commit is contained in:
2026-10-21 23:46:46 +02:00
parent 64a96a13f7
commit 07b3c5e77a
3 changed files with 22 additions and 5 deletions
+13 -3
View File
@@ -1232,10 +1232,11 @@ func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk)
if !a.DryRun && strings.TrimSpace(a.Why) == "" { if !a.DryRun && strings.TrimSpace(a.Why) == "" {
return nil, fmt.Errorf("why is required when dry_run is false") 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 { if err != nil {
return nil, err return nil, err
} }
sizes := map[string]string{}
candidates := []string{} candidates := []string{}
skipped := []map[string]any{} skipped := []map[string]any{}
skip := func(name, why string) { skipped = append(skipped, map[string]any{"name": name, "why": why}) } 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"])+")") skip(name, "not created before "+a.CreatedBefore.Format(time.RFC3339)+" (created "+fmt.Sprint(v["created"])+")")
default: default:
candidates = append(candidates, name) candidates = append(candidates, name)
sizes[name] = fmt.Sprint(v["size"])
} }
} }
answer := map[string]any{"dry_run": a.DryRun, "skipped": skipped, sum := func(names []string) int64 {
"space_freed": "not reported: docker volume rm names what it removed; docker_disk_usage shows the space"} 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 { if a.DryRun {
answer["would_remove"] = candidates 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" answer["note"] = "nothing was removed: call again with dry_run false and why to remove them"
return answer, nil return answer, nil
} }
@@ -1271,6 +1280,7 @@ func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk)
} }
answer["skipped"] = skipped answer["skipped"] = skipped
answer["removed"], answer["count"], answer["why"] = removed, len(removed), a.Why answer["removed"], answer["count"], answer["why"] = removed, len(removed), a.Why
answer["freed_bytes"] = sum(removed)
return answer, nil return answer, nil
} }
@@ -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":"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":"young","Driver":"local","CreatedAt":"2026-10-04T11:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
{"Name":"named","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z"}]`}). {"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"}) on("docker volume rm old", Ran{Stdout: "old\n"})
c := client(f, 1000) c := client(f, 1000)
before := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) 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"}) { if f.ran("docker volume rm") || !reflect.DeepEqual(got["would_remove"], []string{"old"}) {
t.Fatalf("dry run: %v %+v", got, f.calls) 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) skipped := got["skipped"].([]map[string]any)
why := map[string]string{} why := map[string]string{}
for _, s := range skipped { for _, s := range skipped {
why[s["name"].(string)] = s["why"].(string) 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) t.Fatalf("skipped: %v", skipped)
} }
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "leftovers", CreatedBefore: &before}) 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"}) { if !f.ran("docker volume rm old") || got["count"] != 1 || !reflect.DeepEqual(got["removed"], []string{"old"}) {
t.Fatalf("removed: %v %+v", got, f.calls) t.Fatalf("removed: %v %+v", got, f.calls)
} }
if got["freed_bytes"] != int64(1500000) {
t.Fatalf("freed: %v", got)
}
for _, c := range f.calls { 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") { 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) t.Errorf("reached too far: %v", c.args)
+1 -1
View File
@@ -291,7 +291,7 @@ func tools(c *Client) []stdio.Tool {
Name: "docker_volumes_remove_anonymous", 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 " + 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 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)", "A dry run by default; dry_run false needs why. Replaces docker volume rm and docker volume prune. (a)",
Input: map[string]any{ Input: map[string]any{
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"}, "dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},