Files
stackpilot/backend/services/image_service.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

164 lines
5.8 KiB
Python

"""Image listing and pruning for the images router."""
from __future__ import annotations
import logging
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__)
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
def list_images() -> list[dict]:
"""Return one row per image tag, annotated with using-stacks + update status."""
try:
client = get_client()
images = safe_call(client.images.list)
containers = safe_call(client.containers.list, all=True)
except DockerError:
return []
# Map image ref -> stacks using it.
usage: dict[str, set[str]] = {}
for c in containers:
ref = c.attrs.get("Config", {}).get("Image")
stack = c.labels.get(COMPOSE_PROJECT_LABEL)
if ref:
usage.setdefault(ref, set())
if stack:
usage[ref].add(stack)
cache = update_service.get_cache()
result = []
for img in images:
tags = img.tags or []
if not tags:
continue
for tag in tags:
result.append(
{
"id": img.short_id,
"tag": tag,
"size": img.attrs.get("Size", 0),
"created": img.attrs.get("Created"),
"stacks": sorted(usage.get(tag, set())),
"update": cache.get(tag),
}
)
result.sort(key=lambda r: r["tag"])
return result
# --------------------------------------------------------------------------- #
# Prune
# --------------------------------------------------------------------------- #
_prune = PruneJob("image")
def prune_status() -> dict:
return _prune.status()
def _layers_size(client) -> Optional[int]:
"""Total size of all image layers on the host, or None if unavailable.
Taken before and after the prune, the difference is the space actually
freed. Summing the sizes of the deleted images would count base layers
they share with the images that stay. ``type=image`` keeps the daemon from
also walking every volume (API 1.42+; older daemons ignore it).
"""
try:
api = client.api
response = api._get(api._url("/system/df"), params={"type": "image"})
return api._result(response, True).get("LayersSize")
except Exception as exc: # noqa: BLE001 - the size is a nicety, not the job
logger.warning("Could not read image disk usage: %s", exc)
return None
def _candidates(client, all_unused: bool) -> list[dict]:
"""The images ``docker image prune [-a]`` would remove, largest first."""
in_use = {c.get("ImageID") for c in safe_call(client.api.containers, all=True)}
filters = None if all_unused else {"dangling": True}
images = safe_call(client.api.images, filters=filters)
unused = [img for img in images if img["Id"] not in in_use]
return sorted(unused, key=lambda img: img.get("Size", 0), reverse=True)
def _label(image: dict) -> str:
tags = [t for t in image.get("RepoTags") or [] if t != "<none>:<none>"]
if tags:
return tags[0] if len(tags) == 1 else f"{tags[0]} (+{len(tags) - 1})"
return image["Id"].removeprefix("sha256:")[:12]
def _remove(client, image: dict) -> list[dict]:
"""Delete one image the way the prune would, returning Docker's entries.
Tags are removed one by one rather than with force=True: the last one takes
the image with it, and a container created since the candidates were listed
still makes the daemon refuse instead of losing its image.
"""
tags = [t for t in image.get("RepoTags") or [] if t != "<none>:<none>"]
entries: list[dict] = []
for ref in tags or [image["Id"]]:
entries.extend(safe_call(client.api.remove_image, ref) or [])
return entries
def _plan(all_unused: bool) -> PrunePlan:
client = get_long_client()
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,
)
def prune_images(all_unused: bool = False) -> dict:
"""Remove unused images and wait for the result.
By default only dangling (untagged) images are removed; ``all_unused=True``
removes every image not referenced by a container (``docker image 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_images() would return."""
return _prune.start(lambda: _plan(all_unused), on_done)