Files
stackpilot/backend/services/network_service.py
T
menzeljandClaude Opus 4.8 a4e26f880a Phase 9: network management + stack delete in UI (0.9.0)
- Networks: network_service (list w/ subnet/containers/in-use/owning-stack,
  create bridge/macvlan/ipvlan/overlay + optional subnet/gateway/internal,
  delete with default-network guard, prune) + routers/networks.py; real
  Networks page replaces the placeholder.
- Fix: local stacks can now be deleted from the UI — Delete button on stack
  detail (with optional keep-files-on-disk) and a trash action on stack cards,
  via a shared ConfirmDialog. (Backend DELETE existed; no UI surfaced it.)

Verified: py_compile, frontend tsc build, live network list smoke test
(defaults flagged, compose nets + in-use detected); main 104 routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:37:30 +00:00

101 lines
3.0 KiB
Python

"""Docker network management."""
from __future__ import annotations
from typing import Optional
from docker.errors import APIError
from docker.types import IPAMConfig, IPAMPool
from docker_client import DockerError, get_client, safe_call
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
DEFAULT_NETWORKS = {"bridge", "host", "none"}
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 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)
def prune_networks() -> dict:
client = get_client()
return safe_call(client.networks.prune)