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>
200 lines
6.5 KiB
Python
200 lines
6.5 KiB
Python
"""Docker network management."""
|
|
from __future__ import annotations
|
|
|
|
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:
|
|
attrs = net.attrs
|
|
ipam = (attrs.get("IPAM") or {}).get("Config") or []
|
|
subnet = ipam[0].get("Subnet") if ipam else None
|
|
gateway = ipam[0].get("Gateway") if ipam else None
|
|
containers = attrs.get("Containers") or {}
|
|
names = [c.get("Name", cid[:12]) for cid, c in containers.items()]
|
|
labels = attrs.get("Labels") or {}
|
|
name = attrs.get("Name", net.name)
|
|
return {
|
|
"id": net.id[:12],
|
|
"name": name,
|
|
"driver": attrs.get("Driver"),
|
|
"scope": attrs.get("Scope"),
|
|
"internal": attrs.get("Internal", False),
|
|
"attachable": attrs.get("Attachable", False),
|
|
"subnet": subnet,
|
|
"gateway": gateway,
|
|
"containers": names,
|
|
"in_use": bool(names),
|
|
"stack": labels.get(COMPOSE_PROJECT_LABEL),
|
|
"labels": labels,
|
|
"created": attrs.get("Created"),
|
|
"is_default": name in DEFAULT_NETWORKS,
|
|
}
|
|
|
|
|
|
def list_networks() -> list[dict]:
|
|
client = get_client()
|
|
nets = safe_call(client.networks.list)
|
|
# list() entries are lightweight; reload for Containers/IPAM detail.
|
|
out = []
|
|
for n in nets:
|
|
try:
|
|
n.reload()
|
|
except APIError:
|
|
pass
|
|
out.append(_summary(n))
|
|
return sorted(out, key=lambda x: (x["is_default"] is False, x["name"]))
|
|
|
|
|
|
def inspect_network(network_id: str) -> dict:
|
|
client = get_client()
|
|
net = safe_call(client.networks.get, network_id)
|
|
return _summary(net)
|
|
|
|
|
|
def create_network(spec: dict) -> dict:
|
|
name = (spec.get("name") or "").strip()
|
|
if not name:
|
|
raise DockerError("invalid_request", "Network name is required")
|
|
driver = spec.get("driver") or "bridge"
|
|
ipam = None
|
|
subnet = (spec.get("subnet") or "").strip()
|
|
gateway = (spec.get("gateway") or "").strip()
|
|
if subnet:
|
|
pool = IPAMPool(subnet=subnet, gateway=gateway or None)
|
|
ipam = IPAMConfig(pool_configs=[pool])
|
|
|
|
client = get_client()
|
|
net = safe_call(
|
|
client.networks.create,
|
|
name=name,
|
|
driver=driver,
|
|
internal=bool(spec.get("internal")),
|
|
attachable=bool(spec.get("attachable", True)),
|
|
ipam=ipam,
|
|
options=spec.get("options") or None,
|
|
labels=spec.get("labels") or None,
|
|
)
|
|
net.reload()
|
|
return _summary(net)
|
|
|
|
|
|
def connectable_containers(network_id: str) -> list[dict]:
|
|
"""All containers on the host, flagged whether already on this network."""
|
|
client = get_client()
|
|
net = safe_call(client.networks.get, network_id)
|
|
net.reload()
|
|
connected = set((net.attrs.get("Containers") or {}).keys())
|
|
out = []
|
|
for c in safe_call(client.containers.list, all=True):
|
|
labels = c.labels or {}
|
|
out.append(
|
|
{
|
|
"id": c.id[:12],
|
|
"name": c.name,
|
|
"state": c.status,
|
|
"stack": labels.get(COMPOSE_PROJECT_LABEL),
|
|
"connected": c.id in connected,
|
|
}
|
|
)
|
|
return sorted(out, key=lambda x: x["name"])
|
|
|
|
|
|
def connect_container(network_id: str, container: str, aliases: Optional[list[str]] = None) -> None:
|
|
if not (container or "").strip():
|
|
raise DockerError("invalid_request", "Container is required")
|
|
client = get_client()
|
|
net = safe_call(client.networks.get, network_id)
|
|
safe_call(net.connect, container, aliases=aliases or None)
|
|
|
|
|
|
def disconnect_container(network_id: str, container: str, force: bool = False) -> None:
|
|
if not (container or "").strip():
|
|
raise DockerError("invalid_request", "Container is required")
|
|
client = get_client()
|
|
net = safe_call(client.networks.get, network_id)
|
|
safe_call(net.disconnect, container, force=force)
|
|
|
|
|
|
def delete_network(network_id: str) -> None:
|
|
client = get_client()
|
|
net = safe_call(client.networks.get, network_id)
|
|
name = (net.attrs or {}).get("Name", net.name)
|
|
if name in DEFAULT_NETWORKS:
|
|
raise DockerError("forbidden", f"Cannot delete the default '{name}' network")
|
|
safe_call(net.remove)
|
|
|
|
|
|
_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()
|
|
|
|
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)
|