From b230ab593764910b054824f21b7662665cea68f9 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 5 Jul 2026 22:50:34 +0200 Subject: [PATCH] Oikos Week 1: kernel policy, ontology, service contract, topology gen Adds the Oikos agent-OS kernel: oikos/policy.yaml (risk classes + approval rules for every homelab/MCP command), oikos/ontology.yaml (8-domain systems model, typed relationships, node lifecycle), and OIKOS.md (OODA loop operating brief, linked from AGENTS.md). Extends inventory.yaml with a stable service contract (doc_page, config_repo, risk_notes) on all 17 services, and a structured archaeology: section for the 13 destroyed LXCs (was scattered comments + a narrative table). Fixes stale drift found in the process: authentik's backend pointed at a retired LXC (124); core has run on the VPS since 2026-05-31. Adds oikos/gen-topology.py, generating infrastructure/topology.md (Mermaid compute/ingress + storage views) from inventory.yaml. build_host_files.py now carries state/storage/depends_on into generated hosts/*.yaml. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 5 ++ OIKOS.md | 107 ++++++++++++++++++++++ hosts/apps.yaml | 9 ++ hosts/arriman.yaml | 2 + hosts/auth-outpost.yaml | 1 + hosts/caddy.yaml | 5 ++ hosts/dns.yaml | 9 +- hosts/elementsynapse.yaml | 3 + hosts/gitea.yaml | 4 + hosts/grimmory.yaml | 1 + hosts/haos.yaml | 2 + hosts/house.yaml | 1 + hosts/hubris.yaml | 3 + hosts/jellyfin.yaml | 4 + hosts/mac-mini.yaml | 1 + hosts/mule-images.yaml | 3 + hosts/netbird-vps.yaml | 11 +++ hosts/nextcloud.yaml | 2 + hosts/nfs-export.yaml | 1 + hosts/paperless.yaml | 3 + hosts/rclone.yaml | 1 + hosts/republic-laptop.yaml | 1 + hosts/romm.yaml | 1 + hosts/seanime.yaml | 1 + hosts/sophia.yaml | 1 + hosts/strong.yaml | 1 + hosts/trmnl.yaml | 3 + hosts/zimaos.yaml | 2 + infrastructure/topology.md | 93 +++++++++++++++++++ inventory.yaml | 123 ++++++++++++++++++++++++- mcp/build_host_files.py | 4 + oikos/gen-topology.py | 178 +++++++++++++++++++++++++++++++++++++ oikos/ontology.yaml | 137 ++++++++++++++++++++++++++++ oikos/policy.yaml | 117 ++++++++++++++++++++++++ 34 files changed, 832 insertions(+), 8 deletions(-) create mode 100644 OIKOS.md create mode 100644 infrastructure/topology.md create mode 100644 oikos/gen-topology.py create mode 100644 oikos/ontology.yaml create mode 100644 oikos/policy.yaml diff --git a/AGENTS.md b/AGENTS.md index 997428e..eaf8c19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,11 @@ You are running on a machine that is part of the **hubris** homelab. The full context is in this checkout at `/opt/homelab-context/`. This file is the entry point. Read it once at start, then keep working. +The operating model — OODA loop, risk classes, approval rules, the ontology, +and node lifecycle — is defined in [OIKOS.md](OIKOS.md). Before any mutation, +classify the action against `oikos/policy.yaml`; when the class requires +approval, stop and ask the operator. + ## 1. Who you are Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read: diff --git a/OIKOS.md b/OIKOS.md new file mode 100644 index 0000000..cc15bf7 --- /dev/null +++ b/OIKOS.md @@ -0,0 +1,107 @@ +# Oikos — the operating model + +Oikos (Greek: *household*) is the agent operating system layered on this +repo. It is not new infrastructure: `inventory.yaml` is the kernel data +structure, the `homelab` CLI and MCP server are the syscall surface, and +this page defines the rules everything above them follows. + +Read this after [AGENTS.md](AGENTS.md). Machine-readable companions: +[oikos/ontology.yaml](oikos/ontology.yaml) (systems model), +[oikos/policy.yaml](oikos/policy.yaml) (risk & approval). + +## The kernel loop: OODA + +Every Oikos activity — scheduled probe, agent task, operator request — is +one pass through **Observe → Orient → Decide → Act**: + +1. **Observe** — probes, drift detectors, and agent findings produce + **Signals** (structured records, not loose messages): pending updates, + high temperature, low disk, service down, cert expiry, stale backup, + inventory drift. +2. **Orient** — walk the ontology graph: what entity is affected, what + depends on it (blast radius), its lifecycle state, whether a runbook + matches, what the ledger says about past attempts. +3. **Decide** — the classifier scores **risk class × blast radius × + confidence** and routes: + - **auto-act**: within autonomy policy, high confidence, contained radius + - **escalate**: operator approval via Matrix (✅/❌ reaction; destructive + actions additionally need a typed confirmation phrase) + - **queue**: informational — console + reports + The classifier can only *lower* autonomy relative to policy, never raise + it. When in doubt, escalate. +4. **Act** — execute through `homelab` commands or runbooks (never ad-hoc + SSH), then **verify** with the action's verification command, write a + **ledger** entry, resolve the Signal, and update docs in the same session. + +## Primitives + +| Primitive | What it is | Lives in | +|---|---|---| +| Host / Service | topology entities | `inventory.yaml` (+ generated `hosts/*.yaml`) | +| Secret | SOPS+age encrypted value, per-client recipients | `secrets/` + `.sops.yaml` | +| Runbook | executable workflow with risk class + verification | `runbooks/` (Week 2) | +| Signal | something needing attention, with lifecycle | `signals/` ledger (Week 3) | +| Change | one mutation: who, what, risk, approval, verification | `ledger/` (Week 2) | +| Approval | short-TTL signed grant for a gated action | approval engine (Week 3) | +| Incident | investigation narrative | `investigations/` | +| Plan | design doc for non-trivial work | `plans/` | +| Agent | enrolled client identity = its age pubkey | `inventory.yaml` + `.sops.yaml` | + +## Risk classes (enforced, not advisory) + +From [oikos/policy.yaml](oikos/policy.yaml): + +- **read_only** — status, logs, docs, inventory. Unattended. +- **reversible_low** — restart, cache clear, sync pull. Unattended + ledger. +- **config_mutation** — tracked-config edits (commit+push, never local), + deploys, upgrades, DNS/ingress changes. Operator approval. +- **destructive** — destroy, format, wipe, rotate, revoke. Approval + + typed confirmation phrase. + +Lifecycle gates modify these: `provisioning` nodes are freely mutable +(nothing depends on them); `deprecated` nodes accept no new dependents; +anything touching a `destroyed` node is drift. + +## The systems model + +Eight domains — physical, compute, network, storage, software, +identity & access, operations, external — cover everything in the lab; +entities are connected by typed edges (`hosts`, `provides`, `mounts`, +`stores-on`, `routes-to`, `can-decrypt`, `depends-on`, `backs-up-to`, …) +defined in [oikos/ontology.yaml](oikos/ontology.yaml). Rule of +completeness: **if it can break, be changed, or hold data, it has an +entity and edges.** Blast-radius questions ("what breaks if strong goes +down?") are graph walks, not doc archaeology. + +Nodes move through an explicit lifecycle — +`planned → provisioning → active → migrating → deprecated → destroyed` — +stored as `state:` in inventory (absent = active). Destroyed nodes live in +the `archaeology:` section. Each transition is a runbook checklist; +deprecation completes only when inbound edges reach zero. + +Generated views: [infrastructure/topology.md](infrastructure/topology.md) +(Mermaid, regenerated from inventory). + +## Conventions carried forward + +- Inventory is the truth; live state wins over narrative docs. +- Prefer `homelab` CLI and MCP over ad-hoc SSH. +- Meaningful changes update docs in the same session. +- Secrets are decrypted locally via per-client keys; never into docs/comments. +- Tracked configs change by commit + push, not local edits. +- Netbird is the preferred mesh path for new traffic. +- Agents are terse ([CAVEMAN.md](CAVEMAN.md)), verify claims, and fix + collateral drift when found. + +## Build status (30-day roadmap, started 2026-07-05) + +- **Week 1 (this)**: policy, ontology, service contract, archaeology, + topology generator, this brief. +- **Week 2**: context cards, `homelab service …`, change ledger, + `node relations`, runbooks. +- **Week 3**: Prometheus + node_exporter, ops scheduler + state cache, + drift detectors, signal engine, decision classifier, approval engine + (Matrix ✅/❌), daily brief. +- **Week 4**: Oikos Console (oikos.hubris.network, behind Authentik with + step-up re-auth on approvals), per-agent age-key-signed approval + requests, docs pass, 60/90-day backlog. diff --git a/hosts/apps.yaml b/hosts/apps.yaml index 7e3ee4c..ad5a2cb 100644 --- a/hosts/apps.yaml +++ b/hosts/apps.yaml @@ -5,6 +5,7 @@ name: apps kind: lxc os: linux role: docker-apps +state: active host: hubris pve_id: 105 lan_ip: 192.168.8.205 @@ -33,21 +34,29 @@ services_hosted: - name: artifacto backend: apps url: https://artifacto.hubris.network + doc_page: containers/105-apps.md + config_repo: dtoro/Artifacto - name: homelab_mcp backend: apps port: 9810 systemd_unit: homelab-mcp public_host: mcp.hubris.network endpoint: https://mcp.hubris.network/mcp + doc_page: infrastructure/homelab-context.md + config_repo: dtoro/Homelab-Docs note: MCP server. Read-only context + management. Reachable on the LAN via Caddy and from off-LAN via Netbird (192.168.8.0/24 is a network resource routed through hubris). + risk_notes: "agents' primary read surface \u2014 outage degrades every agent to grepping the clone" - name: secrets_issuance backend: apps port: 9820 systemd_unit: secrets-issuance public_host: secrets.hubris.network endpoint: https://secrets.hubris.network/issue + doc_page: operations/agent-enrollment.md + config_repo: dtoro/Homelab-Docs note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN subnets in MESH_SUBNETS). + risk_notes: "identity issuance \u2014 any change is security-sensitive; key operations are destructive-class" age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0 see_also: - containers/105-apps.md diff --git a/hosts/arriman.yaml b/hosts/arriman.yaml index dc3674c..27c0933 100644 --- a/hosts/arriman.yaml +++ b/hosts/arriman.yaml @@ -5,6 +5,7 @@ name: arriman kind: lxc os: linux role: arr-stack +state: active host: strong pve_id: 122 lan_ip: 192.168.8.245 @@ -28,6 +29,7 @@ services_hosted: - name: arr_stack backend: arriman note: jellyseerr / qbit / sab on docker compose + doc_page: containers/122-arriman.md notes: - Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm. - Contains homarr, radarr, sonarr, lidarr, sabnzbd, qbittorrent, bazarr, flaresolverr, prowlarr, jellyseerr diff --git a/hosts/auth-outpost.yaml b/hosts/auth-outpost.yaml index 93112bf..e849205 100644 --- a/hosts/auth-outpost.yaml +++ b/hosts/auth-outpost.yaml @@ -5,6 +5,7 @@ name: auth-outpost kind: lxc os: linux role: authentik-gateway +state: active host: hubris pve_id: 106 lan_ip: 192.168.8.6 diff --git a/hosts/caddy.yaml b/hosts/caddy.yaml index cc0adcc..02c0b8a 100644 --- a/hosts/caddy.yaml +++ b/hosts/caddy.yaml @@ -5,6 +5,7 @@ name: caddy kind: lxc os: linux role: reverse-proxy +state: active host: hubris pve_id: 121 lan_ip: 192.168.8.175 @@ -23,6 +24,10 @@ services_hosted: backend: caddy role: reverse-proxy note: terminates all *.hubris.network + doc_page: containers/121-caddy.md + config_repo: dtoro/caddy-conf + risk_notes: "wide blast radius \u2014 every *.hubris.network route rides on it (see oikos/policy.yaml\ + \ service_overrides)" notes: - Terminates all *.hubris.network - /etc/caddy is a git checkout of dtoro/caddy-conf diff --git a/hosts/dns.yaml b/hosts/dns.yaml index 8e01807..8a966d2 100644 --- a/hosts/dns.yaml +++ b/hosts/dns.yaml @@ -5,6 +5,7 @@ name: dns kind: lxc os: linux role: dns-server +state: active host: hubris pve_id: 107 lan_ip: 192.168.8.2 @@ -14,13 +15,13 @@ mesh_globals: - netbird - tailscale runs: -- authentik +- dns services_hosted: -- name: authentik - url: https://auth.hubris.network +- name: dns backend: dns - dns: null note: Technitium DNS, split-horizon zone + doc_page: containers/107-dns.md + risk_notes: "LAN-wide resolver \u2014 misconfig breaks name resolution for every client" notes: - Technitium DNS, split-horizon zone for *.hubris.network - Primary DNS for 192.168.8.0/24 LAN (inventory.services.dns references this) diff --git a/hosts/elementsynapse.yaml b/hosts/elementsynapse.yaml index a7c64ed..2f2dbad 100644 --- a/hosts/elementsynapse.yaml +++ b/hosts/elementsynapse.yaml @@ -5,6 +5,7 @@ name: elementsynapse kind: lxc os: linux role: matrix-server +state: active host: strong pve_id: 118 lan_ip: 192.168.8.242 @@ -22,6 +23,8 @@ services_hosted: - name: matrix url: https://matrix.hubris.network backend: elementsynapse + doc_page: containers/118-elementsynapse.md + risk_notes: "alert/approval channel for Oikos \u2014 outage silences agent escalation" notes: - Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan). see_also: diff --git a/hosts/gitea.yaml b/hosts/gitea.yaml index 87f25a2..01e8963 100644 --- a/hosts/gitea.yaml +++ b/hosts/gitea.yaml @@ -5,6 +5,7 @@ name: gitea kind: lxc os: linux role: git-server +state: active host: hubris pve_id: 104 lan_ip: 192.168.8.121 @@ -26,6 +27,9 @@ services_hosted: url: https://git.hubris.network backend: gitea backend_url: http://192.168.8.121:3000 + doc_page: containers/104-gitea.md + config_repo: dtoro/gitea-customizations + risk_notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync notes: - Bare repos live at /mnt/library/repos/dtoro/*.git see_also: diff --git a/hosts/grimmory.yaml b/hosts/grimmory.yaml index 364f457..d04d322 100644 --- a/hosts/grimmory.yaml +++ b/hosts/grimmory.yaml @@ -5,6 +5,7 @@ name: grimmory kind: lxc os: linux role: book-library +state: active host: strong pve_id: 130 lan_ip: 192.168.8.247 diff --git a/hosts/haos.yaml b/hosts/haos.yaml index 40e3e9c..909198b 100644 --- a/hosts/haos.yaml +++ b/hosts/haos.yaml @@ -5,6 +5,7 @@ name: haos kind: vm os: linux role: home-automation +state: active host: hubris pve_id: 108 lan_ip: 192.168.8.101 @@ -21,6 +22,7 @@ runs: services_hosted: - name: haos backend: haos + doc_page: vms/108-haos.md see_also: - vms/108-haos.md mcp_endpoint: https://mcp.hubris.network/mcp diff --git a/hosts/house.yaml b/hosts/house.yaml index 5ddef7e..17dc457 100644 --- a/hosts/house.yaml +++ b/hosts/house.yaml @@ -5,6 +5,7 @@ name: house kind: lxc os: linux role: family-planner +state: active host: strong pve_id: 129 lan_ip: 192.168.8.244 diff --git a/hosts/hubris.yaml b/hosts/hubris.yaml index bb156ce..0f3d065 100644 --- a/hosts/hubris.yaml +++ b/hosts/hubris.yaml @@ -5,6 +5,7 @@ name: hubris kind: proxmox-host os: linux role: hypervisor +state: active lan_ip: 192.168.8.77 mesh: netbird: @@ -28,6 +29,8 @@ services_hosted: url: https://proxmox.hubris.network backend: hubris port: 8006 + doc_page: hosts/hubris.md + risk_notes: "hypervisor UI \u2014 changes here affect every guest on the node" age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6 see_also: - hosts/hubris.md diff --git a/hosts/jellyfin.yaml b/hosts/jellyfin.yaml index e80ce92..982a6df 100644 --- a/hosts/jellyfin.yaml +++ b/hosts/jellyfin.yaml @@ -5,6 +5,7 @@ name: jellyfin kind: lxc os: linux role: media-server +state: active host: strong pve_id: 101 lan_ip: 192.168.8.246 @@ -25,6 +26,9 @@ services_hosted: - name: jellyfin url: https://media.hubris.network backend: jellyfin + doc_page: 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 notes: - Jellyfin 10.11.11 with VAAPI hardware acceleration (Radeon 680M iGPU on strong) - 4 cores / 8 GiB RAM / 1 GiB swap diff --git a/hosts/mac-mini.yaml b/hosts/mac-mini.yaml index 036c49e..2784a70 100644 --- a/hosts/mac-mini.yaml +++ b/hosts/mac-mini.yaml @@ -5,6 +5,7 @@ name: mac-mini kind: workstation os: macos role: dev +state: active lan_ip: 192.168.178.182 mesh: netbird: diff --git a/hosts/mule-images.yaml b/hosts/mule-images.yaml index 75b8633..26d4c12 100644 --- a/hosts/mule-images.yaml +++ b/hosts/mule-images.yaml @@ -5,6 +5,7 @@ name: mule-images kind: lxc os: linux role: photo-management +state: active host: hubris pve_id: 120 lan_ip: 192.168.8.136 @@ -25,6 +26,8 @@ services_hosted: - name: photos url: https://photos.hubris.network backend: mule-images + doc_page: containers/120-mule-images.md + config_repo: dtoro/mule-image see_also: - containers/120-mule-images.md mcp_endpoint: https://mcp.hubris.network/mcp diff --git a/hosts/netbird-vps.yaml b/hosts/netbird-vps.yaml index 713a0bb..c3be6b5 100644 --- a/hosts/netbird-vps.yaml +++ b/hosts/netbird-vps.yaml @@ -5,6 +5,7 @@ name: netbird-vps kind: external os: linux role: netbird-mgmt +state: active mesh: netbird: ip: 100.122.165.149 @@ -16,6 +17,16 @@ mesh_globals: - tailscale ssh: user: root +runs: +- authentik +services_hosted: +- name: authentik + url: https://auth.hubris.network + backend: netbird-vps + doc_page: containers/106-auth-outpost.md + note: core runs on the VPS since 2026-05-31; LAN forward-auth outpost is auth-outpost (LXC 106) at 192.168.8.6:9000. + Previous backend value "authentik" referenced the retired embedded-outpost host (LXC 124). + risk_notes: "SSO provider \u2014 outage locks login to OIDC/forward-auth services" notes: - "Public IONOS VPS \u2014 hosts the vanilla netbird mgmt+signal+relay+dashboard stack + host coturn (see\ \ infrastructure/vps-hardening.md + infrastructure/mesh.md changelog 2026-05-21)." diff --git a/hosts/nextcloud.yaml b/hosts/nextcloud.yaml index a0f5a39..f2611cc 100644 --- a/hosts/nextcloud.yaml +++ b/hosts/nextcloud.yaml @@ -5,6 +5,7 @@ name: nextcloud kind: lxc os: linux role: file-sync +state: active host: hubris pve_id: 114 lan_ip: 192.168.8.224 @@ -25,6 +26,7 @@ services_hosted: - name: nextcloud url: https://cloud.hubris.network backend: nextcloud + doc_page: containers/114-nextcloud.md see_also: - containers/114-nextcloud.md mcp_endpoint: https://mcp.hubris.network/mcp diff --git a/hosts/nfs-export.yaml b/hosts/nfs-export.yaml index a38c1b4..ab2e332 100644 --- a/hosts/nfs-export.yaml +++ b/hosts/nfs-export.yaml @@ -5,6 +5,7 @@ name: nfs-export kind: lxc os: linux role: storage-export +state: active host: hubris pve_id: 102 lan_ip: 192.168.8.200 diff --git a/hosts/paperless.yaml b/hosts/paperless.yaml index 71b5372..17bb657 100644 --- a/hosts/paperless.yaml +++ b/hosts/paperless.yaml @@ -5,6 +5,7 @@ name: paperless kind: lxc os: linux role: document-archive +state: active host: hubris pve_id: 103 lan_ip: 192.168.8.130 @@ -25,6 +26,8 @@ services_hosted: - name: paperless url: https://paperless.hubris.network backend: paperless + doc_page: containers/103-paperless.md + risk_notes: "document archive \u2014 treat data as irreplaceable; DB operations are destructive-class" see_also: - containers/103-paperless.md mcp_endpoint: https://mcp.hubris.network/mcp diff --git a/hosts/rclone.yaml b/hosts/rclone.yaml index 45d1341..899f78e 100644 --- a/hosts/rclone.yaml +++ b/hosts/rclone.yaml @@ -5,6 +5,7 @@ name: rclone kind: lxc os: linux role: backup +state: active mesh: netbird: fqdn: rclone.netbird.selfhosted diff --git a/hosts/republic-laptop.yaml b/hosts/republic-laptop.yaml index 23fad6a..c3a280a 100644 --- a/hosts/republic-laptop.yaml +++ b/hosts/republic-laptop.yaml @@ -5,6 +5,7 @@ name: republic-laptop kind: workstation os: linux role: primary-dev +state: active mesh: netbird: fqdn: republic-laptop.netbird.selfhosted diff --git a/hosts/romm.yaml b/hosts/romm.yaml index 2589b43..03b9a91 100644 --- a/hosts/romm.yaml +++ b/hosts/romm.yaml @@ -5,6 +5,7 @@ name: romm kind: lxc os: linux role: rom-manager +state: active host: strong pve_id: 134 lan_ip: 192.168.8.249 diff --git a/hosts/seanime.yaml b/hosts/seanime.yaml index 42ab3b7..1857d2a 100644 --- a/hosts/seanime.yaml +++ b/hosts/seanime.yaml @@ -5,6 +5,7 @@ name: seanime kind: lxc os: linux role: anime-media-server +state: active host: strong pve_id: 133 lan_ip: 192.168.8.248 diff --git a/hosts/sophia.yaml b/hosts/sophia.yaml index 222e00f..c564bb6 100644 --- a/hosts/sophia.yaml +++ b/hosts/sophia.yaml @@ -5,6 +5,7 @@ name: sophia kind: lxc os: linux role: workshop +state: active host: hubris pve_id: 119 lan_ip: 192.168.8.109 diff --git a/hosts/strong.yaml b/hosts/strong.yaml index c926116..463a0f4 100644 --- a/hosts/strong.yaml +++ b/hosts/strong.yaml @@ -5,6 +5,7 @@ name: strong kind: proxmox-host os: linux role: hypervisor +state: active lan_ip: 192.168.178.181 mesh_globals: primary: netbird diff --git a/hosts/trmnl.yaml b/hosts/trmnl.yaml index 13d8db3..2e80ecc 100644 --- a/hosts/trmnl.yaml +++ b/hosts/trmnl.yaml @@ -5,6 +5,7 @@ name: trmnl kind: lxc os: linux role: trmnl-middleware +state: active host: hubris pve_id: 128 lan_ip: 192.168.8.211 @@ -21,6 +22,8 @@ services_hosted: backend: trmnl url: https://trmnl.hubris.network note: self-hosted middleware for TRMNL e-ink plugins (polled by TRMNL cloud) + doc_page: containers/128-trmnl.md + config_repo: dtoro/terminalito see_also: - containers/128-trmnl.md mcp_endpoint: https://mcp.hubris.network/mcp diff --git a/hosts/zimaos.yaml b/hosts/zimaos.yaml index 8b2a553..3fd4ac6 100644 --- a/hosts/zimaos.yaml +++ b/hosts/zimaos.yaml @@ -5,6 +5,7 @@ name: zimaos kind: vm os: linux role: nas-frontend-eval +state: active host: hubris pve_id: 100 lan_ip: 192.168.8.195 @@ -20,6 +21,7 @@ services_hosted: - name: zimaos url: https://zimaos.hubris.network backend: zimaos + doc_page: vms/100-zimaos.md see_also: - vms/100-zimaos.md mcp_endpoint: https://mcp.hubris.network/mcp diff --git a/infrastructure/topology.md b/infrastructure/topology.md new file mode 100644 index 0000000..ae7a31a --- /dev/null +++ b/infrastructure/topology.md @@ -0,0 +1,93 @@ + + + +# Topology (generated) + +Source: [inventory.yaml](../inventory.yaml) — 2 hypervisors, 19 LXCs, 2 VMs, 2 workstations, 17 services. +Edge semantics: [oikos/ontology.yaml](../oikos/ontology.yaml). Operating model: [OIKOS.md](../OIKOS.md). + +## Compute & ingress + +```mermaid +flowchart LR + subgraph hubris_sub["hubris (Proxmox)"] + trmnl["trmnl
LXC 128
trmnl-middleware
192.168.8.211"] + nfs_export["nfs-export
LXC 102
storage-export
192.168.8.200"] + paperless["paperless
LXC 103
document-archive
192.168.8.130"] + gitea["gitea
LXC 104
git-server
192.168.8.121"] + apps["apps
LXC 105
docker-apps
192.168.8.205"] + auth_outpost["auth-outpost
LXC 106
authentik-gateway
192.168.8.6"] + dns["dns
LXC 107
dns-server
192.168.8.2"] + nextcloud["nextcloud
LXC 114
file-sync
192.168.8.224"] + sophia["sophia
LXC 119
workshop
192.168.8.109"] + mule_images["mule-images
LXC 120
photo-management
192.168.8.136"] + caddy["caddy
LXC 121
reverse-proxy
192.168.8.175"] + zimaos["zimaos
VM 100
nas-frontend-eval
192.168.8.195"] + haos["haos
VM 108
home-automation
192.168.8.101"] + end + subgraph strong_sub["strong (Proxmox)"] + house["house
LXC 129
family-planner
192.168.8.244"] + jellyfin["jellyfin
LXC 101
media-server
192.168.8.246"] + elementsynapse["elementsynapse
LXC 118
matrix-server
192.168.8.242"] + arriman["arriman
LXC 122
arr-stack
192.168.8.245"] + grimmory["grimmory
LXC 130
book-library
192.168.8.247"] + seanime["seanime
LXC 133
anime-media-server
192.168.8.248"] + romm["romm
LXC 134
rom-manager
192.168.8.249"] + end + rclone["rclone
lxc
backup"] + republic_laptop([republic-laptop
workstation
primary-dev]) + mac_mini([mac-mini
workstation
dev
192.168.178.182]) + netbird_vps[[netbird-vps
external
netbird-mgmt]] + url_artifacto(["artifacto.hubris.network"]) -->|routes-to| apps + url_authentik(["auth.hubris.network"]) -->|routes-to| netbird_vps + url_gitea(["git.hubris.network"]) -->|routes-to| gitea + url_homelab_mcp(["mcp.hubris.network"]) -->|routes-to| apps + url_jellyfin(["media.hubris.network"]) -->|routes-to| jellyfin + url_matrix(["matrix.hubris.network"]) -->|routes-to| elementsynapse + url_nextcloud(["cloud.hubris.network"]) -->|routes-to| nextcloud + url_paperless(["paperless.hubris.network"]) -->|routes-to| paperless + url_photos(["photos.hubris.network"]) -->|routes-to| mule_images + url_proxmox_ui(["proxmox.hubris.network"]) -->|routes-to| hubris_sub + url_secrets_issuance(["secrets.hubris.network"]) -->|routes-to| apps + url_trmnl(["trmnl.hubris.network"]) -->|routes-to| trmnl + url_zimaos(["zimaos.hubris.network"]) -->|routes-to| zimaos +``` + +## Storage (mounts) + +```mermaid +flowchart LR + mnt_library[("/mnt/library")] + mnt_media_local[("/mnt/media_local")] + mnt_media_local_anime[("/mnt/media_local/anime")] + apps["apps"] -->|mounts| mnt_library + arriman["arriman"] -->|mounts| mnt_media_local + gitea["gitea"] -->|mounts| mnt_library + grimmory["grimmory"] -->|mounts| mnt_media_local + hubris["hubris"] -->|mounts| mnt_library + jellyfin["jellyfin"] -->|mounts| mnt_media_local + mule_images["mule-images"] -->|mounts| mnt_library + nextcloud["nextcloud"] -->|mounts| mnt_library + paperless["paperless"] -->|mounts| mnt_library + romm["romm"] -->|mounts| mnt_media_local + seanime["seanime"] -->|mounts| mnt_media_local_anime + sophia["sophia"] -->|mounts| mnt_library +``` + +## Archaeology (destroyed nodes) + +| Node | ID | Destroyed | Reason | +|---|---|---|---| +| plato | 126 | 2026-06-28 | notes workspace decommissioned; data retained at /mnt/library/documents/plato | +| claudio-bot | 123 | 2026-06-04 | replaced by Hermes Agent on mac-mini; monitoring moved to homelab-health-watchdog cron | +| mule-photos-new | 127 | 2026-05-22 | PhotoPrism test stack promoted to LXC 120 (Mulimage 2.0 merge) | +| heaper | 116 | 2026-05-14 | decommissioned; data retained at /mnt/library/heaper | +| syncthing | 109 | 2026-05-14 | decommissioned; library subtree was empty | +| seafile | 125 | 2026-05-13 | Seafile Pro evaluation rejected; files.hubris.network removed from caddy + dns | +| arr-yunohost | 100 | 2026-04-28 | migrated to docker stack on arriman (LXC 122) | +| flaresolverr | 106 | 2026-04-28 | folded into the arriman docker compose | +| marimo | 107 | 2026-04-28 | decommissioned | +| photoprism | 110 | 2026-04-28 | replaced by mule-images (LXC 120) | +| karakeep | 111 | 2026-04-28 | decommissioned | +| immich | 112 | 2026-04-28 | replaced by mule-images (LXC 120) | +| reticulum | 115 | 2026-04-28 | decommissioned | diff --git a/inventory.yaml b/inventory.yaml index 080f43d..3eed71c 100644 --- a/inventory.yaml +++ b/inventory.yaml @@ -35,72 +35,118 @@ mesh: - tailscale netbird_subnet: 100.122.0.0/16 netbird_domain: netbird.selfhosted +# Service contract (Oikos, 2026-07-05): each service should carry +# backend host/container that runs it (required) +# url public URL if ingress-exposed +# doc_page owning wiki page +# config_repo tracked config repo, if any (mutations go commit+push) +# health health-check URL if it differs from `url` +# risk_notes what an agent must know before touching it +# See oikos/ontology.yaml + oikos/policy.yaml. services: proxmox_ui: url: https://proxmox.hubris.network backend: hubris port: 8006 + doc_page: hosts/hubris.md + risk_notes: hypervisor UI — changes here affect every guest on the node gitea: url: https://git.hubris.network backend: gitea backend_url: http://192.168.8.121:3000 + doc_page: containers/104-gitea.md + config_repo: dtoro/gitea-customizations + risk_notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync caddy: backend: caddy role: reverse-proxy note: terminates all *.hubris.network + doc_page: 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) authentik: url: https://auth.hubris.network - backend: authentik - dns: + backend: netbird-vps + doc_page: containers/106-auth-outpost.md + note: >- + core runs on the VPS since 2026-05-31; LAN forward-auth outpost is + auth-outpost (LXC 106) at 192.168.8.6:9000. Previous backend value + "authentik" referenced the retired embedded-outpost host (LXC 124). + risk_notes: SSO provider — outage locks login to OIDC/forward-auth services + dns: backend: dns note: Technitium DNS, split-horizon zone + doc_page: containers/107-dns.md + risk_notes: LAN-wide resolver — misconfig breaks name resolution for every client jellyfin: url: https://media.hubris.network backend: jellyfin + doc_page: 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 nextcloud: url: https://cloud.hubris.network backend: nextcloud + doc_page: containers/114-nextcloud.md paperless: url: https://paperless.hubris.network backend: paperless + doc_page: containers/103-paperless.md + risk_notes: document archive — treat data as irreplaceable; DB operations are destructive-class matrix: url: https://matrix.hubris.network backend: elementsynapse + doc_page: containers/118-elementsynapse.md + risk_notes: alert/approval channel for Oikos — outage silences agent escalation photos: url: https://photos.hubris.network backend: mule-images + doc_page: containers/120-mule-images.md + config_repo: dtoro/mule-image arr_stack: backend: arriman note: jellyseerr / qbit / sab on docker compose + doc_page: containers/122-arriman.md artifacto: backend: apps url: https://artifacto.hubris.network + doc_page: containers/105-apps.md + config_repo: dtoro/Artifacto trmnl: backend: trmnl url: https://trmnl.hubris.network note: self-hosted middleware for TRMNL e-ink plugins (polled by TRMNL cloud) + doc_page: containers/128-trmnl.md + config_repo: dtoro/terminalito zimaos: url: https://zimaos.hubris.network backend: zimaos + doc_page: vms/100-zimaos.md haos: backend: haos + doc_page: vms/108-haos.md homelab_mcp: backend: apps port: 9810 systemd_unit: homelab-mcp public_host: mcp.hubris.network endpoint: https://mcp.hubris.network/mcp + doc_page: infrastructure/homelab-context.md + config_repo: dtoro/Homelab-Docs note: MCP server. Read-only context + management. Reachable on the LAN via Caddy and from off-LAN via Netbird (192.168.8.0/24 is a network resource routed through hubris). + risk_notes: agents' primary read surface — outage degrades every agent to grepping the clone secrets_issuance: backend: apps port: 9820 systemd_unit: secrets-issuance public_host: secrets.hubris.network endpoint: https://secrets.hubris.network/issue + doc_page: operations/agent-enrollment.md + config_repo: dtoro/Homelab-Docs note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN subnets in MESH_SUBNETS). + risk_notes: identity issuance — any change is security-sensitive; key operations are destructive-class hosts: hubris: kind: proxmox-host @@ -374,8 +420,6 @@ hosts: - MariaDB sidecar at /opt/romm/docker-compose.yml. - ROMs on ludo-lvm media volume at /mnt/media_local/roms. - 1 core / 2 GiB RAM / 16 GiB rootfs (ludo-lvm). - # 123 (claudio-bot) — destroyed 2026-06-04, replaced by Hermes Agent - # 126 (plato) — destroyed 2026-06-28, notes workspace decommissioned zimaos: kind: vm pve_id: 100 @@ -470,3 +514,74 @@ hosts: netbird: fqdn: rclone.netbird.selfhosted age_pubkey: age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x + +# Destroyed nodes (lifecycle state: destroyed — see oikos/ontology.yaml). +# Kept so agents can answer "what happened to X?" from structured data and +# so drift detectors can flag anything still referencing them. +# Full narrative table: containers/index.md "Recently destroyed". +archaeology: + claudio-bot: + kind: lxc + pve_id: 123 + destroyed: 2026-06-04 + reason: replaced by Hermes Agent on mac-mini; monitoring moved to homelab-health-watchdog cron + plato: + kind: lxc + pve_id: 126 + destroyed: 2026-06-28 + reason: notes workspace decommissioned; data retained at /mnt/library/documents/plato + mule-photos-new: + kind: lxc + pve_id: 127 + destroyed: 2026-05-22 + reason: PhotoPrism test stack promoted to LXC 120 (Mulimage 2.0 merge) + heaper: + kind: lxc + pve_id: 116 + destroyed: 2026-05-14 + reason: decommissioned; data retained at /mnt/library/heaper + syncthing: + kind: lxc + pve_id: 109 + destroyed: 2026-05-14 + reason: decommissioned; library subtree was empty + seafile: + kind: lxc + pve_id: 125 + destroyed: 2026-05-13 + reason: Seafile Pro evaluation rejected; files.hubris.network removed from caddy + dns + arr-yunohost: + kind: lxc + pve_id: 100 + destroyed: 2026-04-28 + reason: migrated to docker stack on arriman (LXC 122) + flaresolverr: + kind: lxc + pve_id: 106 + destroyed: 2026-04-28 + reason: folded into the arriman docker compose + marimo: + kind: lxc + pve_id: 107 + destroyed: 2026-04-28 + reason: decommissioned + photoprism: + kind: lxc + pve_id: 110 + destroyed: 2026-04-28 + reason: replaced by mule-images (LXC 120) + karakeep: + kind: lxc + pve_id: 111 + destroyed: 2026-04-28 + reason: decommissioned + immich: + kind: lxc + pve_id: 112 + destroyed: 2026-04-28 + reason: replaced by mule-images (LXC 120) + reticulum: + kind: lxc + pve_id: 115 + destroyed: 2026-04-28 + reason: decommissioned diff --git a/mcp/build_host_files.py b/mcp/build_host_files.py index 10099ff..9630ebb 100755 --- a/mcp/build_host_files.py +++ b/mcp/build_host_files.py @@ -68,8 +68,12 @@ def build_one(name: str, entry: dict, inventory: dict) -> dict: "kind": entry.get("kind"), "os": entry.get("os"), "role": entry.get("role"), + # Oikos lifecycle (oikos/ontology.yaml); absent in inventory = active + "state": entry.get("state", "active"), "host": entry.get("host"), "pve_id": pve_id, + "storage": entry.get("storage"), + "depends_on": entry.get("depends_on", []), "lan_ip": entry.get("lan_ip"), "mesh": entry.get("mesh", {}), "mesh_globals": { diff --git a/oikos/gen-topology.py b/oikos/gen-topology.py new file mode 100644 index 0000000..676c02d --- /dev/null +++ b/oikos/gen-topology.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Generate infrastructure/topology.md (Mermaid views) from inventory.yaml. + +Views: + 1. Compute & ingress — hypervisors → guests → services → public URLs + 2. Storage — mounts and pools per guest + +Run from the repo root: + python3 oikos/gen-topology.py # writes infrastructure/topology.md + python3 oikos/gen-topology.py --check # exit 1 if output would change + +Wired into the same regeneration path as mcp/build_host_files.py so the +diagrams never drift from inventory. Edges follow oikos/ontology.yaml +(hosts, provides, routes-to, mounts, stores-on). +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +try: + import yaml +except ImportError: # pragma: no cover + print("PyYAML is required: pip install pyyaml", file=sys.stderr) + sys.exit(2) + +REPO = Path(__file__).resolve().parent.parent +INVENTORY = REPO / "inventory.yaml" +OUTPUT = REPO / "infrastructure" / "topology.md" + +BANNER = ( + "\n" + "\n" +) + + +def node_id(name: str) -> str: + """Mermaid-safe node id.""" + return name.replace("-", "_").replace(".", "_").replace("/", "_").strip("_") + + +def guest_label(name: str, entry: dict) -> str: + pve = entry.get("pve_id") + role = entry.get("role", "") + tag = f"LXC {pve}" if entry.get("kind") == "lxc" and pve else \ + f"VM {pve}" if entry.get("kind") == "vm" and pve else entry.get("kind", "") + ip = entry.get("lan_ip", "") + parts = [name, tag, role, ip] + return "
".join(str(p) for p in parts if p) + + +def compute_view(inv: dict) -> list[str]: + hosts = inv.get("hosts", {}) + services = inv.get("services", {}) + lines = ["```mermaid", "flowchart LR"] + + hypervisors = {n: e for n, e in hosts.items() if e.get("kind") == "proxmox-host"} + guests = {n: e for n, e in hosts.items() if e.get("kind") in ("lxc", "vm")} + others = {n: e for n, e in hosts.items() + if e.get("kind") in ("workstation", "external")} + + for hv in hypervisors: + lines.append(f' subgraph {node_id(hv)}_sub["{hv} (Proxmox)"]') + for g, e in guests.items(): + if e.get("host") == hv: + lines.append(f' {node_id(g)}["{guest_label(g, e)}"]') + lines.append(" end") + + # guests without a parent hypervisor recorded (e.g. rclone) + for g, e in guests.items(): + if e.get("host") not in hypervisors: + lines.append(f' {node_id(g)}["{guest_label(g, e)}"]') + + for n, e in others.items(): + shape = "([{}])" if e.get("kind") == "workstation" else "[[{}]]" + lines.append(f' {node_id(n)}{shape.format(guest_label(n, e))}') + + # ingress: public URL -> backend (routes-to) + for svc, e in sorted(services.items()): + if not isinstance(e, dict): + continue + backend = e.get("backend") + url = e.get("url") or ( + f'https://{e["public_host"]}' if e.get("public_host") else None) + if backend and url and backend in hosts: + host = url.removeprefix("https://").removeprefix("http://") + # hypervisors are rendered as subgraphs; point edges at the subgraph id + target = node_id(backend) + ("_sub" if backend in hypervisors else "") + lines.append( + f' {node_id("url_" + svc)}(["{host}"]) -->|routes-to| {target}') + + lines.append("```") + return lines + + +def storage_view(inv: dict) -> list[str]: + hosts = inv.get("hosts", {}) + lines = ["```mermaid", "flowchart LR"] + pools: set[str] = set() + edges: list[str] = [] + + for name, e in hosts.items(): + for mount in e.get("mounts", []): + pools.add(mount) + edges.append(f' {node_id(name)}["{name}"] -->|mounts| {node_id(mount)}') + + for pool in sorted(pools): + lines.append(f' {node_id(pool)}[("{pool}")]') + lines.extend(sorted(set(edges))) + lines.append("```") + return lines + + +def archaeology_table(inv: dict) -> list[str]: + arch = inv.get("archaeology", {}) + if not arch: + return [] + lines = ["| Node | ID | Destroyed | Reason |", "|---|---|---|---|"] + entries = sorted(arch.items(), key=lambda kv: str(kv[1].get("destroyed", "")), + reverse=True) + for name, e in entries: + lines.append( + f'| {name} | {e.get("pve_id", "")} | {e.get("destroyed", "")} ' + f'| {e.get("reason", "")} |') + return lines + + +def render(inv: dict) -> str: + hosts = inv.get("hosts", {}) + services = inv.get("services", {}) + counts = ( + f"{sum(1 for e in hosts.values() if e.get('kind') == 'proxmox-host')} hypervisors, " + f"{sum(1 for e in hosts.values() if e.get('kind') == 'lxc')} LXCs, " + f"{sum(1 for e in hosts.values() if e.get('kind') == 'vm')} VMs, " + f"{sum(1 for e in hosts.values() if e.get('kind') == 'workstation')} workstations, " + f"{len(services)} services" + ) + parts = [ + BANNER, + "# Topology (generated)\n", + f"Source: [inventory.yaml](../inventory.yaml) — {counts}.", + "Edge semantics: [oikos/ontology.yaml](../oikos/ontology.yaml). " + "Operating model: [OIKOS.md](../OIKOS.md).\n", + "## Compute & ingress\n", + "\n".join(compute_view(inv)) + "\n", + "## Storage (mounts)\n", + "\n".join(storage_view(inv)) + "\n", + ] + arch = archaeology_table(inv) + if arch: + parts += ["## Archaeology (destroyed nodes)\n", "\n".join(arch) + "\n"] + return "\n".join(parts) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true", + help="exit 1 if output would change (don't write)") + args = parser.parse_args() + + inv = yaml.safe_load(INVENTORY.read_text()) + content = render(inv) + existing = OUTPUT.read_text() if OUTPUT.exists() else "" + if existing == content: + return 0 + if args.check: + print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr) + return 1 + OUTPUT.write_text(content) + print(f"wrote {OUTPUT.relative_to(REPO)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/oikos/ontology.yaml b/oikos/ontology.yaml new file mode 100644 index 0000000..d7d8056 --- /dev/null +++ b/oikos/ontology.yaml @@ -0,0 +1,137 @@ +# Oikos ontology — the systems model of the homelab. +# +# This file defines the closed vocabulary Oikos reasons with: entity +# types (grouped into eight domains), typed relationships (with inverses), +# and the node lifecycle. inventory.yaml holds the *instances*; this file +# defines what those instances and their fields MEAN, so agents, the +# decision classifier, and the topology generator interpret them +# identically. See OIKOS.md for the operating model. +# +# Rule of completeness: if something can break, be changed, or hold data, +# it has an entity type here and edges to the things it touches. + +domains: + physical: + description: Hardware and environment. + entity_types: [site, machine, ups, sensor, peripheral] + compute: + description: Things that execute workloads. + entity_types: [proxmox-host, lxc, vm, workstation, external-host, device] + network: + description: How things reach each other. + entity_types: [lan, mesh, dns-zone, dns-record, ingress-route, certificate, firewall-rule] + storage: + description: Where data lives and how it survives. + entity_types: [storage-pool, volume, mount, backup-target, dataset] + software: + description: What runs and how it is configured and shipped. + entity_types: [service, application, config-repo, package-set, deploy-pipeline] + identity_access: + description: Who and what may do which things. + entity_types: [person, identity-provider, account, secret, key, access-grant] + operations: + description: The OS's own working objects. + entity_types: [agent, runbook, plan, change, incident, signal, approval, report] + external: + description: Dependencies outside the lab's control. + entity_types: [domain-registration, cloud-service, isp-link, vendor-dependency] + +# Relationships. `source:` says which inventory/repo data expresses the edge +# today (thin = not yet structured, derive from docs until backfilled). +relationships: + hosts: + inverse: runs-on + example: hubris hosts lxc:apps + source: hosts..host + pve_id + provides: + inverse: provided-by + example: lxc:apps provides service:homelab_mcp + source: hosts..runs + services..backend + mounts: + inverse: mounted-by + example: lxc:jellyfin mounts /mnt/media_local from strong + source: hosts..mounts (extend with from:) + stores-on: + inverse: stores-for + example: lxc:jellyfin rootfs stores-on storage-pool:ludo-lvm + source: hosts..storage (new field) + routes-to: + inverse: routed-via + example: ingress-route:media.hubris.network routes-to service:jellyfin + source: services..url/public_host + dtoro/caddy-conf + resolves-to: + inverse: resolved-from + example: dns-record:media.hubris.network resolves-to caddy lan_ip + source: Technitium split-horizon zone (LXC 107) + dns-sync job + secured-by: + inverse: secures + example: ingress-route:paperless secured-by identity-provider:authentik + source: caddy-conf forward-auth blocks + service auth notes + authenticates-via: + inverse: authenticates + example: service:jellyfin authenticates-via authentik (native OIDC) + source: services..auth (new field, from risk_notes/docs) + connects-via: + inverse: connects + example: workstation:mac-mini connects-via mesh:netbird + source: hosts..mesh + can-decrypt: + inverse: readable-by + example: lxc:apps can-decrypt secret:gitea-pat + source: .sops.yaml path rules + hosts..age_pubkey + configured-by: + inverse: configures + example: lxc:caddy configured-by config-repo:dtoro/caddy-conf + source: services..config_repo (new field) + deploys-to: + inverse: deployed-from + example: deploy-pipeline:webhook-10 deploys-to /opt/homelab-mcp on lxc:apps + source: infrastructure/auto-deploy.md table + monitors: + inverse: monitored-by + example: agent:scheduler monitors service:* (Week 3) + source: oikos/scheduler config + depends-on: + inverse: dependency-of + example: service:paperless depends-on service:authentik + source: hosts/services depends_on (new field) + backs-up-to: + inverse: backup-of + example: dataset:nextcloud-data backs-up-to backup-target:proton-drive + source: infrastructure backups docs → structured field (thin) + documents: + inverse: documented-by + example: containers/101-jellyfin.md documents lxc:jellyfin + source: generated see_also / services..doc_page + powered-by: + inverse: powers + example: machine:hubris powered-by ups (future, thin record) + source: physical domain (thin) + registered-with: + inverse: registrar-of + example: domain-registration:hubris.network registered-with registrar + source: external domain (thin) + +# Node lifecycle. Stored as `state:` on each inventory host entry +# (absent = active, for backward compatibility). Transitions are runbooks +# (Week 2); drift detectors (Week 3) verify declared state matches reality. +lifecycle: + states: [planned, provisioning, active, migrating, deprecated, destroyed] + default: active + transitions: + planned->provisioning: + requires: [inventory-entry, ip-reserved, storage-pool-chosen, doc-page-stub] + provisioning->active: + requires: [age-key-enrolled-if-needed, mesh-joined-if-needed, + ingress-live-if-public, health-check-answering, + doc-page-complete, ledger-entry] + active->migrating: + requires: [preflight, backup-verified] + migrating->active: + requires: [post-verify, caddy-backends-checked, mounts-checked, docs-updated] + active->deprecated: + requires: [replacement-live-or-role-retired] + complete_when: no inbound depends-on / routes-to edges remain + deprecated->destroyed: + requires: [backups-verified, secrets-revoked-and-rekeyed, + ingress-and-dns-removed, archaeology-entry, ledger-entry] diff --git a/oikos/policy.yaml b/oikos/policy.yaml new file mode 100644 index 0000000..46c3544 --- /dev/null +++ b/oikos/policy.yaml @@ -0,0 +1,117 @@ +# Oikos risk & approval policy — machine-readable safety model. +# +# Every operation an agent can perform maps to exactly one risk class. +# The decision classifier (oikos/decide.py, Week 3) and the homelab CLI +# consult this file before executing; agents consult it before proposing. +# See OIKOS.md for the operating model. +# +# Autonomy default (operator decision 2026-07-05): unattended agents may +# perform read_only and reversible_low actions; config_mutation and +# destructive always require operator approval. + +risk_classes: + read_only: + description: Observes state; cannot change anything. + approval: none + ledger: false + reversible_low: + description: >- + Changes runtime state in a way a single follow-up command undoes + (restart, cache clear, sync pull). No config or data changes. + approval: none + ledger: true # every mutation leaves a ledger entry + config_mutation: + description: >- + Changes tracked configuration or deployed software: repo edit + push, + deploy pipeline trigger, Caddy/Gitea/app config, package upgrades. + Reversible via git, but affects other consumers. + approval: operator # Matrix ✅/❌ reaction (Week 3 approval engine) + ledger: true + destructive: + description: >- + Destroys or irreversibly alters data/entities: container destroy, + disk format, DB wipe, secret rotation, client revocation. + approval: operator_confirmed # approval + typed confirmation phrase + ledger: true + +# Lifecycle gates (see ontology.yaml lifecycle): +# provisioning: config_mutation downgraded to reversible_low (no dependents yet) +# deprecated: adding new inbound edges (depends-on/routes-to) is refused +# destroyed: any action targeting the entity raises a drift signal +lifecycle_overrides: + provisioning: + config_mutation: reversible_low + deprecated: + refuse: [new-inbound-edges] + destroyed: + refuse: [all] + +# homelab CLI subcommands → risk class +commands: + whoami: read_only + list: read_only + status: read_only + logs: read_only + open: read_only + ssh-keyscan: read_only + apt-audit: read_only + mcp: read_only # MCP tools are individually classified below + secret: read_only # decrypt-to-stdout; never write secrets to files/docs + ssh: read_only # interactive shell itself; actions inside it carry + # their own class — agents must not use raw ssh to + # bypass policy (HERMES.md convention) + sync: reversible_low + refresh-creds: reversible_low + ssh-config: reversible_low # rewrites ~/.ssh/config, regenerable + apt-upgrade: config_mutation + render-vps-configs: config_mutation + client-add: config_mutation + client-remove: destructive # revokes key + re-keys all secrets + +# MCP tools → risk class (all currently read-only by design) +mcp_tools: + get_host: read_only + list_services: read_only + find_service: read_only + get_topology: read_only + search_docs: read_only + get_page: read_only + get_changelog: read_only + whoami: read_only + get_service_status: read_only + tail_log: read_only + list_lxcs: read_only + get_lxc_state: read_only + ping_service: read_only + list_my_secrets: read_only + +# Common operational actions (not yet CLI subcommands) → risk class. +# Used by agents to classify ad-hoc work until Week 2/3 wraps them in +# `homelab service` / runbooks. +actions: + service-restart: reversible_low + cache-clear: reversible_low + docker-compose-restart: reversible_low + tracked-config-edit: config_mutation # commit+push to config repo, never local edit + deploy-webhook-trigger: config_mutation + lxc-create: config_mutation # new entity, state: provisioning + lxc-migrate: config_mutation + dns-record-change: config_mutation + ingress-route-change: config_mutation + secret-rotate: destructive + lxc-destroy: destructive + disk-format: destructive + db-wipe: destructive + storage-pool-change: destructive + +# Per-service overrides (schema ready; populate as needs emerge). +# Example: +# jellyfin: +# service-restart: reversible_low # default anyway +# caddy: +# service-restart: config_mutation # wide blast radius: all ingress +service_overrides: + caddy: + service-restart: config_mutation # everything *.hubris.network rides on it + dns: + service-restart: config_mutation # LAN-wide resolver