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

@@ -15,6 +15,19 @@ truth for:
When in doubt, check `/opt/homelab-context/` first.
## Runbooks — load, don't rediscover
For the canonical workflows (service health check, config change +
deploy, client enrollment, incident investigation, and each node
lifecycle transition), read the matching file in `runbooks/*.md` before
acting. Each runbook carries its risk class, required inputs, the
verification command, and a docs-update checklist in its frontmatter —
classify against `oikos/policy.yaml` using that risk class before any
mutation. Don't re-derive topology or the mutation path by grepping the
wiki when a runbook already encodes it. See [OIKOS.md](OIKOS.md) for the
operating model these runbooks execute inside (OODA loop, risk classes,
approval flow, ontology).
## Agent type — how this file gets loaded
| Agent | Loading mechanism |

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")

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.

0
oikos/__init__.py Normal file
View File

21
oikos/cards/host-apps.md Normal file
View File

@@ -0,0 +1,21 @@
# apps (host:apps)
- kind: lxc (LXC 105)
- state: active
- runs-on: host:hubris
- role: docker-apps
- address: 192.168.8.205 (mesh: tailscale:apps)
- mounts: /mnt/library
- doc: containers/105-apps.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: service:artifacto, service:homelab_mcp, service:secrets_issuance
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/Artifacto, repo:dtoro/Homelab-Docs
- full blast radius: service:artifacto, service:homelab_mcp, service:secrets_issuance
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# arriman (host:arriman)
- kind: lxc (LXC 122)
- state: active
- runs-on: host:strong
- role: arr-stack
- address: 192.168.8.245 (mesh: tailscale:arr)
- mounts: /mnt/media_local
- doc: containers/122-arriman.md
## Blast radius
- impacts: service:arr_stack
- affected by: host:strong, mount:/mnt/media_local
- full blast radius: service:arr_stack
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,18 @@
# auth-outpost (host:auth-outpost)
- kind: lxc (LXC 106)
- state: active
- runs-on: host:hubris
- role: authentik-gateway
- address: 192.168.8.6
- doc: containers/106-auth-outpost.md
## Blast radius
- impacts: (none)
- affected by: host:hubris
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

19
oikos/cards/host-caddy.md Normal file
View File

@@ -0,0 +1,19 @@
# caddy (host:caddy)
- kind: lxc (LXC 121)
- state: active
- runs-on: host:hubris
- role: reverse-proxy
- address: 192.168.8.175
- doc: containers/121-caddy.md
## Blast radius
- impacts: service:caddy
- affected by: host:hubris, repo:dtoro/caddy-conf
- full blast radius: service:caddy
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

19
oikos/cards/host-dns.md Normal file
View File

@@ -0,0 +1,19 @@
# dns (host:dns)
- kind: lxc (LXC 107)
- state: active
- runs-on: host:hubris
- role: dns-server
- address: 192.168.8.2
- doc: containers/107-dns.md
## Blast radius
- impacts: service:dns
- affected by: host:hubris
- full blast radius: service:dns
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# elementsynapse (host:elementsynapse)
- kind: lxc (LXC 118)
- state: active
- runs-on: host:strong
- role: matrix-server
- address: 192.168.8.242
- doc: containers/118-elementsynapse.md
## Blast radius
- impacts: service:matrix
- affected by: host:strong
- full blast radius: service:matrix
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

20
oikos/cards/host-gitea.md Normal file
View File

@@ -0,0 +1,20 @@
# gitea (host:gitea)
- kind: lxc (LXC 104)
- state: active
- runs-on: host:hubris
- role: git-server
- address: 192.168.8.121 (mesh: tailscale:gitea)
- mounts: /mnt/library
- doc: containers/104-gitea.md
## Blast radius
- impacts: service:gitea
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/gitea-customizations
- full blast radius: service:gitea
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# grimmory (host:grimmory)
- kind: lxc (LXC 130)
- state: active
- runs-on: host:strong
- role: book-library
- address: 192.168.8.247
- mounts: /mnt/media_local
- doc: containers/130-grimmory.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: (none)
- affected by: host:strong, mount:/mnt/media_local
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

19
oikos/cards/host-haos.md Normal file
View File

@@ -0,0 +1,19 @@
# haos (host:haos)
- kind: vm (VM 108)
- state: active
- runs-on: host:hubris
- role: home-automation
- address: 192.168.8.101 (mesh: tailscale:homeassistant)
- doc: vms/108-haos.md
## Blast radius
- impacts: service:haos
- affected by: host:hubris
- full blast radius: service:haos
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

19
oikos/cards/host-house.md Normal file
View File

