"""Docker network management endpoints.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from pydantic import BaseModel from sqlmodel import Session from auth import get_current_user, require_admin from database import engine, get_session from docker_client import DockerError from models.user import User from services import audit_service, compose_service, network_service from services.prune_job import PruneBusy router = APIRouter(prefix="/api/networks", tags=["networks"]) _STATUS = {"invalid_request": 400, "forbidden": 403, "not_found": 404} def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _map(exc: DockerError): code = _STATUS.get(exc.error) if code: raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise exc # falls through to the global 502 DockerError handler class NetworkCreate(BaseModel): name: str driver: str = "bridge" subnet: str | None = None gateway: str | None = None internal: bool = False attachable: bool = True class ContainerRef(BaseModel): container: str aliases: list[str] | None = None force: bool = False @router.get("") def list_networks( session: Session = Depends(get_session), _user: User = Depends(get_current_user), ) -> list[dict]: rows = network_service.list_networks() ids = compose_service.stack_ids_by_project_in(session) for row in rows: row["stack"] = compose_service.owner_stack(row["stack"], ids) return rows # Before /{network_id}, which would otherwise take "prune-status" for an id. @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 network_service.prune_status() @router.get("/{network_id}") def inspect_network(network_id: str, _user: User = Depends(get_current_user)) -> dict: try: return network_service.inspect_network(network_id) except DockerError as exc: _map(exc) @router.get("/{network_id}/containers") def network_containers( network_id: str, _user: User = Depends(get_current_user) ) -> list[dict]: try: return network_service.connectable_containers(network_id) except DockerError as exc: _map(exc) @router.post("/{network_id}/connect") def connect_container( network_id: str, body: ContainerRef, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: try: network_service.connect_container(network_id, body.container, body.aliases) except DockerError as exc: _map(exc) audit_service.record( session, user=user.username, action="network.connect", target=network_id, detail=body.container, ip=_ip(request), ) return {"ok": True} @router.post("/{network_id}/disconnect") def disconnect_container( network_id: str, body: ContainerRef, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: try: network_service.disconnect_container(network_id, body.container, body.force) except DockerError as exc: _map(exc) audit_service.record( session, user=user.username, action="network.disconnect", target=network_id, detail=body.container, ip=_ip(request), ) return {"ok": True} @router.post("", status_code=201) def create_network( body: NetworkCreate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: try: result = network_service.create_network(body.model_dump()) except DockerError as exc: _map(exc) audit_service.record( session, user=user.username, action="network.create", target=body.name, detail=body.driver, ip=_ip(request), ) return result @router.delete("/{network_id}") def delete_network( network_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: try: network_service.delete_network(network_id) except DockerError as exc: _map(exc) audit_service.record( session, user=user.username, action="network.delete", target=network_id, ip=_ip(request), ) return {"ok": True} @router.post("/prune") def prune_networks( request: Request, response: Response, background: bool = Query(False), user: User = Depends(require_admin), ) -> dict: """Remove networks no container (running or stopped) is attached to. 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. """ 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="network.prune", target="networks", detail=str(result["NetworksDeleted"]), ip=ip, ) try: if background: response.status_code = 202 return network_service.start_prune(on_done=audit) result = network_service.prune_networks() except PruneBusy as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc audit(result) return result