Oikos Week 4: Console v0, approval hardening, docs pass, backlog
Oikos Console v0 (oikos/console/) — read-mostly, server-rendered FastAPI + Jinja2 web UI, no SPA build chain. Signals landing page, service grid + detail, node/blast-radius view, live Mermaid relationship graph, drift findings, approvals queue (approve/deny, destructive confirmation-phrase enforced), daily/weekly reports. Tested end-to-end via the preview tools against live production data, including a real click-through of the approve/deny flow. Found and fixed two bugs during that testing: - Severity-dot CSS classes didn't match the actual severity strings (dot-warn/dot-crit vs "warning"/"critical") — warning-severity signals rendered with no visible indicator at all. - The console's sys.path setup pointed at its own webhook checkout (/opt/oikos-console) rather than /opt/homelab-context, which would have made its oikos.* imports resolve to a SEPARATE copy of oikos/signal.py etc. than the scheduler and CLI use — silently forking signal/approval data into two locations in production. Fixed to match mcp/server.py's CONTEXT_DIR pattern. Also added _commit_push() so the console's writes (approval replies, signal ack/resolve) don't sit uncommitted against the 5-min-synced clone. Split oikos/gen_topology_lib.py out of oikos/gen-topology.py (hyphenated filenames aren't importable) so the console's /graph route can render live without shelling out. oikos/console/deploy/ — third webhook on dtoro/Homelab-Docs (port 9831), matching the homelab-mcp/secrets-issuance precedent. README documents the Caddy route and Gitea webhook registration this repo can't do for itself, and that Authentik step-up on /approvals needs a live instance to configure. Approval hardening: grants are now single-use (oikos/approve.py check_grant marks the request "executed" atomically, so a second call for the same id fails even within the TTL) — verified with a test. Per- agent age-key-signed requests, as originally planned, turned out not to be buildable as stated: age is encryption-only, no signing primitive. Documented the real alternative (SSH-key signing) and moved it to the 60/90-day backlog pending an inventory schema gap (no SSH pubkeys recorded today). Docs pass: added the Oikos command surface to operations/commands.md, new MCP tools to AGENTS.md. Found two more stale references while at it — commands.md and AGENTS.md both still pointed DNS at the destroyed LXC 124/dnsmasq instead of Technitium on dns (107), and a claudio-monitor reference deprecated since 2026-06-04 — fixed both. 60/90-day backlog written into OIKOS.md, derived from gaps actually observed this month, not guesswork. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ except ImportError: # pragma: no cover
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO))
|
||||
from oikos import gen_topology_lib as lib # noqa: E402
|
||||
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
|
||||
@@ -49,96 +50,14 @@ BANNER = (
|
||||
"<!-- 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
|
||||
# View/graph logic lives in oikos/gen_topology_lib.py (importable — this
|
||||
# file's hyphenated name can't be). Re-exported here so existing call
|
||||
# sites in this module don't need a rename.
|
||||
node_id = lib.node_id
|
||||
guest_label = lib.guest_label
|
||||
compute_view = lib.compute_view
|
||||
storage_view = lib.storage_view
|
||||
archaeology_table = lib.archaeology_table
|
||||
|
||||
|
||||
def _host_card(name: str, entry: dict, inv: dict) -> str:
|
||||
|
||||
Reference in New Issue
Block a user