@@ -0,0 +1,19 @@
# house (host:house)
- kind: lxc (LXC 129)
- state: active
- runs-on: host:strong
- role: family-planner
- address: 192.168.8.244
- doc: containers/129-house.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: (none)
- affected by: host:strong
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# hubris (host:hubris)
- kind: proxmox-host
- state: active
- role: hypervisor
- address: 192.168.8.77 (mesh: netbird:proxmox-server.netbird.selfhosted)
- mounts: /mnt/library
- doc: hosts/hubris.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: host:apps, host:auth-outpost, host:caddy, host:dns, host:gitea, host:haos, host:mule-images, host:nextcloud, host:nfs-export, host:paperless, host:sophia, host:trmnl, host:zimaos, service:proxmox_ui
- affected by: mount:/mnt/library
- full blast radius: host:apps, host:auth-outpost, host:caddy, host:dns, host:gitea, host:haos, host:mule-images, host:nextcloud, host:nfs-export, host:paperless, host:sophia, host:trmnl, host:zimaos, service:artifacto, service:caddy, service:dns, service:gitea, service:haos, service:homelab_mcp, service:nextcloud, service:paperless, service:photos, service:proxmox_ui, service:secrets_issuance, service:trmnl, service:zimaos
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# jellyfin (host:jellyfin)
- kind: lxc (LXC 101)
- state: active
- runs-on: host:strong
- role: media-server
- address: 192.168.8.246 (mesh: tailscale:jellyfin)
- mounts: /mnt/media_local
- doc: containers/101-jellyfin.md
## Blast radius
- impacts: service:jellyfin
- affected by: host:strong, mount:/mnt/media_local
- full blast radius: service:jellyfin
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,17 @@
# mac-mini (host:mac-mini)
- kind: workstation
- state: active
- role: dev
- address: 192.168.178.182 (mesh: netbird:mac-mini-234-17.netbird.selfhosted)
- secrets: enrolled (age key present)
## Blast radius
- impacts: (none)
- affected by: (none)
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# mule-images (host:mule-images)
- kind: lxc (LXC 120)
- state: active
- runs-on: host:hubris
- role: photo-management
- address: 192.168.8.136 (mesh: tailscale:muleimage)
- mounts: /mnt/library
- doc: containers/120-mule-images.md
## Blast radius
- impacts: service:photos
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/mule-image
- full blast radius: service:photos
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,17 @@
# netbird-vps (host:netbird-vps)
- kind: external
- state: active
- role: netbird-mgmt
- address: (mesh: netbird:netbird-ionos.netbird.selfhosted)
## Blast radius
- impacts: service:authentik
- affected by: (none)
- full blast radius: service:authentik
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# nextcloud (host:nextcloud)
- kind: lxc (LXC 114)
- state: active
- runs-on: host:hubris
- role: file-sync
- address: 192.168.8.224 (mesh: tailscale:nextcloud)
- mounts: /mnt/library
- doc: containers/114-nextcloud.md
## Blast radius
- impacts: service:nextcloud
- affected by: host:hubris, mount:/mnt/library
- full blast radius: service:nextcloud
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,18 @@
# nfs-export (host:nfs-export)
- kind: lxc (LXC 102)
- state: active
- runs-on: host:hubris
- role: storage-export
- address: 192.168.8.200
- doc: containers/102-nfs-export.md
## Blast radius
- impacts: (none)
- affected by: host:hubris
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# paperless (host:paperless)
- kind: lxc (LXC 103)
- state: active
- runs-on: host:hubris
- role: document-archive
- address: 192.168.8.130 (mesh: tailscale:paperless)
- mounts: /mnt/library
- doc: containers/103-paperless.md
## Blast radius
- impacts: service:paperless
- affected by: host:hubris, mount:/mnt/library
- full blast radius: service:paperless
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,17 @@
# rclone (host:rclone)
- kind: lxc
- state: active
- role: backup
- address: (mesh: netbird:rclone.netbird.selfhosted)
- secrets: enrolled (age key present)
## Blast radius
- impacts: (none)
- affected by: (none)
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,16 @@
# republic-laptop (host:republic-laptop)
- kind: workstation
- state: active
- role: primary-dev
- address: (mesh: netbird:republic-laptop.netbird.selfhosted)
## Blast radius
- impacts: (none)
- affected by: (none)
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

19
oikos/cards/host-romm.md Normal file
View File

@@ -0,0 +1,19 @@
# romm (host:romm)
- kind: lxc (LXC 134)
- state: active
- runs-on: host:strong
- role: rom-manager
- address: 192.168.8.249
- mounts: /mnt/media_local
- doc: containers/134-romm.md
## Blast radius
- impacts: (none)
- affected by: host:strong, mount:/mnt/media_local
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# seanime (host:seanime)
- kind: lxc (LXC 133)
- state: active
- runs-on: host:strong
- role: anime-media-server
- address: 192.168.8.248
- mounts: /mnt/media_local/anime
- doc: containers/133-seanime.md
## Blast radius
- impacts: (none)
- affected by: host:strong, mount:/mnt/media_local/anime
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# sophia (host:sophia)
- kind: lxc (LXC 119)
- state: active
- runs-on: host:hubris
- role: workshop
- address: 192.168.8.109 (mesh: tailscale:sophia)
- mounts: /mnt/library
- doc: containers/119-sophia.md
## Blast radius
- impacts: (none)
- affected by: host:hubris, mount:/mnt/library
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# strong (host:strong)
- kind: proxmox-host
- state: active
- role: hypervisor
- address: 192.168.178.181
- doc: hosts/strong.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: host:arriman, host:elementsynapse, host:grimmory, host:house, host:jellyfin, host:romm, host:seanime
- affected by: (none)
- full blast radius: host:arriman, host:elementsynapse, host:grimmory, host:house, host:jellyfin, host:romm, host:seanime, service:arr_stack, service:jellyfin, service:matrix
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

