Show live progress while pruning networks, and ask first (0.63.0)
CI / check (push) Successful in 13m10s
CI / build-and-push (push) Successful in 1m57s

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>
This commit is contained in:
menzeljandClaude Opus 5 committed 2026-09-18 10:49:29 +02:00
1 parent 57231e4a67
commit ee98d0da6d
12 files changed
+293 -61

No files matched your search

+36 -8
View File
@@ -1,15 +1,16 @@
"""Docker network management endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
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 get_session
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"])
@@ -47,6 +48,13 @@ 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:
@@ -142,12 +150,32 @@ def delete_network(
@router.post("/prune")
def prune_networks(
request: Request,
session: Session = Depends(get_session),
response: Response,
background: bool = Query(False),
user: User = Depends(require_admin),
) -> dict:
result = network_service.prune_networks()
audit_service.record(
session, user=user.username, action="network.prune", target="networks",
detail=str(result.get("NetworksDeleted") or []), ip=_ip(request),
)
"""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
+65 -3
View File
@@ -1,15 +1,18 @@
"""Docker network management."""
from __future__ import annotations
from typing import Optional
from typing import Callable, Optional
from docker.errors import APIError
from docker.types import IPAMConfig, IPAMPool
from docker_client import DockerError, get_client, get_long_client, safe_call
from services.prune_job import PruneJob, PrunePlan
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
DEFAULT_NETWORKS = {"bridge", "host", "none"}
#: Networks the daemon manages for swarm; never a prune's to remove.
_SWARM_NETWORKS = {"ingress", "docker_gwbridge"}
def _summary(net) -> dict:
@@ -132,6 +135,65 @@ def delete_network(network_id: str) -> None:
safe_call(net.remove)
def prune_networks() -> dict:
_prune = PruneJob("network")
def prune_status() -> dict:
return _prune.status()
def _candidates(client) -> list[dict]:
"""The networks no container, running or stopped, is attached to.
Stricter than ``docker network prune``, which goes by active endpoints and
so removes a stopped container's network, after which that container no
longer starts ("network not found").
"""
in_use: set[str] = set()
for c in safe_call(client.api.containers, all=True):
networks = (c.get("NetworkSettings") or {}).get("Networks") or {}
for name, settings in networks.items():
in_use.add(name)
if (settings or {}).get("NetworkID"):
in_use.add(settings["NetworkID"])
return [
n
for n in safe_call(client.api.networks)
if n["Name"] not in DEFAULT_NETWORKS | _SWARM_NETWORKS
and n.get("Scope", "local") == "local"
and n["Name"] not in in_use
and n["Id"] not in in_use
]
def _plan() -> PrunePlan:
client = get_long_client()
return safe_call(client.networks.prune)
def remove(network: dict) -> tuple[list, bool]:
# The daemon refuses if a container was attached since the listing.
safe_call(client.api.remove_network, network["Id"])
return [network["Name"]], True
def finish(names: list) -> tuple[dict, Optional[int]]:
# Networks hold no data: nothing to measure.
return {"NetworksDeleted": names}, None
return PrunePlan(
all=False,
items=_candidates(client),
label=lambda network: network["Name"],
remove=remove,
finish=finish,
)
def prune_networks() -> dict:
"""Remove unused networks and wait for the result. Progress is visible
through prune_status() while it runs."""
return _prune.run(_plan)
def start_prune(on_done: Optional[Callable[[dict], None]] = None) -> dict:
"""Start a prune in the background and return its initial status; the UI
polls prune_status(). ``on_done`` gets what prune_networks() would return."""
return _prune.start(_plan, on_done)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Prunes with visible progress, shared by images and volumes.
"""Prunes with visible progress, shared by images, volumes and networks.
``docker image prune`` / ``docker volume prune`` are single blocking calls that
report nothing until they are done, which on a host with a lot to delete means
+3 -32
View File
@@ -50,35 +50,6 @@ def test_volume_prune_timeout_is_a_502_with_detail(as_admin, monkeypatch):
assert r.json()["error"] == "docker_timeout"
@pytest.mark.parametrize(
"module, fn, args",
[
("network_service", "prune_networks", ()),
],
)
def test_prunes_use_the_long_timeout_client(monkeypatch, module, fn, args):
import importlib
svc = importlib.import_module(f"services.{module}")
used = []
class Any_:
def prune(self, filters=None):
used.append("long")
return {}
class Client:
volumes = images = networks = Any_()
def short():
raise AssertionError("prune must not use the 60s client")
monkeypatch.setattr(svc, "get_client", short)
monkeypatch.setattr(svc, "get_long_client", lambda: Client())
getattr(svc, fn)(*args)
assert used == ["long"]
# The image and volume prunes go item by item and have their own tests in
# test_image_prune.py / test_volume_prune.py, including that they use the
# long-timeout client.
# The image, volume and network prunes go item by item and have their own
# tests (test_image_prune.py, test_volume_prune.py, test_network_prune.py),
# including that they use the long-timeout client.
+141
View File
@@ -0,0 +1,141 @@
"""Network prune with visible progress.
Same mechanism as the image and volume prunes (services/prune_job.py). Docker
has no -a for networks, and nothing to measure: networks hold no data.
Pinned here: the built-in and swarm networks are never touched, and neither is
a network a *stopped* container is attached to. That is stricter than
``docker network prune``, which goes by active endpoints and so leaves a
stopped container unable to start ("network not found").
"""
from __future__ import annotations
import threading
import pytest
from docker.errors import APIError
class FakeApi:
def __init__(self):
self._networks = [
{"Id": "n-bridge", "Name": "bridge", "Scope": "local"},
{"Id": "n-host", "Name": "host", "Scope": "local"},
{"Id": "n-none", "Name": "none", "Scope": "local"},
{"Id": "n-ingress", "Name": "ingress", "Scope": "swarm"},
{"Id": "n-gw", "Name": "docker_gwbridge", "Scope": "local"},
{"Id": "n-overlay", "Name": "shared_overlay", "Scope": "swarm"},
{"Id": "n-run", "Name": "media_default", "Scope": "local"},
{"Id": "n-stopped", "Name": "wiki_default", "Scope": "local"},
{"Id": "n-old1", "Name": "oldstack_default", "Scope": "local"},
{"Id": "n-old2", "Name": "scratch", "Scope": "local"},
]
self._containers = [
# running
{"State": "running", "NetworkSettings": {"Networks": {
"media_default": {"NetworkID": "n-run"}}}},
# stopped: still attached, must keep its network
{"State": "exited", "NetworkSettings": {"Networks": {
"wiki_default": {"NetworkID": "n-stopped"}}}},
]
self.removed: list[str] = []
self.refuse: set[str] = set()
def containers(self, all=False):
assert all is True
return self._containers
def networks(self):
return list(self._networks)
def remove_network(self, net_id):
if net_id in self.refuse:
raise APIError(f"error while removing network: {net_id} has active endpoints")
self.removed.append(net_id)
class FakeClient:
def __init__(self, api):
self.api = api
@pytest.fixture
def svc(monkeypatch):
from services import network_service
api = FakeApi()
def short():
raise AssertionError("prune must not use the 60s client")
monkeypatch.setattr(network_service, "get_client", short)
monkeypatch.setattr(network_service, "get_long_client", lambda: FakeClient(api))
yield network_service, api
if network_service._prune._lock.locked():
network_service._prune._lock.release()
def test_only_networks_no_container_is_attached_to(svc):
network_service, api = svc
result = network_service.prune_networks()
assert set(api.removed) == {"n-old1", "n-old2"}
assert set(result["NetworksDeleted"]) == {"oldstack_default", "scratch"}
status = network_service.prune_status()
assert (status["total"], status["done"], status["deleted"]) == (2, 2, 2)
assert status["reclaimed"] is None
def test_refused_network_does_not_stop_the_rest(svc):
network_service, api = svc
api.refuse.add("n-old1")
result = network_service.prune_networks()
assert result["NetworksDeleted"] == ["scratch"]
status = network_service.prune_status()
assert status["failed"] == 1 and status["errors"][0].startswith("oldstack_default: ")
def test_endpoint_background_then_poll(svc, as_admin):
r = as_admin.post("/api/networks/prune", params={"background": True})
assert r.status_code == 202 and r.json()["total"] == 2
for _ in range(100):
s = as_admin.get("/api/networks/prune-status").json()
if not s["active"]:
break
threading.Event().wait(0.02)
assert s["deleted"] == 2
def test_endpoint_waits_by_default_and_is_audited(svc, as_admin):
from sqlmodel import Session, select
from database import engine
from models.audit import AuditLog
r = as_admin.post("/api/networks/prune")
assert r.status_code == 200
assert r.json()["NetworksDeleted"] == ["oldstack_default", "scratch"]
with Session(engine) as session:
row = session.exec(
select(AuditLog).where(AuditLog.action == "network.prune").order_by(AuditLog.id.desc())
).first()
assert row.detail == "['oldstack_default', 'scratch']"
def test_endpoint_rejects_a_second_prune(svc, as_admin):
network_service, _ = svc
network_service._prune._lock.acquire()
try:
assert as_admin.post("/api/networks/prune").status_code == 409
finally:
network_service._prune._lock.release()
def test_prune_is_admin_only(svc, as_user):
assert as_user.post("/api/networks/prune").status_code == 403
assert as_user.get("/api/networks/prune-status").status_code == 403
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.62.0"
APP_VERSION = "0.63.0"