Oikos Week 4: Console v0, approval hardening, docs pass, backlog

Oikos Console v0 (oikos/console/) — read-mostly, server-rendered FastAPI
+ Jinja2 web UI, no SPA build chain. Signals landing page, service grid
+ detail, node/blast-radius view, live Mermaid relationship graph, drift
findings, approvals queue (approve/deny, destructive confirmation-phrase
enforced), daily/weekly reports. Tested end-to-end via the preview tools
against live production data, including a real click-through of the
approve/deny flow.

Found and fixed two bugs during that testing:
- Severity-dot CSS classes didn't match the actual severity strings
  (dot-warn/dot-crit vs "warning"/"critical") — warning-severity signals
  rendered with no visible indicator at all.
- The console's sys.path setup pointed at its own webhook checkout
  (/opt/oikos-console) rather than /opt/homelab-context, which would have
  made its oikos.* imports resolve to a SEPARATE copy of oikos/signal.py
  etc. than the scheduler and CLI use — silently forking signal/approval
  data into two locations in production. Fixed to match mcp/server.py's
  CONTEXT_DIR pattern. Also added _commit_push() so the console's writes
  (approval replies, signal ack/resolve) don't sit uncommitted against
  the 5-min-synced clone.

Split oikos/gen_topology_lib.py out of oikos/gen-topology.py (hyphenated
filenames aren't importable) so the console's /graph route can render
live without shelling out.

oikos/console/deploy/ — third webhook on dtoro/Homelab-Docs (port 9831),
matching the homelab-mcp/secrets-issuance precedent. README documents the
Caddy route and Gitea webhook registration this repo can't do for itself,
and that Authentik step-up on /approvals needs a live instance to
configure.

Approval hardening: grants are now single-use (oikos/approve.py
check_grant marks the request "executed" atomically, so a second call
for the same id fails even within the TTL) — verified with a test. Per-
agent age-key-signed requests, as originally planned, turned out not to
be buildable as stated: age is encryption-only, no signing primitive.
Documented the real alternative (SSH-key signing) and moved it to the
60/90-day backlog pending an inventory schema gap (no SSH pubkeys
recorded today).

Docs pass: added the Oikos command surface to operations/commands.md,
new MCP tools to AGENTS.md. Found two more stale references while at
it — commands.md and AGENTS.md both still pointed DNS at the destroyed
LXC 124/dnsmasq instead of Technitium on dns (107), and a claudio-monitor
reference deprecated since 2026-06-04 — fixed both.

60/90-day backlog written into OIKOS.md, derived from gaps actually
observed this month, not guesswork.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 00:03:51 +02:00
parent 2084a1583e
commit 205d8a1a43
25 changed files with 1116 additions and 117 deletions

View File

@@ -48,7 +48,7 @@ APPROVALS_DIR = REPO / "approvals"
HMAC_SECRET = REPO / "secrets" / "oikos-approval-hmac.yaml"
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
VALID_STATES = ("pending", "approved", "denied", "expired")
VALID_STATES = ("pending", "approved", "denied", "expired", "executed")
REQUEST_TTL_HOURS = 24
GRANT_TTL_MINUTES = 15
@@ -219,12 +219,13 @@ def reply(request_id: str, decision: str, *, phrase: str | None = None,
def check_grant(request_id: str, entity: str, action: str) -> tuple[bool, str]:
"""Verify a request carries a valid, unexpired, matching grant. Returns
(ok, reason). Callers (homelab CLI mutating commands) must call this
immediately before executing — grants are single-use in spirit (the
60/90-day backlog adds exact command+target binding + replay
prevention; today re-checking the same still-valid grant twice is
possible, so keep grant TTLs short)."""
"""Verify a request carries a valid, unexpired, matching grant, AND
consume it — a grant is exact-bound (this exact request id + entity +
action) and single-use: this call both checks and marks it "executed"
in the same step, so a second call for the same request_id fails with
"not approved" even if the grant's TTL hasn't expired yet. Callers
(homelab CLI mutating commands) must call this immediately before
executing, exactly once."""
entry = current(request_id)
if entry is None:
return False, "unknown approval id"
@@ -238,6 +239,7 @@ def check_grant(request_id: str, entity: str, action: str) -> tuple[bool, str]:
expected = _sign(request_id, entity, action, entry["grant_expires"])
if not hmac.compare_digest(expected, entry.get("grant_token", "")):
return False, "grant signature invalid"
_append({**entry, "ts": now_s, "state": "executed"})
return True, "ok"

View File

226
oikos/console/app.py Normal file
View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""oikos/console/app.py — Oikos Console v0.
Read-mostly, server-rendered web UI over the same data every other Oikos
surface uses (inventory.yaml, oikos/state.json, signals/, approvals/,
ledger/). No SPA build chain — FastAPI + Jinja2, a little vanilla JS for
the approve/deny forms. Deploys to LXC 105 (apps) as its own webhook
checkout (/opt/oikos-console), reading HOMELAB_CONTEXT_DIR=/opt/homelab-
context for data, the same pattern homelab-mcp already uses — see
oikos/console/deploy/.
Mutation surface is intentionally tiny: signal ack/resolve/mute and
approval reply (approve/deny). Nothing here executes a `homelab` command
directly — approving here does exactly what approving via Matrix does
(issues a grant token); actually running the gated action still goes
through the `homelab` CLI with that grant.
Run locally for development:
uvicorn oikos.console.app:app --reload --port 8091
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
# This app deploys to its own webhook checkout (/opt/oikos-console, see
# oikos/console/deploy/) SEPARATE from /opt/homelab-context, the clone
# every other Oikos surface (scheduler, bin/homelab, mcp/server.py) reads
# and writes signals/approvals/ledger data in. `oikos.*` imports MUST
# resolve to THAT copy, not this checkout's own oikos/ directory — two
# separate copies of oikos/signal.py would silently fork the data (this
# app writing to /opt/oikos-console/signals/ while the scheduler writes to
# /opt/homelab-context/signals/). Mirrors mcp/server.py's CONTEXT_DIR
# pattern exactly, for exactly this reason.
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
sys.path.insert(0, str(CONTEXT_DIR))
from oikos import approve as oikos_approve # noqa: E402
from oikos import gen_topology_lib # noqa: E402
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
from oikos import report as oikos_report # noqa: E402
from oikos import scheduler as oikos_scheduler # noqa: E402
from oikos import signal as oikos_signal # noqa: E402
app = FastAPI(title="Oikos Console")
HERE = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(HERE / "templates"))
app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static")
def _inventory() -> dict:
return gen_topology_lib.load_inventory()
def _commit_push(subpath: str, message: str) -> None:
"""Commit + push a mutation immediately, same convention as
oikos/scheduler.py's run-scheduler.sh wrapper and bin/homelab's
_record_change(). Without this, the console's writes would sit
uncommitted in the same /opt/homelab-context clone the 5-min sync
timer pulls into — risking a conflict on the next sync."""
try:
subprocess.run(["git", "add", subpath], check=True, cwd=CONTEXT_DIR)
if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=CONTEXT_DIR).returncode != 0:
subprocess.run(["git", "commit", "-m", message], check=True, cwd=CONTEXT_DIR)
subprocess.run(["git", "push"], check=True, cwd=CONTEXT_DIR)
except subprocess.CalledProcessError:
pass # best-effort; the write itself already succeeded locally
def _open_signals() -> list[dict]:
sigs = oikos_signal.list_signals()
return [s for s in sigs if s.get("state") in ("raised", "acknowledged", "acting")]
def _nav_counts() -> dict:
return {
"open_signals": len(_open_signals()),
"pending_approvals": len(oikos_approve.list_approvals(state="pending")),
}
@app.get("/", response_class=HTMLResponse)
def landing(request: Request):
open_sigs = sorted(_open_signals(), key=lambda s: (
{"critical": 0, "warning": 1, "info": 2}.get(s.get("severity"), 3), s.get("ts", "")
))
state = oikos_scheduler.read_state()
health_summary = None
if state:
checked = [s for s in state.get("services", {}).values() if s.get("checked")]
healthy = sum(1 for s in checked if s.get("ok"))
health_summary = {"healthy": healthy, "total": len(checked),
"as_of": state.get("generated_at")}
return templates.TemplateResponse("landing.html", {
"request": request, "nav": _nav_counts(), "signals": open_sigs,
"health_summary": health_summary,
})
@app.get("/services", response_class=HTMLResponse)
def services(request: Request):
inv = _inventory()
state = oikos_scheduler.read_state()
rows = []
for name, entry in sorted(inv.get("services", {}).items()):
if not isinstance(entry, dict):
continue
cached = (state or {}).get("services", {}).get(name, {})
rows.append({
"name": name, "backend": entry.get("backend"),
"url": entry.get("url") or entry.get("endpoint"),
"ok": cached.get("ok"), "checked": cached.get("checked"),
"risk_notes": entry.get("risk_notes"),
})
return templates.TemplateResponse("services.html", {
"request": request, "nav": _nav_counts(), "services": rows,
"as_of": (state or {}).get("generated_at"),
})
@app.get("/services/{name}", response_class=HTMLResponse)
def service_detail(request: Request, name: str):
inv = _inventory()
entry = inv.get("services", {}).get(name)
if entry is None:
return HTMLResponse(f"unknown service: {name}", status_code=404)
rel = oikos_relations.relations(f"service:{name}", inv)
actions = oikos_policy.safe_actions_for_service(name, entry)
history = oikos_ledger.history(f"service:{name}", limit=20)
cached = oikos_scheduler.cached_service_health(name)
return templates.TemplateResponse("service_detail.html", {
"request": request, "nav": _nav_counts(), "name": name, "entry": entry,
"relations": rel, "actions": actions, "history": history, "health": cached,
})
@app.get("/nodes/{name}", response_class=HTMLResponse)
def node_detail(request: Request, name: str):
inv = _inventory()
entry = inv.get("hosts", {}).get(name)
if entry is None:
return HTMLResponse(f"unknown host: {name}", status_code=404)
rel = oikos_relations.relations(f"host:{name}", inv)
history = oikos_ledger.history(f"host:{name}", limit=20)
return templates.TemplateResponse("node_detail.html", {
"request": request, "nav": _nav_counts(), "name": name, "entry": entry,
"relations": rel, "history": history,
})
@app.get("/graph", response_class=HTMLResponse)
def graph(request: Request):
inv = _inventory()
# compute_view() wraps its output in ``` fences for the markdown doc;
# strip them for browser-side mermaid.js, which wants raw diagram syntax.
view_lines = gen_topology_lib.compute_view(inv)
mermaid_src = "\n".join(line for line in view_lines if not line.startswith("```"))
return templates.TemplateResponse("graph.html", {
"request": request, "nav": _nav_counts(), "mermaid_src": mermaid_src,
})
@app.get("/drift", response_class=HTMLResponse)
def drift(request: Request):
from oikos import drift as oikos_drift
findings = oikos_drift.run_all(raise_signals=False)
return templates.TemplateResponse("drift.html", {
"request": request, "nav": _nav_counts(), "findings": findings,
})
@app.get("/approvals", response_class=HTMLResponse)
def approvals(request: Request):
pending = oikos_approve.list_approvals(state="pending")
return templates.TemplateResponse("approvals.html", {
"request": request, "nav": _nav_counts(), "approvals": pending,
})
@app.post("/approvals/{approval_id}/reply")
def approval_reply(approval_id: str, decision: str = Form(...), phrase: str = Form("")):
# NOTE: this route is where Authentik step-up re-auth (Week-4 plan)
# belongs once deployed behind live Authentik — a forward_auth policy
# requiring fresh authentication specifically on this path, not
# something buildable/verifiable without a live Authentik instance.
# Today it's protected the same way the rest of the console is: the
# ingress-level forward-auth gate.
try:
entry = oikos_approve.reply(approval_id, decision, phrase=phrase or None,
decided_by="oikos-console")
except (ValueError, RuntimeError) as e:
return PlainTextResponse(f"error: {e}", status_code=400)
_commit_push("approvals/", f"approval: {approval_id} {entry['state']} via console")
return RedirectResponse("/approvals", status_code=303)
@app.post("/signals/{signal_id}/{action}")
def signal_action(signal_id: str, action: str, note: str = Form("")):
if action == "ack":
oikos_signal.acknowledge(signal_id, note or None)
elif action == "resolve":
oikos_signal.resolve(signal_id, note or None)
elif action == "mute":
oikos_signal.mute(signal_id, 24, note or None)
else:
return PlainTextResponse("unknown action", status_code=400)
_commit_push("signals/", f"signal: {signal_id} {action} via console")
return RedirectResponse("/", status_code=303)
@app.get("/reports/{kind}", response_class=PlainTextResponse)
def reports(kind: str):
if kind == "daily":
return oikos_report.daily_brief()
if kind == "weekly":
return oikos_report.weekly_report()
return PlainTextResponse("unknown report", status_code=404)

