From f6b57cbe3a2f0a587f1205dfdb7cc4ff2edd6ce5 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 5 Jul 2026 23:02:32 +0200 Subject: [PATCH] Oikos Week 2: Service Console v0, change ledger, node relations, runbooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 explain|health|docs|log|actions|history` (Service Console v0), `change preflight `, `node 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 --- HERMES.md | 13 ++ bin/homelab | 167 +++++++++++++++++++++++- mcp/server.py | 62 ++++++++- oikos/__init__.py | 0 oikos/cards/host-apps.md | 21 +++ oikos/cards/host-arriman.md | 20 +++ oikos/cards/host-auth-outpost.md | 18 +++ oikos/cards/host-caddy.md | 19 +++ oikos/cards/host-dns.md | 19 +++ oikos/cards/host-elementsynapse.md | 19 +++ oikos/cards/host-gitea.md | 20 +++ oikos/cards/host-grimmory.md | 20 +++ oikos/cards/host-haos.md | 19 +++ oikos/cards/host-house.md | 19 +++ oikos/cards/host-hubris.md | 20 +++ oikos/cards/host-jellyfin.md | 20 +++ oikos/cards/host-mac-mini.md | 17 +++ oikos/cards/host-mule-images.md | 20 +++ oikos/cards/host-netbird-vps.md | 17 +++ oikos/cards/host-nextcloud.md | 20 +++ oikos/cards/host-nfs-export.md | 18 +++ oikos/cards/host-paperless.md | 20 +++ oikos/cards/host-rclone.md | 17 +++ oikos/cards/host-republic-laptop.md | 16 +++ oikos/cards/host-romm.md | 19 +++ oikos/cards/host-seanime.md | 19 +++ oikos/cards/host-sophia.md | 19 +++ oikos/cards/host-strong.md | 19 +++ oikos/cards/host-trmnl.md | 19 +++ oikos/cards/host-zimaos.md | 19 +++ oikos/cards/service-arr_stack.md | 17 +++ oikos/cards/service-artifacto.md | 20 +++ oikos/cards/service-authentik.md | 19 +++ oikos/cards/service-caddy.md | 20 +++ oikos/cards/service-dns.md | 18 +++ oikos/cards/service-gitea.md | 21 +++ oikos/cards/service-haos.md | 17 +++ oikos/cards/service-homelab_mcp.md | 21 +++ oikos/cards/service-jellyfin.md | 19 +++ oikos/cards/service-matrix.md | 19 +++ oikos/cards/service-nextcloud.md | 18 +++ oikos/cards/service-paperless.md | 19 +++ oikos/cards/service-photos.md | 20 +++ oikos/cards/service-proxmox_ui.md | 19 +++ oikos/cards/service-secrets_issuance.md | 21 +++ oikos/cards/service-trmnl.md | 20 +++ oikos/cards/service-zimaos.md | 18 +++ oikos/gen-topology.py | 160 +++++++++++++++++++++-- oikos/ledger.py | 109 ++++++++++++++++ oikos/policy.py | 58 ++++++++ oikos/relations.py | 131 +++++++++++++++++++ runbooks/client-enrollment.md | 35 +++++ runbooks/config-change-deploy.md | 34 +++++ runbooks/incident-investigation.md | 34 +++++ runbooks/lifecycle-activate-node.md | 36 +++++ runbooks/lifecycle-deprecate-node.md | 35 +++++ runbooks/lifecycle-destroy-node.md | 42 ++++++ runbooks/lifecycle-migrate-node.md | 39 ++++++ runbooks/lifecycle-provision-node.md | 33 +++++ runbooks/service-health-check.md | 30 +++++ 60 files changed, 1823 insertions(+), 14 deletions(-) create mode 100644 oikos/__init__.py create mode 100644 oikos/cards/host-apps.md create mode 100644 oikos/cards/host-arriman.md create mode 100644 oikos/cards/host-auth-outpost.md create mode 100644 oikos/cards/host-caddy.md create mode 100644 oikos/cards/host-dns.md create mode 100644 oikos/cards/host-elementsynapse.md create mode 100644 oikos/cards/host-gitea.md create mode 100644 oikos/cards/host-grimmory.md create mode 100644 oikos/cards/host-haos.md create mode 100644 oikos/cards/host-house.md create mode 100644 oikos/cards/host-hubris.md create mode 100644 oikos/cards/host-jellyfin.md create mode 100644 oikos/cards/host-mac-mini.md create mode 100644 oikos/cards/host-mule-images.md create mode 100644 oikos/cards/host-netbird-vps.md create mode 100644 oikos/cards/host-nextcloud.md create mode 100644 oikos/cards/host-nfs-export.md create mode 100644 oikos/cards/host-paperless.md create mode 100644 oikos/cards/host-rclone.md create mode 100644 oikos/cards/host-republic-laptop.md create mode 100644 oikos/cards/host-romm.md create mode 100644 oikos/cards/host-seanime.md create mode 100644 oikos/cards/host-sophia.md create mode 100644 oikos/cards/host-strong.md create mode 100644 oikos/cards/host-trmnl.md create mode 100644 oikos/cards/host-zimaos.md create mode 100644 oikos/cards/service-arr_stack.md create mode 100644 oikos/cards/service-artifacto.md create mode 100644 oikos/cards/service-authentik.md create mode 100644 oikos/cards/service-caddy.md create mode 100644 oikos/cards/service-dns.md create mode 100644 oikos/cards/service-gitea.md create mode 100644 oikos/cards/service-haos.md create mode 100644 oikos/cards/service-homelab_mcp.md create mode 100644 oikos/cards/service-jellyfin.md create mode 100644 oikos/cards/service-matrix.md create mode 100644 oikos/cards/service-nextcloud.md create mode 100644 oikos/cards/service-paperless.md create mode 100644 oikos/cards/service-photos.md create mode 100644 oikos/cards/service-proxmox_ui.md create mode 100644 oikos/cards/service-secrets_issuance.md create mode 100644 oikos/cards/service-trmnl.md create mode 100644 oikos/cards/service-zimaos.md create mode 100644 oikos/ledger.py create mode 100644 oikos/policy.py create mode 100644 oikos/relations.py create mode 100644 runbooks/client-enrollment.md create mode 100644 runbooks/config-change-deploy.md create mode 100644 runbooks/incident-investigation.md create mode 100644 runbooks/lifecycle-activate-node.md create mode 100644 runbooks/lifecycle-deprecate-node.md create mode 100644 runbooks/lifecycle-destroy-node.md create mode 100644 runbooks/lifecycle-migrate-node.md create mode 100644 runbooks/lifecycle-provision-node.md create mode 100644 runbooks/service-health-check.md diff --git a/HERMES.md b/HERMES.md index bb5cbf2..03fe761 100644 --- a/HERMES.md +++ b/HERMES.md @@ -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 | diff --git a/bin/homelab b/bin/homelab index e2a05bc..95c0fbc 100755 --- a/bin/homelab +++ b/bin/homelab @@ -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") diff --git a/mcp/server.py b/mcp/server.py index b08700a..03223d8 100755 --- a/mcp/server.py +++ b/mcp/server.py @@ -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. diff --git a/oikos/__init__.py b/oikos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oikos/cards/host-apps.md b/oikos/cards/host-apps.md new file mode 100644 index 0000000..038fa80 --- /dev/null +++ b/oikos/cards/host-apps.md @@ -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) diff --git a/oikos/cards/host-arriman.md b/oikos/cards/host-arriman.md new file mode 100644 index 0000000..f641fa5 --- /dev/null +++ b/oikos/cards/host-arriman.md @@ -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) diff --git a/oikos/cards/host-auth-outpost.md b/oikos/cards/host-auth-outpost.md new file mode 100644 index 0000000..b450032 --- /dev/null +++ b/oikos/cards/host-auth-outpost.md @@ -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) diff --git a/oikos/cards/host-caddy.md b/oikos/cards/host-caddy.md new file mode 100644 index 0000000..73a53a3 --- /dev/null +++ b/oikos/cards/host-caddy.md @@ -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) diff --git a/oikos/cards/host-dns.md b/oikos/cards/host-dns.md new file mode 100644 index 0000000..7157d47 --- /dev/null +++ b/oikos/cards/host-dns.md @@ -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) diff --git a/oikos/cards/host-elementsynapse.md b/oikos/cards/host-elementsynapse.md new file mode 100644 index 0000000..461ace3 --- /dev/null +++ b/oikos/cards/host-elementsynapse.md @@ -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) diff --git a/oikos/cards/host-gitea.md b/oikos/cards/host-gitea.md new file mode 100644 index 0000000..ad600a6 --- /dev/null +++ b/oikos/cards/host-gitea.md @@ -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) diff --git a/oikos/cards/host-grimmory.md b/oikos/cards/host-grimmory.md new file mode 100644 index 0000000..d6a0c72 --- /dev/null +++ b/oikos/cards/host-grimmory.md @@ -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) diff --git a/oikos/cards/host-haos.md b/oikos/cards/host-haos.md new file mode 100644 index 0000000..ada4e76 --- /dev/null +++ b/oikos/cards/host-haos.md @@ -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) diff --git a/oikos/cards/host-house.md b/oikos/cards/host-house.md new file mode 100644 index 0000000..3ad239e --- /dev/null +++ b/oikos/cards/host-house.md @@ -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) diff --git a/oikos/cards/host-hubris.md b/oikos/cards/host-hubris.md new file mode 100644 index 0000000..98db010 --- /dev/null +++ b/oikos/cards/host-hubris.md @@ -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) diff --git a/oikos/cards/host-jellyfin.md b/oikos/cards/host-jellyfin.md new file mode 100644 index 0000000..162b908 --- /dev/null +++ b/oikos/cards/host-jellyfin.md @@ -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) diff --git a/oikos/cards/host-mac-mini.md b/oikos/cards/host-mac-mini.md new file mode 100644 index 0000000..7f683d3 --- /dev/null +++ b/oikos/cards/host-mac-mini.md @@ -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) diff --git a/oikos/cards/host-mule-images.md b/oikos/cards/host-mule-images.md new file mode 100644 index 0000000..242f193 --- /dev/null +++ b/oikos/cards/host-mule-images.md @@ -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) diff --git a/oikos/cards/host-netbird-vps.md b/oikos/cards/host-netbird-vps.md new file mode 100644 index 0000000..511becc --- /dev/null +++ b/oikos/cards/host-netbird-vps.md @@ -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) diff --git a/oikos/cards/host-nextcloud.md b/oikos/cards/host-nextcloud.md new file mode 100644 index 0000000..f3de0e1 --- /dev/null +++ b/oikos/cards/host-nextcloud.md @@ -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) diff --git a/oikos/cards/host-nfs-export.md b/oikos/cards/host-nfs-export.md new file mode 100644 index 0000000..53102e9 --- /dev/null +++ b/oikos/cards/host-nfs-export.md @@ -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) diff --git a/oikos/cards/host-paperless.md b/oikos/cards/host-paperless.md new file mode 100644 index 0000000..f21f1ab --- /dev/null +++ b/oikos/cards/host-paperless.md @@ -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) diff --git a/oikos/cards/host-rclone.md b/oikos/cards/host-rclone.md new file mode 100644 index 0000000..60b05e6 --- /dev/null +++ b/oikos/cards/host-rclone.md @@ -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) diff --git a/oikos/cards/host-republic-laptop.md b/oikos/cards/host-republic-laptop.md new file mode 100644 index 0000000..f70c178 --- /dev/null +++ b/oikos/cards/host-republic-laptop.md @@ -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) diff --git a/oikos/cards/host-romm.md b/oikos/cards/host-romm.md new file mode 100644 index 0000000..6e9d58b --- /dev/null +++ b/oikos/cards/host-romm.md @@ -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) diff --git a/oikos/cards/host-seanime.md b/oikos/cards/host-seanime.md new file mode 100644 index 0000000..d2d58eb --- /dev/null +++ b/oikos/cards/host-seanime.md @@ -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) diff --git a/oikos/cards/host-sophia.md b/oikos/cards/host-sophia.md new file mode 100644 index 0000000..505ebef --- /dev/null +++ b/oikos/cards/host-sophia.md @@ -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) diff --git a/oikos/cards/host-strong.md b/oikos/cards/host-strong.md new file mode 100644 index 0000000..2ada6c4 --- /dev/null +++ b/oikos/cards/host-strong.md @@ -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) diff --git a/oikos/cards/host-trmnl.md b/oikos/cards/host-trmnl.md new file mode 100644 index 0000000..d5d8d33 --- /dev/null +++ b/oikos/cards/host-trmnl.md @@ -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) diff --git a/oikos/cards/host-zimaos.md b/oikos/cards/host-zimaos.md new file mode 100644 index 0000000..877b8f1 --- /dev/null +++ b/oikos/cards/host-zimaos.md @@ -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) diff --git a/oikos/cards/service-arr_stack.md b/oikos/cards/service-arr_stack.md new file mode 100644 index 0000000..335f5c5 --- /dev/null +++ b/oikos/cards/service-arr_stack.md @@ -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) diff --git a/oikos/cards/service-artifacto.md b/oikos/cards/service-artifacto.md new file mode 100644 index 0000000..756c158 --- /dev/null +++ b/oikos/cards/service-artifacto.md @@ -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) diff --git a/oikos/cards/service-authentik.md b/oikos/cards/service-authentik.md new file mode 100644 index 0000000..252ddea --- /dev/null +++ b/oikos/cards/service-authentik.md @@ -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) diff --git a/oikos/cards/service-caddy.md b/oikos/cards/service-caddy.md new file mode 100644 index 0000000..69e013d --- /dev/null +++ b/oikos/cards/service-caddy.md @@ -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) diff --git a/oikos/cards/service-dns.md b/oikos/cards/service-dns.md new file mode 100644 index 0000000..1b3af2e --- /dev/null +++ b/oikos/cards/service-dns.md @@ -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) diff --git a/oikos/cards/service-gitea.md b/oikos/cards/service-gitea.md new file mode 100644 index 0000000..ab6e9bf --- /dev/null +++ b/oikos/cards/service-gitea.md @@ -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) diff --git a/oikos/cards/service-haos.md b/oikos/cards/service-haos.md new file mode 100644 index 0000000..60e6473 --- /dev/null +++ b/oikos/cards/service-haos.md @@ -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) diff --git a/oikos/cards/service-homelab_mcp.md b/oikos/cards/service-homelab_mcp.md new file mode 100644 index 0000000..29462c6 --- /dev/null +++ b/oikos/cards/service-homelab_mcp.md @@ -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) diff --git a/oikos/cards/service-jellyfin.md b/oikos/cards/service-jellyfin.md new file mode 100644 index 0000000..75085ca --- /dev/null +++ b/oikos/cards/service-jellyfin.md @@ -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) diff --git a/oikos/cards/service-matrix.md b/oikos/cards/service-matrix.md new file mode 100644 index 0000000..6d109a4 --- /dev/null +++ b/oikos/cards/service-matrix.md @@ -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) diff --git a/oikos/cards/service-nextcloud.md b/oikos/cards/service-nextcloud.md new file mode 100644 index 0000000..88d1df8 --- /dev/null +++ b/oikos/cards/service-nextcloud.md @@ -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) diff --git a/oikos/cards/service-paperless.md b/oikos/cards/service-paperless.md new file mode 100644 index 0000000..7f0c516 --- /dev/null +++ b/oikos/cards/service-paperless.md @@ -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) diff --git a/oikos/cards/service-photos.md b/oikos/cards/service-photos.md new file mode 100644 index 0000000..76b64d8 --- /dev/null +++ b/oikos/cards/service-photos.md @@ -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) diff --git a/oikos/cards/service-proxmox_ui.md b/oikos/cards/service-proxmox_ui.md new file mode 100644 index 0000000..4c394ee --- /dev/null +++ b/oikos/cards/service-proxmox_ui.md @@ -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) diff --git a/oikos/cards/service-secrets_issuance.md b/oikos/cards/service-secrets_issuance.md new file mode 100644 index 0000000..6ceb33e --- /dev/null +++ b/oikos/cards/service-secrets_issuance.md @@ -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) diff --git a/oikos/cards/service-trmnl.md b/oikos/cards/service-trmnl.md new file mode 100644 index 0000000..690d35e --- /dev/null +++ b/oikos/cards/service-trmnl.md @@ -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) diff --git a/oikos/cards/service-zimaos.md b/oikos/cards/service-zimaos.md new file mode 100644 index 0000000..f834747 --- /dev/null +++ b/oikos/cards/service-zimaos.md @@ -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) diff --git a/oikos/gen-topology.py b/oikos/gen-topology.py index 676c02d..473810b 100644 --- a/oikos/gen-topology.py +++ b/oikos/gen-topology.py @@ -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/.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 = ( "\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: - print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr) - return 1 - OUTPUT.write_text(content) - print(f"wrote {OUTPUT.relative_to(REPO)}") + if topology_changed: + print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr) + 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 diff --git a/oikos/ledger.py b/oikos/ledger.py new file mode 100644 index 0000000..a867dd2 --- /dev/null +++ b/oikos/ledger.py @@ -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/.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 [--verification ...] [--result ...] + python3 oikos/ledger.py history [--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()) diff --git a/oikos/policy.py b/oikos/policy.py new file mode 100644 index 0000000..9e04a2e --- /dev/null +++ b/oikos/policy.py @@ -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 ` 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 diff --git a/oikos/relations.py b/oikos/relations.py new file mode 100644 index 0000000..df1ee3e --- /dev/null +++ b/oikos/relations.py @@ -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)] diff --git a/runbooks/client-enrollment.md b/runbooks/client-enrollment.md new file mode 100644 index 0000000..341bc64 --- /dev/null +++ b/runbooks/client-enrollment.md @@ -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 ` — appends a + `hosts.:` 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 + --finalize-pubkey ` — 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/.yaml`). diff --git a/runbooks/config-change-deploy.md b/runbooks/config-change-deploy.md new file mode 100644 index 0000000..8767f47 --- /dev/null +++ b/runbooks/config-change-deploy.md @@ -0,0 +1,34 @@ +--- +name: config-change-deploy +risk_class: config_mutation +inputs: [service_name, change_description] +verification: "curl -sf (or homelab service 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 ` — 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 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. diff --git a/runbooks/incident-investigation.md b/runbooks/incident-investigation.md new file mode 100644 index 0000000..3d8a36f --- /dev/null +++ b/runbooks/incident-investigation.md @@ -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 explain` (or `homelab node 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 health` + `homelab service 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 + 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 history` (once + populated) or grep `ledger/*.jsonl`. +6. Write findings to a new `investigations/-.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. diff --git a/runbooks/lifecycle-activate-node.md b/runbooks/lifecycle-activate-node.md new file mode 100644 index 0000000..dd3772b --- /dev/null +++ b/runbooks/lifecycle-activate-node.md @@ -0,0 +1,36 @@ +--- +name: lifecycle-activate-node +risk_class: config_mutation +inputs: [node_name] +verification: "homelab service 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 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: 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/.yaml`, the topology diagram, and +the context card all reflect the new state. diff --git a/runbooks/lifecycle-deprecate-node.md b/runbooks/lifecycle-deprecate-node.md new file mode 100644 index 0000000..b667494 --- /dev/null +++ b/runbooks/lifecycle-deprecate-node.md @@ -0,0 +1,35 @@ +--- +name: lifecycle-deprecate-node +risk_class: config_mutation +inputs: [node_name, replacement_node_or_reason] +verification: "homelab node 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 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 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). diff --git a/runbooks/lifecycle-destroy-node.md b/runbooks/lifecycle-destroy-node.md new file mode 100644 index 0000000..ffd4963 --- /dev/null +++ b/runbooks/lifecycle-destroy-node.md @@ -0,0 +1,42 @@ +--- +name: lifecycle-destroy-node +risk_class: destructive +inputs: [node_name] +verification: "homelab node 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 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 ` — 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.:` 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: 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. diff --git a/runbooks/lifecycle-migrate-node.md b/runbooks/lifecycle-migrate-node.md new file mode 100644 index 0000000..da1c82a --- /dev/null +++ b/runbooks/lifecycle-migrate-node.md @@ -0,0 +1,39 @@ +--- +name: lifecycle-migrate-node +risk_class: config_mutation +inputs: [node_name, source_host, target_host] +verification: "homelab node relations (re-check blast radius); homelab service 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 ` — 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 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`. diff --git a/runbooks/lifecycle-provision-node.md b/runbooks/lifecycle-provision-node.md new file mode 100644 index 0000000..1f0fb6b --- /dev/null +++ b/runbooks/lifecycle-provision-node.md @@ -0,0 +1,33 @@ +--- +name: lifecycle-provision-node +risk_class: config_mutation +inputs: [node_name, kind, storage_pool] +verification: "grep 'state: provisioning' hosts/.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 ` for anything that + will run the `homelab` CLI, or a direct `hosts.:` 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/-.md` or + `vms/-.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). diff --git a/runbooks/service-health-check.md b/runbooks/service-health-check.md new file mode 100644 index 0000000..4434bbf --- /dev/null +++ b/runbooks/service-health-check.md @@ -0,0 +1,30 @@ +--- +name: service-health-check +risk_class: read_only +inputs: [service_name] +verification: "homelab service health" +docs_update_checklist: [] +--- + +# Service health check + +Goal: determine whether a service is actually healthy, without ad-hoc SSH. + +1. `homelab service explain` — read the context card: backend, + blast radius, doc pointer, risk notes. +2. `homelab service 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 log` (or MCP `tail_log`) for the + last 200 lines. +4. Cross-check blast radius: `homelab node 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.