stage-26b: Docker topology in /admin — read-only socket-proxy + graph

New tecnativa/docker-socket-proxy sidecar exposes only GET on
containers/networks/info/ping; POST and DELETE are blocked. The cockpit
queries it over the backend network — /var/run/docker.sock is never
mounted into a web-facing container.

cockpit/docker_view.py normalizes the daemon view: containers carry
per-network IP/MAC + published_ports; networks carry subnet/gateway from
IPAM; host_info pulls /info (degrades gracefully). topology() returns
the combined snapshot.

/admin/docker (admin-gated): a force-directed graph (pure SVG +
vanilla JS, ~280 lines) renders the complete setup — a host node,
switch nodes with subnet labels colored by driver, container nodes
colored by state, member wires labeled with the container's IP on that
network, uplinks from non-internal switches to the host labeled with
the gateway, and dashed publish-edges from containers to the host for
their published ports. Drag to rearrange, scroll to zoom, re-settle
kicks the physics. Below the graph: containers table + grouped network
cards as a textual mirror. 12 docker_view tests; verified live (32
containers, 11 switches, real subnets + gateways).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-23 03:08:39 +02:00
parent eaca27be26
commit b51a88d502
8 changed files with 783 additions and 3 deletions

124
tests/test_docker_view.py Normal file
View File

@@ -0,0 +1,124 @@
"""Docker topology — normalization + error handling against the socket-proxy."""
from __future__ import annotations
import pytest
from psyc.cockpit import docker_view
_CONTAINERS_FIXTURE = [
{
"Id": "abcdef1234567890",
"Names": ["/psyc-cockpit-1"],
"Image": "psyc:latest",
"State": "running",
"Status": "Up 5 minutes (healthy)",
"NetworkSettings": {"Networks": {"backend": {"IPAddress": "172.20.0.5"}}},
"Ports": [{"IP": "0.0.0.0", "PrivatePort": 8767, "PublicPort": 8767, "Type": "tcp"}],
},
{
"Id": "fedcba0987654321",
"Names": ["/some-stopped"],
"Image": "alpine",
"State": "exited",
"Status": "Exited (0) 2 hours ago",
"NetworkSettings": {"Networks": {}},
"Ports": [],
},
]
_NETWORKS_FIXTURE = [
{
"Id": "n1", "Name": "backend", "Driver": "bridge", "Scope": "local", "Internal": False,
"IPAM": {"Config": [{"Subnet": "172.20.0.0/16", "Gateway": "172.20.0.1"}]},
"Containers": {
"abcdef1234567890": {
"Name": "psyc-cockpit-1", "IPv4Address": "172.20.0.5/16",
"MacAddress": "02:42:ac:14:00:05",
},
},
},
{"Id": "n2", "Name": "bridge", "Driver": "bridge", "Scope": "local", "Internal": False, "Containers": {}},
]
def _fake_get_factory(monkeypatch, payloads: dict):
"""Patch docker_view._get to return canned payloads by path.
Unknown paths raise DockerProxyError (mimicking a proxy that blocks the
endpoint), so callers like host_info() exercise their fallback paths.
"""
def fake_get(path: str):
for prefix, body in payloads.items():
if path.startswith(prefix):
return body
raise docker_view.DockerProxyError(f"blocked: {path}")
monkeypatch.setattr(docker_view, "_get", fake_get)
def test_list_containers_normalizes_fields(monkeypatch):
_fake_get_factory(monkeypatch, {"/containers/json": _CONTAINERS_FIXTURE})
out = docker_view.list_containers()
by_name = {c["name"]: c for c in out}
# running comes before exited
assert out[0]["state"] == "running"
assert out[-1]["state"] == "exited"
cockpit = by_name["psyc-cockpit-1"]
assert cockpit["id"] == "abcdef123456"
assert cockpit["image"] == "psyc:latest"
assert cockpit["networks"][0]["name"] == "backend"
assert cockpit["networks"][0]["ip"] == "172.20.0.5"
assert "0.0.0.0:8767->8767/tcp" in cockpit["ports"]
def test_list_networks_attaches_containers_with_ip(monkeypatch):
_fake_get_factory(monkeypatch, {"/networks": _NETWORKS_FIXTURE})
out = docker_view.list_networks()
backend = next(n for n in out if n["name"] == "backend")
assert backend["driver"] == "bridge"
assert backend["subnet"] == "172.20.0.0/16"
assert backend["gateway"] == "172.20.0.1"
assert backend["containers"][0]["name"] == "psyc-cockpit-1"
assert backend["containers"][0]["ip"] == "172.20.0.5"
assert backend["containers"][0]["mac"] == "02:42:ac:14:00:05"
# default networks pushed to bottom
assert out[-1]["name"] == "bridge"
def test_list_containers_extracts_published_ports(monkeypatch):
_fake_get_factory(monkeypatch, {"/containers/json": _CONTAINERS_FIXTURE})
out = docker_view.list_containers()
cockpit = next(c for c in out if c["name"] == "psyc-cockpit-1")
assert cockpit["published_ports"] == ["8767/tcp"]
# stopped container with no ports → empty
stopped = next(c for c in out if c["name"] == "some-stopped")
assert stopped["published_ports"] == []
def test_host_info_falls_back_when_proxy_blocks_info(monkeypatch):
def boom(path):
raise docker_view.DockerProxyError("info forbidden")
monkeypatch.setattr(docker_view, "_get", boom)
info = docker_view.host_info()
assert info["name"] == "docker host"
def test_topology_returns_both_with_no_error(monkeypatch):
_fake_get_factory(monkeypatch, {
"/containers/json": _CONTAINERS_FIXTURE,
"/networks": _NETWORKS_FIXTURE,
})
snap = docker_view.topology()
assert snap["error"] is None
assert len(snap["containers"]) == 2
assert len(snap["networks"]) == 2
def test_topology_surfaces_proxy_failure(monkeypatch):
def boom(path):
raise docker_view.DockerProxyError("connection refused")
monkeypatch.setattr(docker_view, "_get", boom)
snap = docker_view.topology()
assert snap["error"] is not None and "connection refused" in snap["error"]
assert snap["containers"] == [] and snap["networks"] == []