Files
stackpilot/backend/services/prune_job.py
T
menzeljandClaude Opus 5 57231e4a67
CI / check (push) Successful in 13m19s
CI / build-and-push (push) Successful in 1m57s
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>
2026-09-18 10:23:51 +02:00

171 lines
5.9 KiB
Python

"""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()