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>
This commit is contained in:
2026-07-05 23:02:32 +02:00
parent b230ab5937
commit f6b57cbe3a
60 changed files with 1823 additions and 14 deletions

View File

@@ -20,16 +20,27 @@ import os
import re
import shlex
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp import FastMCP # noqa: E402 — must precede the sys.path
# insert below: CONTEXT_DIR contains its
# own top-level "mcp/" directory, which
# would shadow the real `mcp` package if
# inserted first.
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
INVENTORY = CONTEXT_DIR / "inventory.yaml"
HOSTS_DIR = CONTEXT_DIR / "hosts"
CARDS_DIR = CONTEXT_DIR / "oikos" / "cards"
sys.path.insert(0, str(CONTEXT_DIR))
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
# All management tools proxy through hubris (the Proxmox host) via a single
# restricted-shell SSH connection. The wrapper at mcp/mcp-reader-shell on
# hubris validates each command against a strict read-only allowlist.
@@ -230,6 +241,55 @@ def whoami(hostname: str) -> dict:
return yaml.safe_load(candidate.read_text())
@mcp.tool()
def explain(service: str) -> str:
"""Return the compact context card for a service: identity, blast
radius, safe actions + risk class, doc pointer, recent ledger history.
Card-first — cheaper for agent orientation than search_docs + get_page.
"""
card = CARDS_DIR / f"service-{service}.md"
if not card.exists():
raise ValueError(f"no context card for {service} — has gen-topology.py run?")
return card.read_text()
@mcp.tool()
def preflight(service: str) -> dict:
"""Dry-run report before mutating a service: risk class, approval
requirement, current health, config repo, and the verification command
to run after the change."""
inv_svc = inventory().get("services", {}).get(service)
if not inv_svc:
raise ValueError(f"unknown service: {service}")
risk = (oikos_policy.classify_action("tracked-config-edit", service)
if inv_svc.get("config_repo")
else oikos_policy.classify_action("service-restart", service)) or "config_mutation"
url = inv_svc.get("url") or inv_svc.get("endpoint")
return {
"service": service,
"risk_class": risk,
"approval": oikos_policy.approval_for(risk),
"config_repo": inv_svc.get("config_repo"),
"risk_notes": inv_svc.get("risk_notes"),
"verification": f"curl -sf {url}" if url else f"tail_log({service!r})",
}
@mcp.tool()
def get_relations(entity: str) -> list[dict]:
"""Walk the ontology graph both directions for a host or service name:
what it impacts, what affects it, and its full transitive blast radius.
"""
return oikos_relations.relations_for_name(entity)
@mcp.tool()
def get_change_history(entity: str, limit: int = 20) -> list[dict]:
"""Ledger entries for `entity` (e.g. "service:jellyfin", "host:strong"),
newest first."""
return oikos_ledger.history(entity, limit=limit)
@mcp.tool()
def list_my_secrets(caller_pubkey: str) -> list[str]:
"""Return the names of secrets the caller (identified by age pubkey) can decrypt.