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

@@ -37,6 +37,17 @@ INVENTORY = CONTEXT / "inventory.yaml"
HOSTS_DIR = CONTEXT / "hosts"
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
# Oikos kernel modules (policy classification, ontology relations, change
# ledger). Optional at import time so a stale/partial checkout degrades to
# "feature unavailable" instead of crashing every subcommand.
sys.path.insert(0, str(CONTEXT))
try:
from oikos import ledger as oikos_ledger
from oikos import policy as oikos_policy
from oikos import relations as oikos_relations
except ImportError:
oikos_ledger = oikos_policy = oikos_relations = None
# ---------- helpers ----------
@@ -172,6 +183,23 @@ def service_backend_host(name: str) -> str:
return service(name)["backend"]
def _record_change(entity: str, action: str, risk: str, *,
result: str | None = None, verification: str | None = None) -> None:
"""Append a ledger entry and push it standalone (used by mutations that
don't already go through push_inventory, e.g. restart)."""
if oikos_ledger is None:
return
oikos_ledger.append(entity, action, risk, result=result, verification=verification)
try:
subprocess.run(["git", "add", "ledger/"], check=True, cwd=CONTEXT)
if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=CONTEXT).returncode != 0:
subprocess.run(["git", "commit", "-m", f"ledger: {entity} {action} ({risk})"],
check=True, cwd=CONTEXT)
subprocess.run(["git", "push"], check=True, cwd=CONTEXT)
except subprocess.CalledProcessError as e:
print(f"warning: could not commit/push ledger entry: {e}", file=sys.stderr)
def push_inventory(message: str, extra_paths: list[str] | None = None) -> None:
"""Stage + commit + push inventory + regenerated hosts/ (+ any extras)."""
subprocess.run(["python3", str(CONTEXT / "mcp" / "build_host_files.py")],
@@ -574,7 +602,12 @@ def cmd_restart(args: argparse.Namespace) -> int:
if not confirm(f"restart systemd unit '{unit}' on {host_name}?"):
return 1
base = ssh_base(host_name)
return subprocess.call(base + ["--", "systemctl", "restart", unit])
rc = subprocess.call(base + ["--", "systemctl", "restart", unit])
risk = (oikos_policy.classify_action("service-restart", svc)
if oikos_policy else "reversible_low") or "reversible_low"
_record_change(f"service:{svc}", "restart", risk,
result=("ok" if rc == 0 else f"failed rc={rc}"))
return rc
def cmd_open(args: argparse.Namespace) -> int:
@@ -1103,9 +1136,11 @@ def cmd_client_add(args: argparse.Namespace) -> int:
print("granting hermes-only secrets...")
_grant_shared_secrets(pubkey, HERMES_SECRETS)
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared + hermes secrets)"
if oikos_ledger is not None:
oikos_ledger.append(f"host:{name}", "client-add-finalize", "config_mutation", result="ok")
push_inventory(
commit_subject,
extra_paths=[".sops.yaml", "secrets/"],
extra_paths=[".sops.yaml", "secrets/", "ledger/"],
)
print(f"finalized {name}.")
return 0
@@ -1167,9 +1202,11 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
print(f" issuance revoke failed: {e}")
# 4. Commit + push (extras: .sops.yaml + secrets/ may also have changed).
if oikos_ledger is not None:
oikos_ledger.append(f"host:{name}", "client-remove", "destructive", result="ok")
push_inventory(
f"client-remove: {name}",
extra_paths=[".sops.yaml", "secrets/"],
extra_paths=[".sops.yaml", "secrets/", "ledger/"],
)
print()
@@ -1182,6 +1219,112 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
return 0
def _require_oikos() -> None:
if oikos_policy is None or oikos_relations is None or oikos_ledger is None:
die("oikos/ kernel modules not importable — is this checkout up to date?")
def cmd_service(args: argparse.Namespace) -> int:
"""Service Console v0 — explain/health/docs/log/actions/history for one service."""
_require_oikos()
name = args.name
svc = service(name) # dies with a clear message if unknown
if args.action == "explain":
card = CONTEXT / "oikos" / "cards" / f"service-{name}.md"
if not card.exists():
die(f"no context card for {name} — run: python3 oikos/gen-topology.py")
print(card.read_text())
return 0
if args.action == "health":
url = svc.get("url") or svc.get("endpoint")
if not url:
die(f"service {name} has no url/endpoint in inventory")
proc = subprocess.run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "5", url],
capture_output=True, text=True,
)
code = proc.stdout.strip() or "no response"
print(f"{name}: {url} -> {code} (live probe — cache-first reads land Week 3)")
return 0 if code.startswith(("2", "3")) else 1
if args.action == "docs":
doc = svc.get("doc_page")
if not doc:
die(f"no doc_page recorded for {name} in inventory.yaml")
path = CONTEXT / doc
if not path.exists():
die(f"doc_page {doc} does not exist")
print(path.read_text())
return 0
if args.action == "log":
return cmd_logs(argparse.Namespace(service=name, lines=args.lines, follow=False))
if args.action == "actions":
for a in oikos_policy.safe_actions_for_service(name, svc):
print(f"{a['action']:<24} {a['risk']:<16} approval={a['approval']}")
return 0
if args.action == "history":
entries = oikos_ledger.history(f"service:{name}", limit=args.limit)
if not entries:
print(f"(no ledger entries for service:{name} yet)")
for e in entries:
print(json.dumps(e))
return 0
die(f"unknown service action: {args.action}")
def cmd_change_preflight(args: argparse.Namespace) -> int:
"""Dry-run report before mutating a service: health, risk class, approval
requirement, and the verification command to run after."""
_require_oikos()
name = args.service
svc = service(name)
risk = (oikos_policy.classify_action("tracked-config-edit", name)
if svc.get("config_repo")
else oikos_policy.classify_action("service-restart", name)) or "config_mutation"
approval = oikos_policy.approval_for(risk)
print(f"Preflight: {name}")
print(f" risk class: {risk} (approval: {approval})")
url = svc.get("url") or svc.get("endpoint")
if url:
proc = subprocess.run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", url],
capture_output=True, text=True,
)
print(f" current health: {url} -> {proc.stdout.strip() or 'no response'}")
if svc.get("config_repo"):
print(f" config repo: {svc['config_repo']} "
f"(verify the backend's working tree is clean before editing)")
if svc.get("risk_notes"):
print(f" risk notes: {svc['risk_notes']}")
print(f" verification after change: "
+ (f"curl -sf {url}" if url else f"homelab logs {name}"))
if approval != "none":
print(f" requires operator approval before mutating ({approval})")
return 0
def cmd_node_relations(args: argparse.Namespace) -> int:
"""Walk the ontology graph both directions for a host or service name."""
_require_oikos()
results = oikos_relations.relations_for_name(args.name)
if not results:
die(f"unknown entity: {args.name}")
for r in results:
print(f"entity: {r['entity']}")
print(f" impacts: {', '.join(r['impacts']) or '(none)'}")
print(f" affected by: {', '.join(r['affected_by']) or '(none)'}")
print(f" full blast radius: {', '.join(r['blast_radius']) or '(none)'}")
return 0
def cmd_nuke(args: argparse.Namespace) -> int:
name = args.name
if not args.yes:
@@ -1554,6 +1697,24 @@ def main() -> int:
help="skip the pre-flight dpkg-audit gate AND proceed past snapshot failures")
sp.set_defaults(func=cmd_apt_upgrade)
sp = sub.add_parser("service", help="Service Console v0 — explain/health/docs/log/actions/history")
sp.add_argument("name")
sp.add_argument("action", choices=["explain", "health", "docs", "log", "actions", "history"])
sp.add_argument("--lines", "-n", type=int, default=200, help="for 'log'")
sp.add_argument("--limit", type=int, default=20, help="for 'history'")
sp.set_defaults(func=cmd_service)
change = sub.add_parser("change", help="change/mutation workflow")
chsub = change.add_subparsers(dest="action", required=True)
ch_preflight = chsub.add_parser("preflight")
ch_preflight.add_argument("service")
ch_preflight.set_defaults(func=cmd_change_preflight)
sp = sub.add_parser("node", help="ontology queries on a host/service")
sp.add_argument("name")
sp.add_argument("action", choices=["relations"])
sp.set_defaults(func=cmd_node_relations)
sp = sub.add_parser("nuke", help="shred /etc/age/key.txt + /opt/homelab-context on a host")
sp.add_argument("name")
sp.add_argument("--yes", "-y", action="store_true")