Files
dsh-skills/skills/drawio-skill/scripts/dockerimports.py

149 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Draw the containers that are ACTUALLY running from `docker inspect` output.
Where composeimports.py reads the *declared* stack (compose file), this reads
the *live* one: pipe `docker inspect` of the running containers and it maps the
real topology — every container, the user networks they are attached to, the
named volumes they mount, and the container->container edges recorded in
`links` / compose `depends_on` labels. The output feeds autolayout.py:
docker inspect $(docker ps -q) | python3 dockerimports.py - -o graph.json
python3 autolayout.py graph.json -o running.drawio
Input is the JSON array `docker inspect` prints (a file path, or `-` for
stdin). Containers become rounded boxes (name + image), user networks become
green ellipses, named volumes become cylinders — visually matching the compose
importer so declared and live diagrams read alike. `--group` boxes containers
by their compose project (falling back to their first user network).
Usage: docker inspect $(docker ps -q) | python3 dockerimports.py - [-o graph.json]
[--direction TB|LR] [--group]
"""
import argparse
import json
import sys
CONTAINER_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
NETWORK_STYLE = "ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;"
VOLUME_STYLE = ("shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;size=15;"
"fillColor=#f5f5f5;strokeColor=#666666;")
# Docker's built-in networks are topology noise — a compose stack's own
# networks are what tell the architecture story.
BUILTIN_NETS = {"bridge", "host", "none", "ingress"}
def cname(obj):
"""A container's short name (strip docker's leading slash)."""
return (obj.get("Name") or obj.get("Id", "")[:12]).lstrip("/")
def links_of(obj):
"""Container names this one links to (HostConfig.Links + per-network Links)."""
out = set()
raw = list((obj.get("HostConfig") or {}).get("Links") or [])
for net in ((obj.get("NetworkSettings") or {}).get("Networks") or {}).values():
raw.extend((net or {}).get("Links") or [])
for link in raw:
# "/db:/web/db" -> target container is the part before the first colon.
target = str(link).lstrip("/").split(":", 1)[0]
if target:
out.add(target)
return out
def depends_on(obj):
"""Compose service names this container depends on (label form)."""
label = (obj.get("Config") or {}).get("Labels", {}).get("com.docker.compose.depends_on")
if not label:
return set()
# "db:service_healthy:false,cache:service_started:false" -> {db, cache}
return {part.split(":", 1)[0] for part in label.split(",") if part.strip()}
def main():
ap = argparse.ArgumentParser(description="`docker inspect` output -> autolayout graph JSON.")
ap.add_argument("input", help="`docker inspect` JSON file, or - for stdin")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="group containers by compose project (else first network)")
args = ap.parse_args()
if args.input == "-":
text = sys.stdin.read()
else:
with open(args.input, encoding="utf-8") as f:
text = f.read()
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
sys.exit(f"error: input is not valid JSON ({exc}) — feed `docker inspect ...`")
containers = data if isinstance(data, list) else [data]
containers = [c for c in containers if isinstance(c, dict) and c.get("Id")]
if not containers:
sys.exit("error: no containers found (feed `docker inspect $(docker ps -q)`)")
names = {cname(c) for c in containers}
# compose service label -> container name, so depends_on (which names
# services) can resolve to the real container node.
svc_to_name = {}
for c in containers:
svc = (c.get("Config") or {}).get("Labels", {}).get("com.docker.compose.service")
if svc:
svc_to_name[svc] = cname(c)
nodes, edges, nets, vols = [], set(), set(), set()
for c in containers:
name = cname(c)
image = (c.get("Config") or {}).get("Image") or "?"
labels = (c.get("Config") or {}).get("Labels", {}) or {}
node = {"id": name, "label": f"{name}\n{image}", "style": CONTAINER_STYLE,
"width": 160, "height": 60}
attached = [n for n in ((c.get("NetworkSettings") or {}).get("Networks") or {})
if n not in BUILTIN_NETS]
if args.group:
project = labels.get("com.docker.compose.project")
grp = project or (attached[0] if attached else None)
if grp:
node["group"] = str(grp)
nodes.append(node)
for net in attached:
nets.add(net)
edges.add((name, f"net:{net}"))
for m in c.get("Mounts") or []:
if m.get("Type") == "volume" and m.get("Name"):
vols.add(m["Name"])
edges.add((name, f"vol:{m['Name']}"))
for target in links_of(c):
if target in names and target != name:
edges.add((name, target))
for dep in depends_on(c):
target = svc_to_name.get(dep, dep)
if target in names and target != name:
edges.add((name, target))
for net in sorted(nets):
nodes.append({"id": f"net:{net}", "label": net, "style": NETWORK_STYLE,
"width": 120, "height": 70})
for vol in sorted(vols):
nodes.append({"id": f"vol:{vol}", "label": vol, "style": VOLUME_STYLE,
"width": 120, "height": 70})
graph = {"direction": args.direction, "nodes": nodes,
"edges": [{"source": s, "target": t} for s, t in sorted(edges)]}
out = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(out)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(out)
sys.stderr.write(f"{len(nodes)} nodes ({len(containers)} containers, "
f"{len(nets)} networks, {len(vols)} volumes), {len(edges)} edges\n")
if __name__ == "__main__":
main()