diff --git a/README.md b/README.md index 1713df4..9f01763 100644 --- a/README.md +++ b/README.md @@ -831,7 +831,11 @@ GET /api/images/prune-status (progress of the running / last prune - **Network management**: list, inspect, create, delete, prune, and connect/disconnect containers — including a *Prune unused* button, which resolves the common "all predefined address pools have been fully subnetted" - deploy error without SSH. + deploy error without SSH. The prune asks first, runs in the background with + the same **live progress** as the image and volume prunes, and keeps any + network a *stopped* container is attached to (plain `docker network prune` + removes those, and the container then fails to start) + (`POST /api/networks/prune?background=true`, `GET /api/networks/prune-status`). - **Images**: list image tags **grouped by the stack that uses them** and run on-demand update checks. An image has no compose label, so its owners come from the containers running it — which means an image can have several, and @@ -1064,7 +1068,8 @@ POST /api/backups/schedules/{id}/run ``` GET /api/networks | /{id} POST /api/networks -DELETE /api/networks/{id} POST /api/networks/prune +DELETE /api/networks/{id} POST /api/networks/prune?background= +GET /api/networks/prune-status DELETE /api/stacks/{id}?delete_files= (stack delete, now surfaced in the UI) ``` diff --git a/backend/routers/networks.py b/backend/routers/networks.py index ca573a0..62df9da 100644 --- a/backend/routers/networks.py +++ b/backend/routers/networks.py @@ -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 diff --git a/backend/services/network_service.py b/backend/services/network_service.py index da17ffc..1836f8b 100644 --- a/backend/services/network_service.py +++ b/backend/services/network_service.py @@ -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) diff --git a/backend/services/prune_job.py b/backend/services/prune_job.py index d7b565b..b3ff89d 100644 --- a/backend/services/prune_job.py +++ b/backend/services/prune_job.py @@ -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 diff --git a/backend/tests/test_docker_timeouts.py b/backend/tests/test_docker_timeouts.py index 3f28ad0..c194d9b 100644 --- a/backend/tests/test_docker_timeouts.py +++ b/backend/tests/test_docker_timeouts.py @@ -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. diff --git a/backend/tests/test_network_prune.py b/backend/tests/test_network_prune.py new file mode 100644 index 0000000..da0bf39 --- /dev/null +++ b/backend/tests/test_network_prune.py @@ -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 diff --git a/backend/version.py b/backend/version.py index c37f516..65e9033 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.62.0" +APP_VERSION = "0.63.0" diff --git a/frontend/package.json b/frontend/package.json index df5ff6a..39a9cda 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.62.0", + "version": "0.63.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/networks.ts b/frontend/src/api/networks.ts index d3c5edb..c59f299 100644 --- a/frontend/src/api/networks.ts +++ b/frontend/src/api/networks.ts @@ -1,4 +1,5 @@ import api from "./client"; +import type { PruneStatus } from "@/types"; export interface NetworkInfo { id: string; @@ -47,6 +48,10 @@ export const networksApi = { disconnect: (id: string, container: string, force = false) => api.post(`${base}/${id}/disconnect`, { container, force }).then((r) => r.data), remove: (id: string) => api.delete(`${base}/${id}`).then((r) => r.data), - prune: () => - api.post<{ NetworksDeleted: string[] | null }>(`${base}/prune`).then((r) => r.data), + // Runs in the background on the server; poll pruneStatus() for progress. + startPrune: () => + api + .post(`${base}/prune`, null, { params: { background: true } }) + .then((r) => r.data), + pruneStatus: () => api.get(`${base}/prune-status`).then((r) => r.data), }; diff --git a/frontend/src/hooks/usePruneJob.ts b/frontend/src/hooks/usePruneJob.ts index 1e14fe5..e1c0654 100644 --- a/frontend/src/hooks/usePruneJob.ts +++ b/frontend/src/hooks/usePruneJob.ts @@ -6,7 +6,7 @@ import { formatBytes } from "@/lib/utils"; import type { PruneStatus } from "@/types"; /** - * A server-side prune that runs in the background (images, volumes). + * A server-side prune that runs in the background (images, volumes, networks). * * Polls the status while a prune is active. It is fetched once on mount too, * so a prune started in another tab, or before a reload, still shows up. The diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx index 389f3a3..bdb373f 100644 --- a/frontend/src/pages/Networks.tsx +++ b/frontend/src/pages/Networks.tsx @@ -14,6 +14,8 @@ import { toast } from "sonner"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { networksApi, type NetworkInfo } from "@/api/networks"; +import { PruneProgress } from "@/components/ui/PruneProgress"; +import { usePruneJob } from "@/hooks/usePruneJob"; import { stacksApi } from "@/api/stacks"; import { groupByStack, stripStackPrefix, type StackGroup } from "@/lib/stackGroups"; import { StackGroupHeader } from "@/components/ui/StackGroupHeader"; @@ -38,6 +40,7 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) { const [creating, setCreating] = useState(false); const [toDelete, setToDelete] = useState(null); const [expanded, setExpanded] = useState(null); + const [pruneOpen, setPruneOpen] = useState(false); const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] }); const colSpan = isAdmin ? 6 : 5; @@ -63,14 +66,13 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) { [data, stackById] ); - const prune = useMutation({ - mutationFn: () => networksApi.prune(), - onSuccess: (r) => { - const n = r.NetworksDeleted?.length ?? 0; - toast.success(n ? `Pruned ${n} network(s)` : "No unused networks"); - invalidate(); - }, - onError: (e) => toast.error(apiErrorMessage(e)), + const prune = usePruneJob({ + noun: "network", + queryKey: "network-prune-status", + fetchStatus: networksApi.pruneStatus, + start: networksApi.startPrune, + enabled: isAdmin, + onFinished: invalidate, }); const remove = useMutation({ mutationFn: (id: string) => networksApi.remove(id), @@ -86,7 +88,7 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
{isAdmin && (
-
)} + {prune.running && ( + + )} + {isLoading ? ( ) : ( @@ -150,6 +156,20 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) { onCancel={() => setCreating(false)} /> )} + {pruneOpen && ( + + prune.start.mutate(false, { onSuccess: () => setPruneOpen(false) }) + } + onCancel={() => setPruneOpen(false)} + /> + )} + {toDelete && (