Oikos Week 2: Service Console v0, change ledger, node relations, runbooks
Adds the shared kernel modules (oikos/policy.py, oikos/relations.py, oikos/ledger.py) that let every surface — CLI, MCP, context-card generator — agree on risk classification and ontology graph walks from one implementation. homelab CLI: `service <name> explain|health|docs|log|actions|history` (Service Console v0), `change preflight <service>`, `node <name> relations`. Restart and client add/remove now append change-ledger entries (ledger/*.jsonl, committed alongside the change they record). mcp/server.py mirrors explain/preflight/get_relations/get_change_history as MCP tools, card-first so agent orientation is one call instead of several search_docs/get_page round-trips. oikos/gen-topology.py now also emits a compact context card per host and service (oikos/cards/*.md) — identity, blast radius, safe actions + risk class, doc pointer, recent ledger history. runbooks/*.md: service health check, config change + deploy, client enrollment, incident investigation, and the five node lifecycle transitions (provision/activate/migrate/deprecate/destroy), each with machine-readable frontmatter (risk class, inputs, verification, docs-update checklist). Wired into HERMES.md so agents load these instead of rediscovering topology per-task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
0
oikos/__init__.py
Normal file
0
oikos/__init__.py
Normal file
21
oikos/cards/host-apps.md
Normal file
21
oikos/cards/host-apps.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# apps (host:apps)
|
||||
|
||||
- kind: lxc (LXC 105)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: docker-apps
|
||||
- address: 192.168.8.205 (mesh: tailscale:apps)
|
||||
- mounts: /mnt/library
|
||||
- doc: containers/105-apps.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:artifacto, service:homelab_mcp, service:secrets_issuance
|
||||
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/Artifacto, repo:dtoro/Homelab-Docs
|
||||
- full blast radius: service:artifacto, service:homelab_mcp, service:secrets_issuance
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/host-arriman.md
Normal file
20
oikos/cards/host-arriman.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# arriman (host:arriman)
|
||||
|
||||
- kind: lxc (LXC 122)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: arr-stack
|
||||
- address: 192.168.8.245 (mesh: tailscale:arr)
|
||||
- mounts: /mnt/media_local
|
||||
- doc: containers/122-arriman.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:arr_stack
|
||||
- affected by: host:strong, mount:/mnt/media_local
|
||||
- full blast radius: service:arr_stack
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
18
oikos/cards/host-auth-outpost.md
Normal file
18
oikos/cards/host-auth-outpost.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# auth-outpost (host:auth-outpost)
|
||||
|
||||
- kind: lxc (LXC 106)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: authentik-gateway
|
||||
- address: 192.168.8.6
|
||||
- doc: containers/106-auth-outpost.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:hubris
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-caddy.md
Normal file
19
oikos/cards/host-caddy.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# caddy (host:caddy)
|
||||
|
||||
- kind: lxc (LXC 121)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: reverse-proxy
|
||||
- address: 192.168.8.175
|
||||
- doc: containers/121-caddy.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:caddy
|
||||
- affected by: host:hubris, repo:dtoro/caddy-conf
|
||||
- full blast radius: service:caddy
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-dns.md
Normal file
19
oikos/cards/host-dns.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# dns (host:dns)
|
||||
|
||||
- kind: lxc (LXC 107)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: dns-server
|
||||
- address: 192.168.8.2
|
||||
- doc: containers/107-dns.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:dns
|
||||
- affected by: host:hubris
|
||||
- full blast radius: service:dns
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-elementsynapse.md
Normal file
19
oikos/cards/host-elementsynapse.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# elementsynapse (host:elementsynapse)
|
||||
|
||||
- kind: lxc (LXC 118)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: matrix-server
|
||||
- address: 192.168.8.242
|
||||
- doc: containers/118-elementsynapse.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:matrix
|
||||
- affected by: host:strong
|
||||
- full blast radius: service:matrix
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/host-gitea.md
Normal file
20
oikos/cards/host-gitea.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# gitea (host:gitea)
|
||||
|
||||
- kind: lxc (LXC 104)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: git-server
|
||||
- address: 192.168.8.121 (mesh: tailscale:gitea)
|
||||
- mounts: /mnt/library
|
||||
- doc: containers/104-gitea.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:gitea
|
||||
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/gitea-customizations
|
||||
- full blast radius: service:gitea
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/host-grimmory.md
Normal file
20
oikos/cards/host-grimmory.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# grimmory (host:grimmory)
|
||||
|
||||
- kind: lxc (LXC 130)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: book-library
|
||||
- address: 192.168.8.247
|
||||
- mounts: /mnt/media_local
|
||||
- doc: containers/130-grimmory.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:strong, mount:/mnt/media_local
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-haos.md
Normal file
19
oikos/cards/host-haos.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# haos (host:haos)
|
||||
|
||||
- kind: vm (VM 108)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: home-automation
|
||||
- address: 192.168.8.101 (mesh: tailscale:homeassistant)
|
||||
- doc: vms/108-haos.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:haos
|
||||
- affected by: host:hubris
|
||||
- full blast radius: service:haos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-house.md
Normal file
19
oikos/cards/host-house.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# house (host:house)
|
||||
|
||||
- kind: lxc (LXC 129)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: family-planner
|
||||
- address: 192.168.8.244
|
||||
- doc: containers/129-house.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:strong
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/host-hubris.md
Normal file
20
oikos/cards/host-hubris.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# hubris (host:hubris)
|
||||
|
||||
- kind: proxmox-host
|
||||
- state: active
|
||||
- role: hypervisor
|
||||
- address: 192.168.8.77 (mesh: netbird:proxmox-server.netbird.selfhosted)
|
||||
- mounts: /mnt/library
|
||||
- doc: hosts/hubris.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: host:apps, host:auth-outpost, host:caddy, host:dns, host:gitea, host:haos, host:mule-images, host:nextcloud, host:nfs-export, host:paperless, host:sophia, host:trmnl, host:zimaos, service:proxmox_ui
|
||||
- affected by: mount:/mnt/library
|
||||
- full blast radius: host:apps, host:auth-outpost, host:caddy, host:dns, host:gitea, host:haos, host:mule-images, host:nextcloud, host:nfs-export, host:paperless, host:sophia, host:trmnl, host:zimaos, service:artifacto, service:caddy, service:dns, service:gitea, service:haos, service:homelab_mcp, service:nextcloud, service:paperless, service:photos, service:proxmox_ui, service:secrets_issuance, service:trmnl, service:zimaos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/host-jellyfin.md
Normal file
20
oikos/cards/host-jellyfin.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# jellyfin (host:jellyfin)
|
||||
|
||||
- kind: lxc (LXC 101)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: media-server
|
||||
- address: 192.168.8.246 (mesh: tailscale:jellyfin)
|
||||
- mounts: /mnt/media_local
|
||||
- doc: containers/101-jellyfin.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:jellyfin
|
||||
- affected by: host:strong, mount:/mnt/media_local
|
||||
- full blast radius: service:jellyfin
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
17
oikos/cards/host-mac-mini.md
Normal file
17
oikos/cards/host-mac-mini.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# mac-mini (host:mac-mini)
|
||||
|
||||
- kind: workstation
|
||||
- state: active
|
||||
- role: dev
|
||||
- address: 192.168.178.182 (mesh: netbird:mac-mini-234-17.netbird.selfhosted)
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: (none)
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/host-mule-images.md
Normal file
20
oikos/cards/host-mule-images.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# mule-images (host:mule-images)
|
||||
|
||||
- kind: lxc (LXC 120)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: photo-management
|
||||
- address: 192.168.8.136 (mesh: tailscale:muleimage)
|
||||
- mounts: /mnt/library
|
||||
- doc: containers/120-mule-images.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:photos
|
||||
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/mule-image
|
||||
- full blast radius: service:photos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
17
oikos/cards/host-netbird-vps.md
Normal file
17
oikos/cards/host-netbird-vps.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# netbird-vps (host:netbird-vps)
|
||||
|
||||
- kind: external
|
||||
- state: active
|
||||
- role: netbird-mgmt
|
||||
- address: (mesh: netbird:netbird-ionos.netbird.selfhosted)
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:authentik
|
||||
- affected by: (none)
|
||||
- full blast radius: service:authentik
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/host-nextcloud.md
Normal file
20
oikos/cards/host-nextcloud.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# nextcloud (host:nextcloud)
|
||||
|
||||
- kind: lxc (LXC 114)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: file-sync
|
||||
- address: 192.168.8.224 (mesh: tailscale:nextcloud)
|
||||
- mounts: /mnt/library
|
||||
- doc: containers/114-nextcloud.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:nextcloud
|
||||
- affected by: host:hubris, mount:/mnt/library
|
||||
- full blast radius: service:nextcloud
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
18
oikos/cards/host-nfs-export.md
Normal file
18
oikos/cards/host-nfs-export.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# nfs-export (host:nfs-export)
|
||||
|
||||
- kind: lxc (LXC 102)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: storage-export
|
||||
- address: 192.168.8.200
|
||||
- doc: containers/102-nfs-export.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:hubris
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/host-paperless.md
Normal file
20
oikos/cards/host-paperless.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# paperless (host:paperless)
|
||||
|
||||
- kind: lxc (LXC 103)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: document-archive
|
||||
- address: 192.168.8.130 (mesh: tailscale:paperless)
|
||||
- mounts: /mnt/library
|
||||
- doc: containers/103-paperless.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:paperless
|
||||
- affected by: host:hubris, mount:/mnt/library
|
||||
- full blast radius: service:paperless
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
17
oikos/cards/host-rclone.md
Normal file
17
oikos/cards/host-rclone.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# rclone (host:rclone)
|
||||
|
||||
- kind: lxc
|
||||
- state: active
|
||||
- role: backup
|
||||
- address: (mesh: netbird:rclone.netbird.selfhosted)
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: (none)
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
16
oikos/cards/host-republic-laptop.md
Normal file
16
oikos/cards/host-republic-laptop.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# republic-laptop (host:republic-laptop)
|
||||
|
||||
- kind: workstation
|
||||
- state: active
|
||||
- role: primary-dev
|
||||
- address: (mesh: netbird:republic-laptop.netbird.selfhosted)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: (none)
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-romm.md
Normal file
19
oikos/cards/host-romm.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# romm (host:romm)
|
||||
|
||||
- kind: lxc (LXC 134)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: rom-manager
|
||||
- address: 192.168.8.249
|
||||
- mounts: /mnt/media_local
|
||||
- doc: containers/134-romm.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:strong, mount:/mnt/media_local
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-seanime.md
Normal file
19
oikos/cards/host-seanime.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# seanime (host:seanime)
|
||||
|
||||
- kind: lxc (LXC 133)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: anime-media-server
|
||||
- address: 192.168.8.248
|
||||
- mounts: /mnt/media_local/anime
|
||||
- doc: containers/133-seanime.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:strong, mount:/mnt/media_local/anime
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-sophia.md
Normal file
19
oikos/cards/host-sophia.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# sophia (host:sophia)
|
||||
|
||||
- kind: lxc (LXC 119)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: workshop
|
||||
- address: 192.168.8.109 (mesh: tailscale:sophia)
|
||||
- mounts: /mnt/library
|
||||
- doc: containers/119-sophia.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:hubris, mount:/mnt/library
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-strong.md
Normal file
19
oikos/cards/host-strong.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# strong (host:strong)
|
||||
|
||||
- kind: proxmox-host
|
||||
- state: active
|
||||
- role: hypervisor
|
||||
- address: 192.168.178.181
|
||||
- doc: hosts/strong.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: host:arriman, host:elementsynapse, host:grimmory, host:house, host:jellyfin, host:romm, host:seanime
|
||||
- affected by: (none)
|
||||
- full blast radius: host:arriman, host:elementsynapse, host:grimmory, host:house, host:jellyfin, host:romm, host:seanime, service:arr_stack, service:jellyfin, service:matrix
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-trmnl.md
Normal file
19
oikos/cards/host-trmnl.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# trmnl (host:trmnl)
|
||||
|
||||
- kind: lxc (LXC 128)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: trmnl-middleware
|
||||
- address: 192.168.8.211
|
||||
- doc: containers/128-trmnl.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:trmnl
|
||||
- affected by: host:hubris, repo:dtoro/terminalito
|
||||
- full blast radius: service:trmnl
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/host-zimaos.md
Normal file
19
oikos/cards/host-zimaos.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# zimaos (host:zimaos)
|
||||
|
||||
- kind: vm (VM 100)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: nas-frontend-eval
|
||||
- address: 192.168.8.195
|
||||
- doc: vms/100-zimaos.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:zimaos
|
||||
- affected by: host:hubris
|
||||
- full blast radius: service:zimaos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
17
oikos/cards/service-arr_stack.md
Normal file
17
oikos/cards/service-arr_stack.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# arr_stack (service:arr_stack)
|
||||
|
||||
- backend: host:arriman
|
||||
- doc: containers/122-arriman.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:arriman
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/service-artifacto.md
Normal file
20
oikos/cards/service-artifacto.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# artifacto (service:artifacto)
|
||||
|
||||
- backend: host:apps
|
||||
- url: https://artifacto.hubris.network
|
||||
- doc: containers/105-apps.md
|
||||
- config repo: dtoro/Artifacto
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:apps
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
- edit-config-and-deploy — config_mutation (approval: operator)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/service-authentik.md
Normal file
19
oikos/cards/service-authentik.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# authentik (service:authentik)
|
||||
|
||||
- backend: host:netbird-vps
|
||||
- url: https://auth.hubris.network
|
||||
- doc: containers/106-auth-outpost.md
|
||||
- risk notes: SSO provider — outage locks login to OIDC/forward-auth services
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:netbird-vps
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/service-caddy.md
Normal file
20
oikos/cards/service-caddy.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# caddy (service:caddy)
|
||||
|
||||
- backend: host:caddy
|
||||
- doc: containers/121-caddy.md
|
||||
- config repo: dtoro/caddy-conf
|
||||
- risk notes: wide blast radius — every *.hubris.network route rides on it (see oikos/policy.yaml service_overrides)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:caddy
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — config_mutation (approval: operator)
|
||||
- edit-config-and-deploy — config_mutation (approval: operator)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
18
oikos/cards/service-dns.md
Normal file
18
oikos/cards/service-dns.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# dns (service:dns)
|
||||
|
||||
- backend: host:dns
|
||||
- doc: containers/107-dns.md
|
||||
- risk notes: LAN-wide resolver — misconfig breaks name resolution for every client
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:dns
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — config_mutation (approval: operator)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
21
oikos/cards/service-gitea.md
Normal file
21
oikos/cards/service-gitea.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# gitea (service:gitea)
|
||||
|
||||
- backend: host:gitea
|
||||
- url: https://git.hubris.network
|
||||
- doc: containers/104-gitea.md
|
||||
- config repo: dtoro/gitea-customizations
|
||||
- risk notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:gitea
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
- edit-config-and-deploy — config_mutation (approval: operator)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
17
oikos/cards/service-haos.md
Normal file
17
oikos/cards/service-haos.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# haos (service:haos)
|
||||
|
||||
- backend: host:haos
|
||||
- doc: vms/108-haos.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:haos
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
21
oikos/cards/service-homelab_mcp.md
Normal file
21
oikos/cards/service-homelab_mcp.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# homelab_mcp (service:homelab_mcp)
|
||||
|
||||
- backend: host:apps
|
||||
- url: https://mcp.hubris.network/mcp
|
||||
- doc: infrastructure/homelab-context.md
|
||||
- config repo: dtoro/Homelab-Docs
|
||||
- risk notes: agents' primary read surface — outage degrades every agent to grepping the clone
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:apps
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
- edit-config-and-deploy — config_mutation (approval: operator)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/service-jellyfin.md
Normal file
19
oikos/cards/service-jellyfin.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# jellyfin (service:jellyfin)
|
||||
|
||||
- backend: host:jellyfin
|
||||
- url: https://media.hubris.network
|
||||
- doc: containers/101-jellyfin.md
|
||||
- risk notes: native Authentik OIDC via SSO-Auth plugin, no Caddy forward-auth gate; VAAPI transcode depends on GPU passthrough on strong
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:jellyfin
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/service-matrix.md
Normal file
19
oikos/cards/service-matrix.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# matrix (service:matrix)
|
||||
|
||||
- backend: host:elementsynapse
|
||||
- url: https://matrix.hubris.network
|
||||
- doc: containers/118-elementsynapse.md
|
||||
- risk notes: alert/approval channel for Oikos — outage silences agent escalation
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:elementsynapse
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
18
oikos/cards/service-nextcloud.md
Normal file
18
oikos/cards/service-nextcloud.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# nextcloud (service:nextcloud)
|
||||
|
||||
- backend: host:nextcloud
|
||||
- url: https://cloud.hubris.network
|
||||
- doc: containers/114-nextcloud.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:nextcloud
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/service-paperless.md
Normal file
19
oikos/cards/service-paperless.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# paperless (service:paperless)
|
||||
|
||||
- backend: host:paperless
|
||||
- url: https://paperless.hubris.network
|
||||
- doc: containers/103-paperless.md
|
||||
- risk notes: document archive — treat data as irreplaceable; DB operations are destructive-class
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:paperless
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/service-photos.md
Normal file
20
oikos/cards/service-photos.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# photos (service:photos)
|
||||
|
||||
- backend: host:mule-images
|
||||
- url: https://photos.hubris.network
|
||||
- doc: containers/120-mule-images.md
|
||||
- config repo: dtoro/mule-image
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:mule-images
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
- edit-config-and-deploy — config_mutation (approval: operator)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
19
oikos/cards/service-proxmox_ui.md
Normal file
19
oikos/cards/service-proxmox_ui.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# proxmox_ui (service:proxmox_ui)
|
||||
|
||||
- backend: host:hubris
|
||||
- url: https://proxmox.hubris.network
|
||||
- doc: hosts/hubris.md
|
||||
- risk notes: hypervisor UI — changes here affect every guest on the node
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:hubris
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
21
oikos/cards/service-secrets_issuance.md
Normal file
21
oikos/cards/service-secrets_issuance.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# secrets_issuance (service:secrets_issuance)
|
||||
|
||||
- backend: host:apps
|
||||
- url: https://secrets.hubris.network/issue
|
||||
- doc: operations/agent-enrollment.md
|
||||
- config repo: dtoro/Homelab-Docs
|
||||
- risk notes: identity issuance — any change is security-sensitive; key operations are destructive-class
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:apps
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
- edit-config-and-deploy — config_mutation (approval: operator)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
20
oikos/cards/service-trmnl.md
Normal file
20
oikos/cards/service-trmnl.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# trmnl (service:trmnl)
|
||||
|
||||
- backend: host:trmnl
|
||||
- url: https://trmnl.hubris.network
|
||||
- doc: containers/128-trmnl.md
|
||||
- config repo: dtoro/terminalito
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:trmnl
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
- edit-config-and-deploy — config_mutation (approval: operator)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
18
oikos/cards/service-zimaos.md
Normal file
18
oikos/cards/service-zimaos.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# zimaos (service:zimaos)
|
||||
|
||||
- backend: host:zimaos
|
||||
- url: https://zimaos.hubris.network
|
||||
- doc: vms/100-zimaos.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:zimaos
|
||||
|
||||
## Safe actions
|
||||
- health-check — read_only (approval: none)
|
||||
- view-logs — read_only (approval: none)
|
||||
- view-docs — read_only (approval: none)
|
||||
- restart — reversible_low (approval: none)
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,18 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate infrastructure/topology.md (Mermaid views) from inventory.yaml.
|
||||
Generate infrastructure/topology.md (Mermaid views) and per-entity context
|
||||
cards from inventory.yaml.
|
||||
|
||||
Views:
|
||||
1. Compute & ingress — hypervisors → guests → services → public URLs
|
||||
2. Storage — mounts and pools per guest
|
||||
|
||||
Context cards (oikos/cards/<name>.md): one compact (~30-line) file per
|
||||
host and service — identity, ontology edges, safe actions + risk class,
|
||||
doc pointer, recent ledger history. This is the token-efficiency layer:
|
||||
an agent orienting on an entity reads one card instead of several
|
||||
search_docs/get_page round-trips.
|
||||
|
||||
Run from the repo root:
|
||||
python3 oikos/gen-topology.py # writes infrastructure/topology.md
|
||||
python3 oikos/gen-topology.py # writes topology.md + cards/
|
||||
python3 oikos/gen-topology.py --check # exit 1 if output would change
|
||||
|
||||
Wired into the same regeneration path as mcp/build_host_files.py so the
|
||||
diagrams never drift from inventory. Edges follow oikos/ontology.yaml
|
||||
(hosts, provides, routes-to, mounts, stores-on).
|
||||
diagrams and cards never drift from inventory. Edges follow
|
||||
oikos/ontology.yaml (hosts, provides, routes-to, mounts, stores-on).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,8 +35,14 @@ except ImportError: # pragma: no cover
|
||||
sys.exit(2)
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO))
|
||||
from oikos import ledger as oikos_ledger # noqa: E402
|
||||
from oikos import policy as oikos_policy # noqa: E402
|
||||
from oikos import relations as oikos_relations # noqa: E402
|
||||
|
||||
INVENTORY = REPO / "inventory.yaml"
|
||||
OUTPUT = REPO / "infrastructure" / "topology.md"
|
||||
CARDS_DIR = REPO / "oikos" / "cards"
|
||||
|
||||
BANNER = (
|
||||
"<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->\n"
|
||||
@@ -128,6 +141,125 @@ def archaeology_table(inv: dict) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def _host_card(name: str, entry: dict, inv: dict) -> str:
|
||||
lines = [f"# {name} (host:{name})\n"]
|
||||
tag = "LXC" if entry.get("kind") == "lxc" else "VM" if entry.get("kind") == "vm" else entry.get("kind", "")
|
||||
pve = entry.get("pve_id")
|
||||
lines.append(f"- kind: {entry.get('kind', '?')}" + (f" ({tag} {pve})" if pve else ""))
|
||||
lines.append(f"- state: {entry.get('state', 'active')}")
|
||||
if entry.get("host"):
|
||||
lines.append(f"- runs-on: host:{entry['host']}")
|
||||
if entry.get("role"):
|
||||
lines.append(f"- role: {entry['role']}")
|
||||
addr = entry.get("lan_ip", "")
|
||||
mesh = entry.get("mesh", {})
|
||||
mesh_bits = []
|
||||
for m, v in mesh.items():
|
||||
if isinstance(v, dict) and (v.get("ip") or v.get("fqdn")):
|
||||
mesh_bits.append(f"{m}:{v.get('fqdn') or v.get('ip')}")
|
||||
if addr or mesh_bits:
|
||||
lines.append(f"- address: {addr}" + (f" (mesh: {', '.join(mesh_bits)})" if mesh_bits else ""))
|
||||
if entry.get("mounts"):
|
||||
lines.append(f"- mounts: {', '.join(entry['mounts'])}")
|
||||
doc = None
|
||||
if entry.get("kind") == "lxc" and pve:
|
||||
cand = REPO / "containers" / f"{pve}-{name}.md"
|
||||
if cand.exists():
|
||||
doc = str(cand.relative_to(REPO))
|
||||
elif entry.get("kind") == "vm" and pve:
|
||||
cand = REPO / "vms" / f"{pve}-{name}.md"
|
||||
if cand.exists():
|
||||
doc = str(cand.relative_to(REPO))
|
||||
elif entry.get("kind") == "proxmox-host":
|
||||
cand = REPO / "hosts" / f"{name}.md"
|
||||
if cand.exists():
|
||||
doc = str(cand.relative_to(REPO))
|
||||
if doc:
|
||||
lines.append(f"- doc: {doc}")
|
||||
if entry.get("age_pubkey"):
|
||||
lines.append("- secrets: enrolled (age key present)")
|
||||
|
||||
rel = oikos_relations.relations(f"host:{name}", inv)
|
||||
lines.append("\n## Blast radius")
|
||||
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
|
||||
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
|
||||
if rel["blast_radius"]:
|
||||
lines.append(f"- full blast radius: {', '.join(rel['blast_radius'])}")
|
||||
|
||||
lines.append("\n## Safe actions")
|
||||
lines.append("- see the services this host runs for action-level risk classes")
|
||||
|
||||
hist = oikos_ledger.history(f"host:{name}", limit=5)
|
||||
lines.append("\n## Recent changes")
|
||||
if hist:
|
||||
for h in hist:
|
||||
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
|
||||
else:
|
||||
lines.append("- (none yet)")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _service_card(name: str, entry: dict, inv: dict) -> str:
|
||||
lines = [f"# {name} (service:{name})\n"]
|
||||
if entry.get("backend"):
|
||||
lines.append(f"- backend: host:{entry['backend']}")
|
||||
url = entry.get("url") or entry.get("endpoint")
|
||||
if url:
|
||||
lines.append(f"- url: {url}")
|
||||
if entry.get("doc_page"):
|
||||
lines.append(f"- doc: {entry['doc_page']}")
|
||||
if entry.get("config_repo"):
|
||||
lines.append(f"- config repo: {entry['config_repo']}")
|
||||
if entry.get("risk_notes"):
|
||||
lines.append(f"- risk notes: {entry['risk_notes']}")
|
||||
|
||||
rel = oikos_relations.relations(f"service:{name}", inv)
|
||||
lines.append("\n## Blast radius")
|
||||
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
|
||||
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
|
||||
|
||||
lines.append("\n## Safe actions")
|
||||
for a in oikos_policy.safe_actions_for_service(name, entry):
|
||||
lines.append(f"- {a['action']} — {a['risk']} (approval: {a['approval']})")
|
||||
|
||||
hist = oikos_ledger.history(f"service:{name}", limit=5)
|
||||
lines.append("\n## Recent changes")
|
||||
if hist:
|
||||
for h in hist:
|
||||
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
|
||||
else:
|
||||
lines.append("- (none yet)")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def generate_cards(inv: dict) -> dict[Path, str]:
|
||||
desired: dict[Path, str] = {}
|
||||
for name, entry in inv.get("hosts", {}).items():
|
||||
desired[CARDS_DIR / f"host-{name}.md"] = _host_card(name, entry, inv)
|
||||
for name, entry in inv.get("services", {}).items():
|
||||
if isinstance(entry, dict):
|
||||
desired[CARDS_DIR / f"service-{name}.md"] = _service_card(name, entry, inv)
|
||||
return desired
|
||||
|
||||
|
||||
def write_cards(inv: dict, check: bool = False) -> int:
|
||||
CARDS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
desired = generate_cards(inv)
|
||||
diff_count = 0
|
||||
for path, content in desired.items():
|
||||
existing = path.read_text() if path.exists() else ""
|
||||
if existing != content:
|
||||
diff_count += 1
|
||||
if not check:
|
||||
path.write_text(content)
|
||||
for existing_path in CARDS_DIR.glob("*.md"):
|
||||
if existing_path not in desired:
|
||||
diff_count += 1
|
||||
if not check:
|
||||
existing_path.unlink()
|
||||
return diff_count
|
||||
|
||||
|
||||
def render(inv: dict) -> str:
|
||||
hosts = inv.get("hosts", {})
|
||||
services = inv.get("services", {})
|
||||
@@ -164,13 +296,21 @@ def main() -> int:
|
||||
inv = yaml.safe_load(INVENTORY.read_text())
|
||||
content = render(inv)
|
||||
existing = OUTPUT.read_text() if OUTPUT.exists() else ""
|
||||
if existing == content:
|
||||
return 0
|
||||
topology_changed = existing != content
|
||||
card_diffs = write_cards(inv, check=args.check)
|
||||
|
||||
if args.check:
|
||||
print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr)
|
||||
return 1
|
||||
OUTPUT.write_text(content)
|
||||
print(f"wrote {OUTPUT.relative_to(REPO)}")
|
||||
if topology_changed:
|
||||
print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr)
|
||||
if card_diffs:
|
||||
print(f"{card_diffs} card(s) in oikos/cards/ would change", file=sys.stderr)
|
||||
return 1 if (topology_changed or card_diffs) else 0
|
||||
|
||||
if topology_changed:
|
||||
OUTPUT.write_text(content)
|
||||
print(f"wrote {OUTPUT.relative_to(REPO)}")
|
||||
if card_diffs:
|
||||
print(f"wrote/updated {card_diffs} card(s) in oikos/cards/")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
109
oikos/ledger.py
Normal file
109
oikos/ledger.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""oikos/ledger.py — append-only change ledger.
|
||||
|
||||
Every mutation an agent or operator performs (reversible_low and above,
|
||||
per oikos/policy.yaml) gets one JSON line in ledger/<YYYY-MM>.jsonl:
|
||||
timestamp, acting agent identity, entity, action, risk class, approval
|
||||
reference, verification result. Append-only, committed like any tracked
|
||||
file — never edited or reordered in place.
|
||||
|
||||
CLI:
|
||||
python3 oikos/ledger.py append <entity> <action> <risk> [--verification ...] [--result ...]
|
||||
python3 oikos/ledger.py history <entity> [--limit N]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
LEDGER_DIR = REPO / "ledger"
|
||||
|
||||
|
||||
def _agent_identity() -> str:
|
||||
"""Best-effort identity for the acting agent. HOMELAB_AGENT_ID lets
|
||||
approval-engine callers stamp the real requester; falls back to the
|
||||
local hostname."""
|
||||
return os.environ.get("HOMELAB_AGENT_ID") or socket.gethostname().split(".")[0]
|
||||
|
||||
|
||||
def append(entity: str, action: str, risk: str, *, verification: str | None = None,
|
||||
result: str | None = None, approval_ref: str | None = None,
|
||||
agent: str | None = None, notes: str | None = None) -> dict:
|
||||
"""Append one change entry. Returns the recorded dict (None fields dropped)."""
|
||||
entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"agent": agent or _agent_identity(),
|
||||
"entity": entity,
|
||||
"action": action,
|
||||
"risk": risk,
|
||||
"approval_ref": approval_ref,
|
||||
"verification": verification,
|
||||
"result": result,
|
||||
"notes": notes,
|
||||
}
|
||||
entry = {k: v for k, v in entry.items() if v is not None}
|
||||
LEDGER_DIR.mkdir(exist_ok=True)
|
||||
month = datetime.now(timezone.utc).strftime("%Y-%m")
|
||||
path = LEDGER_DIR / f"{month}.jsonl"
|
||||
with path.open("a") as f:
|
||||
f.write(json.dumps(entry, sort_keys=False) + "\n")
|
||||
return entry
|
||||
|
||||
|
||||
def history(entity: str, limit: int = 20) -> list[dict]:
|
||||
"""Most recent `limit` ledger entries for `entity`, newest first."""
|
||||
entries: list[dict] = []
|
||||
if not LEDGER_DIR.exists():
|
||||
return entries
|
||||
for path in sorted(LEDGER_DIR.glob("*.jsonl")):
|
||||
for line in path.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if e.get("entity") == entity:
|
||||
entries.append(e)
|
||||
entries.sort(key=lambda e: e.get("ts", ""), reverse=True)
|
||||
return entries[:limit]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="oikos change ledger")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sp = sub.add_parser("append")
|
||||
sp.add_argument("entity")
|
||||
sp.add_argument("action")
|
||||
sp.add_argument("risk")
|
||||
sp.add_argument("--verification")
|
||||
sp.add_argument("--result")
|
||||
sp.add_argument("--approval-ref")
|
||||
sp.add_argument("--notes")
|
||||
|
||||
sh = sub.add_parser("history")
|
||||
sh.add_argument("entity")
|
||||
sh.add_argument("--limit", type=int, default=20)
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "append":
|
||||
entry = append(args.entity, args.action, args.risk,
|
||||
verification=args.verification, result=args.result,
|
||||
approval_ref=args.approval_ref, notes=args.notes)
|
||||
print(json.dumps(entry, indent=2))
|
||||
elif args.cmd == "history":
|
||||
for e in history(args.entity, args.limit):
|
||||
print(json.dumps(e))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
58
oikos/policy.py
Normal file
58
oikos/policy.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""oikos/policy.py — load oikos/policy.yaml and classify actions.
|
||||
|
||||
Shared by bin/homelab, mcp/server.py, and oikos/gen-topology.py so every
|
||||
surface agrees on risk classes. See OIKOS.md for the operating model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
POLICY_FILE = REPO / "oikos" / "policy.yaml"
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
return yaml.safe_load(POLICY_FILE.read_text())
|
||||
|
||||
|
||||
def classify_command(cmd: str) -> str | None:
|
||||
"""Risk class for a `homelab <cmd>` subcommand."""
|
||||
return load().get("commands", {}).get(cmd)
|
||||
|
||||
|
||||
def classify_action(action: str, service: str | None = None) -> str | None:
|
||||
"""Risk class for a generic action, honoring per-service overrides."""
|
||||
pol = load()
|
||||
if service:
|
||||
override = pol.get("service_overrides", {}).get(service, {}).get(action)
|
||||
if override:
|
||||
return override
|
||||
return pol.get("actions", {}).get(action)
|
||||
|
||||
|
||||
def approval_for(risk: str) -> str:
|
||||
return load().get("risk_classes", {}).get(risk, {}).get("approval", "unknown")
|
||||
|
||||
|
||||
def safe_actions_for_service(name: str, svc_entry: dict) -> list[dict]:
|
||||
"""Actions an agent can propose for this service, each tagged with its
|
||||
risk class and whether operator approval is required. Derived from what
|
||||
the service entry actually declares — no action is offered that the
|
||||
service doesn't support.
|
||||
"""
|
||||
out = [
|
||||
{"action": "health-check", "risk": "read_only", "approval": "none"},
|
||||
{"action": "view-logs", "risk": "read_only", "approval": "none"},
|
||||
{"action": "view-docs", "risk": "read_only", "approval": "none"},
|
||||
]
|
||||
if svc_entry.get("backend"):
|
||||
risk = classify_action("service-restart", name)
|
||||
out.append({"action": "restart", "risk": risk, "approval": approval_for(risk)})
|
||||
if svc_entry.get("config_repo"):
|
||||
risk = classify_action("tracked-config-edit", name)
|
||||
out.append({"action": "edit-config-and-deploy", "risk": risk,
|
||||
"approval": approval_for(risk)})
|
||||
return out
|
||||
131
oikos/relations.py
Normal file
131
oikos/relations.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""oikos/relations.py — walk the ontology graph derived from inventory.yaml.
|
||||
|
||||
Wires up the subset of oikos/ontology.yaml relationships that are already
|
||||
structured data today: hosts, mounts, provides, configured-by, depends-on.
|
||||
Everything else in the ontology (physical, external, identity domains) is
|
||||
documented but thin — not yet backed by inventory fields, so it doesn't
|
||||
appear in the graph until those fields are populated.
|
||||
|
||||
Entities are namespaced ("host:name", "service:name", "repo:name",
|
||||
"mount:path") because host and service names collide in this inventory
|
||||
(e.g. "jellyfin" is both a service and its own LXC).
|
||||
|
||||
Impact polarity: build_impacts() returns edges in the direction
|
||||
"if SOURCE fails/disappears, TARGET is affected" — this is not the same
|
||||
direction as how the fact is stored in inventory (e.g. a mount is stored
|
||||
as guest -> pool, but if the POOL fails the GUEST is impacted, so the
|
||||
impact edge runs pool -> guest).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
INVENTORY = REPO / "inventory.yaml"
|
||||
|
||||
|
||||
def _hid(name: str) -> str:
|
||||
return f"host:{name}"
|
||||
|
||||
|
||||
def _sid(name: str) -> str:
|
||||
return f"service:{name}"
|
||||
|
||||
|
||||
def _rid(name: str) -> str:
|
||||
return f"repo:{name}"
|
||||
|
||||
|
||||
def _mid(name: str) -> str:
|
||||
return f"mount:{name}"
|
||||
|
||||
|
||||
def load_inventory() -> dict:
|
||||
return yaml.safe_load(INVENTORY.read_text())
|
||||
|
||||
|
||||
def resolve(entity: str, inv: dict | None = None) -> list[str]:
|
||||
"""Map a bare name (as typed on the CLI) to every namespaced id it
|
||||
could refer to. A bare name may match a host AND a service."""
|
||||
inv = inv or load_inventory()
|
||||
if ":" in entity:
|
||||
return [entity]
|
||||
ids = []
|
||||
if entity in inv.get("hosts", {}):
|
||||
ids.append(_hid(entity))
|
||||
if entity in inv.get("services", {}):
|
||||
ids.append(_sid(entity))
|
||||
if not ids:
|
||||
ids.append(entity)
|
||||
return ids
|
||||
|
||||
|
||||
def build_impacts(inv: dict | None = None) -> dict[str, set[str]]:
|
||||
inv = inv or load_inventory()
|
||||
hosts = inv.get("hosts", {})
|
||||
services = inv.get("services", {})
|
||||
impacts: dict[str, set[str]] = {}
|
||||
|
||||
def add(source: str, target: str) -> None:
|
||||
impacts.setdefault(source, set()).add(target)
|
||||
|
||||
for name, e in hosts.items():
|
||||
hid = _hid(name)
|
||||
parent = e.get("host")
|
||||
if parent:
|
||||
add(_hid(parent), hid) # host failing -> guest impacted
|
||||
for mount in e.get("mounts", []):
|
||||
add(_mid(mount), hid) # pool failing -> mounter impacted
|
||||
for dep in e.get("depends_on", []) or []:
|
||||
add(_hid(dep), hid) # dependency failing -> dependent impacted
|
||||
|
||||
for svc, e in services.items():
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
sid = _sid(svc)
|
||||
backend = e.get("backend")
|
||||
if backend and backend in hosts:
|
||||
add(_hid(backend), sid) # backend failing -> service impacted
|
||||
config_repo = e.get("config_repo")
|
||||
if config_repo:
|
||||
add(_rid(config_repo), _hid(backend)) # bad config -> backend impacted
|
||||
|
||||
return impacts
|
||||
|
||||
|
||||
def blast_radius(entity_id: str, inv: dict | None = None) -> list[str]:
|
||||
"""Transitive closure: every entity affected if `entity_id` fails."""
|
||||
impacts = build_impacts(inv)
|
||||
seen: set[str] = set()
|
||||
stack = [entity_id]
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
for nxt in impacts.get(cur, ()):
|
||||
if nxt not in seen:
|
||||
seen.add(nxt)
|
||||
stack.append(nxt)
|
||||
return sorted(seen)
|
||||
|
||||
|
||||
def relations(entity_id: str, inv: dict | None = None) -> dict:
|
||||
"""One entity's direct edges plus its full transitive blast radius."""
|
||||
impacts = build_impacts(inv)
|
||||
impacts_on = sorted(impacts.get(entity_id, ()))
|
||||
affected_by = sorted(src for src, targets in impacts.items() if entity_id in targets)
|
||||
return {
|
||||
"entity": entity_id,
|
||||
"impacts": impacts_on,
|
||||
"affected_by": affected_by,
|
||||
"blast_radius": blast_radius(entity_id, inv),
|
||||
}
|
||||
|
||||
|
||||
def relations_for_name(name: str, inv: dict | None = None) -> list[dict]:
|
||||
"""CLI/MCP entry point: resolve a bare name and report relations for
|
||||
every matching entity id (usually one; two if host and service share
|
||||
a name)."""
|
||||
inv = inv or load_inventory()
|
||||
return [relations(eid, inv) for eid in resolve(name, inv)]
|
||||
Reference in New Issue
Block a user