19
oikos/cards/host-trmnl.md Normal file
View File

@@ -0,0 +1,19 @@
# trmnl (host:trmnl)
- kind: lxc (LXC 128)
- state: active
- runs-on: host:hubris
- role: trmnl-middleware
- address: 192.168.8.211
- doc: containers/128-trmnl.md
## Blast radius
- impacts: service:trmnl
- affected by: host:hubris, repo:dtoro/terminalito
- full blast radius: service:trmnl
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# zimaos (host:zimaos)
- kind: vm (VM 100)
- state: active
- runs-on: host:hubris
- role: nas-frontend-eval
- address: 192.168.8.195
- doc: vms/100-zimaos.md
## Blast radius
- impacts: service:zimaos
- affected by: host:hubris
- full blast radius: service:zimaos
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -0,0 +1,17 @@
# arr_stack (service:arr_stack)
- backend: host:arriman
- doc: containers/122-arriman.md
## Blast radius
- impacts: (none)
- affected by: host:arriman
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# artifacto (service:artifacto)
- backend: host:apps
- url: https://artifacto.hubris.network
- doc: containers/105-apps.md
- config repo: dtoro/Artifacto
## Blast radius
- impacts: (none)
- affected by: host:apps
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# authentik (service:authentik)
- backend: host:netbird-vps
- url: https://auth.hubris.network
- doc: containers/106-auth-outpost.md
- risk notes: SSO provider — outage locks login to OIDC/forward-auth services
## Blast radius
- impacts: (none)
- affected by: host:netbird-vps
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# caddy (service:caddy)
- backend: host:caddy
- doc: containers/121-caddy.md
- config repo: dtoro/caddy-conf
- risk notes: wide blast radius — every *.hubris.network route rides on it (see oikos/policy.yaml service_overrides)
## Blast radius
- impacts: (none)
- affected by: host:caddy
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — config_mutation (approval: operator)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,18 @@
# dns (service:dns)
- backend: host:dns
- doc: containers/107-dns.md
- risk notes: LAN-wide resolver — misconfig breaks name resolution for every client
## Blast radius
- impacts: (none)
- affected by: host:dns
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,21 @@
# gitea (service:gitea)
- backend: host:gitea
- url: https://git.hubris.network
- doc: containers/104-gitea.md
- config repo: dtoro/gitea-customizations
- risk notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync
## Blast radius
- impacts: (none)
- affected by: host:gitea
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,17 @@
# haos (service:haos)
- backend: host:haos
- doc: vms/108-haos.md
## Blast radius
- impacts: (none)
- affected by: host:haos
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,21 @@
# homelab_mcp (service:homelab_mcp)
- backend: host:apps
- url: https://mcp.hubris.network/mcp
- doc: infrastructure/homelab-context.md
- config repo: dtoro/Homelab-Docs
- risk notes: agents' primary read surface — outage degrades every agent to grepping the clone
## Blast radius
- impacts: (none)
- affected by: host:apps
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# jellyfin (service:jellyfin)
- backend: host:jellyfin
- url: https://media.hubris.network
- doc: containers/101-jellyfin.md
- risk notes: native Authentik OIDC via SSO-Auth plugin, no Caddy forward-auth gate; VAAPI transcode depends on GPU passthrough on strong
## Blast radius
- impacts: (none)
- affected by: host:jellyfin
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# matrix (service:matrix)
- backend: host:elementsynapse
- url: https://matrix.hubris.network
- doc: containers/118-elementsynapse.md
- risk notes: alert/approval channel for Oikos — outage silences agent escalation
## Blast radius
- impacts: (none)
- affected by: host:elementsynapse
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,18 @@
# nextcloud (service:nextcloud)
- backend: host:nextcloud
- url: https://cloud.hubris.network
- doc: containers/114-nextcloud.md
## Blast radius
- impacts: (none)
- affected by: host:nextcloud
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# paperless (service:paperless)
- backend: host:paperless
- url: https://paperless.hubris.network
- doc: containers/103-paperless.md
- risk notes: document archive — treat data as irreplaceable; DB operations are destructive-class
## Blast radius
- impacts: (none)
- affected by: host:paperless
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# photos (service:photos)
- backend: host:mule-images
- url: https://photos.hubris.network
- doc: containers/120-mule-images.md
- config repo: dtoro/mule-image
## Blast radius
- impacts: (none)
- affected by: host:mule-images
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,19 @@
# proxmox_ui (service:proxmox_ui)
- backend: host:hubris
- url: https://proxmox.hubris.network
- doc: hosts/hubris.md
- risk notes: hypervisor UI — changes here affect every guest on the node
## Blast radius
- impacts: (none)
- affected by: host:hubris
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,21 @@
# secrets_issuance (service:secrets_issuance)
- backend: host:apps
- url: https://secrets.hubris.network/issue
- doc: operations/agent-enrollment.md
- config repo: dtoro/Homelab-Docs
- risk notes: identity issuance — any change is security-sensitive; key operations are destructive-class
## Blast radius
- impacts: (none)
- affected by: host:apps
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,20 @@
# trmnl (service:trmnl)
- backend: host:trmnl
- url: https://trmnl.hubris.network
- doc: containers/128-trmnl.md
- config repo: dtoro/terminalito
## Blast radius
- impacts: (none)
- affected by: host:trmnl
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -0,0 +1,18 @@
# zimaos (service:zimaos)
- backend: host:zimaos
- url: https://zimaos.hubris.network
- doc: vms/100-zimaos.md
## Blast radius
- impacts: (none)
- affected by: host:zimaos
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,18 +1,25 @@
#!/usr/bin/env python3
"""
Generate infrastructure/topology.md (Mermaid views) from inventory.yaml.
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 infrastructure/topology.md
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 never drift from inventory. Edges follow oikos/ontology.yaml
(hosts, provides, routes-to, mounts, stores-on).
diagrams and cards never drift from inventory. Edges follow
oikos/ontology.yaml (hosts, provides, routes-to, mounts, stores-on).
"""
from __future__ import annotations
@@ -28,8 +35,14 @@ except ImportError: # pragma: no cover
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"
@@ -128,6 +141,125 @@ def archaeology_table(inv: dict) -> list[str]:
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", {})
@@ -164,13 +296,21 @@ def main() -> int:
inv = yaml.safe_load(INVENTORY.read_text())
content = render(inv)
existing = OUTPUT.read_text() if OUTPUT.exists() else ""
if existing == content:
return 0
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)
return 1
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

