db as source of truth: wiki→seeds, archive old artifacts, knowledge ingestion
- Migrations 010 (content_hash) + 011 (search tsvector column) - new: internal/knowledge/seed.go — knowledge seed ingest engine - new: internal/httpapi/knowledge.go — SearchKnowledge + GetEntityKnowledge - wire knowledge ingest into oikos seed pipeline - convert all 36 wiki docs + 6 investigations + 12 runbooks → seeds/knowledge.yaml - archive: knowledge/wiki/→archive/, oikos/cards/→archive/, .hermes/plans/→archive/ - delete: 9 superseded Python kernel files, ledger/, mcp/build_host_files.py - remove empty knowledge/ directory tree
This commit is contained in:
302
oikos/approve.py
302
oikos/approve.py
@@ -1,302 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""oikos/approve.py — the approval engine (escalate route of the OODA loop).
|
||||
|
||||
Repo-side half of the Week-3 approval flow. This module owns the request/
|
||||
grant lifecycle and the HMAC signing; it does NOT talk to Matrix directly.
|
||||
There is no dedicated Matrix bot in this homelab — alerts already go out
|
||||
as the operator's own Hermes agent posting to @dtoro:avispero (see
|
||||
knowledge/wiki/infrastructure/monitoring.md's homelab-health-watchdog). The integration
|
||||
contract is:
|
||||
|
||||
1. An agent or the Week-3 scheduler calls `request()` (or the CLI
|
||||
`request` subcommand) to open an approval. This prints the Matrix-
|
||||
ready message text (evidence, options, reply format).
|
||||
2. Hermes posts that text to Matrix using its existing send capability
|
||||
(the same one homelab-health-watchdog already uses) and later reads
|
||||
the operator's reply/reaction.
|
||||
3. Hermes calls back `reply()` (or `oikos/approve.py reply <id>
|
||||
approve|deny`) with the operator's decision. This module verifies
|
||||
`requires_phrase` for destructive actions, then issues a short-TTL
|
||||
HMAC-signed grant.
|
||||
4. Mutating `homelab` commands call `check_grant()` before executing a
|
||||
config_mutation/destructive action.
|
||||
|
||||
Storage: approvals/<YYYY-MM>.jsonl, same append-only-JSONL-with-latest-
|
||||
state-wins convention as oikos/signal.py.
|
||||
|
||||
Grant signing key: secrets/oikos-approval-hmac.yaml (SOPS, recipients:
|
||||
apps + hubris — see .sops.yaml). Decrypted on demand; never cached to
|
||||
disk in plaintext.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
APPROVALS_DIR = REPO / "approvals"
|
||||
HMAC_SECRET = REPO / "secrets" / "oikos-approval-hmac.yaml"
|
||||
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
|
||||
|
||||
VALID_STATES = ("pending", "approved", "denied", "expired", "executed")
|
||||
REQUEST_TTL_HOURS = 24
|
||||
GRANT_TTL_MINUTES = 15
|
||||
|
||||
|
||||
def _month_path(dt: datetime | None = None) -> Path:
|
||||
dt = dt or datetime.now(timezone.utc)
|
||||
return APPROVALS_DIR / f"{dt.strftime('%Y-%m')}.jsonl"
|
||||
|
||||
|
||||
def _all_entries() -> list[dict]:
|
||||
if not APPROVALS_DIR.exists():
|
||||
return []
|
||||
out = []
|
||||
for path in sorted(APPROVALS_DIR.glob("*.jsonl")):
|
||||
for line in path.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _next_id(dt: datetime | None = None) -> str:
|
||||
dt = dt or datetime.now(timezone.utc)
|
||||
prefix = f"appr-{dt.strftime('%Y-%m-%d')}-"
|
||||
existing = [e["id"] for e in _all_entries() if e.get("id", "").startswith(prefix)]
|
||||
n = 1
|
||||
while f"{prefix}{n:04d}" in existing:
|
||||
n += 1
|
||||
return f"{prefix}{n:04d}"
|
||||
|
||||
|
||||
def _append(entry: dict) -> dict:
|
||||
APPROVALS_DIR.mkdir(exist_ok=True)
|
||||
entry = {k: v for k, v in entry.items() if v is not None}
|
||||
with _month_path().open("a") as f:
|
||||
f.write(json.dumps(entry, sort_keys=False) + "\n")
|
||||
return entry
|
||||
|
||||
|
||||
def current(request_id: str) -> dict | None:
|
||||
matches = [e for e in _all_entries() if e.get("id") == request_id]
|
||||
if not matches:
|
||||
return None
|
||||
matches.sort(key=lambda e: e.get("ts", ""))
|
||||
return matches[-1]
|
||||
|
||||
|
||||
def list_approvals(state: str | None = None) -> list[dict]:
|
||||
latest: dict[str, dict] = {}
|
||||
for e in sorted(_all_entries(), key=lambda e: e.get("ts", "")):
|
||||
rid = e.get("id")
|
||||
if rid:
|
||||
latest[rid] = e
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
out = []
|
||||
for e in latest.values():
|
||||
if e.get("state") == "pending" and e.get("expires_at") and e["expires_at"] < now:
|
||||
e = {**e, "state": "expired"}
|
||||
if state and e.get("state") != state:
|
||||
continue
|
||||
out.append(e)
|
||||
out.sort(key=lambda e: e.get("ts", ""), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def _matrix_message(entry: dict) -> str:
|
||||
lines = [
|
||||
f"[Oikos approval {entry['id']}] {entry['entity']} — {entry['action']}",
|
||||
f"risk: {entry['risk']}",
|
||||
f"evidence: {entry['evidence']}",
|
||||
]
|
||||
if entry.get("verification"):
|
||||
lines.append(f"verification: {entry['verification']}")
|
||||
if entry.get("requires_phrase"):
|
||||
lines.append(f'reply: "approve {entry["id"]} {entry["confirmation_phrase"]}" or "deny {entry["id"]}"')
|
||||
else:
|
||||
lines.append(f'reply: ✅ to approve, ❌ to deny (or "approve {entry["id"]}" / "deny {entry["id"]}")')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def request(entity: str, action: str, risk: str, evidence: str, *,
|
||||
verification: str | None = None, signal_id: str | None = None,
|
||||
requires_phrase: bool = False, requested_by: str | None = None,
|
||||
ttl_hours: int = REQUEST_TTL_HOURS) -> dict:
|
||||
"""Open a new approval request. `requires_phrase=True` (use for
|
||||
`destructive`-class actions per oikos/policy.yaml) generates a
|
||||
confirmation phrase the operator must echo back — a reaction alone
|
||||
can't authorize it."""
|
||||
rid = _next_id()
|
||||
now = datetime.now(timezone.utc)
|
||||
phrase = f"{action} {entity}" if requires_phrase else None
|
||||
entry = {
|
||||
"id": rid,
|
||||
"ts": now.isoformat(timespec="seconds"),
|
||||
"entity": entity,
|
||||
"action": action,
|
||||
"risk": risk,
|
||||
"evidence": evidence,
|
||||
"verification": verification,
|
||||
"signal_id": signal_id,
|
||||
"requires_phrase": requires_phrase,
|
||||
"confirmation_phrase": phrase,
|
||||
"requested_by": requested_by or os.environ.get("HOMELAB_AGENT_ID"),
|
||||
"state": "pending",
|
||||
"expires_at": (now + timedelta(hours=ttl_hours)).isoformat(timespec="seconds"),
|
||||
}
|
||||
recorded = _append(entry)
|
||||
recorded["matrix_message"] = _matrix_message(recorded)
|
||||
return recorded
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _hmac_key() -> str:
|
||||
if not HMAC_SECRET.exists():
|
||||
raise RuntimeError(f"no secret at {HMAC_SECRET} — has it been provisioned?")
|
||||
env = {**os.environ}
|
||||
if AGE_KEY.exists():
|
||||
env["SOPS_AGE_KEY_FILE"] = str(AGE_KEY)
|
||||
proc = subprocess.run(["sops", "-d", str(HMAC_SECRET)],
|
||||
capture_output=True, text=True, env=env)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"sops decrypt of oikos-approval-hmac failed (is this host a recipient?): "
|
||||
f"{proc.stderr.strip()}")
|
||||
return yaml.safe_load(proc.stdout)["hmac_key"]
|
||||
|
||||
|
||||
def _sign(request_id: str, entity: str, action: str, expires_at: str) -> str:
|
||||
key = _hmac_key().encode()
|
||||
msg = f"{request_id}:{entity}:{action}:{expires_at}".encode()
|
||||
return hmac.new(key, msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def reply(request_id: str, decision: str, *, phrase: str | None = None,
|
||||
decided_by: str | None = None) -> dict:
|
||||
"""Record the operator's decision. On approval, issues a short-TTL
|
||||
HMAC-signed grant token. Raises ValueError if a required confirmation
|
||||
phrase is missing or wrong, or if the request already expired."""
|
||||
if decision not in ("approve", "deny"):
|
||||
raise ValueError("decision must be 'approve' or 'deny'")
|
||||
entry = current(request_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"unknown approval id: {request_id}")
|
||||
if entry.get("state") != "pending":
|
||||
raise ValueError(f"{request_id} is not pending (state={entry.get('state')})")
|
||||
now_s = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
if entry.get("expires_at") and entry["expires_at"] < now_s:
|
||||
_append({**entry, "ts": now_s, "state": "expired"})
|
||||
raise ValueError(f"{request_id} expired at {entry['expires_at']}")
|
||||
|
||||
if decision == "deny":
|
||||
return _append({**entry, "ts": now_s, "state": "denied", "decided_by": decided_by})
|
||||
|
||||
if entry.get("requires_phrase"):
|
||||
if phrase != entry.get("confirmation_phrase"):
|
||||
raise ValueError(
|
||||
"confirmation phrase missing or incorrect — a reaction alone "
|
||||
"cannot approve a destructive action")
|
||||
|
||||
grant_expires = (datetime.now(timezone.utc) + timedelta(minutes=GRANT_TTL_MINUTES)) \
|
||||
.isoformat(timespec="seconds")
|
||||
grant_token = _sign(request_id, entry["entity"], entry["action"], grant_expires)
|
||||
return _append({**entry, "ts": now_s, "state": "approved", "decided_by": decided_by,
|
||||
"grant_token": grant_token, "grant_expires": grant_expires})
|
||||
|
||||
|
||||
def check_grant(request_id: str, entity: str, action: str) -> tuple[bool, str]:
|
||||
"""Verify a request carries a valid, unexpired, matching grant, AND
|
||||
consume it — a grant is exact-bound (this exact request id + entity +
|
||||
action) and single-use: this call both checks and marks it "executed"
|
||||
in the same step, so a second call for the same request_id fails with
|
||||
"not approved" even if the grant's TTL hasn't expired yet. Callers
|
||||
(homelab CLI mutating commands) must call this immediately before
|
||||
executing, exactly once."""
|
||||
entry = current(request_id)
|
||||
if entry is None:
|
||||
return False, "unknown approval id"
|
||||
if entry.get("state") != "approved":
|
||||
return False, f"not approved (state={entry.get('state')})"
|
||||
if entry.get("entity") != entity or entry.get("action") != action:
|
||||
return False, "grant does not match entity/action"
|
||||
now_s = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
if not entry.get("grant_expires") or entry["grant_expires"] < now_s:
|
||||
return False, "grant expired"
|
||||
expected = _sign(request_id, entity, action, entry["grant_expires"])
|
||||
if not hmac.compare_digest(expected, entry.get("grant_token", "")):
|
||||
return False, "grant signature invalid"
|
||||
_append({**entry, "ts": now_s, "state": "executed"})
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="oikos approval engine")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
r = sub.add_parser("request")
|
||||
r.add_argument("entity")
|
||||
r.add_argument("action")
|
||||
r.add_argument("risk")
|
||||
r.add_argument("evidence")
|
||||
r.add_argument("--verification")
|
||||
r.add_argument("--signal-id")
|
||||
r.add_argument("--requires-phrase", action="store_true")
|
||||
r.add_argument("--ttl-hours", type=int, default=REQUEST_TTL_HOURS)
|
||||
|
||||
ls = sub.add_parser("list")
|
||||
ls.add_argument("--state", choices=VALID_STATES)
|
||||
|
||||
rp = sub.add_parser("reply")
|
||||
rp.add_argument("id")
|
||||
rp.add_argument("decision", choices=["approve", "deny"])
|
||||
rp.add_argument("--phrase")
|
||||
rp.add_argument("--decided-by")
|
||||
|
||||
ck = sub.add_parser("check")
|
||||
ck.add_argument("id")
|
||||
ck.add_argument("entity")
|
||||
ck.add_argument("action")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "request":
|
||||
entry = request(args.entity, args.action, args.risk, args.evidence,
|
||||
verification=args.verification, signal_id=args.signal_id,
|
||||
requires_phrase=args.requires_phrase, ttl_hours=args.ttl_hours)
|
||||
print(json.dumps({k: v for k, v in entry.items() if k != "matrix_message"}, indent=2))
|
||||
print()
|
||||
print("--- post this to Matrix ---")
|
||||
print(entry["matrix_message"])
|
||||
elif args.cmd == "list":
|
||||
for e in list_approvals(state=args.state):
|
||||
print(json.dumps(e))
|
||||
elif args.cmd == "reply":
|
||||
try:
|
||||
entry = reply(args.id, args.decision, phrase=args.phrase, decided_by=args.decided_by)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(entry, indent=2))
|
||||
elif args.cmd == "check":
|
||||
ok, reason = check_grant(args.id, args.entity, args.action)
|
||||
print(f"{'GRANTED' if ok else 'DENIED'}: {reason}")
|
||||
return 0 if ok else 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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: knowledge/wiki/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
|
||||
- 2026-07-06T11:57:21+00:00 deploy-oikos-console (config_mutation) — ok
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,18 +0,0 @@
|
||||
# auth-outpost (host:auth-outpost)
|
||||
|
||||
- kind: lxc (LXC 106)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: authentik-gateway
|
||||
- address: 192.168.8.6
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# caddy (host:caddy)
|
||||
|
||||
- kind: lxc (LXC 121)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: reverse-proxy
|
||||
- address: 192.168.8.175
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# dns (host:dns)
|
||||
|
||||
- kind: lxc (LXC 107)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: dns-server
|
||||
- address: 192.168.8.2
|
||||
- doc: knowledge/wiki/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
|
||||
- 2026-07-06T11:40:28+00:00 add-record (config_mutation) — ok
|
||||
@@ -1,19 +0,0 @@
|
||||
# elementsynapse (host:elementsynapse)
|
||||
|
||||
- kind: lxc (LXC 118)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: matrix-server
|
||||
- address: 192.168.8.242
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# house (host:house)
|
||||
|
||||
- kind: lxc (LXC 129)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: family-planner
|
||||
- address: 192.168.8.244
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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: knowledge/wiki/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:teddycloud, 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:teddycloud, 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:teddycloud, service:trmnl, service:zimaos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,18 +0,0 @@
|
||||
# nfs-export (host:nfs-export)
|
||||
|
||||
- kind: lxc (LXC 102)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: storage-export
|
||||
- address: 192.168.8.200
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,16 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# strong (host:strong)
|
||||
|
||||
- kind: proxmox-host
|
||||
- state: active
|
||||
- role: hypervisor
|
||||
- address: 192.168.178.181
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# teddycloud (host:teddycloud)
|
||||
|
||||
- kind: lxc (LXC 131)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: teddycloud
|
||||
- address: 192.168.8.150
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/containers/131-teddycloud.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:teddycloud
|
||||
- affected by: host:hubris, mount:/mnt/library
|
||||
- full blast radius: service:teddycloud
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- 2026-07-06T11:05:35+00:00 activate (config_mutation) — ok
|
||||
@@ -1,19 +0,0 @@
|
||||
# trmnl (host:trmnl)
|
||||
|
||||
- kind: lxc (LXC 128)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: trmnl-middleware
|
||||
- address: 192.168.8.211
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# zimaos (host:zimaos)
|
||||
|
||||
- kind: vm (VM 100)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: nas-frontend-eval
|
||||
- address: 192.168.8.195
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,17 +0,0 @@
|
||||
# arr_stack (service:arr_stack)
|
||||
|
||||
- backend: host:arriman
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# artifacto (service:artifacto)
|
||||
|
||||
- backend: host:apps
|
||||
- url: https://artifacto.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# authentik (service:authentik)
|
||||
|
||||
- backend: host:netbird-vps
|
||||
- url: https://auth.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# caddy (service:caddy)
|
||||
|
||||
- backend: host:caddy
|
||||
- doc: knowledge/wiki/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
|
||||
- 2026-07-06T11:29:56+00:00 add-site-block (config_mutation) — ok
|
||||
@@ -1,18 +0,0 @@
|
||||
# dns (service:dns)
|
||||
|
||||
- backend: host:dns
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,21 +0,0 @@
|
||||
# gitea (service:gitea)
|
||||
|
||||
- backend: host:gitea
|
||||
- url: https://git.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,17 +0,0 @@
|
||||
# haos (service:haos)
|
||||
|
||||
- backend: host:haos
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,21 +0,0 @@
|
||||
# homelab_mcp (service:homelab_mcp)
|
||||
|
||||
- backend: host:apps
|
||||
- url: https://mcp.hubris.network/mcp
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# jellyfin (service:jellyfin)
|
||||
|
||||
- backend: host:jellyfin
|
||||
- url: https://media.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# matrix (service:matrix)
|
||||
|
||||
- backend: host:elementsynapse
|
||||
- url: https://matrix.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,18 +0,0 @@
|
||||
# nextcloud (service:nextcloud)
|
||||
|
||||
- backend: host:nextcloud
|
||||
- url: https://cloud.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# paperless (service:paperless)
|
||||
|
||||
- backend: host:paperless
|
||||
- url: https://paperless.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# photos (service:photos)
|
||||
|
||||
- backend: host:mule-images
|
||||
- url: https://photos.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# proxmox_ui (service:proxmox_ui)
|
||||
|
||||
- backend: host:hubris
|
||||
- url: https://proxmox.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,21 +0,0 @@
|
||||
# secrets_issuance (service:secrets_issuance)
|
||||
|
||||
- backend: host:apps
|
||||
- url: https://secrets.hubris.network/issue
|
||||
- doc: .agents/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)
|
||||
@@ -1,19 +0,0 @@
|
||||
# teddycloud (service:teddycloud)
|
||||
|
||||
- backend: host:teddycloud
|
||||
- url: https://teddy.hubris.network
|
||||
- doc: knowledge/wiki/containers/131-teddycloud.md
|
||||
- risk notes: no Caddy forward-auth gate (unlike sab.hubris.network on the same Caddyfile) — reachable to anyone on the LAN/mesh who can resolve teddy.hubris.network; undocumented in inventory.yaml until 2026-07-06 (drift-caught)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:teddycloud
|
||||
|
||||
## 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,20 +0,0 @@
|
||||
# trmnl (service:trmnl)
|
||||
|
||||
- backend: host:trmnl
|
||||
- url: https://trmnl.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
@@ -1,18 +0,0 @@
|
||||
# zimaos (service:zimaos)
|
||||
|
||||
- backend: host:zimaos
|
||||
- url: https://zimaos.hubris.network
|
||||
- doc: knowledge/wiki/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)
|
||||
140
oikos/decide.py
140
oikos/decide.py
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""oikos/decide.py — the Decide stage of the OODA loop (decision classifier).
|
||||
|
||||
Scores a proposed action against three inputs and routes it:
|
||||
|
||||
1. risk class — oikos/policy.yaml (read_only / reversible_low /
|
||||
config_mutation / destructive)
|
||||
2. blast radius — oikos/relations.py transitive impact graph
|
||||
3. confidence — prior ledger history of this exact action on this
|
||||
exact entity, falling back to "is this a
|
||||
well-known mechanical action" when there's no
|
||||
history yet
|
||||
|
||||
Routes: **auto-act** (execute — risk is within unattended policy, blast
|
||||
radius is contained, confidence isn't low) or **escalate** (request
|
||||
operator approval via oikos/approve.py — anything else). The classifier
|
||||
can only make an action MORE cautious than policy says, never less: if
|
||||
policy already requires approval, escalate always wins regardless of
|
||||
confidence or radius.
|
||||
|
||||
Note on the third OODA route, "queue": that's the Signal engine's
|
||||
info-severity routing (oikos/signal.py SEVERITY_ROUTE — informational
|
||||
findings that need no action land in the console/weekly report, not
|
||||
here). This module only classifies things that might actually need to
|
||||
run, so it only ever returns auto-act or escalate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
# Actions we consider "well-known mechanical" absent any ledger history —
|
||||
# mirrors oikos/policy.py's safe_actions_for_service() baseline.
|
||||
_ROUTINE_ACTIONS = {"restart", "service-restart", "health-check", "view-logs",
|
||||
"view-docs", "sync", "cache-clear"}
|
||||
|
||||
# A blast radius at or below this size counts as "contained" for auto-act
|
||||
# purposes. Anything wider always escalates regardless of risk/confidence.
|
||||
_CONTAINED_RADIUS = 1
|
||||
|
||||
|
||||
def _confidence(action: str, entity_id: str) -> tuple[str, str]:
|
||||
hist = oikos_ledger.history(entity_id, limit=50)
|
||||
successes = [h for h in hist if h.get("action") == action and h.get("result") == "ok"]
|
||||
failures = [h for h in hist
|
||||
if h.get("action") == action and str(h.get("result", "")).startswith("failed")]
|
||||
if failures and not successes:
|
||||
return "low", f"{len(failures)} prior failed attempt(s) of '{action}' on this entity"
|
||||
if successes:
|
||||
return "high", f"{len(successes)} prior successful run(s) of '{action}' on this entity"
|
||||
if action in _ROUTINE_ACTIONS:
|
||||
return "medium", "no history yet, but this is a well-known mechanical action"
|
||||
return "low", "no ledger history and not a recognized routine action"
|
||||
|
||||
|
||||
def classify(action: str, entity: str, *, service_name: str | None = None,
|
||||
record: bool = False) -> dict:
|
||||
"""Classify one proposed action against one entity. `entity` may be a
|
||||
bare name (resolved via oikos/relations.py) or an already-namespaced
|
||||
id ("service:x" / "host:x"). If bare and ambiguous (matches both a
|
||||
host and a service), the first resolved id is used — pass a
|
||||
namespaced id explicitly to disambiguate.
|
||||
"""
|
||||
entity_ids = oikos_relations.resolve(entity)
|
||||
entity_id = entity_ids[0]
|
||||
|
||||
action = oikos_policy.canonical_action(action)
|
||||
# Per-service policy overrides are keyed by service name (e.g. "caddy").
|
||||
# Most single-purpose nodes are named identically to the service they
|
||||
# run, so infer it from the entity's bare name when not given explicitly.
|
||||
if service_name is None:
|
||||
service_name = entity_id.split(":", 1)[1] if ":" in entity_id else entity_id
|
||||
|
||||
risk = (oikos_policy.classify_action(action, service_name)
|
||||
or oikos_policy.classify_command(action))
|
||||
if risk is None:
|
||||
risk = "config_mutation" # unknown action: default to the cautious class
|
||||
|
||||
approval = oikos_policy.approval_for(risk)
|
||||
radius = oikos_relations.blast_radius(entity_id)
|
||||
contained = len(radius) <= _CONTAINED_RADIUS
|
||||
confidence, why = _confidence(action, entity_id)
|
||||
|
||||
if approval != "none":
|
||||
route = "escalate"
|
||||
reasoning = f"risk class '{risk}' requires approval ({approval}) per oikos/policy.yaml"
|
||||
elif not contained:
|
||||
route = "escalate"
|
||||
reasoning = f"blast radius not contained ({len(radius)} entities: {', '.join(radius)})"
|
||||
elif confidence == "low":
|
||||
route = "escalate"
|
||||
reasoning = f"low confidence — {why}"
|
||||
else:
|
||||
route = "auto-act"
|
||||
reasoning = (f"risk '{risk}' is unattended-safe, blast radius contained "
|
||||
f"({radius or 'none'}), confidence {confidence} — {why}")
|
||||
|
||||
result = {
|
||||
"entity": entity_id,
|
||||
"action": action,
|
||||
"risk": risk,
|
||||
"approval": approval,
|
||||
"blast_radius": radius,
|
||||
"contained": contained,
|
||||
"confidence": confidence,
|
||||
"confidence_reason": why,
|
||||
"route": route,
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
if record:
|
||||
oikos_ledger.append(entity_id, f"decide:{action}", risk,
|
||||
result=route, notes=reasoning)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="oikos decision classifier")
|
||||
p.add_argument("action")
|
||||
p.add_argument("entity")
|
||||
p.add_argument("--service-name", help="disambiguate policy overrides for a service action")
|
||||
p.add_argument("--record", action="store_true",
|
||||
help="append this classification to the change ledger")
|
||||
args = p.parse_args()
|
||||
result = classify(args.action, args.entity, service_name=args.service_name,
|
||||
record=args.record)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result["route"] == "auto-act" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
302
oikos/drift.py
302
oikos/drift.py
@@ -1,302 +0,0 @@
|
||||
#!/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())
|
||||
109
oikos/ledger.py
109
oikos/ledger.py
@@ -1,109 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,72 +0,0 @@
|
||||
"""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)
|
||||
|
||||
|
||||
# Synonyms for the canonical action keys in oikos/policy.yaml `actions:`.
|
||||
# `homelab restart <service>` is the real CLI verb; "restart" is what an
|
||||
# agent proposing the action is most likely to say. Keep this list in sync
|
||||
# with anything oikos/decide.py or the CLI classifies by name.
|
||||
ACTION_ALIASES = {
|
||||
"restart": "service-restart",
|
||||
}
|
||||
|
||||
|
||||
def canonical_action(action: str) -> str:
|
||||
return ACTION_ALIASES.get(action, action)
|
||||
|
||||
|
||||
def classify_action(action: str, service: str | None = None) -> str | None:
|
||||
"""Risk class for a generic action, honoring per-service overrides."""
|
||||
action = canonical_action(action)
|
||||
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
|
||||
@@ -1,131 +0,0 @@
|
||||
"""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)]
|
||||
@@ -1,230 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""oikos/scheduler.py — the Observe stage: periodic probes -> Signals + state cache.
|
||||
|
||||
Intended to run under a systemd timer (see scripts/sync/linux/ for the
|
||||
existing timer pattern this should mirror — deploy target: LXC 105
|
||||
alongside the MCP server) every 5-15 minutes. Each run:
|
||||
|
||||
1. Probes every service's HTTP health and writes oikos/state.json — the
|
||||
snapshot `homelab service <name> health` serves by default (cache-
|
||||
first reads); pass --live on that command to force a fresh probe
|
||||
instead of trusting the cache.
|
||||
2. Probes disk usage on hubris + strong (best-effort SSH; degrades to a
|
||||
"probe-unreachable" info Signal rather than a false disk-threshold
|
||||
alarm when unreachable).
|
||||
3. Runs the Week-3 drift detectors (oikos/drift.py).
|
||||
4. Raises Signals for anything over threshold or drifted — skipping
|
||||
duplicates via oikos_signal.open_signal_for() — and auto-resolves
|
||||
any open Signal whose condition has since cleared.
|
||||
|
||||
Temperature probing is NOT implemented here: LXCs don't expose host
|
||||
sensors, and hubris/strong's actual sensor path (lm-sensors vs vendor
|
||||
tool) hasn't been confirmed on either box yet — a real check needs that
|
||||
groundwork first rather than a guessed command. Backup-freshness is
|
||||
likewise deferred: `backs-up-to` isn't populated in inventory yet (see
|
||||
oikos/ontology.yaml — it's a documented thin field), so there's nothing
|
||||
structured to check freshness against.
|
||||
|
||||
CLI:
|
||||
python3 oikos/scheduler.py run # probe, raise signals, write state.json
|
||||
python3 oikos/scheduler.py run --dry-run # probe and print only, no side effects
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO))
|
||||
from oikos import drift as oikos_drift # noqa: E402
|
||||
from oikos import signal as oikos_signal # noqa: E402
|
||||
|
||||
INVENTORY = REPO / "inventory.yaml"
|
||||
STATE_FILE = REPO / "oikos" / "state.json"
|
||||
|
||||
DISK_WARN_PCT = 85
|
||||
DISK_CRIT_PCT = 95
|
||||
HEALTH_TIMEOUT_S = 5
|
||||
|
||||
|
||||
def _load_inventory() -> dict:
|
||||
return yaml.safe_load(INVENTORY.read_text())
|
||||
|
||||
|
||||
# Services whose public `url`/`endpoint` isn't a plain-GET health check —
|
||||
# mirrors the special-casing bin/homelab's `doctor` command already does.
|
||||
# A generic per-service `health:` inventory field (checked URL/command
|
||||
# distinct from the public url) would remove the need for this; until
|
||||
# that's backfilled, these are the two known exceptions.
|
||||
_HEALTH_OVERRIDES = {
|
||||
# FastMCP's streamable-http endpoint expects a JSON-RPC POST handshake,
|
||||
# not a bare GET — a plain curl gets 4xx even when the server is fully
|
||||
# healthy (confirmed against the live server: consistently 400, never
|
||||
# 2xx/3xx, for a GET). Any HTTP response at all (vs. no response/
|
||||
# connection refused) proves the process is up and answering, which is
|
||||
# what "service-down" alerting actually cares about here.
|
||||
"homelab_mcp": {"extra_curl_args": ["-H", "Accept: text/event-stream"],
|
||||
"any_response_ok": True},
|
||||
"secrets_issuance": {
|
||||
"url_transform": lambda url: url.rstrip("/").rsplit("/", 1)[0] + "/health",
|
||||
},
|
||||
# Token-gated at the app level (401 without a token is correct, not
|
||||
# down) — see knowledge/wiki/containers/128-trmnl.md, which documents a dedicated
|
||||
# /health endpoint returning 200 unauthenticated.
|
||||
"trmnl": {"url_transform": lambda url: url.rstrip("/") + "/health"},
|
||||
}
|
||||
|
||||
|
||||
def probe_service_health(name: str, entry: dict) -> dict:
|
||||
url = entry.get("url") or entry.get("endpoint")
|
||||
if not url:
|
||||
return {"service": name, "checked": False}
|
||||
override = _HEALTH_OVERRIDES.get(name, {})
|
||||
check_url = override.get("url_transform", lambda u: u)(url)
|
||||
curl_cmd = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"--max-time", str(HEALTH_TIMEOUT_S), *override.get("extra_curl_args", []), check_url]
|
||||
try:
|
||||
proc = subprocess.run(curl_cmd, capture_output=True, text=True)
|
||||
code = proc.stdout.strip()
|
||||
except OSError:
|
||||
code = ""
|
||||
ok = bool(code) if override.get("any_response_ok") else code.startswith(("2", "3"))
|
||||
return {"service": name, "checked": True, "url": url, "checked_url": check_url,
|
||||
"http_code": code or None, "ok": ok}
|
||||
|
||||
|
||||
def _ssh(host_ip: str, remote_cmd: str, timeout: int = 10) -> str | None:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", f"ConnectTimeout={min(timeout, 5)}",
|
||||
f"root@{host_ip}", remote_cmd],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
return proc.stdout.strip() if proc.returncode == 0 else None
|
||||
|
||||
|
||||
def probe_disk(name: str, entry: dict) -> dict | None:
|
||||
"""`df -P /` on a proxmox-host's own root fs. LXC-level disk usage is
|
||||
already covered by MCP's get_lxc_state; this probe is host-level."""
|
||||
if entry.get("kind") != "proxmox-host" or not entry.get("lan_ip"):
|
||||
return None
|
||||
out = _ssh(entry["lan_ip"], "df -P / | tail -1")
|
||||
if out is None:
|
||||
return {"host": name, "checked": False}
|
||||
parts = out.split()
|
||||
if len(parts) < 5 or not parts[4].rstrip("%").isdigit():
|
||||
return {"host": name, "checked": False}
|
||||
return {"host": name, "checked": True, "used_pct": int(parts[4].rstrip("%"))}
|
||||
|
||||
|
||||
def run(dry_run: bool = False) -> dict:
|
||||
inv = _load_inventory()
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
services_state = {}
|
||||
for name, entry in inv.get("services", {}).items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
result = probe_service_health(name, entry)
|
||||
services_state[name] = result
|
||||
if not result.get("checked"):
|
||||
continue
|
||||
sig_kind = "service-down"
|
||||
if result["ok"]:
|
||||
open_sig = oikos_signal.open_signal_for(f"service:{name}", sig_kind)
|
||||
if open_sig and not dry_run:
|
||||
oikos_signal.resolve(open_sig["id"], note="health probe recovered")
|
||||
else:
|
||||
if not dry_run and oikos_signal.open_signal_for(f"service:{name}", sig_kind) is None:
|
||||
oikos_signal.raise_signal(
|
||||
sig_kind, "critical", f"service:{name}",
|
||||
f"{result['url']} -> {result.get('http_code') or 'no response'}",
|
||||
likely_cause="backend down, crashed, or ingress misconfigured",
|
||||
recommended_action={"runbook": "service-health-check", "risk": "reversible_low"},
|
||||
verification=f"homelab service {name} health --live",
|
||||
)
|
||||
|
||||
hosts_state = {}
|
||||
for name, entry in inv.get("hosts", {}).items():
|
||||
disk = probe_disk(name, entry)
|
||||
if disk is None:
|
||||
continue
|
||||
hosts_state[name] = disk
|
||||
if not disk.get("checked"):
|
||||
continue
|
||||
pct = disk["used_pct"]
|
||||
sig_kind = "disk-threshold"
|
||||
if pct >= DISK_CRIT_PCT:
|
||||
severity = "critical"
|
||||
elif pct >= DISK_WARN_PCT:
|
||||
severity = "warning"
|
||||
else:
|
||||
severity = None
|
||||
open_sig = oikos_signal.open_signal_for(f"host:{name}", sig_kind)
|
||||
if severity is None:
|
||||
if open_sig and not dry_run:
|
||||
oikos_signal.resolve(open_sig["id"], note=f"disk usage back to {pct}%")
|
||||
elif open_sig is None and not dry_run:
|
||||
oikos_signal.raise_signal(
|
||||
sig_kind, severity, f"host:{name}", f"root filesystem {pct}% used",
|
||||
likely_cause="data growth or a runaway log",
|
||||
recommended_action={"runbook": "config-change-deploy", "risk": "config_mutation"},
|
||||
verification=f"homelab ssh {name} -- df -h /",
|
||||
)
|
||||
|
||||
drift_findings = oikos_drift.run_all(inv, raise_signals=not dry_run)
|
||||
|
||||
snapshot = {
|
||||
"generated_at": now,
|
||||
"services": services_state,
|
||||
"hosts": hosts_state,
|
||||
"drift_findings": len(drift_findings),
|
||||
}
|
||||
if not dry_run:
|
||||
STATE_FILE.write_text(json.dumps(snapshot, indent=2) + "\n")
|
||||
return snapshot
|
||||
|
||||
|
||||
def read_state() -> dict | None:
|
||||
"""Load the last scheduler snapshot, or None if it's never run."""
|
||||
if not STATE_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def cached_service_health(name: str) -> dict | None:
|
||||
state = read_state()
|
||||
if state is None:
|
||||
return None
|
||||
entry = state.get("services", {}).get(name)
|
||||
if entry is None:
|
||||
return None
|
||||
return {**entry, "as_of": state.get("generated_at")}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="oikos scheduler (Observe stage)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
r = sub.add_parser("run")
|
||||
r.add_argument("--dry-run", action="store_true")
|
||||
args = p.parse_args()
|
||||
if args.cmd == "run":
|
||||
snapshot = run(dry_run=args.dry_run)
|
||||
print(json.dumps(snapshot, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
239
oikos/signal.py
239
oikos/signal.py
@@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""oikos/signal.py — the Signal engine (attention layer).
|
||||
|
||||
A Signal is anything the lab notices that needs attention and possibly
|
||||
action: pending updates, high temperature, low disk, a service down, a
|
||||
cert expiring, a stale backup, inventory drift, a node stuck mid-
|
||||
lifecycle-transition. See OIKOS.md / oikos/ontology.yaml "Signals — the
|
||||
attention layer".
|
||||
|
||||
Storage: signals/<YYYY-MM>.jsonl, same append-only-JSONL convention as
|
||||
oikos/ledger.py, but a signal's *state* changes over its lifecycle
|
||||
(raised -> acknowledged -> acting -> resolved | muted). Each state change
|
||||
is a NEW line with the same id — never edit a line in place, so git
|
||||
history stays a legible append-only audit trail. `current()` /
|
||||
`list_signals()` reduce the log to each id's latest state.
|
||||
|
||||
CLI:
|
||||
python3 oikos/signal.py raise <kind> <severity> <entity> <evidence> [--likely-cause] [--action-runbook] [--action-risk] [--verification]
|
||||
python3 oikos/signal.py list [--state raised] [--entity strong] [--severity warning]
|
||||
python3 oikos/signal.py ack <id>
|
||||
python3 oikos/signal.py resolve <id> [--note ...]
|
||||
python3 oikos/signal.py mute <id> [--ttl-hours 24]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SIGNALS_DIR = REPO / "signals"
|
||||
|
||||
VALID_SEVERITIES = ("info", "warning", "critical")
|
||||
VALID_STATES = ("raised", "acknowledged", "acting", "resolved", "muted")
|
||||
# Routing per oikos/ontology.yaml "Signals" section.
|
||||
SEVERITY_ROUTE = {
|
||||
"info": "console+report",
|
||||
"warning": "matrix-digest",
|
||||
"critical": "matrix-immediate",
|
||||
}
|
||||
|
||||
|
||||
def _month_path(dt: datetime | None = None) -> Path:
|
||||
dt = dt or datetime.now(timezone.utc)
|
||||
return SIGNALS_DIR / f"{dt.strftime('%Y-%m')}.jsonl"
|
||||
|
||||
|
||||
def _all_entries() -> list[dict]:
|
||||
if not SIGNALS_DIR.exists():
|
||||
return []
|
||||
out = []
|
||||
for path in sorted(SIGNALS_DIR.glob("*.jsonl")):
|
||||
for line in path.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _next_id(dt: datetime | None = None) -> str:
|
||||
dt = dt or datetime.now(timezone.utc)
|
||||
prefix = f"sig-{dt.strftime('%Y-%m-%d')}-"
|
||||
existing = [e["id"] for e in _all_entries() if e.get("id", "").startswith(prefix)]
|
||||
n = 1
|
||||
while f"{prefix}{n:04d}" in existing:
|
||||
n += 1
|
||||
return f"{prefix}{n:04d}"
|
||||
|
||||
|
||||
def _append(entry: dict) -> dict:
|
||||
SIGNALS_DIR.mkdir(exist_ok=True)
|
||||
entry = {k: v for k, v in entry.items() if v is not None}
|
||||
with _month_path().open("a") as f:
|
||||
f.write(json.dumps(entry, sort_keys=False) + "\n")
|
||||
return entry
|
||||
|
||||
|
||||
def raise_signal(kind: str, severity: str, entity: str, evidence: str, *,
|
||||
likely_cause: str | None = None,
|
||||
recommended_action: dict | None = None,
|
||||
verification: str | None = None,
|
||||
signal_id: str | None = None) -> dict:
|
||||
"""Raise a new Signal, or (if `signal_id` names an existing one and it's
|
||||
not currently open) re-raise it. Returns the recorded entry."""
|
||||
if severity not in VALID_SEVERITIES:
|
||||
raise ValueError(f"severity must be one of {VALID_SEVERITIES}")
|
||||
sid = signal_id or _next_id()
|
||||
entry = {
|
||||
"id": sid,
|
||||
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"kind": kind,
|
||||
"severity": severity,
|
||||
"entity": entity,
|
||||
"evidence": evidence,
|
||||
"likely_cause": likely_cause,
|
||||
"recommended_action": recommended_action,
|
||||
"verification": verification,
|
||||
"state": "raised",
|
||||
"route": SEVERITY_ROUTE.get(severity, "console+report"),
|
||||
}
|
||||
return _append(entry)
|
||||
|
||||
|
||||
def _transition(signal_id: str, state: str, *, note: str | None = None,
|
||||
mute_until: str | None = None) -> dict:
|
||||
if state not in VALID_STATES:
|
||||
raise ValueError(f"state must be one of {VALID_STATES}")
|
||||
prior = current(signal_id)
|
||||
if prior is None:
|
||||
raise ValueError(f"unknown signal id: {signal_id}")
|
||||
entry = {**prior, "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"state": state, "note": note, "mute_until": mute_until}
|
||||
return _append(entry)
|
||||
|
||||
|
||||
def acknowledge(signal_id: str, note: str | None = None) -> dict:
|
||||
return _transition(signal_id, "acknowledged", note=note)
|
||||
|
||||
|
||||
def start_acting(signal_id: str, note: str | None = None) -> dict:
|
||||
return _transition(signal_id, "acting", note=note)
|
||||
|
||||
|
||||
def resolve(signal_id: str, note: str | None = None) -> dict:
|
||||
return _transition(signal_id, "resolved", note=note)
|
||||
|
||||
|
||||
def mute(signal_id: str, ttl_hours: int = 24, note: str | None = None) -> dict:
|
||||
until = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat(timespec="seconds")
|
||||
return _transition(signal_id, "muted", note=note, mute_until=until)
|
||||
|
||||
|
||||
def current(signal_id: str) -> dict | None:
|
||||
"""Latest recorded state for one signal id, or None if unknown."""
|
||||
matches = [e for e in _all_entries() if e.get("id") == signal_id]
|
||||
if not matches:
|
||||
return None
|
||||
matches.sort(key=lambda e: e.get("ts", ""))
|
||||
return matches[-1]
|
||||
|
||||
|
||||
def list_signals(state: str | None = None, entity: str | None = None,
|
||||
severity: str | None = None, kind: str | None = None) -> list[dict]:
|
||||
"""Every signal's latest state, optionally filtered. Auto-expires mute
|
||||
(a signal muted past its mute_until is treated as raised again)."""
|
||||
latest: dict[str, dict] = {}
|
||||
for e in sorted(_all_entries(), key=lambda e: e.get("ts", "")):
|
||||
sid = e.get("id")
|
||||
if sid:
|
||||
latest[sid] = e
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
out = []
|
||||
for e in latest.values():
|
||||
if e.get("state") == "muted" and e.get("mute_until") and e["mute_until"] < now:
|
||||
e = {**e, "state": "raised", "note": "mute expired"}
|
||||
if state and e.get("state") != state:
|
||||
continue
|
||||
if entity and e.get("entity") != entity:
|
||||
continue
|
||||
if severity and e.get("severity") != severity:
|
||||
continue
|
||||
if kind and e.get("kind") != kind:
|
||||
continue
|
||||
out.append(e)
|
||||
out.sort(key=lambda e: e.get("ts", ""), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def open_signal_for(entity: str, kind: str) -> dict | None:
|
||||
"""The currently-open (raised/acknowledged/acting) signal for this
|
||||
entity+kind, if any. Used by probes to avoid raising duplicates and to
|
||||
auto-resolve when a condition clears."""
|
||||
for e in list_signals(entity=entity, kind=kind):
|
||||
if e.get("state") in ("raised", "acknowledged", "acting"):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="oikos signal engine")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
r = sub.add_parser("raise")
|
||||
r.add_argument("kind")
|
||||
r.add_argument("severity", choices=VALID_SEVERITIES)
|
||||
r.add_argument("entity")
|
||||
r.add_argument("evidence")
|
||||
r.add_argument("--likely-cause")
|
||||
r.add_argument("--action-runbook")
|
||||
r.add_argument("--action-risk")
|
||||
r.add_argument("--verification")
|
||||
|
||||
ls = sub.add_parser("list")
|
||||
ls.add_argument("--state", choices=VALID_STATES)
|
||||
ls.add_argument("--entity")
|
||||
ls.add_argument("--severity", choices=VALID_SEVERITIES)
|
||||
ls.add_argument("--kind")
|
||||
|
||||
for name in ("ack", "resolve"):
|
||||
sp = sub.add_parser(name)
|
||||
sp.add_argument("id")
|
||||
sp.add_argument("--note")
|
||||
|
||||
mt = sub.add_parser("mute")
|
||||
mt.add_argument("id")
|
||||
mt.add_argument("--ttl-hours", type=int, default=24)
|
||||
mt.add_argument("--note")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "raise":
|
||||
action = None
|
||||
if args.action_runbook or args.action_risk:
|
||||
action = {"runbook": args.action_runbook, "risk": args.action_risk}
|
||||
entry = raise_signal(args.kind, args.severity, args.entity, args.evidence,
|
||||
likely_cause=args.likely_cause, recommended_action=action,
|
||||
verification=args.verification)
|
||||
print(json.dumps(entry, indent=2))
|
||||
elif args.cmd == "list":
|
||||
for e in list_signals(state=args.state, entity=args.entity,
|
||||
severity=args.severity, kind=args.kind):
|
||||
print(json.dumps(e))
|
||||
elif args.cmd == "ack":
|
||||
print(json.dumps(acknowledge(args.id, args.note), indent=2))
|
||||
elif args.cmd == "resolve":
|
||||
print(json.dumps(resolve(args.id, args.note), indent=2))
|
||||
elif args.cmd == "mute":
|
||||
print(json.dumps(mute(args.id, args.ttl_hours, args.note), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user