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 <noreply@anthropic.com>
This commit is contained in:
178
oikos/gen-topology.py
Normal file
178
oikos/gen-topology.py
Normal file
@@ -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 = (
|
||||
"<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->\n"
|
||||
"<!-- Do NOT edit by hand - your changes will be overwritten. -->\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 "<br/>".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())
|
||||
137
oikos/ontology.yaml
Normal file
137
oikos/ontology.yaml
Normal file
@@ -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.<lxc>.host + pve_id
|
||||
provides:
|
||||
inverse: provided-by
|
||||
example: lxc:apps provides service:homelab_mcp
|
||||
source: hosts.<name>.runs + services.<svc>.backend
|
||||
mounts:
|
||||
inverse: mounted-by
|
||||
example: lxc:jellyfin mounts /mnt/media_local from strong
|
||||
source: hosts.<name>.mounts (extend with from:)
|
||||
stores-on:
|
||||
inverse: stores-for
|
||||
example: lxc:jellyfin rootfs stores-on storage-pool:ludo-lvm
|
||||
source: hosts.<name>.storage (new field)
|
||||
routes-to:
|
||||
inverse: routed-via
|
||||
example: ingress-route:media.hubris.network routes-to service:jellyfin
|
||||
source: services.<svc>.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.<svc>.auth (new field, from risk_notes/docs)
|
||||
connects-via:
|
||||
inverse: connects
|
||||
example: workstation:mac-mini connects-via mesh:netbird
|
||||
source: hosts.<name>.mesh
|
||||
can-decrypt:
|
||||
inverse: readable-by
|
||||
example: lxc:apps can-decrypt secret:gitea-pat
|
||||
source: .sops.yaml path rules + hosts.<name>.age_pubkey
|
||||
configured-by:
|
||||
inverse: configures
|
||||
example: lxc:caddy configured-by config-repo:dtoro/caddy-conf
|
||||
source: services.<svc>.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.<svc>.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]
|
||||
117
oikos/policy.yaml
Normal file
117
oikos/policy.yaml
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user