#!/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()