109
oikos/ledger.py Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""oikos/ledger.py — append-only change ledger.
Every mutation an agent or operator performs (reversible_low and above,
per oikos/policy.yaml) gets one JSON line in ledger/<YYYY-MM>.jsonl:
timestamp, acting agent identity, entity, action, risk class, approval
reference, verification result. Append-only, committed like any tracked
file — never edited or reordered in place.
CLI:
python3 oikos/ledger.py append <entity> <action> <risk> [--verification ...] [--result ...]
python3 oikos/ledger.py history <entity> [--limit N]
"""
from __future__ import annotations
import json
import os
import socket
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
LEDGER_DIR = REPO / "ledger"
def _agent_identity() -> str:
"""Best-effort identity for the acting agent. HOMELAB_AGENT_ID lets
approval-engine callers stamp the real requester; falls back to the
local hostname."""
return os.environ.get("HOMELAB_AGENT_ID") or socket.gethostname().split(".")[0]
def append(entity: str, action: str, risk: str, *, verification: str | None = None,
result: str | None = None, approval_ref: str | None = None,
agent: str | None = None, notes: str | None = None) -> dict:
"""Append one change entry. Returns the recorded dict (None fields dropped)."""
entry = {
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"agent": agent or _agent_identity(),
"entity": entity,
"action": action,
"risk": risk,
"approval_ref": approval_ref,
"verification": verification,
"result": result,
"notes": notes,
}
entry = {k: v for k, v in entry.items() if v is not None}
LEDGER_DIR.mkdir(exist_ok=True)
month = datetime.now(timezone.utc).strftime("%Y-%m")
path = LEDGER_DIR / f"{month}.jsonl"
with path.open("a") as f:
f.write(json.dumps(entry, sort_keys=False) + "\n")
return entry
def history(entity: str, limit: int = 20) -> list[dict]:
"""Most recent `limit` ledger entries for `entity`, newest first."""
entries: list[dict] = []
if not LEDGER_DIR.exists():
return entries
for path in sorted(LEDGER_DIR.glob("*.jsonl")):
for line in path.read_text().splitlines():
if not line.strip():
continue
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if e.get("entity") == entity:
entries.append(e)
entries.sort(key=lambda e: e.get("ts", ""), reverse=True)
return entries[:limit]
def main() -> int:
import argparse
p = argparse.ArgumentParser(description="oikos change ledger")
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("append")
sp.add_argument("entity")
sp.add_argument("action")
sp.add_argument("risk")
sp.add_argument("--verification")
sp.add_argument("--result")
sp.add_argument("--approval-ref")
sp.add_argument("--notes")
sh = sub.add_parser("history")
sh.add_argument("entity")
sh.add_argument("--limit", type=int, default=20)
args = p.parse_args()
if args.cmd == "append":
entry = append(args.entity, args.action, args.risk,
verification=args.verification, result=args.result,
approval_ref=args.approval_ref, notes=args.notes)
print(json.dumps(entry, indent=2))
elif args.cmd == "history":
for e in history(args.entity, args.limit):
print(json.dumps(e))
return 0
if __name__ == "__main__":
sys.exit(main())

58
oikos/policy.py Normal file
View File

@@ -0,0 +1,58 @@
"""oikos/policy.py — load oikos/policy.yaml and classify actions.
Shared by bin/homelab, mcp/server.py, and oikos/gen-topology.py so every
surface agrees on risk classes. See OIKOS.md for the operating model.
"""
from __future__ import annotations
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
POLICY_FILE = REPO / "oikos" / "policy.yaml"
def load() -> dict:
return yaml.safe_load(POLICY_FILE.read_text())
def classify_command(cmd: str) -> str | None:
"""Risk class for a `homelab <cmd>` subcommand."""
return load().get("commands", {}).get(cmd)
def classify_action(action: str, service: str | None = None) -> str | None:
"""Risk class for a generic action, honoring per-service overrides."""
pol = load()
if service:
override = pol.get("service_overrides", {}).get(service, {}).get(action)
if override:
return override
return pol.get("actions", {}).get(action)
def approval_for(risk: str) -> str:
return load().get("risk_classes", {}).get(risk, {}).get("approval", "unknown")
def safe_actions_for_service(name: str, svc_entry: dict) -> list[dict]:
"""Actions an agent can propose for this service, each tagged with its
risk class and whether operator approval is required. Derived from what
the service entry actually declares — no action is offered that the
service doesn't support.
"""
out = [
{"action": "health-check", "risk": "read_only", "approval": "none"},
{"action": "view-logs", "risk": "read_only", "approval": "none"},
{"action": "view-docs", "risk": "read_only", "approval": "none"},
]
if svc_entry.get("backend"):
risk = classify_action("service-restart", name)
out.append({"action": "restart", "risk": risk, "approval": approval_for(risk)})
if svc_entry.get("config_repo"):
risk = classify_action("tracked-config-edit", name)
out.append({"action": "edit-config-and-deploy", "risk": risk,
"approval": approval_for(risk)})
return out

131
oikos/relations.py Normal file
View File

@@ -0,0 +1,131 @@
"""oikos/relations.py — walk the ontology graph derived from inventory.yaml.
Wires up the subset of oikos/ontology.yaml relationships that are already
structured data today: hosts, mounts, provides, configured-by, depends-on.
Everything else in the ontology (physical, external, identity domains) is
documented but thin — not yet backed by inventory fields, so it doesn't
appear in the graph until those fields are populated.
Entities are namespaced ("host:name", "service:name", "repo:name",
"mount:path") because host and service names collide in this inventory
(e.g. "jellyfin" is both a service and its own LXC).
Impact polarity: build_impacts() returns edges in the direction
"if SOURCE fails/disappears, TARGET is affected" — this is not the same
direction as how the fact is stored in inventory (e.g. a mount is stored
as guest -> pool, but if the POOL fails the GUEST is impacted, so the
impact edge runs pool -> guest).
"""
from __future__ import annotations
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
INVENTORY = REPO / "inventory.yaml"
def _hid(name: str) -> str:
return f"host:{name}"
def _sid(name: str) -> str:
return f"service:{name}"
def _rid(name: str) -> str:
return f"repo:{name}"
def _mid(name: str) -> str:
return f"mount:{name}"
def load_inventory() -> dict:
return yaml.safe_load(INVENTORY.read_text())
def resolve(entity: str, inv: dict | None = None) -> list[str]:
"""Map a bare name (as typed on the CLI) to every namespaced id it
could refer to. A bare name may match a host AND a service."""
inv = inv or load_inventory()
if ":" in entity:
return [entity]
ids = []
if entity in inv.get("hosts", {}):
ids.append(_hid(entity))
if entity in inv.get("services", {}):
ids.append(_sid(entity))
if not ids:
ids.append(entity)
return ids
def build_impacts(inv: dict | None = None) -> dict[str, set[str]]:
inv = inv or load_inventory()
hosts = inv.get("hosts", {})
services = inv.get("services", {})
impacts: dict[str, set[str]] = {}
def add(source: str, target: str) -> None:
impacts.setdefault(source, set()).add(target)
for name, e in hosts.items():
hid = _hid(name)
parent = e.get("host")
if parent:
add(_hid(parent), hid) # host failing -> guest impacted
for mount in e.get("mounts", []):
add(_mid(mount), hid) # pool failing -> mounter impacted
for dep in e.get("depends_on", []) or []:
add(_hid(dep), hid) # dependency failing -> dependent impacted
for svc, e in services.items():
if not isinstance(e, dict):
continue
sid = _sid(svc)
backend = e.get("backend")
if backend and backend in hosts:
add(_hid(backend), sid) # backend failing -> service impacted
config_repo = e.get("config_repo")
if config_repo:
add(_rid(config_repo), _hid(backend)) # bad config -> backend impacted
return impacts
def blast_radius(entity_id: str, inv: dict | None = None) -> list[str]:
"""Transitive closure: every entity affected if `entity_id` fails."""
impacts = build_impacts(inv)
seen: set[str] = set()
stack = [entity_id]
while stack:
cur = stack.pop()
for nxt in impacts.get(cur, ()):
if nxt not in seen:
seen.add(nxt)
stack.append(nxt)
return sorted(seen)
def relations(entity_id: str, inv: dict | None = None) -> dict:
"""One entity's direct edges plus its full transitive blast radius."""
impacts = build_impacts(inv)
impacts_on = sorted(impacts.get(entity_id, ()))
affected_by = sorted(src for src, targets in impacts.items() if entity_id in targets)
return {
"entity": entity_id,
"impacts": impacts_on,
"affected_by": affected_by,
"blast_radius": blast_radius(entity_id, inv),
}
def relations_for_name(name: str, inv: dict | None = None) -> list[dict]:
"""CLI/MCP entry point: resolve a bare name and report relations for
every matching entity id (usually one; two if host and service share
a name)."""
inv = inv or load_inventory()
return [relations(eid, inv) for eid in resolve(name, inv)]

