"""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, compose_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( session: Session = Depends(get_session), _user: User = Depends(get_current_user), ) -> list[dict]: rows = volume_service.list_volumes() ids = compose_service.stack_ids_by_project_in(session) for row in rows: row["stack"] = compose_service.owner_stack(row["stack"], ids) return rows @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