Show live progress while pruning volumes, and ask first (0.62.0)
CI / check (push) Successful in 13m19s
CI / build-and-push (push) Successful in 1m57s

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:
menzeljandClaude Opus 5 committed 2026-09-18 10:23:51 +02:00
1 parent faff2be944
commit 57231e4a67
18 files changed
+816 -286

No files matched your search

+6 -6
View File
@@ -37,12 +37,12 @@ def test_safe_call_wraps_connection_error():
def test_volume_prune_timeout_is_a_502_with_detail(as_admin, monkeypatch):
from services import volume_service
class Volumes:
def prune(self, filters=None):
class Api:
def containers(self, all=False):
raise requests.exceptions.ReadTimeout("Read timed out.")
class Client:
volumes = Volumes()
api = Api()
monkeypatch.setattr(volume_service, "get_long_client", lambda: Client())
r = as_admin.post("/api/volumes/prune")
@@ -53,7 +53,6 @@ def test_volume_prune_timeout_is_a_502_with_detail(as_admin, monkeypatch):
@pytest.mark.parametrize(
"module, fn, args",
[
("volume_service", "prune_volumes", ()),
("network_service", "prune_networks", ()),
],
)
@@ -80,5 +79,6 @@ def test_prunes_use_the_long_timeout_client(monkeypatch, module, fn, args):
assert used == ["long"]
# The image prune goes image by image and has its own tests in
# test_image_prune.py, including that it uses the long-timeout client.
# The image and volume prunes go item by item and have their own tests in
# test_image_prune.py / test_volume_prune.py, including that they use the
# long-timeout client.
+8 -7
View File
@@ -112,10 +112,9 @@ def svc(monkeypatch):
monkeypatch.setattr(image_service, "get_client", short)
monkeypatch.setattr(image_service, "get_long_client", lambda: client)
image_service._pruning.update({"active": False, "run": 0})
yield image_service, api, client
if image_service._prune_lock.locked(): # a test that failed mid-prune
image_service._prune_lock.release()
if image_service._prune._lock.locked(): # a test that failed mid-prune
image_service._prune._lock.release()
def test_all_unused_removes_everything_no_container_uses(svc):
@@ -167,7 +166,9 @@ def test_background_prune_reports_progress_and_blocks_a_second_one(svc):
started = image_service.start_prune(True, on_done=lambda r: finished.set())
assert started["active"] is True and started["total"] == 3
with pytest.raises(image_service.PruneBusy):
from services.prune_job import PruneBusy
with pytest.raises(PruneBusy):
image_service.start_prune(True)
api.gate.set()
@@ -194,7 +195,7 @@ def test_background_prune_error_lands_in_the_status(svc):
status = image_service.prune_status()
assert status["active"] is False
assert status["error"] == "Docker did not answer in time"
assert not image_service._prune_lock.locked()
assert not image_service._prune._lock.locked()
def test_endpoint_waits_by_default_and_is_audited(svc, as_admin):
@@ -229,12 +230,12 @@ def test_endpoint_background_returns_202_and_status_is_pollable(svc, as_admin):
def test_endpoint_rejects_a_second_prune(svc, as_admin):
image_service, _, _ = svc
image_service._prune_lock.acquire()
image_service._prune._lock.acquire()
try:
r = as_admin.post("/api/images/prune", params={"background": True})
assert r.status_code == 409
finally:
image_service._prune_lock.release()
image_service._prune._lock.release()
def test_prune_is_admin_only(svc, as_user):
+189
View File
@@ -0,0 +1,189 @@
"""Volume prune with visible progress, and an explicit -a.
Same mechanism as the image prune (services/prune_job.py): the candidates are
listed, removed one by one and the progress is published through
/api/volumes/prune-status. On top of that the button used to call a bare
``docker volume prune``, which on Engine 23+ only removes anonymous volumes and
offered no way to include named ones.
Pinned here: nothing a container mounts is touched, named volumes only go with
-a, a refusal does not stop the rest, and the freed space is the sum of what
was actually deleted.
"""
from __future__ import annotations
import threading
import pytest
from docker.errors import APIError
MB = 1024 * 1024
ANON = {"com.docker.volume.anonymous": ""}
ANON_ID = "3f1c" + "0" * 60
class FakeApi:
def __init__(self):
self._volumes = [
{"Name": ANON_ID, "Labels": ANON, "Scope": "local"},
{"Name": "media_cache", "Labels": {"com.docker.compose.project": "media"}, "Scope": "local"},
{"Name": "old_data", "Labels": None, "Scope": "local"},
{"Name": "pg_data", "Labels": {}, "Scope": "local"},
{"Name": "cluster_vol", "Labels": {}, "Scope": "global"},
]
# pg_data is mounted by a (possibly stopped) container.
self._containers = [
{"Mounts": [{"Type": "volume", "Name": "pg_data"}, {"Type": "bind", "Source": "/srv"}]}
]
self.removed: list[str] = []
self.refuse: set[str] = set()
self.gate: threading.Event | None = None
def containers(self, all=False):
return self._containers
def volumes(self, filters=None):
return {"Volumes": list(self._volumes), "Warnings": None}
def remove_volume(self, name, force=False):
assert force is False
if self.gate:
self.gate.wait(5)
if name in self.refuse:
raise APIError(f"remove {name}: volume is in use")
self.removed.append(name)
self._volumes = [v for v in self._volumes if v["Name"] != name]
class FakeClient:
def __init__(self, api):
self.api = api
@pytest.fixture
def svc(monkeypatch):
from services import volume_service
api = FakeApi()
client = FakeClient(api)
def short():
raise AssertionError("prune must not use the 60s client")
monkeypatch.setattr(volume_service, "get_client", short)
monkeypatch.setattr(volume_service, "get_long_client", lambda: client)
monkeypatch.setattr(
volume_service,
"volume_sizes",
lambda force=False: {ANON_ID: 5 * MB, "media_cache": 300 * MB, "old_data": None, "pg_data": 900 * MB},
)
yield volume_service, api
if volume_service._prune._lock.locked():
volume_service._prune._lock.release()
def test_default_only_removes_unused_anonymous_volumes(svc):
volume_service, api = svc
result = volume_service.prune_volumes()
assert api.removed == [ANON_ID]
assert result == {"VolumesDeleted": [ANON_ID], "SpaceReclaimed": 5 * MB}
def test_all_also_removes_named_but_never_mounted_or_cluster_volumes(svc):
volume_service, api = svc
result = volume_service.prune_volumes(all_unused=True)
assert set(api.removed) == {ANON_ID, "media_cache", "old_data"}
# A volume of unknown size counts as nothing rather than failing the sum.
assert result["SpaceReclaimed"] == 305 * MB
status = volume_service.prune_status()
assert (status["total"], status["done"], status["deleted"]) == (3, 3, 3)
assert status["reclaimed"] == 305 * MB and status["active"] is False
def test_refused_volume_does_not_stop_the_rest(svc):
volume_service, api = svc
api.refuse.add("media_cache")
result = volume_service.prune_volumes(all_unused=True)
assert set(result["VolumesDeleted"]) == {ANON_ID, "old_data"}
status = volume_service.prune_status()
assert status["failed"] == 1 and status["errors"][0].startswith("media_cache: ")
assert result["SpaceReclaimed"] == 5 * MB
def test_unknown_sizes_leave_reclaimed_unknown(svc, monkeypatch):
volume_service, _ = svc
def broken(force=False):
raise RuntimeError("df failed")
monkeypatch.setattr(volume_service, "volume_sizes", broken)
result = volume_service.prune_volumes(all_unused=True)
assert len(result["VolumesDeleted"]) == 3
assert volume_service.prune_status()["reclaimed"] is None
def test_anonymous_volumes_are_labelled_by_short_id(svc):
volume_service, api = svc
api.gate = threading.Event()
finished = threading.Event()
volume_service.start_prune(all_unused=False, on_done=lambda r: finished.set())
for _ in range(100):
status = volume_service.prune_status()
if status["current"]:
break
threading.Event().wait(0.02)
api.gate.set()
assert finished.wait(5)
assert status["current"] == ANON_ID[:12]
def test_endpoint_background_then_poll(svc, as_admin):
r = as_admin.post("/api/volumes/prune", params={"all": True, "background": True})
assert r.status_code == 202 and r.json()["total"] == 3
for _ in range(100):
s = as_admin.get("/api/volumes/prune-status").json()
if not s["active"]:
break
threading.Event().wait(0.02)
assert s["deleted"] == 3 and s["reclaimed"] == 305 * MB
def test_endpoint_waits_by_default_and_is_audited(svc, as_admin):
from sqlmodel import Session, select
from database import engine
from models.audit import AuditLog
r = as_admin.post("/api/volumes/prune")
assert r.status_code == 200
assert r.json()["VolumesDeleted"] == [ANON_ID]
with Session(engine) as session:
row = session.exec(
select(AuditLog).where(AuditLog.action == "volume.prune").order_by(AuditLog.id.desc())
).first()
assert row.detail == f"all=False deleted=['{ANON_ID}']"
def test_endpoint_rejects_a_second_prune(svc, as_admin):
volume_service, _ = svc
volume_service._prune._lock.acquire()
try:
assert as_admin.post("/api/volumes/prune").status_code == 409
finally:
volume_service._prune._lock.release()
def test_prune_is_admin_only(svc, as_user):
assert as_user.post("/api/volumes/prune").status_code == 403
assert as_user.get("/api/volumes/prune-status").status_code == 403