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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user