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:
13
HERMES.md
13
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 |
|
||||
|
||||
167
bin/homelab
167
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")
|
||||
|
||||
@@ -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
0
oikos/__init__.py
Normal file
21
oikos/cards/host-apps.md
Normal file
21
oikos/cards/host-apps.md
Normal 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)
|
||||
20
oikos/cards/host-arriman.md
Normal file
20
oikos/cards/host-arriman.md
Normal 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)
|
||||
18
oikos/cards/host-auth-outpost.md
Normal file
18
oikos/cards/host-auth-outpost.md
Normal 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
19
oikos/cards/host-caddy.md
Normal 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
19
oikos/cards/host-dns.md
Normal 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)
|
||||
19
oikos/cards/host-elementsynapse.md
Normal file
19
oikos/cards/host-elementsynapse.md
Normal 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
20
oikos/cards/host-gitea.md
Normal 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)
|
||||
20
oikos/cards/host-grimmory.md
Normal file
20
oikos/cards/host-grimmory.md
Normal 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
19
oikos/cards/host-haos.md
Normal 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
19
oikos/cards/host-house.md
Normal 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)
|
||||
20
oikos/cards/host-hubris.md
Normal file
20
oikos/cards/host-hubris.md
Normal 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)
|
||||
20
oikos/cards/host-jellyfin.md
Normal file
20
oikos/cards/host-jellyfin.md
Normal 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)
|
||||
17
oikos/cards/host-mac-mini.md
Normal file
17
oikos/cards/host-mac-mini.md
Normal 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)
|
||||
20
oikos/cards/host-mule-images.md
Normal file
20
oikos/cards/host-mule-images.md
Normal 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)
|
||||
17
oikos/cards/host-netbird-vps.md
Normal file
17
oikos/cards/host-netbird-vps.md
Normal 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)
|
||||
20
oikos/cards/host-nextcloud.md
Normal file
20
oikos/cards/host-nextcloud.md
Normal 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)
|
||||
18
oikos/cards/host-nfs-export.md
Normal file
18
oikos/cards/host-nfs-export.md
Normal 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)
|
||||
20
oikos/cards/host-paperless.md
Normal file
20
oikos/cards/host-paperless.md
Normal 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)
|
||||
17
oikos/cards/host-rclone.md
Normal file
17
oikos/cards/host-rclone.md
Normal 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)
|
||||
16
oikos/cards/host-republic-laptop.md
Normal file
16
oikos/cards/host-republic-laptop.md
Normal 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
19
oikos/cards/host-romm.md
Normal 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)
|
||||
19
oikos/cards/host-seanime.md
Normal file
19
oikos/cards/host-seanime.md
Normal 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)
|
||||
19
oikos/cards/host-sophia.md
Normal file
19
oikos/cards/host-sophia.md
Normal 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)
|
||||
19
oikos/cards/host-strong.md
Normal file
19
oikos/cards/host-strong.md
Normal 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
19
oikos/cards/host-trmnl.md
Normal 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)
|
||||
19
oikos/cards/host-zimaos.md
Normal file
19
oikos/cards/host-zimaos.md
Normal 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)
|
||||
17
oikos/cards/service-arr_stack.md
Normal file
17
oikos/cards/service-arr_stack.md
Normal 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)
|
||||
20
oikos/cards/service-artifacto.md
Normal file
20
oikos/cards/service-artifacto.md
Normal 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)
|
||||
19
oikos/cards/service-authentik.md
Normal file
19
oikos/cards/service-authentik.md
Normal 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)
|
||||
20
oikos/cards/service-caddy.md
Normal file
20
oikos/cards/service-caddy.md
Normal 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)
|
||||
18
oikos/cards/service-dns.md
Normal file
18
oikos/cards/service-dns.md
Normal 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)
|
||||
21
oikos/cards/service-gitea.md
Normal file
21
oikos/cards/service-gitea.md
Normal 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)
|
||||
17
oikos/cards/service-haos.md
Normal file
17
oikos/cards/service-haos.md
Normal 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)
|
||||
21
oikos/cards/service-homelab_mcp.md
Normal file
21
oikos/cards/service-homelab_mcp.md
Normal 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)
|
||||
19
oikos/cards/service-jellyfin.md
Normal file
19
oikos/cards/service-jellyfin.md
Normal 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)
|
||||
19
oikos/cards/service-matrix.md
Normal file
19
oikos/cards/service-matrix.md
Normal 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)
|
||||
18
oikos/cards/service-nextcloud.md
Normal file
18
oikos/cards/service-nextcloud.md
Normal 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)
|
||||
19
oikos/cards/service-paperless.md
Normal file
19
oikos/cards/service-paperless.md
Normal 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)
|
||||
20
oikos/cards/service-photos.md
Normal file
20
oikos/cards/service-photos.md
Normal 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)
|
||||
19
oikos/cards/service-proxmox_ui.md
Normal file
19
oikos/cards/service-proxmox_ui.md
Normal 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)
|
||||
21
oikos/cards/service-secrets_issuance.md
Normal file
21
oikos/cards/service-secrets_issuance.md
Normal 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)
|
||||
20
oikos/cards/service-trmnl.md
Normal file
20
oikos/cards/service-trmnl.md
Normal 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)
|
||||
18
oikos/cards/service-zimaos.md
Normal file
18
oikos/cards/service-zimaos.md
Normal 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)
|
||||
@@ -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:
|
||||
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
|
||||
|
||||
|
||||
|
||||
109
oikos/ledger.py
Normal file
109
oikos/ledger.py
Normal 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
58
oikos/policy.py
Normal 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
131
oikos/relations.py
Normal 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)]
|
||||
35
runbooks/client-enrollment.md
Normal file
35
runbooks/client-enrollment.md
Normal 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`).
|
||||
34
runbooks/config-change-deploy.md
Normal file
34
runbooks/config-change-deploy.md
Normal 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.
|
||||
34
runbooks/incident-investigation.md
Normal file
34
runbooks/incident-investigation.md
Normal 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.
|
||||
36
runbooks/lifecycle-activate-node.md
Normal file
36
runbooks/lifecycle-activate-node.md
Normal 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.
|
||||
35
runbooks/lifecycle-deprecate-node.md
Normal file
35
runbooks/lifecycle-deprecate-node.md
Normal 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).
|
||||
42
runbooks/lifecycle-destroy-node.md
Normal file
42
runbooks/lifecycle-destroy-node.md
Normal 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.
|
||||
39
runbooks/lifecycle-migrate-node.md
Normal file
39
runbooks/lifecycle-migrate-node.md
Normal 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`.
|
||||
33
runbooks/lifecycle-provision-node.md
Normal file
33
runbooks/lifecycle-provision-node.md
Normal 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).
|
||||
30
runbooks/service-health-check.md
Normal file
30
runbooks/service-health-check.md
Normal 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.
|
||||
Reference in New Issue
Block a user