"""Image listing, update checks and vulnerability scanning.""" from __future__ import annotations import json from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlmodel import Session from auth import get_current_user, require_admin from database import 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 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, all_unused: bool = Query(False, alias="all"), session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: result = image_service.prune_images(all_unused) audit_service.record( session, user=user.username, action="image.prune", target="*", detail=f"all={all_unused} reclaimed={result.get('SpaceReclaimed')}", ip=_ip(request), ) return result # --------------------------------------------------------------------------- # # 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()