"""oikos/gen_topology_lib.py — shared Mermaid-view logic. Split out of oikos/gen-topology.py so it's importable (a hyphenated filename can't be `import`ed as a module). oikos/gen-topology.py is the CLI entrypoint that writes knowledge/wiki/infrastructure/topology.md + oikos/cards/; oikos/console/app.py imports this module directly to render the live /graph page without shelling out. """ from __future__ import annotations from pathlib import Path import yaml REPO = Path(__file__).resolve().parent.parent INVENTORY = REPO / "inventory.yaml" def load_inventory() -> dict: return yaml.safe_load(INVENTORY.read_text()) def node_id(name: str) -> str: """Mermaid-safe node id.""" return name.replace("-", "_").replace(".", "_").replace("/", "_").strip("_") def guest_label(name: str, entry: dict) -> str: pve = entry.get("pve_id") role = entry.get("role", "") tag = f"LXC {pve}" if entry.get("kind") == "lxc" and pve else \ f"VM {pve}" if entry.get("kind") == "vm" and pve else entry.get("kind", "") ip = entry.get("lan_ip", "") parts = [name, tag, role, ip] return "
".join(str(p) for p in parts if p) def compute_view(inv: dict) -> list[str]: hosts = inv.get("hosts", {}) services = inv.get("services", {}) lines = ["```mermaid", "flowchart LR"] hypervisors = {n: e for n, e in hosts.items() if e.get("kind") == "proxmox-host"} guests = {n: e for n, e in hosts.items() if e.get("kind") in ("lxc", "vm")} others = {n: e for n, e in hosts.items() if e.get("kind") in ("workstation", "external")} for hv in hypervisors: lines.append(f' subgraph {node_id(hv)}_sub["{hv} (Proxmox)"]') for g, e in guests.items(): if e.get("host") == hv: lines.append(f' {node_id(g)}["{guest_label(g, e)}"]') lines.append(" end") # guests without a parent hypervisor recorded (e.g. rclone) for g, e in guests.items(): if e.get("host") not in hypervisors: lines.append(f' {node_id(g)}["{guest_label(g, e)}"]') for n, e in others.items(): shape = "([{}])" if e.get("kind") == "workstation" else "[[{}]]" lines.append(f' {node_id(n)}{shape.format(guest_label(n, e))}') # ingress: public URL -> backend (routes-to) for svc, e in sorted(services.items()): if not isinstance(e, dict): continue backend = e.get("backend") url = e.get("url") or ( f'https://{e["public_host"]}' if e.get("public_host") else None) if backend and url and backend in hosts: host = url.removeprefix("https://").removeprefix("http://") # hypervisors are rendered as subgraphs; point edges at the subgraph id target = node_id(backend) + ("_sub" if backend in hypervisors else "") lines.append( f' {node_id("url_" + svc)}(["{host}"]) -->|routes-to| {target}') lines.append("```") return lines def storage_view(inv: dict) -> list[str]: hosts = inv.get("hosts", {}) lines = ["```mermaid", "flowchart LR"] pools: set[str] = set() edges: list[str] = [] for name, e in hosts.items(): for mount in e.get("mounts", []): pools.add(mount) edges.append(f' {node_id(name)}["{name}"] -->|mounts| {node_id(mount)}') for pool in sorted(pools): lines.append(f' {node_id(pool)}[("{pool}")]') lines.extend(sorted(set(edges))) lines.append("```") return lines def archaeology_table(inv: dict) -> list[str]: arch = inv.get("archaeology", {}) if not arch: return [] lines = ["| Node | ID | Destroyed | Reason |", "|---|---|---|---|"] entries = sorted(arch.items(), key=lambda kv: str(kv[1].get("destroyed", "")), reverse=True) for name, e in entries: lines.append( f'| {name} | {e.get("pve_id", "")} | {e.get("destroyed", "")} ' f'| {e.get("reason", "")} |') return lines