The volume prune gets the same treatment as the image prune in 0.61.0:
the candidates are listed and removed one by one in the background, and
the Volumes page shows a progress bar (x / y, the volume being removed,
failures) followed by a toast with the space freed.
The button also used to delete without asking, and a bare docker volume
prune only removes anonymous volumes on Engine 23+ with no way to include
named ones. It now opens a confirmation dialog with an explicit -a option
that warns it also takes the data of every stack that is down.
- Candidates: volumes no container (running or stopped) mounts, local
scope only, and anonymous (com.docker.volume.anonymous) unless -a.
Volumes from pre-23 daemons carry no such label, so they only go with
-a, which errs on the side of keeping data.
- Removed without force, so a volume mounted since the listing is
refused; one refusal does not stop the rest.
- No closing daemon prune: nothing is left over, and on a pre-23 daemon an
unfiltered one would delete named volumes too.
- Freed space is the sum of the deleted volumes' sizes (volumes share no
data), from the size cache, which is invalidated afterwards.
- POST /api/volumes/prune takes all= and background=; without background
it still waits and returns {VolumesDeleted, SpaceReclaimed}.
GET /api/volumes/prune-status is admin-only.
- volume_sizes() ("Compute sizes") now uses the long-timeout client; it
walks every volume and could hit the same 60s limit.
The progress machinery moved into services/prune_job.py (PruneJob,
PrunePlan) and, on the frontend, the usePruneJob hook and PruneProgress
component, shared by both pages. The status now carries a phase
(preparing / removing / finishing) so the parts without a count say what
they are doing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326 lines
11 KiB
Python
326 lines
11 KiB
Python
"""Docker volume management + Compose volume YAML generation."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
from typing import Callable, Literal, Optional
|
|
|
|
import yaml
|
|
|
|
from docker_client import get_client, get_long_client, safe_call
|
|
from services.prune_job import PruneJob, PrunePlan
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
|
#: Set by the daemon (Engine 23+) on volumes created without a name. A plain
|
|
#: `docker volume prune` only removes these; `-a` removes named ones too.
|
|
ANONYMOUS_LABEL = "com.docker.volume.anonymous"
|
|
|
|
# `docker system df -v` walks every volume's contents, so it can take many
|
|
# seconds. Cache the result so the (polled) UI and repeated requests reuse it.
|
|
_SIZE_TTL = 60.0
|
|
_size_cache: dict = {"at": 0.0, "data": {}}
|
|
_size_lock = threading.Lock()
|
|
|
|
|
|
def volume_sizes(force: bool = False) -> dict:
|
|
"""Return {volume_name: size_bytes|None}. Cached (~60s) since it's expensive."""
|
|
now = time.time()
|
|
with _size_lock:
|
|
if not force and _size_cache["data"] and now - _size_cache["at"] < _SIZE_TTL:
|
|
return _size_cache["data"]
|
|
|
|
# The slow call this cache exists for; the 60s client would time out on a
|
|
# host with large volumes.
|
|
client = get_long_client()
|
|
df = safe_call(client.df)
|
|
sizes: dict[str, Optional[int]] = {}
|
|
for v in df.get("Volumes") or []:
|
|
ud = v.get("UsageData") or {}
|
|
size = ud.get("Size")
|
|
sizes[v.get("Name")] = size if isinstance(size, int) and size >= 0 else None
|
|
|
|
with _size_lock:
|
|
_size_cache["at"] = time.time()
|
|
_size_cache["data"] = sizes
|
|
return sizes
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Listing / pruning
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def list_volumes() -> list[dict]:
|
|
client = get_client()
|
|
volumes = safe_call(client.volumes.list)
|
|
# Map volume name -> set of containers using it.
|
|
containers = safe_call(client.containers.list, all=True)
|
|
usage: dict[str, list[str]] = {}
|
|
for c in containers:
|
|
for mount in c.attrs.get("Mounts", []) or []:
|
|
if mount.get("Type") == "volume" and mount.get("Name"):
|
|
usage.setdefault(mount["Name"], []).append(c.name)
|
|
|
|
result = []
|
|
for v in volumes:
|
|
attrs = v.attrs
|
|
name = v.name
|
|
result.append(
|
|
{
|
|
"name": name,
|
|
"driver": attrs.get("Driver"),
|
|
"mountpoint": attrs.get("Mountpoint"),
|
|
"created_at": attrs.get("CreatedAt"),
|
|
"labels": attrs.get("Labels") or {},
|
|
"scope": attrs.get("Scope"),
|
|
"stack": (attrs.get("Labels") or {}).get(COMPOSE_PROJECT_LABEL),
|
|
"used_by": usage.get(name, []),
|
|
"in_use": bool(usage.get(name)),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def orphaned_volumes() -> list[dict]:
|
|
return [v for v in list_volumes() if not v["in_use"]]
|
|
|
|
|
|
def remove_volume(name: str, force: bool = False) -> None:
|
|
client = get_client()
|
|
volume = safe_call(client.volumes.get, name)
|
|
safe_call(volume.remove, force=force)
|
|
|
|
|
|
_prune = PruneJob("volume")
|
|
|
|
|
|
def prune_status() -> dict:
|
|
return _prune.status()
|
|
|
|
|
|
def _candidates(client, all_unused: bool) -> list[dict]:
|
|
"""The volumes ``docker volume prune [-a]`` would remove.
|
|
|
|
Unused means no container, running or stopped, mounts it. Without -a only
|
|
anonymous volumes qualify. Volumes created before Engine 23 carry no
|
|
anonymous label and so are only removed with -a, which errs on the side of
|
|
keeping data.
|
|
"""
|
|
in_use = set()
|
|
for c in safe_call(client.api.containers, all=True):
|
|
for mount in c.get("Mounts") or []:
|
|
if mount.get("Type") == "volume" and mount.get("Name"):
|
|
in_use.add(mount["Name"])
|
|
volumes = (safe_call(client.api.volumes) or {}).get("Volumes") or []
|
|
return [
|
|
v
|
|
for v in volumes
|
|
if v["Name"] not in in_use
|
|
# Cluster (swarm) volumes are not the local daemon's to prune.
|
|
and v.get("Scope", "local") == "local"
|
|
and (all_unused or ANONYMOUS_LABEL in (v.get("Labels") or {}))
|
|
]
|
|
|
|
|
|
def _label(volume: dict) -> str:
|
|
name = volume["Name"]
|
|
# Anonymous volumes are 64 hex characters; the first 12 identify them.
|
|
return name[:12] if ANONYMOUS_LABEL in (volume.get("Labels") or {}) else name
|
|
|
|
|
|
def _plan(all_unused: bool) -> PrunePlan:
|
|
client = get_long_client()
|
|
measured: dict = {}
|
|
|
|
def prepare() -> None:
|
|
# Volumes share no data, so the freed space is simply the sum of what
|
|
# was deleted, provided their sizes are known up front. Unused volumes
|
|
# cannot change, so the cached figures are good enough.
|
|
try:
|
|
measured["sizes"] = volume_sizes()
|
|
except Exception as exc: # noqa: BLE001 - the size is a nicety, not the job
|
|
logger.warning("Could not measure volume sizes: %s", exc)
|
|
|
|
def remove(volume: dict) -> tuple[list, bool]:
|
|
# No force: a volume mounted since the listing makes the daemon refuse.
|
|
safe_call(client.api.remove_volume, volume["Name"])
|
|
return [volume["Name"]], True
|
|
|
|
def finish(names: list) -> tuple[dict, Optional[int]]:
|
|
# No closing daemon prune as for images: nothing is left behind, and
|
|
# on a pre-23 daemon an unfiltered one would delete named volumes too.
|
|
sizes = measured.get("sizes")
|
|
reclaimed = (
|
|
sum(sizes.get(name) or 0 for name in names) if sizes is not None else None
|
|
)
|
|
with _size_lock:
|
|
_size_cache["at"] = 0.0 # deleted volumes must not linger in it
|
|
return {"VolumesDeleted": names, "SpaceReclaimed": reclaimed or 0}, reclaimed
|
|
|
|
return PrunePlan(
|
|
all=all_unused,
|
|
items=_candidates(client, all_unused),
|
|
label=_label,
|
|
remove=remove,
|
|
finish=finish,
|
|
prepare=prepare,
|
|
)
|
|
|
|
|
|
def prune_volumes(all_unused: bool = False) -> dict:
|
|
"""Remove unused volumes and wait for the result.
|
|
|
|
By default only anonymous volumes; ``all_unused=True`` also removes named
|
|
ones (``docker volume prune -a``). Progress is visible through
|
|
prune_status() while it runs.
|
|
"""
|
|
return _prune.run(lambda: _plan(all_unused))
|
|
|
|
|
|
def start_prune(
|
|
all_unused: bool = False, on_done: Optional[Callable[[dict], None]] = None
|
|
) -> dict:
|
|
"""Start a prune in the background and return its initial status; the UI
|
|
polls prune_status(). ``on_done`` gets what prune_volumes() would return."""
|
|
return _prune.start(lambda: _plan(all_unused), on_done)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# YAML generation
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
VolumeType = Literal["bind", "named", "nfs", "smb", "tmpfs"]
|
|
|
|
|
|
def _nfs_options(server: str, opts: dict) -> str:
|
|
"""Build the `o:` option string for an NFS driver_opts block."""
|
|
parts = [f"addr={server}"]
|
|
# rw / ro
|
|
if opts.get("rw", True):
|
|
parts.append("rw")
|
|
else:
|
|
parts.append("ro")
|
|
for flag in ("soft", "hard", "nolock", "noatime", "noacl", "nocto", "bg"):
|
|
if opts.get(flag):
|
|
parts.append(flag)
|
|
version = opts.get("nfsvers") or opts.get("version")
|
|
if version:
|
|
parts.append(f"nfsvers={version}")
|
|
if opts.get("timeo") is not None:
|
|
parts.append(f"timeo={opts['timeo']}")
|
|
if opts.get("retrans") is not None:
|
|
parts.append(f"retrans={opts['retrans']}")
|
|
extra = (opts.get("extra") or "").strip()
|
|
if extra:
|
|
parts.append(extra.lstrip(","))
|
|
return ",".join(parts)
|
|
|
|
|
|
def _smb_options(opts: dict) -> str:
|
|
parts = []
|
|
if opts.get("username"):
|
|
parts.append(f"username={opts['username']}")
|
|
if opts.get("password") is not None:
|
|
parts.append(f"password={opts['password']}")
|
|
if opts.get("uid") is not None:
|
|
parts.append(f"uid={opts['uid']}")
|
|
if opts.get("gid") is not None:
|
|
parts.append(f"gid={opts['gid']}")
|
|
version = opts.get("vers")
|
|
if version:
|
|
parts.append(f"vers={version}")
|
|
if opts.get("noperm"):
|
|
parts.append("noperm")
|
|
if opts.get("file_mode"):
|
|
parts.append(f"file_mode={opts['file_mode']}")
|
|
if opts.get("dir_mode"):
|
|
parts.append(f"dir_mode={opts['dir_mode']}")
|
|
extra = (opts.get("extra") or "").strip()
|
|
if extra:
|
|
parts.append(extra.lstrip(","))
|
|
return ",".join(parts)
|
|
|
|
|
|
def build_volume_definition(spec: dict) -> dict:
|
|
"""Return the top-level `volumes:` declaration for a named/nfs/smb volume.
|
|
|
|
Returns {} for bind/tmpfs (those live entirely on the service).
|
|
"""
|
|
vtype: VolumeType = spec["type"]
|
|
name = spec.get("volume_name") or spec.get("name")
|
|
|
|
if vtype == "named":
|
|
decl: dict = {"driver": spec.get("driver", "local")}
|
|
if spec.get("driver_opts"):
|
|
decl["driver_opts"] = spec["driver_opts"]
|
|
if not decl.get("driver_opts") and decl["driver"] == "local":
|
|
# A plain named volume can be declared as null.
|
|
return {name: None}
|
|
return {name: decl}
|
|
|
|
if vtype == "nfs":
|
|
o = _nfs_options(spec["nfs_server"], spec.get("options", {}))
|
|
device = spec["nfs_path"]
|
|
if not device.startswith(":"):
|
|
device = ":" + device
|
|
return {
|
|
name: {
|
|
"driver": "local",
|
|
"driver_opts": {"type": "nfs", "o": o, "device": device},
|
|
}
|
|
}
|
|
|
|
if vtype == "smb":
|
|
o = _smb_options(spec.get("options", {}))
|
|
device = spec["smb_share"] # //server/share
|
|
return {
|
|
name: {
|
|
"driver": "local",
|
|
"driver_opts": {"type": "cifs", "o": o, "device": device},
|
|
}
|
|
}
|
|
|
|
return {}
|
|
|
|
|
|
def build_service_mount(spec: dict) -> str | dict:
|
|
"""Return the entry to add to a service's `volumes:` list."""
|
|
vtype: VolumeType = spec["type"]
|
|
if vtype == "bind":
|
|
mode = "ro" if not spec.get("options", {}).get("rw", True) else "rw"
|
|
return f"{spec['host_path']}:{spec['container_path']}:{mode}"
|
|
if vtype == "tmpfs":
|
|
return {
|
|
"type": "tmpfs",
|
|
"target": spec["container_path"],
|
|
"tmpfs": {
|
|
k: v
|
|
for k, v in {
|
|
"size": spec.get("options", {}).get("size"),
|
|
"mode": spec.get("options", {}).get("mode"),
|
|
}.items()
|
|
if v is not None
|
|
},
|
|
}
|
|
# named / nfs / smb
|
|
name = spec.get("volume_name") or spec.get("name")
|
|
return f"{name}:{spec['container_path']}"
|
|
|
|
|
|
def generate_yaml(spec: dict) -> str:
|
|
"""Produce a ready-to-insert YAML fragment for the given volume spec.
|
|
|
|
Includes the top-level `volumes:` block (if any) and a `services:` example
|
|
showing the mount, mirroring the wizard preview.
|
|
"""
|
|
doc: dict = {}
|
|
definition = build_volume_definition(spec)
|
|
if definition:
|
|
doc["volumes"] = definition
|
|
service_name = spec.get("service", "service")
|
|
doc["services"] = {service_name: {"volumes": [build_service_mount(spec)]}}
|
|
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)
|