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>
184 lines
6.2 KiB
Python
184 lines
6.2 KiB
Python
"""Image listing, update checks and vulnerability scanning."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
|
from sqlmodel import Session
|
|
|
|
from auth import get_current_user, require_admin
|
|
from database import engine, get_session
|
|
from models.image_scan import ImageScan, ScanDetail, ScanRequest, ScanSummary
|
|
from models.user import User
|
|
from services import audit_service, image_service, scan_service, update_service
|
|
from services.prune_job import PruneBusy
|
|
|
|
router = APIRouter(prefix="/api/images", tags=["images"])
|
|
|
|
|
|
def _ip(request: Request) -> str:
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
@router.get("")
|
|
def list_images(_user: User = Depends(get_current_user)) -> list[dict]:
|
|
return image_service.list_images()
|
|
|
|
|
|
@router.get("/updates")
|
|
def updates(_user: User = Depends(get_current_user)) -> dict:
|
|
return update_service.get_cache()
|
|
|
|
|
|
@router.post("/check")
|
|
async def check(_user: User = Depends(require_admin)) -> dict:
|
|
return await update_service.check_all()
|
|
|
|
|
|
@router.post("/prune")
|
|
def prune(
|
|
request: Request,
|
|
response: Response,
|
|
all_unused: bool = Query(False, alias="all"),
|
|
background: bool = Query(False),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
"""Remove unused images (``all=true``: also unused tagged ones, ``-a``).
|
|
|
|
Waits for the result by default. ``background=true`` returns 202 with the
|
|
initial status at once and leaves the caller to poll /prune-status, which
|
|
is what the UI does: a large prune can outlast the proxy's read timeout.
|
|
"""
|
|
username, ip = user.username, _ip(request)
|
|
|
|
def audit(result: dict) -> None:
|
|
# Docker lists every untagging too; only "Deleted" entries are images.
|
|
deleted = sum(1 for entry in result["ImagesDeleted"] if "Deleted" in entry)
|
|
# Its own session: in the background case the request's is long closed.
|
|
with Session(engine) as own:
|
|
audit_service.record(
|
|
own, user=username, action="image.prune", target="*",
|
|
detail=(
|
|
f"all={all_unused} deleted={deleted} "
|
|
f"reclaimed={result['SpaceReclaimed']}"
|
|
),
|
|
ip=ip,
|
|
)
|
|
|
|
try:
|
|
if background:
|
|
response.status_code = 202
|
|
return image_service.start_prune(all_unused, on_done=audit)
|
|
result = image_service.prune_images(all_unused)
|
|
except PruneBusy as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
audit(result)
|
|
return result
|
|
|
|
|
|
@router.get("/prune-status")
|
|
def prune_status(_admin: User = Depends(require_admin)) -> dict:
|
|
"""Progress of a running (or the last) prune. Admin-only like the prune."""
|
|
return image_service.prune_status()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Vulnerability scanning
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _summary(row: ImageScan, stale: bool = False) -> ScanSummary:
|
|
return ScanSummary(
|
|
image=row.image,
|
|
digest=row.digest,
|
|
scanner=row.scanner,
|
|
critical=row.critical,
|
|
high=row.high,
|
|
medium=row.medium,
|
|
low=row.low,
|
|
unknown=row.unknown,
|
|
fixable=row.fixable,
|
|
total=scan_service.total(row),
|
|
scanned_at=row.scanned_at,
|
|
duration_ms=row.duration_ms,
|
|
error=row.error,
|
|
stale=stale,
|
|
)
|
|
|
|
|
|
@router.get("/scans", response_model=list[ScanSummary])
|
|
def list_scans(
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> list[ScanSummary]:
|
|
"""Every cached scan result. Cheap — no scanning happens here."""
|
|
rows = scan_service.all_scans(session).values()
|
|
return [_summary(row, scan_service.is_stale(row)) for row in rows]
|
|
|
|
|
|
@router.get("/scan", response_model=ScanDetail)
|
|
def scan_detail(
|
|
image: str = Query(...),
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> ScanDetail:
|
|
row = scan_service.all_scans(session).get(image)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail=f"'{image}' has not been scanned")
|
|
try:
|
|
findings = json.loads(row.findings or "[]")
|
|
except json.JSONDecodeError:
|
|
findings = []
|
|
return ScanDetail(
|
|
**_summary(row, scan_service.is_stale(row)).model_dump(), findings=findings
|
|
)
|
|
|
|
|
|
@router.post("/scan", response_model=ScanSummary)
|
|
async def scan_image(
|
|
body: ScanRequest,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> ScanSummary:
|
|
"""Scan one image. Takes a while — the scanner runs as a container."""
|
|
image = (body.image or "").strip()
|
|
if not image:
|
|
raise HTTPException(status_code=400, detail="An image is required")
|
|
row = await scan_service.scan(session, image)
|
|
audit_service.record(
|
|
session, user=user.username, action="image.scan", target=image,
|
|
detail=row.error or f"{scan_service.total(row)} finding(s), {row.fixable} fixable",
|
|
ip=_ip(request),
|
|
)
|
|
if row.error:
|
|
# The row is stored either way, so the UI can show why it failed.
|
|
raise HTTPException(status_code=502, detail=row.error)
|
|
return _summary(row)
|
|
|
|
|
|
@router.post("/scan-all")
|
|
async def scan_all(
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
"""Scan every image a stack is using. Serial, and one sweep at a time."""
|
|
images = sorted({row["tag"] for row in image_service.list_images() if row["stacks"]})
|
|
try:
|
|
result = await scan_service.sweep(session, images)
|
|
except scan_service.ScanError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
audit_service.record(
|
|
session, user=user.username, action="image.scan-all", target="*",
|
|
detail=f"{result['scanned']} scanned, {result['failed']} failed",
|
|
ip=_ip(request),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get("/scan-status")
|
|
def scan_status(_user: User = Depends(get_current_user)) -> dict:
|
|
"""Progress of a running sweep, so the button can show it."""
|
|
return scan_service.sweep_status()
|