Add docker_volumes_remove_anonymous: remove leftover unmounted anonymous volumes (issue 1796)
mesh/merge-gate building on ace since 23:45
mesh/delivery superseded: a newer head of the same pull request

This commit is contained in:
2026-10-21 23:44:18 +02:00
parent f1fb73e12c
commit 64a96a13f7
5 changed files with 158 additions and 0 deletions
+64
View File
@@ -1215,3 +1215,67 @@ 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, false)
if err != nil {
return nil, err
}
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)
}
}
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"}
if a.DryRun {
answer["would_remove"] = 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
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)
}