View File

@@ -0,0 +1,35 @@
---
name: client-enrollment
risk_class: config_mutation
inputs: [hostname, kind, role]
verification: "homelab doctor (on the new client)"
docs_update_checklist: [hosts_narrative_page_if_lxc_or_vm]
---
# Client enrollment
Goal: bring a new host (workstation, LXC, VM) into the mesh, inventory,
and secrets model. This wraps the existing `homelab client add` flow —
see [operations/agent-enrollment.md](../operations/agent-enrollment.md)
for the full walkthrough; this runbook is the risk/lifecycle framing.
1. On any enrolled client: `homelab client add <hostname>` — appends a
`hosts.<name>:` block to `inventory.yaml` (lifecycle `state: planned`
`provisioning`, per [oikos/ontology.yaml](../oikos/ontology.yaml)),
commits + pushes.
2. Join the new host to Netbird (out-of-band, console or setup key).
3. On the new host: run `bootstrap.sh` (add `--with-hermes` to also
enroll the Hermes agent). This provisions `/etc/age/key.txt`, the
sync timer, and prints an age pubkey.
4. Back on an enrolled client: `homelab client add <hostname>
--finalize-pubkey <age1...>` — sets `age_pubkey`, grants shared
secrets, re-keys SOPS, commits + pushes. This is the
`provisioning → active` transition.
5. Verify: `homelab doctor` on the new client should show all checks
green (clone, sync timer, age key, CLI symlink, MCP reachable).
Docs-update checklist: if the new host is an LXC/VM, add its narrative
page under `containers/` or `vms/` and set `doc_page` in its inventory
entry (host-level cards don't have a `doc_page` field yet — services do;
narrative pages are still found via the generated `see_also` in
`hosts/<name>.yaml`).

