Files
oikos/oikos/drift.py
dtoro 8a6422bd7d docs: move narrative wiki under knowledge/wiki/ (phase 3)
Problem: node and cross-cutting narratives lived at the repo root
(containers/, vms/, infrastructure/, host .md files), interleaved with the
machine-readable substrate.

Change:
- Move containers/ -> knowledge/wiki/containers/, vms/ -> knowledge/wiki/vms/,
  infrastructure/ -> knowledge/wiki/infrastructure/, hosts/{hubris,strong}.md ->
  knowledge/wiki/hosts/, infrastructure/references/ -> knowledge/sources/references/,
  GLOSSARY.md -> knowledge/GLOSSARY.md.
- Add knowledge/{index.md,log.md,sources/index.md} scaffolding.
- Rewrite all relative links repo-wide via a path-resolving mapper (inbound +
  outbound + between-moved-files), including .hermes/, runbooks, operations,
  investigations, plans, README, AGENTS.
- Repoint inventory.yaml doc_page fields and regenerate hosts/*.yaml (which
  embed doc_page); update oikos/gen-topology.py output path, candidate doc
  paths, and footer links; update code-comment doc paths.

Substrate untouched in place: inventory.yaml, hosts/*.yaml (regenerated,
idempotent), oikos/ code, mcp/, secrets/, bin/.

Verification:
- Logical broken-link set identical to pre-move baseline (net 128 -> 127; the
  topology regen fixed one, introduced none). Remaining are pre-existing refs
  to destroyed/archived nodes, out of scope for this move.
- gen-topology.py --check exit 0 (in sync); cards carry knowledge/wiki/ doc paths.
- build_host_files.py idempotent; all inventory doc_page targets resolve.
- MCP contract verified: get_page/search_docs/get_changelog resolve moved pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:35:23 +02:00

303 lines
14 KiB
Python

#!/usr/bin/env python3
"""oikos/drift.py — drift detectors (Week 3 reliability layer).
Each detector returns a list of finding dicts:
{kind, severity, entity, evidence, likely_cause, recommended_action,
verification}
matching the Signal schema in oikos/signal.py. `run_all()` runs every
detector and raises a Signal for each finding, skipping ones that already
have an open Signal for the same (entity, kind) — see
oikos/signal.py open_signal_for(). SSH-based detectors degrade to a
"probe-unreachable" info finding instead of a false drift signal when the
target can't be reached (no ssh, no network) — drift means "state
disagrees," not "couldn't check."
Two detectors are fully local (no SSH, always safe to run from anywhere
with the repo checked out): SOPS-recipient-vs-inventory and lifecycle
consistency. Two need live access to hubris (pct list, caddy backend
config) and gracefully no-op when unreachable.
Not yet implemented — needs data this repo doesn't structure yet:
- DNS records vs inventory services (no Technitium API wiring here)
- Generic tracked-config-repo cleanliness for every service (needs the
`mutation_path`/local-checkout-path field per service; only caddy's
path is documented today, so that's the one config-cleanliness check
implemented below)
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from oikos import relations as oikos_relations # noqa: E402
from oikos import signal as oikos_signal # noqa: E402
import yaml # noqa: E402
SOPS_FILE = REPO / ".sops.yaml"
INVENTORY = REPO / "inventory.yaml"
_AGE_RE = re.compile(r"age1[a-z0-9]+")
def _load_inventory() -> dict:
return yaml.safe_load(INVENTORY.read_text())
def _sops_rules() -> list[dict]:
"""Parse .sops.yaml's creation_rules: [{path_regex, recipients: [...]}]."""
doc = yaml.safe_load(SOPS_FILE.read_text())
return [
{"path_regex": r.get("path_regex", ""),
"recipients": _AGE_RE.findall(r.get("age", "") or "")}
for r in doc.get("creation_rules", [])
]
def check_sops_recipients(inv: dict | None = None) -> list[dict]:
"""Two directions of drift:
1. A recipient pubkey in .sops.yaml that matches no host's age_pubkey
in inventory — likely a removed/revoked host whose secrets weren't
fully cleaned up (or a stale rule).
2. An enrolled host (non-empty age_pubkey) missing from the
secrets/hello.yaml rule — every enrolled client should be able to
decrypt the bootstrap canary; absence usually means enrollment
stalled partway.
"""
inv = inv or _load_inventory()
hosts = inv.get("hosts", {})
known_pubkeys = {e["age_pubkey"]: name for name, e in hosts.items()
if e.get("age_pubkey")}
rules = _sops_rules()
findings = []
all_recipients = {pk for r in rules for pk in r["recipients"]}
for pk in all_recipients:
if pk not in known_pubkeys:
findings.append({
"kind": "sops-orphan-recipient", "severity": "warning",
"entity": "repo:Homelab-Docs",
"evidence": f"age recipient {pk[:20]}... appears in .sops.yaml but "
f"matches no host's age_pubkey in inventory.yaml",
"likely_cause": "host removed without full secret revocation, or a stale rule",
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
"verification": "grep <pubkey> .sops.yaml; check inventory.yaml + archaeology",
})
hello_rule = next((r for r in rules if "hello" in r["path_regex"]), None)
if hello_rule:
for pubkey, name in known_pubkeys.items():
if pubkey not in hello_rule["recipients"]:
findings.append({
"kind": "sops-missing-recipient", "severity": "warning",
"entity": f"host:{name}",
"evidence": f"{name}'s age_pubkey is not a recipient of secrets/hello.yaml",
"likely_cause": "client-add --finalize-pubkey ran incompletely",
"recommended_action": {"runbook": "client-enrollment", "risk": "config_mutation"},
"verification": f"homelab client add {name} --finalize-pubkey <key>",
})
return findings
def check_lifecycle_consistency(inv: dict | None = None) -> list[dict]:
"""state: vs reality, using the ontology graph (oikos/relations.py) —
no SSH needed, this only reasons over inventory.yaml itself."""
inv = inv or _load_inventory()
hosts = inv.get("hosts", {})
archaeology = inv.get("archaeology", {})
findings = []
for name, e in hosts.items():
state = e.get("state", "active")
if state == "deprecated":
rel = oikos_relations.relations(f"host:{name}", inv)
if rel["affected_by"]:
findings.append({
"kind": "lifecycle-inconsistent", "severity": "warning",
"entity": f"host:{name}",
"evidence": f"deprecated but still has inbound edges: {', '.join(rel['affected_by'])}",
"likely_cause": "deprecation started before dependents were migrated off",
"recommended_action": {"runbook": "lifecycle-deprecate-node", "risk": "config_mutation"},
"verification": f"homelab node {name} relations",
})
elif state == "destroyed":
findings.append({
"kind": "lifecycle-inconsistent", "severity": "critical",
"entity": f"host:{name}",
"evidence": "state is 'destroyed' but the host still has a live hosts.<name>: entry "
"(destroyed nodes belong in the archaeology: section, not hosts:)",
"likely_cause": "lifecycle-destroy-node runbook step 6 (move to archaeology) was skipped",
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
"verification": "grep -A3 '^ " + name + ":' inventory.yaml",
})
# A pve_id collision between an active host and an archaeology entry on
# the same Proxmox node usually means the ID was reused without the old
# entry's data being fully accounted for.
active_by_id = {(e.get("host"), e.get("pve_id")): name
for name, e in hosts.items() if e.get("pve_id")}
for aname, ae in archaeology.items():
key = (None, ae.get("pve_id")) # archaeology doesn't record which node
for (host, pve_id), name in active_by_id.items():
if pve_id == ae.get("pve_id") and name != aname:
findings.append({
"kind": "lifecycle-pve-id-reuse", "severity": "info",
"entity": f"host:{name}",
"evidence": f"pve_id {pve_id} is both active ({name}) and in archaeology ({aname}, "
f"destroyed {ae.get('destroyed', '?')})",
"likely_cause": "normal ID reuse after destroy — informational only",
"recommended_action": None,
"verification": None,
})
return findings
def _ssh_hubris(cmd: list[str]) -> subprocess.CompletedProcess | None:
"""Best-effort ssh to hubris. Returns None (not a CompletedProcess with
nonzero rc) if ssh itself can't run/connect at all, so callers can
distinguish "unreachable" from "reachable but the command failed."""
try:
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"root@192.168.8.77", " ".join(cmd)],
capture_output=True, text=True, timeout=15)
except (subprocess.TimeoutExpired, OSError):
return None
def check_pct_list(inv: dict | None = None) -> list[dict]:
"""Inventory LXCs/VMs on hubris vs live `pct list` + `qm list`."""
inv = inv or _load_inventory()
hosts = inv.get("hosts", {})
proc = _ssh_hubris(["pct", "list"])
if proc is None or proc.returncode != 0:
return [{
"kind": "probe-unreachable", "severity": "info", "entity": "host:hubris",
"evidence": "could not reach hubris to run `pct list`",
"likely_cause": "no network path from this client to hubris, or ssh key not authorized",
"recommended_action": None, "verification": "homelab ssh hubris -- pct list",
}]
live_ids = set()
for line in proc.stdout.splitlines()[1:]:
parts = line.split()
if parts and parts[0].isdigit():
live_ids.add(int(parts[0]))
inventory_ids = {e["pve_id"] for name, e in hosts.items()
if e.get("kind") == "lxc" and e.get("host") == "hubris" and e.get("pve_id")}
findings = []
for missing in sorted(inventory_ids - live_ids):
name = next(n for n, e in hosts.items() if e.get("pve_id") == missing)
findings.append({
"kind": "inventory-vs-live", "severity": "critical", "entity": f"host:{name}",
"evidence": f"pve_id {missing} ({name}) is in inventory.yaml but absent from "
f"`pct list` on hubris",
"likely_cause": "destroyed outside the lifecycle-destroy-node runbook, or migrated "
"without updating inventory",
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
"verification": "homelab ssh hubris -- pct list",
})
for extra in sorted(live_ids - inventory_ids):
findings.append({
"kind": "inventory-vs-live", "severity": "warning", "entity": "host:hubris",
"evidence": f"pve_id {extra} exists on hubris (`pct list`) but has no inventory.yaml entry",
"likely_cause": "created outside the provision-node runbook",
"recommended_action": {"runbook": "lifecycle-provision-node", "risk": "config_mutation"},
"verification": "homelab ssh hubris -- pct config " + str(extra),
})
return findings
def check_caddy_backends(inv: dict | None = None) -> list[dict]:
"""Caddy's /etc/caddy (a git checkout of dtoro/caddy-conf, per
knowledge/wiki/containers/121-caddy.md) vs inventory service backend IPs. Best-effort
grep for reverse_proxy targets; skips services whose Caddyfile snippet
doesn't use a bare IP (e.g. references a Caddy snippet/import)."""
inv = inv or _load_inventory()
hosts = inv.get("hosts", {})
services = inv.get("services", {})
caddy_ip = hosts.get("caddy", {}).get("lan_ip")
if not caddy_ip:
return []
try:
proc = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", f"root@{caddy_ip}",
"grep -rhoE 'reverse_proxy[^{]*' /etc/caddy/ 2>/dev/null"],
capture_output=True, text=True, timeout=15,
)
except (subprocess.TimeoutExpired, OSError):
proc = None
if proc is None or proc.returncode != 0:
return [{
"kind": "probe-unreachable", "severity": "info", "entity": "host:caddy",
"evidence": "could not reach caddy to inspect /etc/caddy",
"likely_cause": "no network path from this client to caddy, or ssh key not authorized",
"recommended_action": None, "verification": "homelab ssh caddy -- grep -r reverse_proxy /etc/caddy",
}]
live_ips = set(re.findall(r"\d+\.\d+\.\d+\.\d+", proc.stdout))
findings = []
for svc, e in services.items():
if not isinstance(e, dict):
continue
backend = e.get("backend")
if not backend or not (e.get("url") or e.get("public_host")):
continue
backend_ip = hosts.get(backend, {}).get("lan_ip")
if backend_ip and live_ips and backend_ip not in live_ips:
findings.append({
"kind": "caddy-backend-mismatch", "severity": "warning", "entity": f"service:{svc}",
"evidence": f"inventory backend IP {backend_ip} ({backend}) not found in any "
f"reverse_proxy directive on caddy",
"likely_cause": "backend migrated (lan_ip changed) without updating the Caddyfile, "
"or the service uses a snippet/import this grep can't resolve",
"recommended_action": {"runbook": "config-change-deploy", "risk": "config_mutation"},
"verification": f"homelab service {svc} health",
})
return findings
DETECTORS = [check_sops_recipients, check_lifecycle_consistency,
check_pct_list, check_caddy_backends]
def run_all(inv: dict | None = None, *, raise_signals: bool = False) -> list[dict]:
inv = inv or _load_inventory()
findings: list[dict] = []
for detector in DETECTORS:
findings.extend(detector(inv))
if raise_signals:
for f in findings:
if oikos_signal.open_signal_for(f["entity"], f["kind"]) is not None:
continue # already open, don't duplicate
oikos_signal.raise_signal(
f["kind"], f["severity"], f["entity"], f["evidence"],
likely_cause=f.get("likely_cause"),
recommended_action=f.get("recommended_action"),
verification=f.get("verification"),
)
return findings
def main() -> int:
import argparse
import json
p = argparse.ArgumentParser(description="oikos drift detectors")
p.add_argument("--raise-signals", action="store_true",
help="raise a Signal for each new finding (default: print only)")
args = p.parse_args()
findings = run_all(raise_signals=args.raise_signals)
for f in findings:
print(json.dumps(f))
print(f"\n{len(findings)} finding(s)", file=sys.stderr)
return 1 if any(f["severity"] == "critical" for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())