View File

@@ -0,0 +1,66 @@
# Oikos Console — deploy notes
Deploys the same way `homelab-mcp` and `secrets-issuance` already do:
Shape B webhook (own checkout, own systemd units, own deploy secret) on
LXC 105 (apps), reading `HOMELAB_CONTEXT_DIR=/opt/homelab-context` for
all data. See [infrastructure/auto-deploy.md](../../../infrastructure/auto-deploy.md)
for the general pattern; webhook ids 10 (homelab-mcp, :9811) and 11
(secrets-issuance, :9821) are the direct precedent — this is a third
webhook on `dtoro/Homelab-Docs`, port :9831.
## One-time setup on apps (105)
```bash
git clone https://git.hubris.network/dtoro/Homelab-Docs.git /opt/oikos-console
cd /opt/oikos-console
./oikos/console/deploy/deploy.sh # first install
./oikos/console/deploy/webhook/install.sh # generates the deploy secret, prints it
systemctl enable --now oikos-console.service oikos-console-deploy.service
```
Then register the Gitea webhook (`dtoro/Homelab-Docs` → Settings →
Webhooks) with the URL/secret `install.sh` printed, same as webhooks 10/11.
## Caddy route — NOT in this repo, needs manual addition to `dtoro/caddy-conf`
The console binds `127.0.0.1:8091` only (see `oikos-console.service`
`ProtectSystem=strict`, no LAN listener). Caddy on LXC 121 needs a new
site block proxying to it, forward-auth gated the same way
`paperless`/other LAN-only services are (via the shared `(authentik)`
snippet referenced in
[containers/106-auth-outpost.md](../../../containers/106-auth-outpost.md)).
Confirm the exact snippet name/import syntax against the live
`dtoro/caddy-conf` repo — this is the shape, not verified against it:
```caddyfile
oikos.hubris.network {
import (authentik)
reverse_proxy 192.168.8.205:8091
}
```
Add `oikos.hubris.network` to the split-horizon DNS zone (Technitium,
LXC 107) pointing at Caddy's LAN IP, same as every other `*.hubris.network`
host.
## Authentik step-up re-auth on approval actions — deferred, needs live Authentik
The Week-4 plan calls for the `/approvals/{id}/reply` POST specifically
to require fresh re-authentication (not just an existing session), so a
stolen session cookie can't approve a mutation. That's an Authentik
policy binding (a `PromptStage`/reauth flow scoped to that path), which
needs a live Authentik instance to configure and test — not buildable or
verifiable from a repo checkout alone. Today the whole console (including
this route) is protected the same way every other console-with-a-forward-
auth-gate service is: the ingress-level Authentik check, not a per-action
step-up. Tracked in the 60/90-day backlog (OIKOS.md).
## What this deploy does NOT do
- Does not run any `homelab` command directly. Approving in the console
issues a grant token exactly like approving via Matrix would — actually
executing the gated action still goes through the `homelab` CLI on
whichever host runs it, with `--approval-id`.
- Does not touch inventory.yaml, secrets/, or anything outside
signals/ and approvals/ (both committed+pushed immediately on write,
see `oikos/console/app.py`'s `_commit_push()`).

49
oikos/console/deploy/deploy.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# Install/update the Oikos Console on LXC 105 (apps). Idempotent.
# Triggered by the gitea webhook or run by hand. Mirrors mcp/deploy/deploy.sh.
set -euo pipefail
REPO_DIR=${REPO_DIR:-/opt/oikos-console}
cd "$REPO_DIR"
echo "[deploy] git pull"
git pull --ff-only
echo "[deploy] ensure python venv + deps"
if [ ! -d "$REPO_DIR/.venv" ]; then
python3 -m venv "$REPO_DIR/.venv"
fi
"$REPO_DIR/.venv/bin/pip" install --quiet --upgrade pip
"$REPO_DIR/.venv/bin/pip" install --quiet \
"fastapi==0.139.*" "starlette<1" "jinja2<4" "uvicorn" "python-multipart" pyyaml
echo "[deploy] install systemd units"
install -m 644 oikos/console/deploy/oikos-console.service \
/etc/systemd/system/oikos-console.service
install -m 644 oikos/console/deploy/webhook/oikos-console-deploy.service \
/etc/systemd/system/oikos-console-deploy.service
echo "[deploy] context clone check"
# Reads inventory/signals/ledger/approvals from the same synced clone
# every client has (see infrastructure/homelab-context.md), NOT from this
# deploy-only checkout.
if [ ! -d /opt/homelab-context/.git ]; then
echo " /opt/homelab-context is not a git clone — run bootstrap.sh first." >&2
exit 1
fi
systemctl daemon-reload
if systemctl is-active --quiet oikos-console.service; then
systemctl restart oikos-console.service
fi
if systemctl is-active --quiet oikos-console-deploy.service; then
systemctl restart oikos-console-deploy.service
fi
echo "[deploy] done"
echo "First-time enable:"
echo " systemctl enable --now oikos-console.service oikos-console-deploy.service"
echo "Webhook first-time setup (generates secret):"
echo " $REPO_DIR/oikos/console/deploy/webhook/install.sh"
echo "Caddy route (dtoro/caddy-conf, NOT this repo) still needs adding — see"
echo " oikos/console/deploy/README.md for the exact snippet."

View File

@@ -0,0 +1,24 @@
[Unit]
Description=Oikos Console v0 (read-mostly web UI)
After=network-online.target homelab-context-sync.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/oikos-console
Environment=HOMELAB_CONTEXT_DIR=/opt/homelab-context
ExecStart=/opt/oikos-console/.venv/bin/uvicorn oikos.console.app:app --host 127.0.0.1 --port 8091
Restart=on-failure
RestartSec=5
# Stay confined. Bound to loopback only — Caddy (dtoro/caddy-conf) is the
# one that terminates TLS + forward-auth and proxies to 127.0.0.1:8091;
# this unit never listens on the LAN interface directly.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadOnlyPaths=/opt/homelab-context /opt/oikos-console
ReadWritePaths=/opt/homelab-context/signals /opt/homelab-context/approvals /opt/homelab-context/ledger /opt/homelab-context/oikos
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# First-time setup for the Oikos Console deploy webhook. Generates a
# secret, installs the systemd unit, and starts it. Re-run is safe (won't
# regenerate the secret if one exists). Mirrors mcp/deploy/webhook/install.sh.
set -euo pipefail
SECRET_DIR=/etc/oikos-console-deploy
SECRET=$SECRET_DIR/secret
UNIT=oikos-console-deploy.service
install -d -m 700 "$SECRET_DIR"
if [ ! -s "$SECRET" ]; then
head -c 32 /dev/urandom | base64 > "$SECRET"
chmod 600 "$SECRET"
echo "[install] generated webhook secret at $SECRET"
fi
systemctl daemon-reload
systemctl enable --now "$UNIT"
systemctl status "$UNIT" --no-pager | head -10
cat <<EOF
[install] webhook listening on :9831/deploy.
Configure Gitea (dtoro/Homelab-Docs → Settings → Webhooks → Add Webhook → Gitea):
Target URL: http://<lxc-105-mesh-ip>:9831/deploy
HTTP Method: POST
Content-Type: application/json
Secret: $(cat $SECRET)
Trigger: Push events
EOF

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Gitea deploy webhook for dtoro/Homelab-Docs → Oikos Console
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/oikos-console/oikos/console/deploy/webhook/webhook.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Deploy webhook for dtoro/Homelab-Docs -> Oikos Console, on LXC 105 (apps).
Listens on 0.0.0.0:9831/deploy. Validates Gitea HMAC, runs deploy.sh.
Port numbering follows the existing convention for this repo's two
webhook targets (homelab-mcp=9811, secrets-issuance=9821): 9831.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
BIND_HOST = "0.0.0.0"
BIND_PORT = 9831
SECRET_PATH = "/etc/oikos-console-deploy/secret"
DEPLOY_CMD = ["/opt/oikos-console/oikos/console/deploy/deploy.sh"]
TARGET_REF = "refs/heads/main"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("oikos-console-deploy")
_deploy_lock = threading.Lock()
def load_secret() -> bytes:
with open(SECRET_PATH, "rb") as f:
return f.read().strip()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log.info("%s - %s", self.address_string(), fmt % args)
def _reply(self, status: int, msg: str = "") -> None:
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.end_headers()
if msg:
self.wfile.write(msg.encode())
def do_POST(self) -> None:
if self.path != "/deploy":
self._reply(404, "not found")
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) if length else b""
sig_header = self.headers.get("X-Gitea-Signature", "")
secret = load_secret()
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig_header):
log.warning("signature mismatch")
self._reply(403, "bad signature")
return
try:
payload = json.loads(body)
except json.JSONDecodeError:
self._reply(400, "bad json")
return
if payload.get("ref", "") != TARGET_REF:
self._reply(204)
return
if not _deploy_lock.acquire(blocking=False):
self._reply(202, "already running")
return
try:
log.info("running deploy: %s", DEPLOY_CMD)
result = subprocess.run(DEPLOY_CMD, capture_output=True, text=True, timeout=180)
if result.returncode != 0:
log.error("deploy failed: %s\n%s", result.stdout, result.stderr)
self._reply(500, "deploy failed")
return
self._reply(204)
finally:
_deploy_lock.release()
def main() -> int:
if not os.path.exists(SECRET_PATH):
log.error("secret file missing: %s", SECRET_PATH)
return 1
server = HTTPServer((BIND_HOST, BIND_PORT), Handler)
log.info("listening on %s:%d", BIND_HOST, BIND_PORT)
server.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,124 @@
:root {
--bg: #0f1115;
--panel: #171a21;
--border: #262b36;
--text: #e6e8ec;
--muted: #8b93a7;
--accent: #5b8cff;
--ok: #35c47a;
--warn: #e0a638;
--crit: #e05a5a;
--info: #5b8cff;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, sans-serif;
}
nav {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 0.75rem 1.5rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
nav a {
color: var(--muted);
text-decoration: none;
font-size: 13px;
}
nav a:hover { color: var(--text); }
nav .brand {
color: var(--text);
font-weight: 600;
font-size: 15px;
margin-right: 0.5rem;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
}
h1 { font-size: 1.3rem; margin: 0 0 1rem; }
h2 { font-size: 1rem; color: var(--muted); margin: 1.5rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.04em; }
.badge {
display: inline-block;
background: var(--accent);
color: #fff;
border-radius: 10px;
padding: 0 6px;
font-size: 11px;
font-weight: 600;
}
.badge-warn { background: var(--warn); }
.badge-crit { background: var(--crit); }
table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; }
tr:hover { background: rgba(255,255,255,0.02); }
a.link { color: var(--accent); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
.dot-ok { background: var(--ok); }
.dot-warning { background: var(--warn); }
.dot-critical { background: var(--crit); }
.dot-info { background: var(--info); }
.dot-unknown { background: var(--muted); }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
.pill {
display: inline-block;
border: 1px solid var(--border);
border-radius: 12px;
padding: 1px 8px;
font-size: 11.5px;
color: var(--muted);
}
form.inline { display: inline; }
button {
background: var(--accent);
color: #fff;
border: none;
border-radius: 5px;
padding: 0.3rem 0.7rem;
font-size: 12.5px;
cursor: pointer;
}
button.deny { background: var(--crit); }
button.secondary { background: transparent; border: 1px solid var(--border); color: var(--muted); }
pre.report {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
white-space: pre-wrap;
}
.empty { color: var(--muted); padding: 2rem 0; text-align: center; }

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Oikos — approvals{% endblock %}
{% block content %}
<h1>Pending approvals</h1>
<p class="muted">Mirrors what would be posted to Matrix. Approving/denying here calls the same
oikos/approve.py engine — actually executing the gated action still goes through the
<span class="mono">homelab</span> CLI with the resulting grant.</p>
{% if approvals %}
{% for a in approvals %}
<div class="card">
<p><span class="pill">{{ a.id }}</span> <span class="pill">{{ a.risk }}</span></p>
<p><b>{{ a.entity }}</b> — {{ a.action }}</p>
<p class="muted">{{ a.evidence }}</p>
{% if a.verification %}<p class="muted mono">verify: {{ a.verification }}</p>{% endif %}
<form method="post" action="/approvals/{{ a.id }}/reply">
<input type="hidden" name="decision" value="approve">
{% if a.requires_phrase %}
<input type="text" name="phrase" placeholder="type: {{ a.confirmation_phrase }}" size="30">
{% endif %}
<button type="submit">approve</button>
</form>
<form class="inline" method="post" action="/approvals/{{ a.id }}/reply">
<input type="hidden" name="decision" value="deny">
<button class="deny" type="submit">deny</button>
</form>
</div>
{% endfor %}
{% else %}
<div class="empty">No pending approvals.</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{% block title %}Oikos{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<a href="/" class="brand">Oikos</a>
<a href="/">Signals{% if nav.open_signals %} <span class="badge">{{ nav.open_signals }}</span>{% endif %}</a>
<a href="/services">Services</a>
<a href="/graph">Graph</a>
<a href="/drift">Drift</a>
<a href="/approvals">Approvals{% if nav.pending_approvals %} <span class="badge badge-warn">{{ nav.pending_approvals }}</span>{% endif %}</a>
<a href="/reports/daily">Daily brief</a>
<a href="/reports/weekly">Weekly report</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Oikos — drift{% endblock %}
{% block content %}
<h1>Drift findings</h1>
<p class="muted">Live run of oikos/drift.py detectors — not raised as Signals from here (that's the scheduler's job).</p>
{% if findings %}
<table>
<tr><th></th><th>Kind</th><th>Entity</th><th>Evidence</th></tr>
{% for f in findings %}
<tr>
<td><span class="dot dot-{{ f.severity }}"></span></td>
<td>{{ f.kind }}</td>
<td class="mono">{{ f.entity }}</td>
<td>{{ f.evidence }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">No drift found.</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Oikos — graph{% endblock %}
{% block content %}
<h1>Topology</h1>
<p class="muted">Same Mermaid source as infrastructure/topology.md, rendered live from inventory.yaml.</p>
<div class="mermaid">
{{ mermaid_src }}
</div>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>mermaid.initialize({ startOnLoad: true, theme: "dark" });</script>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Oikos — signals{% endblock %}
{% block content %}
<h1>What needs attention?</h1>
{% if health_summary %}
<p class="muted">{{ health_summary.healthy }}/{{ health_summary.total }} services healthy
&middot; as of {{ health_summary.as_of }}</p>
{% else %}
<p class="muted">no scheduler snapshot yet — has oikos-scheduler.timer run?</p>
{% endif %}
{% if signals %}
<table>
<tr><th></th><th>Kind</th><th>Entity</th><th>Evidence</th><th>Raised</th><th></th></tr>
{% for s in signals %}
<tr>
<td><span class="dot dot-{{ s.severity }}"></span></td>
<td>{{ s.kind }}</td>
<td class="mono">{{ s.entity }}</td>
<td>{{ s.evidence }}</td>
<td class="muted">{{ s.ts }}</td>
<td>
<form class="inline" method="post" action="/signals/{{ s.id }}/ack">
<button class="secondary" type="submit">ack</button>
</form>
<form class="inline" method="post" action="/signals/{{ s.id }}/resolve">
<button class="secondary" type="submit">resolve</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">No open signals. All quiet.</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Oikos — {{ name }}{% endblock %}
{% block content %}
<h1>{{ name }}</h1>
<div class="card">
<p><span class="pill">host:{{ name }}</span> <span class="pill">{{ entry.kind }}</span>
<span class="pill">{{ entry.get('state', 'active') }}</span></p>
{% if entry.host %}<p>runs on: <a class="link" href="/nodes/{{ entry.host }}">{{ entry.host }}</a></p>{% endif %}
{% if entry.role %}<p>role: {{ entry.role }}</p>{% endif %}
{% if entry.lan_ip %}<p>address: <span class="mono">{{ entry.lan_ip }}</span></p>{% endif %}
{% if entry.mounts %}<p>mounts: {% for m in entry.mounts %}<span class="pill">{{ m }}</span> {% endfor %}</p>{% endif %}
</div>
<h2>Blast radius</h2>
<div class="card">
<p>impacts: {% for e in relations.impacts %}<a class="pill link" href="/{{ 'services' if e.startswith('service:') else 'nodes' }}/{{ e.split(':',1)[1] }}">{{ e }}</a> {% else %}<span class="muted">none</span>{% endfor %}</p>
<p>affected by: {% for e in relations.affected_by %}<span class="pill">{{ e }}</span> {% else %}<span class="muted">none</span>{% endfor %}</p>
{% if relations.blast_radius %}
<p>full blast radius: {% for e in relations.blast_radius %}<span class="pill">{{ e }}</span> {% endfor %}</p>
{% endif %}
</div>
<h2>Recent changes</h2>
{% if history %}
<table>
<tr><th>Time</th><th>Action</th><th>Risk</th><th>Result</th></tr>
{% for h in history %}
<tr><td class="muted">{{ h.ts }}</td><td>{{ h.action }}</td><td>{{ h.risk }}</td><td>{{ h.result }}</td></tr>
{% endfor %}
</table>
{% else %}
<p class="muted">(none yet)</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Oikos — {{ name }}{% endblock %}
{% block content %}
<h1>{{ name }}</h1>
<div class="card">
<p><span class="pill">service:{{ name }}</span>
{% if health %}
<span class="pill">{{ 'ok' if health.ok else 'unhealthy' }} as of {{ health.as_of }}</span>
{% endif %}
</p>
{% if entry.backend %}<p>backend: <a class="link" href="/nodes/{{ entry.backend }}">{{ entry.backend }}</a></p>{% endif %}
{% if entry.url or entry.endpoint %}<p>url: <a class="link" href="{{ entry.url or entry.endpoint }}">{{ entry.url or entry.endpoint }}</a></p>{% endif %}
{% if entry.doc_page %}<p>doc: <span class="mono">{{ entry.doc_page }}</span></p>{% endif %}
{% if entry.config_repo %}<p>config repo: <span class="mono">{{ entry.config_repo }}</span></p>{% endif %}
{% if entry.risk_notes %}<p class="muted">{{ entry.risk_notes }}</p>{% endif %}
</div>
<h2>Blast radius</h2>
<div class="card">
<p>impacts: {% for e in relations.impacts %}<span class="pill">{{ e }}</span> {% else %}<span class="muted">none</span>{% endfor %}</p>
<p>affected by: {% for e in relations.affected_by %}<span class="pill">{{ e }}</span> {% else %}<span class="muted">none</span>{% endfor %}</p>
</div>
<h2>Safe actions</h2>
<table>
<tr><th>Action</th><th>Risk</th><th>Approval</th></tr>
{% for a in actions %}
<tr><td>{{ a.action }}</td><td>{{ a.risk }}</td><td>{{ a.approval }}</td></tr>
{% endfor %}
</table>
<h2>Recent changes</h2>
{% if history %}
<table>
<tr><th>Time</th><th>Action</th><th>Risk</th><th>Result</th></tr>
{% for h in history %}
<tr><td class="muted">{{ h.ts }}</td><td>{{ h.action }}</td><td>{{ h.risk }}</td><td>{{ h.result }}</td></tr>
{% endfor %}
</table>
{% else %}
<p class="muted">(none yet)</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Oikos — services{% endblock %}
{% block content %}
<h1>Services</h1>
{% if as_of %}<p class="muted">cached health as of {{ as_of }}</p>{% endif %}
<table>
<tr><th></th><th>Service</th><th>Backend</th><th>URL</th><th>Risk notes</th></tr>
{% for s in services %}
<tr>
<td>
{% if s.checked %}
<span class="dot {{ 'dot-ok' if s.ok else 'dot-crit' }}"></span>
{% else %}
<span class="dot dot-unknown" title="no health check configured"></span>
{% endif %}
</td>
<td><a class="link" href="/services/{{ s.name }}">{{ s.name }}</a></td>
<td class="mono">{{ s.backend }}</td>
<td class="muted">{{ s.url or "—" }}</td>
<td class="muted">{{ s.risk_notes or "" }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -36,6 +36,7 @@ except ImportError: # pragma: no cover
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from oikos import gen_topology_lib as lib # noqa: E402
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
@@ -49,96 +50,14 @@ BANNER = (
"<!-- 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
# View/graph logic lives in oikos/gen_topology_lib.py (importable — this
# file's hyphenated name can't be). Re-exported here so existing call
# sites in this module don't need a rename.
node_id = lib.node_id
guest_label = lib.guest_label
compute_view = lib.compute_view
storage_view = lib.storage_view
archaeology_table = lib.archaeology_table
def _host_card(name: str, entry: dict, inv: dict) -> str:

112
oikos/gen_topology_lib.py Normal file
View File

@@ -0,0 +1,112 @@
"""oikos/gen_topology_lib.py — shared Mermaid-view logic.
Split out of oikos/gen-topology.py so it's importable (a hyphenated
filename can't be `import`ed as a module). oikos/gen-topology.py is the
CLI entrypoint that writes infrastructure/topology.md + oikos/cards/;
oikos/console/app.py imports this module directly to render the live
/graph page without shelling out.
"""
from __future__ import annotations
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
INVENTORY = REPO / "inventory.yaml"
def load_inventory() -> dict:
return yaml.safe_load(INVENTORY.read_text())
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