View File

@@ -0,0 +1,34 @@
---
name: config-change-deploy
risk_class: config_mutation
inputs: [service_name, change_description]
verification: "curl -sf <service_url> (or homelab service <name> health)"
docs_update_checklist: [doc_page, changelog]
---
# Config change + deploy
Goal: change a tracked config repo (Caddy, Gitea customizations, an app's
own repo) and get it live, safely.
1. `homelab change preflight <service>` — current health, the service's
`config_repo`, its risk class, and the verification command to run
after. If risk class requires approval (`config_mutation` or
`destructive`), stop and get operator sign-off before editing — see
`oikos/policy.yaml`.
2. Clone/pull the `config_repo` (never edit the backend's working tree
directly — tracked configs change by commit + push, per
[OIKOS.md](../OIKOS.md) conventions).
3. Make the change, commit, push to `main`.
4. The Gitea webhook fires the deploy pipeline for that repo (see
[infrastructure/auto-deploy.md](../infrastructure/auto-deploy.md) for
the exact receiver/reload for this service).
5. Run the preflight's verification command. If it fails, check
`homelab service <name> log` for the reload/restart error.
6. Record the change: once `oikos/ledger.py` is wired into deploy tooling
(Week 3), this is automatic; until then, note the change and outcome
in the relevant investigation/plan doc.
Docs-update checklist: update the service's `doc_page` if the change
alters its behavior, ingress route, or ownership; add a changelog entry
if the page has one.

View File

@@ -0,0 +1,34 @@
---
name: incident-investigation
risk_class: read_only
inputs: [symptom, affected_entity]
verification: "n/a — investigation produces a written record, not a state change"
docs_update_checklist: [investigations_entry]
---
# Incident investigation
Goal: understand what broke and why, before touching anything.
1. `homelab service <name> explain` (or `homelab node <name> relations`
if the affected entity is a host) — get the blast radius and doc
pointer first. Don't start pulling logs blind.
2. `homelab service <name> health` + `homelab service <name> log` (or
MCP `get_service_status` / `tail_log`) for the affected service.
3. Walk the blast radius: is a shared dependency down (`caddy`, `dns`,
`authentik`, or the backend host itself)? `homelab node <name>
relations` shows "affected by" — check those first.
4. `homelab apt-audit` if the symptom looks like a dpkg/upgrade
interaction.
5. Check the change ledger for recent mutations to the affected entity
or anything upstream of it: `homelab service <name> history` (once
populated) or grep `ledger/*.jsonl`.
6. Write findings to a new `investigations/<date>-<slug>.md` — symptom,
timeline, root cause, fix applied, prevention. This is the durable
record; don't rely on chat history.
Docs-update checklist: always create the investigation entry. If the
root cause was stale/wrong inventory data (a `doc_page`, `config_repo`,
or `backend` that didn't match reality — this happened during Week 1
kernel work, see the `authentik` backend fix), correct `inventory.yaml`
in the same session.

