The network prune joins the image and volume prunes on the shared
PruneJob: it runs in the background, the Networks page shows a progress
bar and a toast with the outcome, and the button now asks before deleting
instead of pruning on click. Docker has no -a for networks and they hold
no data, so there is no option and no space figure.
Candidates are stricter than docker network prune, which goes by active
endpoints and so removes a stopped container's network, after which that
container fails to start ("network not found"). A network any container,
running or stopped, is attached to (by name or id) is kept, as are
bridge/host/none, ingress/docker_gwbridge and swarm-scoped networks.
Removal is per network, so one the daemon refuses does not stop the rest.
- POST /api/networks/prune takes background=; without it it still waits
and returns {NetworksDeleted}. GET /api/networks/prune-status is
admin-only and registered before /{network_id}, which would otherwise
take "prune-status" for an id.
- The long-timeout test for network prune in test_docker_timeouts.py is
superseded by test_network_prune.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
5.3 KiB
Python
182 lines
5.3 KiB
Python
"""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, 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(_user: User = Depends(get_current_user)) -> list[dict]:
|
|
return network_service.list_networks()
|
|
|
|
|
|
# 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
|