Show live progress while pruning volumes, and ask first (0.62.0)
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>
This commit is contained in:
1 parent
faff2be944
commit
57231e4a67
18 files changed
+816
-286
No files matched your search
@@ -2,11 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
from docker_client import DockerError, get_client, get_long_client, safe_call
|
||||
from services import update_service
|
||||
from services.prune_job import PruneJob, PrunePlan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,31 +57,11 @@ def list_images() -> list[dict]:
|
||||
# Prune
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
#: One prune at a time: a second pass over the same images would only collect
|
||||
#: errors for everything the first one already removed.
|
||||
_prune_lock = threading.Lock()
|
||||
_pruning: dict = {
|
||||
"active": False,
|
||||
"run": 0,
|
||||
"all": False,
|
||||
"total": 0,
|
||||
"done": 0,
|
||||
"current": None,
|
||||
"deleted": 0,
|
||||
"failed": 0,
|
||||
"errors": [],
|
||||
"reclaimed": None,
|
||||
"error": None,
|
||||
}
|
||||
_MAX_ERRORS = 10
|
||||
|
||||
|
||||
class PruneBusy(Exception):
|
||||
"""A prune is already running."""
|
||||
_prune = PruneJob("image")
|
||||
|
||||
|
||||
def prune_status() -> dict:
|
||||
return {**_pruning, "errors": list(_pruning["errors"])}
|
||||
return _prune.status()
|
||||
|
||||
|
||||
def _layers_size(client) -> Optional[int]:
|
||||
@@ -131,65 +111,38 @@ def _remove(client, image: dict) -> list[dict]:
|
||||
return entries
|
||||
|
||||
|
||||
def _begin(all_unused: bool) -> tuple:
|
||||
def _plan(all_unused: bool) -> PrunePlan:
|
||||
client = get_long_client()
|
||||
candidates = _candidates(client, all_unused)
|
||||
_pruning.update(
|
||||
{
|
||||
"active": True,
|
||||
"run": _pruning["run"] + 1,
|
||||
"all": all_unused,
|
||||
"total": len(candidates),
|
||||
"done": 0,
|
||||
"current": None,
|
||||
"deleted": 0,
|
||||
"failed": 0,
|
||||
"errors": [],
|
||||
"reclaimed": None,
|
||||
"error": None,
|
||||
}
|
||||
measured: dict = {}
|
||||
|
||||
def prepare() -> None:
|
||||
measured["before"] = _layers_size(client)
|
||||
|
||||
def remove(image: dict) -> tuple[list, bool]:
|
||||
entries = _remove(client, image)
|
||||
return entries, any("Deleted" in e for e in entries)
|
||||
|
||||
def finish(entries: list) -> tuple[dict, Optional[int]]:
|
||||
# Let the daemon sweep up whatever the per-image pass left, such as
|
||||
# untagged images referenced from several repositories.
|
||||
filters = {"dangling": False} if all_unused else {"dangling": True}
|
||||
sweep = safe_call(client.images.prune, filters=filters)
|
||||
entries = entries + (sweep.get("ImagesDeleted") or [])
|
||||
before, after = measured.get("before"), _layers_size(client)
|
||||
if before is not None and after is not None:
|
||||
reclaimed = max(before - after, 0)
|
||||
else:
|
||||
reclaimed = sweep.get("SpaceReclaimed", 0)
|
||||
return {"ImagesDeleted": entries, "SpaceReclaimed": reclaimed}, reclaimed
|
||||
|
||||
return PrunePlan(
|
||||
all=all_unused,
|
||||
items=_candidates(client, all_unused),
|
||||
label=_label,
|
||||
remove=remove,
|
||||
finish=finish,
|
||||
prepare=prepare,
|
||||
)
|
||||
return client, candidates
|
||||
|
||||
|
||||
def _execute(client, candidates: list[dict], all_unused: bool) -> dict:
|
||||
before = _layers_size(client)
|
||||
deleted: list[dict] = []
|
||||
for image in candidates:
|
||||
_pruning["current"] = _label(image)
|
||||
try:
|
||||
entries = _remove(client, image)
|
||||
deleted.extend(entries)
|
||||
if any("Deleted" in e for e in entries):
|
||||
_pruning["deleted"] += 1
|
||||
except DockerError as exc:
|
||||
# One image must not stop the rest, e.g. a container started from
|
||||
# it since the list was taken.
|
||||
logger.warning("Prune: could not remove %s: %s", _pruning["current"], exc)
|
||||
_pruning["failed"] += 1
|
||||
if len(_pruning["errors"]) < _MAX_ERRORS:
|
||||
_pruning["errors"].append(f"{_pruning['current']}: {exc.detail or exc.error}")
|
||||
_pruning["done"] += 1
|
||||
_pruning["current"] = None
|
||||
|
||||
# Let the daemon sweep up whatever the per-image pass left, such as
|
||||
# untagged images referenced from several repositories.
|
||||
filters = {"dangling": False} if all_unused else {"dangling": True}
|
||||
sweep = safe_call(client.images.prune, filters=filters)
|
||||
deleted.extend(sweep.get("ImagesDeleted") or [])
|
||||
|
||||
after = _layers_size(client)
|
||||
if before is not None and after is not None:
|
||||
reclaimed = max(before - after, 0)
|
||||
else:
|
||||
reclaimed = sweep.get("SpaceReclaimed", 0)
|
||||
_pruning["reclaimed"] = reclaimed
|
||||
return {"ImagesDeleted": deleted, "SpaceReclaimed": reclaimed}
|
||||
|
||||
|
||||
def _finish(error: Optional[str] = None) -> None:
|
||||
_pruning.update({"active": False, "current": None, "error": error})
|
||||
_prune_lock.release()
|
||||
|
||||
|
||||
def prune_images(all_unused: bool = False) -> dict:
|
||||
@@ -199,51 +152,12 @@ def prune_images(all_unused: bool = False) -> dict:
|
||||
removes every image not referenced by a container (``docker image prune
|
||||
-a``). Progress is visible through prune_status() while it runs.
|
||||
"""
|
||||
if not _prune_lock.acquire(blocking=False):
|
||||
raise PruneBusy("An image prune is already running")
|
||||
error = None
|
||||
try:
|
||||
client, candidates = _begin(all_unused)
|
||||
return _execute(client, candidates, all_unused)
|
||||
except DockerError as exc:
|
||||
error = exc.detail or exc.error
|
||||
raise
|
||||
finally:
|
||||
_finish(error)
|
||||
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.
|
||||
|
||||
A prune removing gigabytes of layers can outlast the proxy's read timeout,
|
||||
so the UI starts it this way and polls prune_status(). ``on_done`` gets
|
||||
the same result prune_images() returns, from the worker thread.
|
||||
"""
|
||||
if not _prune_lock.acquire(blocking=False):
|
||||
raise PruneBusy("An image prune is already running")
|
||||
try:
|
||||
client, candidates = _begin(all_unused)
|
||||
except BaseException:
|
||||
_prune_lock.release()
|
||||
raise
|
||||
|
||||
def work() -> None:
|
||||
error = None
|
||||
result = None
|
||||
try:
|
||||
result = _execute(client, candidates, all_unused)
|
||||
except Exception as exc: # noqa: BLE001 - reported through the status
|
||||
logger.warning("Image prune failed: %s", exc)
|
||||
error = exc.detail if isinstance(exc, DockerError) else str(exc)
|
||||
finally:
|
||||
_finish(error)
|
||||
if result is not None and on_done:
|
||||
try:
|
||||
on_done(result)
|
||||
except Exception as exc: # noqa: BLE001 - the prune itself succeeded
|
||||
logger.warning("Image prune follow-up failed: %s", exc)
|
||||
|
||||
threading.Thread(target=work, name="image-prune", daemon=True).start()
|
||||
return prune_status()
|
||||
"""Start a prune in the background and return its initial status; the UI
|
||||
polls prune_status(). ``on_done`` gets what prune_images() would return."""
|
||||
return _prune.start(lambda: _plan(all_unused), on_done)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Prunes with visible progress, shared by images and volumes.
|
||||
|
||||
``docker image prune`` / ``docker volume prune`` are single blocking calls that
|
||||
report nothing until they are done, which on a host with a lot to delete means
|
||||
minutes of a spinner and, past the proxy's read timeout, an error while the
|
||||
daemon carries on. Instead, a service lists what the prune would remove and a
|
||||
PruneJob deletes it one item at a time, publishing how far it is so the UI can
|
||||
poll it. One prune of each kind runs at a time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from docker_client import DockerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_ERRORS = 10
|
||||
|
||||
|
||||
class PruneBusy(Exception):
|
||||
"""A prune of this kind is already running."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrunePlan:
|
||||
"""What one prune will do, built by the owning service.
|
||||
|
||||
``remove`` deletes one item and returns the entries it contributes to the
|
||||
result plus whether the item is actually gone (removing one of several
|
||||
image tags is not). It raises DockerError to mark just that item failed.
|
||||
``prepare`` runs before the first removal (e.g. measuring disk usage) and
|
||||
``finish`` after the last; it gets every collected entry and returns the
|
||||
API result together with the space reclaimed, or None if unknown.
|
||||
"""
|
||||
|
||||
all: bool
|
||||
items: list
|
||||
label: Callable[[Any], str]
|
||||
remove: Callable[[Any], tuple[list, bool]]
|
||||
finish: Callable[[list], tuple[dict, Optional[int]]]
|
||||
prepare: Optional[Callable[[], None]] = None
|
||||
|
||||
|
||||
class PruneJob:
|
||||
def __init__(self, what: str):
|
||||
self._what = what
|
||||
self._lock = threading.Lock()
|
||||
self._status: dict = {
|
||||
"active": False,
|
||||
"run": 0,
|
||||
"all": False,
|
||||
# preparing -> removing -> finishing, so the UI can say what the
|
||||
# parts without a count are doing.
|
||||
"phase": None,
|
||||
"total": 0,
|
||||
"done": 0,
|
||||
"current": None,
|
||||
"deleted": 0,
|
||||
"failed": 0,
|
||||
"errors": [],
|
||||
"reclaimed": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
def status(self) -> dict:
|
||||
return {**self._status, "errors": list(self._status["errors"])}
|
||||
|
||||
def run(self, build: Callable[[], PrunePlan]) -> dict:
|
||||
"""Prune and wait for the result."""
|
||||
plan = self._begin(build)
|
||||
error = None
|
||||
try:
|
||||
return self._execute(plan)
|
||||
except DockerError as exc:
|
||||
error = exc.detail or exc.error
|
||||
raise
|
||||
finally:
|
||||
self._finish(error)
|
||||
|
||||
def start(
|
||||
self,
|
||||
build: Callable[[], PrunePlan],
|
||||
on_done: Optional[Callable[[dict], None]] = None,
|
||||
) -> dict:
|
||||
"""Prune in a background thread and return the initial status.
|
||||
|
||||
The plan is still built here, so a daemon that cannot even list the
|
||||
candidates fails the request instead of a status nobody polls yet.
|
||||
``on_done`` gets the result from the worker thread.
|
||||
"""
|
||||
plan = self._begin(build)
|
||||
|
||||
def work() -> None:
|
||||
error = None
|
||||
result = None
|
||||
try:
|
||||
result = self._execute(plan)
|
||||
except Exception as exc: # noqa: BLE001 - reported through the status
|
||||
logger.warning("%s prune failed: %s", self._what.capitalize(), exc)
|
||||
error = exc.detail if isinstance(exc, DockerError) else str(exc)
|
||||
finally:
|
||||
self._finish(error)
|
||||
if result is not None and on_done:
|
||||
try:
|
||||
on_done(result)
|
||||
except Exception as exc: # noqa: BLE001 - the prune itself succeeded
|
||||
logger.warning("%s prune follow-up failed: %s", self._what, exc)
|
||||
|
||||
threading.Thread(target=work, name=f"{self._what}-prune", daemon=True).start()
|
||||
return self.status()
|
||||
|
||||
def _begin(self, build: Callable[[], PrunePlan]) -> PrunePlan:
|
||||
if not self._lock.acquire(blocking=False):
|
||||
raise PruneBusy(f"{self._what.capitalize()} prune is already running")
|
||||
try:
|
||||
plan = build()
|
||||
except BaseException:
|
||||
self._lock.release()
|
||||
raise
|
||||
self._status.update(
|
||||
{
|
||||
"active": True,
|
||||
"run": self._status["run"] + 1,
|
||||
"all": plan.all,
|
||||
"phase": "preparing",
|
||||
"total": len(plan.items),
|
||||
"done": 0,
|
||||
"current": None,
|
||||
"deleted": 0,
|
||||
"failed": 0,
|
||||
"errors": [],
|
||||
"reclaimed": None,
|
||||
"error": None,
|
||||
}
|
||||
)
|
||||
return plan
|
||||
|
||||
def _execute(self, plan: PrunePlan) -> dict:
|
||||
status = self._status
|
||||
if plan.prepare:
|
||||
plan.prepare()
|
||||
status["phase"] = "removing"
|
||||
entries: list = []
|
||||
for item in plan.items:
|
||||
status["current"] = plan.label(item)
|
||||
try:
|
||||
produced, gone = plan.remove(item)
|
||||
entries.extend(produced)
|
||||
if gone:
|
||||
status["deleted"] += 1
|
||||
except DockerError as exc:
|
||||
# One item must not stop the rest, e.g. something started
|
||||
# using it since the candidates were listed.
|
||||
logger.warning("Prune: could not remove %s: %s", status["current"], exc)
|
||||
status["failed"] += 1
|
||||
if len(status["errors"]) < _MAX_ERRORS:
|
||||
status["errors"].append(f"{status['current']}: {exc.detail or exc.error}")
|
||||
status["done"] += 1
|
||||
status.update({"phase": "finishing", "current": None})
|
||||
result, reclaimed = plan.finish(entries)
|
||||
status["reclaimed"] = reclaimed
|
||||
return result
|
||||
|
||||
def _finish(self, error: Optional[str]) -> None:
|
||||
self._status.update({"active": False, "phase": None, "current": None, "error": error})
|
||||
self._lock.release()
|
||||
@@ -1,15 +1,22 @@
|
||||
"""Docker volume management + Compose volume YAML generation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Literal, Optional
|
||||
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.
|
||||
@@ -25,7 +32,9 @@ def volume_sizes(force: bool = False) -> dict:
|
||||
if not force and _size_cache["data"] and now - _size_cache["at"] < _SIZE_TTL:
|
||||
return _size_cache["data"]
|
||||
|
||||
client = get_client()
|
||||
# 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 []:
|
||||
@@ -85,9 +94,98 @@ def remove_volume(name: str, force: bool = False) -> None:
|
||||
safe_call(volume.remove, force=force)
|
||||
|
||||
|
||||
def prune_volumes() -> dict:
|
||||
_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()
|
||||
return safe_call(client.volumes.prune)
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in new issue
Block a user