View File

@@ -0,0 +1,36 @@
---
name: lifecycle-activate-node
risk_class: config_mutation
inputs: [node_name]
verification: "homelab service <name> health (if it hosts a service); homelab doctor (if it's a client)"
docs_update_checklist: [doc_page_complete]
transition: "provisioning -> active"
---
# Lifecycle: activate a node
Per [oikos/ontology.yaml](../oikos/ontology.yaml). Requires: age key
enrolled if it needs secrets, mesh joined if it needs off-LAN reach,
ingress live if public, health check answering, doc page complete,
ledger entry.
1. If the node is a `homelab` client: finish enrollment per
[client-enrollment.md](client-enrollment.md) (`--finalize-pubkey`,
mesh join, `homelab doctor` green).
2. If it hosts a public service: add the `services:` entry in
`inventory.yaml` (backend, url, doc_page, config_repo, risk_notes —
see the Week-1 service contract fields) and wire the Caddy route in
`dtoro/caddy-conf`.
3. Confirm the health check answers: `homelab service <name> health` or
a direct `curl`.
4. Flip `state: provisioning``state: active` (or delete the `state:`
field — `active` is the default) in `inventory.yaml`.
5. Complete the doc page (stub → full narrative: role, specs, how it's
configured, dependencies).
6. Record the activation: `oikos/ledger.py append host:<name> activate
config_mutation --result ok` (or let the CLI wrapper do this once
Week 3's runbook automation lands).
Regenerate derived data: `python3 mcp/build_host_files.py && python3
oikos/gen-topology.py` so `hosts/<name>.yaml`, the topology diagram, and
the context card all reflect the new state.

View File

@@ -0,0 +1,35 @@
---
name: lifecycle-deprecate-node
risk_class: config_mutation
inputs: [node_name, replacement_node_or_reason]
verification: "homelab node <name> relations — 'affected by' must be empty before completing"
docs_update_checklist: [doc_page_deprecation_note]
transition: "active -> deprecated"
---
# Lifecycle: deprecate a node
Per [oikos/ontology.yaml](../oikos/ontology.yaml): a node keeps running
but takes no new dependents. **Completion condition: zero remaining
inbound `depends-on`/`routes-to` edges** — this is a hard gate, not a
suggestion; `oikos/policy.yaml` `lifecycle_overrides.deprecated.refuse`
lists `new-inbound-edges` as refused going forward.
1. Set `state: deprecated` on the node.
2. `homelab node <name> relations` — read `affected_by`. Every entry
there is something still relying on this node.
3. Migrate or retire each dependent one at a time (point its `backend`/
`config_repo`/ingress route elsewhere, or deprecate it too if it's
being retired alongside).
4. Re-run `homelab node <name> relations` after each dependent is moved.
The transition to `destroyed` is only safe once `affected_by` is
empty — check this every time, don't assume from memory.
5. Note the deprecation on the doc page: reason, replacement (if any),
date.
If step 2 shows dependents you didn't expect, stop and investigate
before proceeding — that's exactly the kind of drift the Week-3 detector
will catch automatically, but until then this manual check is the gate.
Next (once `affected_by` is empty):
[lifecycle-destroy-node.md](lifecycle-destroy-node.md).

View File

@@ -0,0 +1,42 @@
---
name: lifecycle-destroy-node
risk_class: destructive
inputs: [node_name]
verification: "homelab node <name> relations returns unknown-entity; pct list on the backend no longer shows it"
docs_update_checklist: [archaeology_entry, containers_index_update]
transition: "deprecated -> destroyed"
---
# Lifecycle: destroy a node
**Destructive.** Requires operator approval + typed confirmation phrase
per `oikos/policy.yaml`. Requires (ontology): backups verified, secrets
recipients removed + re-keyed, ingress/DNS removed, archaeology entry,
ledger entry.
1. Confirm the node is `deprecated` with zero `affected_by` edges
(`homelab node <name> relations`) — do not skip this even if the
deprecation runbook was followed recently; state can drift.
2. If it's an enrolled client: `homelab client remove <name>` — revokes
the age key, re-keys SOPS, removes the inventory entry. This is
already destructive-class and confirmed in the CLI.
3. Remove any ingress route (Caddy config repo) and DNS record still
pointing at it.
4. Verify backups of anything on it are retained per policy before the
disk goes away (see `backs-up-to`).
5. Destroy the LXC/VM (`pct destroy` / `qm destroy`).
6. Move the `hosts.<name>:` block (if any inventory remnant survives
`client remove`, e.g. infra-only LXCs with no age key) into
inventory.yaml's `archaeology:` section: `pve_id`, `destroyed` date,
`reason`. Add a row to `containers/index.md` "Recently destroyed"
table (kept for human-readable browsing alongside the structured
data).
7. `oikos/ledger.py append host:<name> destroy destructive --result ok`.
8. Regenerate: `python3 mcp/build_host_files.py && python3
oikos/gen-topology.py` — the node drops out of `hosts/*.yaml` and
appears in the topology doc's archaeology table.
If the destroy fails partway (e.g. secrets revoked but pct destroy
errors), do not re-run step 2 — `client remove` is not idempotent
against a second revocation attempt on the issuance server. Finish the
remaining steps manually and note the partial state in an investigation.

View File

@@ -0,0 +1,39 @@
---
name: lifecycle-migrate-node
risk_class: config_mutation
inputs: [node_name, source_host, target_host]
verification: "homelab node <name> relations (re-check blast radius); homelab service <svc> health for every hosted service"
docs_update_checklist: [doc_page_migration_note, inventory_host_and_lan_ip]
transition: "active -> migrating -> active"
---
# Lifecycle: migrate a node
Modeled on the strong Phase 1+2 migration
([plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)).
Requires (ontology): preflight + backup-verified before migrating;
post-verify + Caddy backends checked + mounts checked + docs updated
before returning to `active`.
1. `homelab change preflight <every service the node hosts>` — capture
current health as a baseline.
2. Verify backups are current for anything with data at rest on the
node (see `backs-up-to` edges once populated).
3. Set `state: migrating` in `inventory.yaml`.
4. Perform the migration (pct/qm move, or create-on-target +
data-copy + destroy-source, per the specific case).
5. Update `inventory.yaml`: new `host:`, `lan_ip`, `mesh` addresses for
the node; update every `services:` entry whose `backend` pointed at
it if the backend name itself changes (usually it doesn't — only the
`host:`/`lan_ip` on the guest entry moves).
6. Post-verify: re-run the Week-1 drift check by hand — confirm Caddy's
backend IP for each affected service matches the new `lan_ip`
(automatic in Week 3's drift detector), confirm mounts still resolve.
7. `homelab service <name> health` for every service the node hosts.
8. Set `state: active`. Add a migration note to the node's doc page
(old host/IP → new, date, phase reference) — this repo's convention
for every past migration (see `containers/101-jellyfin.md`,
`containers/129-house.md`).
Regenerate: `python3 mcp/build_host_files.py && python3
oikos/gen-topology.py`.

View File

@@ -0,0 +1,33 @@
---
name: lifecycle-provision-node
risk_class: config_mutation
inputs: [node_name, kind, storage_pool]
verification: "grep 'state: provisioning' hosts/<name>.yaml"
docs_update_checklist: [doc_page_stub]
transition: "planned -> provisioning"
---
# Lifecycle: provision a node
Per [oikos/ontology.yaml](../oikos/ontology.yaml) `lifecycle.transitions`.
Policy note: `provisioning` nodes get a lifecycle override —
`config_mutation` actions downgrade to `reversible_low` because nothing
depends on the node yet (see `oikos/policy.yaml` `lifecycle_overrides`).
Requires (from ontology): inventory entry, IP reserved, storage pool
chosen, doc page stub.
1. Create the LXC/VM on its target Proxmox host (`pct create` /
`qm create`), choosing the storage pool deliberately — record it as
the `storage:` field once populated (Week 1 schema; not yet backfilled
for existing nodes).
2. Add the inventory entry: `homelab client add <name>` for anything that
will run the `homelab` CLI, or a direct `hosts.<name>:` block with
`state: provisioning`, `kind`, `host`, `pve_id`, `lan_ip` for
infra-only LXCs that won't self-enroll.
3. Stub the doc page (`containers/<pve_id>-<name>.md` or
`vms/<pve_id>-<name>.md`) — even a one-line "provisioning, see plan X"
is enough to satisfy the transition requirement.
4. Reserve the IP in DNS/DHCP notes if it's a fixed LAN address.
Next: [lifecycle-activate-node.md](lifecycle-activate-node.md).

View File

@@ -0,0 +1,30 @@
---
name: service-health-check
risk_class: read_only
inputs: [service_name]
verification: "homelab service <name> health"
docs_update_checklist: []
---
# Service health check
Goal: determine whether a service is actually healthy, without ad-hoc SSH.
1. `homelab service <name> explain` — read the context card: backend,
blast radius, doc pointer, risk notes.
2. `homelab service <name> health` — live health probe (HTTP code against
the service's `url`/`endpoint`). Once the Week-3 scheduler ships, this
reads a cached snapshot by default; pass `--live` to force a fresh probe.
3. If unhealthy, `homelab service <name> log` (or MCP `tail_log`) for the
last 200 lines.
4. Cross-check blast radius: `homelab node <name> relations` — is this
entity's own backend host healthy? A downstream failure (e.g. `strong`
down) will show up here before the service's own logs explain anything.
5. If the fix is a restart: classify first (`oikos/policy.yaml`
`service-restart` is `reversible_low` unless the service has a
`service_overrides` entry, e.g. `caddy`/`dns` are `config_mutation`).
Unattended agents may act on `reversible_low` without approval.
Docs-update checklist: none for a pure health check. If the investigation
reveals stale `risk_notes` or a wrong `doc_page`, fix `inventory.yaml` in
the same session.