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:
2026-07-05 23:02:32 +02:00
parent b230ab5937
commit f6b57cbe3a
60 changed files with 1823 additions and 14 deletions

0
oikos/__init__.py Normal file
View File

21
oikos/cards/host-apps.md Normal file
View 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)

View 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)

View 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
View 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
View 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)

View 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
View 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)

View 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
View 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
View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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
View 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)

View 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)

View 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)

View 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
View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View File

@@ -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
View 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
View 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
View 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)]