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>
125 lines
4.5 KiB
Python
125 lines
4.5 KiB
Python
"""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"] == []
|