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.
|
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 type — how this file gets loaded
|
||||||
|
|
||||||
| Agent | Loading mechanism |
|
| Agent | Loading mechanism |
|
||||||
|
|||||||
167
bin/homelab
167
bin/homelab
@@ -37,6 +37,17 @@ INVENTORY = CONTEXT / "inventory.yaml"
|
|||||||
HOSTS_DIR = CONTEXT / "hosts"
|
HOSTS_DIR = CONTEXT / "hosts"
|
||||||
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
|
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 ----------
|
# ---------- helpers ----------
|
||||||
|
|
||||||
@@ -172,6 +183,23 @@ def service_backend_host(name: str) -> str:
|
|||||||
return service(name)["backend"]
|
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:
|
def push_inventory(message: str, extra_paths: list[str] | None = None) -> None:
|
||||||
"""Stage + commit + push inventory + regenerated hosts/ (+ any extras)."""
|
"""Stage + commit + push inventory + regenerated hosts/ (+ any extras)."""
|
||||||
subprocess.run(["python3", str(CONTEXT / "mcp" / "build_host_files.py")],
|
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}?"):
|
if not confirm(f"restart systemd unit '{unit}' on {host_name}?"):
|
||||||
return 1
|
return 1
|
||||||
base = ssh_base(host_name)
|
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:
|
def cmd_open(args: argparse.Namespace) -> int:
|
||||||
@@ -1103,9 +1136,11 @@ def cmd_client_add(args: argparse.Namespace) -> int:
|
|||||||
print("granting hermes-only secrets...")
|
print("granting hermes-only secrets...")
|
||||||
_grant_shared_secrets(pubkey, HERMES_SECRETS)
|
_grant_shared_secrets(pubkey, HERMES_SECRETS)
|
||||||
commit_subject = f"client-add: {name} (finalize age_pubkey + grant shared + 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(
|
push_inventory(
|
||||||
commit_subject,
|
commit_subject,
|
||||||
extra_paths=[".sops.yaml", "secrets/"],
|
extra_paths=[".sops.yaml", "secrets/", "ledger/"],
|
||||||
)
|
)
|
||||||
print(f"finalized {name}.")
|
print(f"finalized {name}.")
|
||||||
return 0
|
return 0
|
||||||
@@ -1167,9 +1202,11 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
|
|||||||
print(f" issuance revoke failed: {e}")
|
print(f" issuance revoke failed: {e}")
|
||||||
|
|
||||||
# 4. Commit + push (extras: .sops.yaml + secrets/ may also have changed).
|
# 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(
|
push_inventory(
|
||||||
f"client-remove: {name}",
|
f"client-remove: {name}",
|
||||||
extra_paths=[".sops.yaml", "secrets/"],
|
extra_paths=[".sops.yaml", "secrets/", "ledger/"],
|
||||||
)
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
@@ -1182,6 +1219,112 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
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:
|
def cmd_nuke(args: argparse.Namespace) -> int:
|
||||||
name = args.name
|
name = args.name
|
||||||
if not args.yes:
|
if not args.yes:
|
||||||
@@ -1554,6 +1697,24 @@ def main() -> int:
|
|||||||
help="skip the pre-flight dpkg-audit gate AND proceed past snapshot failures")
|
help="skip the pre-flight dpkg-audit gate AND proceed past snapshot failures")
|
||||||
sp.set_defaults(func=cmd_apt_upgrade)
|
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 = sub.add_parser("nuke", help="shred /etc/age/key.txt + /opt/homelab-context on a host")
|
||||||
sp.add_argument("name")
|
sp.add_argument("name")
|
||||||
sp.add_argument("--yes", "-y", action="store_true")
|
sp.add_argument("--yes", "-y", action="store_true")
|
||||||
|
|||||||
@@ -20,16 +20,27 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
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"))
|
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
|
||||||
INVENTORY = CONTEXT_DIR / "inventory.yaml"
|
INVENTORY = CONTEXT_DIR / "inventory.yaml"
|
||||||
HOSTS_DIR = CONTEXT_DIR / "hosts"
|
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
|
# All management tools proxy through hubris (the Proxmox host) via a single
|
||||||
# restricted-shell SSH connection. The wrapper at mcp/mcp-reader-shell on
|
# restricted-shell SSH connection. The wrapper at mcp/mcp-reader-shell on
|
||||||
# hubris validates each command against a strict read-only allowlist.
|
# 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())
|
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()
|
@mcp.tool()
|
||||||
def list_my_secrets(caller_pubkey: str) -> list[str]:
|
def list_my_secrets(caller_pubkey: str) -> list[str]:
|
||||||
"""Return the names of secrets the caller (identified by age pubkey) can decrypt.
|
"""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
|
#!/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:
|
Views:
|
||||||
1. Compute & ingress — hypervisors → guests → services → public URLs
|
1. Compute & ingress — hypervisors → guests → services → public URLs
|
||||||
2. Storage — mounts and pools per guest
|
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:
|
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
|
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
|
Wired into the same regeneration path as mcp/build_host_files.py so the
|
||||||
diagrams never drift from inventory. Edges follow oikos/ontology.yaml
|
diagrams and cards never drift from inventory. Edges follow
|
||||||
(hosts, provides, routes-to, mounts, stores-on).
|
oikos/ontology.yaml (hosts, provides, routes-to, mounts, stores-on).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -28,8 +35,14 @@ except ImportError: # pragma: no cover
|
|||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
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"
|
INVENTORY = REPO / "inventory.yaml"
|
||||||
OUTPUT = REPO / "infrastructure" / "topology.md"
|
OUTPUT = REPO / "infrastructure" / "topology.md"
|
||||||
|
CARDS_DIR = REPO / "oikos" / "cards"
|
||||||
|
|
||||||
BANNER = (
|
BANNER = (
|
||||||
"<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->\n"
|
"<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->\n"
|
||||||
@@ -128,6 +141,125 @@ def archaeology_table(inv: dict) -> list[str]:
|
|||||||
return lines
|
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:
|
def render(inv: dict) -> str:
|
||||||
hosts = inv.get("hosts", {})
|
hosts = inv.get("hosts", {})
|
||||||
services = inv.get("services", {})
|
services = inv.get("services", {})
|
||||||
@@ -164,13 +296,21 @@ def main() -> int:
|
|||||||
inv = yaml.safe_load(INVENTORY.read_text())
|
inv = yaml.safe_load(INVENTORY.read_text())
|
||||||
content = render(inv)
|
content = render(inv)
|
||||||
existing = OUTPUT.read_text() if OUTPUT.exists() else ""
|
existing = OUTPUT.read_text() if OUTPUT.exists() else ""
|
||||||
if existing == content:
|
topology_changed = existing != content
|
||||||
return 0
|
card_diffs = write_cards(inv, check=args.check)
|
||||||
|
|
||||||
if args.check:
|
if args.check:
|
||||||
print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr)
|
if topology_changed:
|
||||||
return 1
|
print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr)
|
||||||
OUTPUT.write_text(content)
|
if card_diffs:
|
||||||
print(f"wrote {OUTPUT.relative_to(REPO)}")
|
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
|
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