Files
oikos/oikos/gen-topology.py
dtoro f6b57cbe3a Oikos Week 2: Service Console v0, change ledger, node relations, runbooks
Adds the shared kernel modules (oikos/policy.py, oikos/relations.py,
oikos/ledger.py) that let every surface — CLI, MCP, context-card
generator — agree on risk classification and ontology graph walks
from one implementation.

homelab CLI: `service <name> explain|health|docs|log|actions|history`
(Service Console v0), `change preflight <service>`, `node <name>
relations`. Restart and client add/remove now append change-ledger
entries (ledger/*.jsonl, committed alongside the change they record).

mcp/server.py mirrors explain/preflight/get_relations/get_change_history
as MCP tools, card-first so agent orientation is one call instead of
several search_docs/get_page round-trips.

oikos/gen-topology.py now also emits a compact context card per host
and service (oikos/cards/*.md) — identity, blast radius, safe actions +
risk class, doc pointer, recent ledger history.

runbooks/*.md: service health check, config change + deploy, client
enrollment, incident investigation, and the five node lifecycle
transitions (provision/activate/migrate/deprecate/destroy), each with
machine-readable frontmatter (risk class, inputs, verification,
docs-update checklist). Wired into HERMES.md so agents load these
instead of rediscovering topology per-task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:02:32 +02:00

319 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Generate infrastructure/topology.md (Mermaid views) and per-entity context
cards from inventory.yaml.
Views:
1. Compute & ingress — hypervisors → guests → services → public URLs
2. Storage — mounts and pools per guest
Context cards (oikos/cards/<name>.md): one compact (~30-line) file per
host and service — identity, ontology edges, safe actions + risk class,
doc pointer, recent ledger history. This is the token-efficiency layer:
an agent orienting on an entity reads one card instead of several
search_docs/get_page round-trips.
Run from the repo root:
python3 oikos/gen-topology.py # writes topology.md + cards/
python3 oikos/gen-topology.py --check # exit 1 if output would change
Wired into the same regeneration path as mcp/build_host_files.py so the
diagrams and cards never drift from inventory. Edges follow
oikos/ontology.yaml (hosts, provides, routes-to, mounts, stores-on).
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("PyYAML is required: pip install pyyaml", file=sys.stderr)
sys.exit(2)
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from oikos import ledger as oikos_ledger # noqa: E402
from oikos import policy as oikos_policy # noqa: E402
from oikos import relations as oikos_relations # noqa: E402
INVENTORY = REPO / "inventory.yaml"
OUTPUT = REPO / "infrastructure" / "topology.md"
CARDS_DIR = REPO / "oikos" / "cards"
BANNER = (
"<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->\n"
"<!-- Do NOT edit by hand - your changes will be overwritten. -->\n"
)
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 "<br/>".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
def _host_card(name: str, entry: dict, inv: dict) -> str:
lines = [f"# {name} (host:{name})\n"]
tag = "LXC" if entry.get("kind") == "lxc" else "VM" if entry.get("kind") == "vm" else entry.get("kind", "")
pve = entry.get("pve_id")
lines.append(f"- kind: {entry.get('kind', '?')}" + (f" ({tag} {pve})" if pve else ""))
lines.append(f"- state: {entry.get('state', 'active')}")
if entry.get("host"):
lines.append(f"- runs-on: host:{entry['host']}")
if entry.get("role"):
lines.append(f"- role: {entry['role']}")
addr = entry.get("lan_ip", "")
mesh = entry.get("mesh", {})
mesh_bits = []
for m, v in mesh.items():
if isinstance(v, dict) and (v.get("ip") or v.get("fqdn")):
mesh_bits.append(f"{m}:{v.get('fqdn') or v.get('ip')}")
if addr or mesh_bits:
lines.append(f"- address: {addr}" + (f" (mesh: {', '.join(mesh_bits)})" if mesh_bits else ""))
if entry.get("mounts"):
lines.append(f"- mounts: {', '.join(entry['mounts'])}")
doc = None
if entry.get("kind") == "lxc" and pve:
cand = REPO / "containers" / f"{pve}-{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
elif entry.get("kind") == "vm" and pve:
cand = REPO / "vms" / f"{pve}-{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
elif entry.get("kind") == "proxmox-host":
cand = REPO / "hosts" / f"{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
if doc:
lines.append(f"- doc: {doc}")
if entry.get("age_pubkey"):
lines.append("- secrets: enrolled (age key present)")
rel = oikos_relations.relations(f"host:{name}", inv)
lines.append("\n## Blast radius")
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
if rel["blast_radius"]:
lines.append(f"- full blast radius: {', '.join(rel['blast_radius'])}")
lines.append("\n## Safe actions")
lines.append("- see the services this host runs for action-level risk classes")
hist = oikos_ledger.history(f"host:{name}", limit=5)
lines.append("\n## Recent changes")
if hist:
for h in hist:
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
else:
lines.append("- (none yet)")
return "\n".join(lines) + "\n"
def _service_card(name: str, entry: dict, inv: dict) -> str:
lines = [f"# {name} (service:{name})\n"]
if entry.get("backend"):
lines.append(f"- backend: host:{entry['backend']}")
url = entry.get("url") or entry.get("endpoint")
if url:
lines.append(f"- url: {url}")
if entry.get("doc_page"):
lines.append(f"- doc: {entry['doc_page']}")
if entry.get("config_repo"):
lines.append(f"- config repo: {entry['config_repo']}")
if entry.get("risk_notes"):
lines.append(f"- risk notes: {entry['risk_notes']}")
rel = oikos_relations.relations(f"service:{name}", inv)
lines.append("\n## Blast radius")
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
lines.append("\n## Safe actions")
for a in oikos_policy.safe_actions_for_service(name, entry):
lines.append(f"- {a['action']}{a['risk']} (approval: {a['approval']})")
hist = oikos_ledger.history(f"service:{name}", limit=5)
lines.append("\n## Recent changes")
if hist:
for h in hist:
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
else:
lines.append("- (none yet)")
return "\n".join(lines) + "\n"
def generate_cards(inv: dict) -> dict[Path, str]:
desired: dict[Path, str] = {}
for name, entry in inv.get("hosts", {}).items():
desired[CARDS_DIR / f"host-{name}.md"] = _host_card(name, entry, inv)
for name, entry in inv.get("services", {}).items():
if isinstance(entry, dict):
desired[CARDS_DIR / f"service-{name}.md"] = _service_card(name, entry, inv)
return desired
def write_cards(inv: dict, check: bool = False) -> int:
CARDS_DIR.mkdir(parents=True, exist_ok=True)
desired = generate_cards(inv)
diff_count = 0
for path, content in desired.items():
existing = path.read_text() if path.exists() else ""
if existing != content:
diff_count += 1
if not check:
path.write_text(content)
for existing_path in CARDS_DIR.glob("*.md"):
if existing_path not in desired:
diff_count += 1
if not check:
existing_path.unlink()
return diff_count
def render(inv: dict) -> str:
hosts = inv.get("hosts", {})
services = inv.get("services", {})
counts = (
f"{sum(1 for e in hosts.values() if e.get('kind') == 'proxmox-host')} hypervisors, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'lxc')} LXCs, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'vm')} VMs, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'workstation')} workstations, "
f"{len(services)} services"
)
parts = [
BANNER,
"# Topology (generated)\n",
f"Source: [inventory.yaml](../inventory.yaml) — {counts}.",
"Edge semantics: [oikos/ontology.yaml](../oikos/ontology.yaml). "
"Operating model: [OIKOS.md](../OIKOS.md).\n",
"## Compute & ingress\n",
"\n".join(compute_view(inv)) + "\n",
"## Storage (mounts)\n",
"\n".join(storage_view(inv)) + "\n",
]
arch = archaeology_table(inv)
if arch:
parts += ["## Archaeology (destroyed nodes)\n", "\n".join(arch) + "\n"]
return "\n".join(parts)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true",
help="exit 1 if output would change (don't write)")
args = parser.parse_args()
inv = yaml.safe_load(INVENTORY.read_text())
content = render(inv)
existing = OUTPUT.read_text() if OUTPUT.exists() else ""
topology_changed = existing != content
card_diffs = write_cards(inv, check=args.check)
if args.check:
if topology_changed:
print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr)
if card_diffs:
print(f"{card_diffs} card(s) in oikos/cards/ would change", file=sys.stderr)
return 1 if (topology_changed or card_diffs) else 0
if topology_changed:
OUTPUT.write_text(content)
print(f"wrote {OUTPUT.relative_to(REPO)}")
if card_diffs:
print(f"wrote/updated {card_diffs} card(s) in oikos/cards/")
return 0
if __name__ == "__main__":
sys.exit(main())