docker image prune is a single blocking call that reports nothing until it
is done. With -a on a host with many images that meant minutes of a
spinner, and past the proxy timeout an error while the daemon carried on.
The prune now removes its candidates one by one and publishes how far it
is. The candidates are the ones Docker would pick: dangling only by
default, with -a every image no container (running or stopped) uses. The
UI starts the prune in the background (POST /api/images/prune?background=
true, 202) and polls GET /api/images/prune-status. A progress bar above
the image table shows x / y, the image being removed and any failures,
and a toast reports the outcome. It survives a reload and shows up in a
second tab.
- Multi-tag images lose their tags one by one instead of force=True, so a
container created since the listing still makes the daemon refuse.
- One refused image does not stop the rest; up to ten errors are kept.
- Docker's own prune runs last to sweep up leftovers.
- Freed space is measured (image LayersSize before and after) rather than
summed, which would count base layers shared with the images that stay.
- Without background=true the endpoint still waits and returns the old
{ImagesDeleted, SpaceReclaimed} shape, so API-token scripts are
unaffected. A second prune while one runs gets 409.
- The audit entry now counts deleted images, not Docker's untag entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
8.1 KiB
Python
243 lines
8.1 KiB
Python
"""Image prune with visible progress.
|
|
|
|
``docker image prune -a`` is one blocking call that reports nothing until it
|
|
is done; on a host with a lot of images that meant minutes of a spinner and
|
|
then, past the proxy timeout, an error while the daemon carried on. The prune
|
|
now removes the candidates one by one, publishes how far it is through
|
|
/api/images/prune-status, and the UI starts it in the background and polls.
|
|
|
|
What these tests pin: the candidate set matches Docker's (in-use images are
|
|
never touched, dangling-only without -a), multi-tag images lose every tag,
|
|
one failing image does not stop the rest, reclaimed space is measured rather
|
|
than summed, and the endpoints keep the old synchronous contract for scripts.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
from docker.errors import APIError
|
|
|
|
MB = 1024 * 1024
|
|
|
|
|
|
class FakeApi:
|
|
"""Just enough of docker-py's low-level APIClient for the prune."""
|
|
|
|
def __init__(self, images, containers, layers=500 * MB):
|
|
self._images = images
|
|
self._containers = containers
|
|
self.layers = layers
|
|
self.removed: list[str] = []
|
|
self.refuse: set[str] = set()
|
|
self.gate: threading.Event | None = None
|
|
|
|
def containers(self, all=False):
|
|
return self._containers
|
|
|
|
def images(self, filters=None):
|
|
if filters and filters.get("dangling"):
|
|
return [i for i in self._images if not i.get("RepoTags")]
|
|
return list(self._images)
|
|
|
|
def remove_image(self, ref, force=False, noprune=False):
|
|
if self.gate:
|
|
self.gate.wait(5)
|
|
if ref in self.refuse:
|
|
raise APIError(f"conflict: unable to remove {ref}")
|
|
self.removed.append(ref)
|
|
for img in self._images:
|
|
if ref in (img.get("RepoTags") or []):
|
|
img["RepoTags"].remove(ref)
|
|
if img["RepoTags"]:
|
|
return [{"Untagged": ref}]
|
|
self._images.remove(img)
|
|
self.layers -= img["Size"]
|
|
return [{"Untagged": ref}, {"Deleted": img["Id"]}]
|
|
if img["Id"] == ref:
|
|
self._images.remove(img)
|
|
self.layers -= img["Size"]
|
|
return [{"Deleted": img["Id"]}]
|
|
raise APIError(f"No such image: {ref}")
|
|
|
|
# /system/df?type=image
|
|
def _url(self, path):
|
|
return path
|
|
|
|
def _get(self, url, params=None):
|
|
assert params == {"type": "image"}
|
|
return self
|
|
|
|
def _result(self, response, json=False):
|
|
return {"LayersSize": self.layers}
|
|
|
|
|
|
class FakeImages:
|
|
def __init__(self, api):
|
|
self.api = api
|
|
self.prune_filters = None
|
|
|
|
def prune(self, filters=None):
|
|
self.prune_filters = filters
|
|
return {"ImagesDeleted": None, "SpaceReclaimed": 0}
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self, api):
|
|
self.api = api
|
|
self.images = FakeImages(api)
|
|
|
|
|
|
def _host():
|
|
images = [
|
|
{"Id": "sha256:aaa", "RepoTags": ["nginx:1.25"], "Size": 100 * MB},
|
|
{"Id": "sha256:bbb", "RepoTags": ["redis:7", "redis:latest"], "Size": 50 * MB},
|
|
{"Id": "sha256:ccc", "RepoTags": [], "Size": 20 * MB},
|
|
{"Id": "sha256:ddd", "RepoTags": ["postgres:16"], "Size": 200 * MB},
|
|
]
|
|
# postgres is used by a (possibly stopped) container and must survive.
|
|
containers = [{"ImageID": "sha256:ddd"}]
|
|
return FakeApi(images, containers)
|
|
|
|
|
|
@pytest.fixture
|
|
def svc(monkeypatch):
|
|
from services import image_service
|
|
|
|
api = _host()
|
|
client = FakeClient(api)
|
|
|
|
def short():
|
|
raise AssertionError("prune must not use the 60s client")
|
|
|
|
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()
|
|
|
|
|
|
def test_all_unused_removes_everything_no_container_uses(svc):
|
|
image_service, api, client = svc
|
|
|
|
result = image_service.prune_images(all_unused=True)
|
|
|
|
assert {i["Id"] for i in api._images} == {"sha256:ddd"}
|
|
# Every tag of a multi-tag image is removed, without force.
|
|
assert set(api.removed) == {"nginx:1.25", "redis:7", "redis:latest", "sha256:ccc"}
|
|
assert {e["Deleted"] for e in result["ImagesDeleted"] if "Deleted" in e} == {
|
|
"sha256:aaa", "sha256:bbb", "sha256:ccc",
|
|
}
|
|
assert result["SpaceReclaimed"] == 170 * MB
|
|
# The daemon's own prune still runs afterwards to sweep up leftovers.
|
|
assert client.images.prune_filters == {"dangling": False}
|
|
|
|
status = image_service.prune_status()
|
|
assert status["active"] is False
|
|
assert (status["total"], status["done"], status["deleted"]) == (3, 3, 3)
|
|
assert status["reclaimed"] == 170 * MB
|
|
|
|
|
|
def test_default_only_touches_dangling_images(svc):
|
|
image_service, api, client = svc
|
|
|
|
image_service.prune_images(all_unused=False)
|
|
|
|
assert api.removed == ["sha256:ccc"]
|
|
assert client.images.prune_filters == {"dangling": True}
|
|
|
|
|
|
def test_one_refused_image_does_not_stop_the_rest(svc):
|
|
image_service, api, _ = svc
|
|
api.refuse.add("nginx:1.25")
|
|
|
|
image_service.prune_images(all_unused=True)
|
|
|
|
status = image_service.prune_status()
|
|
assert status["failed"] == 1 and status["done"] == 3 and status["deleted"] == 2
|
|
assert status["errors"][0].startswith("nginx:1.25: ")
|
|
assert any(i["Id"] == "sha256:aaa" for i in api._images)
|
|
|
|
|
|
def test_background_prune_reports_progress_and_blocks_a_second_one(svc):
|
|
image_service, api, _ = svc
|
|
api.gate = threading.Event()
|
|
finished = threading.Event()
|
|
|
|
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):
|
|
image_service.start_prune(True)
|
|
|
|
api.gate.set()
|
|
assert finished.wait(5)
|
|
status = image_service.prune_status()
|
|
assert status["run"] == started["run"]
|
|
assert status["done"] == 3 and status["reclaimed"] == 170 * MB
|
|
|
|
|
|
def test_background_prune_error_lands_in_the_status(svc):
|
|
from docker_client import DockerError
|
|
|
|
image_service, _, client = svc
|
|
|
|
def broken(filters=None):
|
|
raise DockerError("docker_timeout", "Docker did not answer in time")
|
|
|
|
client.images.prune = broken
|
|
image_service.start_prune(True)
|
|
for _ in range(100):
|
|
if not image_service.prune_status()["active"]:
|
|
break
|
|
threading.Event().wait(0.02)
|
|
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()
|
|
|
|
|
|
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/images/prune", params={"all": True})
|
|
assert r.status_code == 200
|
|
assert r.json()["SpaceReclaimed"] == 170 * MB
|
|
|
|
with Session(engine) as session:
|
|
row = session.exec(
|
|
select(AuditLog).where(AuditLog.action == "image.prune").order_by(AuditLog.id.desc())
|
|
).first()
|
|
assert row.detail == f"all=True deleted=3 reclaimed={170 * MB}"
|
|
|
|
|
|
def test_endpoint_background_returns_202_and_status_is_pollable(svc, as_admin):
|
|
r = as_admin.post("/api/images/prune", params={"all": True, "background": True})
|
|
assert r.status_code == 202
|
|
assert r.json()["total"] == 3
|
|
|
|
for _ in range(100):
|
|
s = as_admin.get("/api/images/prune-status").json()
|
|
if not s["active"]:
|
|
break
|
|
threading.Event().wait(0.02)
|
|
assert s["done"] == 3 and s["reclaimed"] == 170 * MB
|
|
|
|
|
|
def test_endpoint_rejects_a_second_prune(svc, as_admin):
|
|
image_service, _, _ = svc
|
|
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()
|
|
|
|
|
|
def test_prune_is_admin_only(svc, as_user):
|
|
assert as_user.post("/api/images/prune").status_code == 403
|
|
assert as_user.get("/api/images/prune-status").status_code == 403
|