Files
stackpilot/backend/routers/volumes.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

139 lines
4.6 KiB
Python

"""Volume management, NFS/SMB YAML generation, and host path browser."""
from __future__ import annotations
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.user import User
from services import audit_service, device_service, volume_service
from services.prune_job import PruneBusy
router = APIRouter(tags=["volumes"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
# --------------------------------------------------------------------------- #
# Volumes
# --------------------------------------------------------------------------- #
@router.get("/api/volumes")
def list_volumes(_user: User = Depends(get_current_user)) -> list[dict]:
return volume_service.list_volumes()
@router.get("/api/volumes/orphaned")
def orphaned(_user: User = Depends(get_current_user)) -> list[dict]:
return volume_service.orphaned_volumes()
@router.get("/api/volumes/sizes")
def volume_sizes(
force: bool = Query(False),
_user: User = Depends(get_current_user),
) -> dict:
"""Volume sizes in bytes ({name: size|null}). Expensive; cached ~60s."""
return volume_service.volume_sizes(force=force)
@router.delete("/api/volumes/{name}")
def delete_volume(
name: str,
request: Request,
force: bool = Query(False),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
vols = {v["name"]: v for v in volume_service.list_volumes()}
if name in vols and vols[name]["in_use"] and not force:
raise HTTPException(
status_code=409,
detail={
"error": "volume_in_use",
"detail": f"Volume '{name}' is used by: {', '.join(vols[name]['used_by'])}",
},
)
volume_service.remove_volume(name, force=force)
audit_service.record(
session, user=user.username, action="volume.delete", target=name, ip=_ip(request)
)
return {"ok": True}
@router.post("/api/volumes/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 volumes: anonymous ones, or with ``all=true`` named ones
too (``-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:
# 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="volume.prune", target="*",
detail=f"all={all_unused} deleted={result['VolumesDeleted']}", ip=ip,
)
try:
if background:
response.status_code = 202
return volume_service.start_prune(all_unused, on_done=audit)
result = volume_service.prune_volumes(all_unused)
except PruneBusy as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
audit(result)
return result
@router.get("/api/volumes/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 volume_service.prune_status()
@router.post("/api/volumes/generate-yaml")
def generate_yaml(
spec: dict,
_user: User = Depends(get_current_user),
) -> dict:
try:
return {"yaml": volume_service.generate_yaml(spec)}
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=400, detail=f"Invalid volume spec: {exc}") from exc
# --------------------------------------------------------------------------- #
# Host path browser
# --------------------------------------------------------------------------- #
@router.get("/api/host/paths")
def host_paths(
path: str = Query("/"),
show_hidden: bool = Query(False),
_admin: User = Depends(require_admin),
) -> dict:
"""Directory picker for the volume wizard. Same browse() as the file
browser, so it carries the same admin requirement — and only admins can
create a volume with the result anyway."""
try:
return device_service.browse(path, show_hidden)
except device_service.BrowseError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc