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

@@ -27,10 +27,11 @@ the operator to run `homelab client add <hostname>` from an existing client.
- `/opt/homelab-context/infrastructure/mesh.md` — Tailscale → Netbird state.
Both meshes are accepted today; Netbird is preferred for new traffic.
- `/opt/homelab-context/infrastructure/dns.md` — split-horizon DNS via
dnsmasq on LXC 124. `*.hubris.network` resolves to 192.168.x.x on the LAN
and to mesh addresses off-LAN.
Technitium on [dns (107)](../containers/107-dns.md). `*.hubris.network`
resolves to 192.168.x.x on the LAN and to mesh addresses off-LAN.
- `/opt/homelab-context/operations/commands.md` — the operator's cheatsheet
for pct, caddy, dnsmasq. Use these verbs when you take actions.
for pct, caddy, DNS, and the Oikos command surface. Use these verbs when
you take actions.
## 3. The MCP server
@@ -49,8 +50,15 @@ Available tools:
get_service_status(service), tail_log(service, lines=200),
list_lxcs(), get_lxc_state(lxc), ping_service(service)
Oikos (read-only; see OIKOS.md):
explain(service) — compact context card, cheaper than search_docs+get_page
preflight(service) — risk class, approval requirement, verification command
get_relations(entity) — ontology blast-radius query (host: or service: id)
get_change_history(entity, limit=20) — change-ledger entries
get_state_snapshot() — last scheduler Observe-pass (health, disk, drift count)
Mutations are **not** exposed via MCP. Use the `homelab` CLI for those, with
operator confirmation.
operator confirmation — see OIKOS.md's risk classes and approval flow.
**When to prefer MCP over grepping the clone:** any time you need to resolve a
name to an address, look up service status, or search the wiki by content.
@@ -81,10 +89,14 @@ Grep is fine for browsing or when MCP is unreachable.
demand using the per-client age key at `/etc/age/key.txt`. Secrets ARE
available in this system — `list_my_secrets()` (MCP) shows what you can
decrypt.
- **Mutations** (restart, edit configs, etc.): the `homelab` CLI's mutating
subcommands ask for confirmation. For ad-hoc work, SSH and edit directly —
but commit changes that touch tracked configs (caddy, gitea custom,
artifacto, mule-image, etc.; see `infrastructure/auto-deploy.md`).
- **Mutations** (restart, edit configs, etc.): classify against
`oikos/policy.yaml` first (`homelab decide <action> <entity>`).
`reversible_low` actions just need the interactive confirmation prompt;
`config_mutation`/`destructive` actions are mechanically refused without
a valid `--approval-id` from `homelab approval request` — see OIKOS.md.
For ad-hoc work, SSH and edit directly — but commit changes that touch
tracked configs (caddy, gitea custom, artifacto, mule-image, etc.; see
`infrastructure/auto-deploy.md`).
- **Wiki updates**: same-session rule applies to any meaningful state change
this client makes.

View File

@@ -24,8 +24,9 @@ one pass through **Observe → Orient → Decide → Act**:
3. **Decide** — the classifier scores **risk class × blast radius ×
confidence** and routes:
- **auto-act**: within autonomy policy, high confidence, contained radius
- **escalate**: operator approval via Matrix (✅/❌ reaction; destructive
actions additionally need a typed confirmation phrase)
- **escalate**: operator approval via Matrix (✅/❌ reaction) or the
Oikos Console's `/approvals` page (destructive actions additionally
need a typed confirmation phrase either way)
- **queue**: informational — console + reports
The classifier can only *lower* autonomy relative to policy, never raise
it. When in doubt, escalate.
@@ -80,7 +81,8 @@ the `archaeology:` section. Each transition is a runbook checklist;
deprecation completes only when inbound edges reach zero.
Generated views: [infrastructure/topology.md](infrastructure/topology.md)
(Mermaid, regenerated from inventory).
(Mermaid, regenerated from inventory) and the live, clickable version at
`oikos.hubris.network/graph` once the Console is deployed.
## Conventions carried forward
@@ -112,10 +114,31 @@ Generated views: [infrastructure/topology.md](infrastructure/topology.md)
temperature isn't probed at all yet (no confirmed sensor path on
hubris/strong). DNS-vs-inventory and generic tracked-config-cleanliness
drift checks are also deferred (see `oikos/drift.py` docstring).
- **Week 4**: Oikos Console (oikos.hubris.network, behind Authentik with
step-up re-auth on approvals), per-agent age-key-signed approval
requests (upgrading from Week 3's shared-HMAC), docs pass, 60/90-day
backlog.
- **Week 4**: Oikos Console v0 shipped — signals landing page, service
grid + detail, node/blast-radius view, live Mermaid graph, drift view,
approvals queue (approve/deny, destructive confirmation-phrase
enforced), daily/weekly reports. Server-rendered FastAPI + Jinja2, no
SPA build chain, tested end-to-end against live production data (see
`oikos/console/`). Deploys as a third webhook on `dtoro/Homelab-Docs`
(`/opt/oikos-console`, port :9831) — see
[oikos/console/deploy/README.md](oikos/console/deploy/README.md) for
the Caddy route and Gitea webhook registration this repo can't do for
itself. Approval grants are now single-use (a second `check_grant` call
for the same request fails even within the TTL) and already exact-bound
to request id + entity + action.
**Not shipped as originally planned:** per-agent *age-key-signed*
request authentication — age has no signing primitive (it's an
encryption-only keypair format), so "age-key-signed" wasn't
buildable as stated. The real alternative (SSH-key signing via
`ssh-keygen -Y sign`/`-Y verify`, using each host's already-provisioned
SSH key) is real and buildable, but needs SSH public keys recorded in
inventory first — not there today. Moved to the 60/90-day backlog.
Authentik step-up re-auth on the approve/deny route is documented but
needs a live Authentik instance to configure — also backlog.
Docs pass done (this file, AGENTS.md, operations/commands.md); found
and fixed two more stale references while at it (DNS section still
pointed at destroyed LXC 124/dnsmasq instead of Technitium on 107, and
a `claudio-monitor` reference that's been deprecated since 2026-06-04).
### Real drift found while building Week 3 (unresolved, needs operator action)
@@ -138,3 +161,59 @@ each is a `config_mutation`/`destructive`-class decision:
- Three `lifecycle-pve-id-reuse` info findings (100, 106, 107 each shared
between an active host and an archaeology entry) — expected/benign ID
reuse after destroy, no action needed.
## 60/90-day backlog
Derived from gaps observed while building the 30-day roadmap, not
guesswork. Roughly ordered by what unblocks the most:
- **SSH-key-signed approval requests.** Replaces the design note in
Week 4: age keys can't sign (encryption-only format), so per-agent
request authentication needs `ssh-keygen -Y sign`/`-Y verify` against
each host's existing SSH key. Blocked on a schema gap: inventory
doesn't record SSH public keys today, only ports/users. First step is
populating that field on enrollment, then wiring `oikos/approve.py` to
require and verify a signature over the request payload.
- **Authentik step-up re-auth** on the Console's `/approvals` POST route
— needs a live Authentik `PromptStage`/reauth flow scoped to that path;
not configurable without a running instance to test against.
- **Prometheus provisioning** (see
[plans/2026-07-05-oikos-prometheus-lxc.md](plans/2026-07-05-oikos-prometheus-lxc.md))
— unblocks trend signals (disk-full prediction, temp creep) and real
sparklines in the Console; investigate the undocumented `pve_id 131`
on hubris first.
- **CPU/NVMe temperature probing** in the scheduler — needs a confirmed
sensor path on hubris and strong (lm-sensors vs vendor tool) before a
real check can be written; guessing one risks a probe that silently
never fires.
- **DNS-vs-inventory drift check** — compare Technitium zone records
against `services.*.url`/`public_host`; not implemented (`oikos/drift.py`
has no Technitium API wiring yet).
- **Generic tracked-config-cleanliness drift check** — today only caddy's
`/etc/caddy` git-checkout path is hardcoded in `oikos/drift.py`; every
other service with a `config_repo` needs its local checkout path
recorded (a `mutation_path`-style field, same gap Week 1's service
contract flagged but didn't backfill) before this generalizes.
- **Per-service policy overrides** (`oikos/policy.yaml`
`service_overrides`) — schema is ready (caddy/dns already use it);
populate more as specific services turn out to need non-default risk
classes.
- **Incident timeline generator** — stitch ledger + signal history into
a single narrative for `investigations/` entries instead of writing
them by hand.
- **Secret access audit** — who-can-decrypt-what report from
`.sops.yaml` + inventory `age_pubkey`s, extending what
`oikos/drift.py`'s SOPS check already partially does.
- **Restore drills** — exercise `backs-up-to` (once populated) by
actually restoring from a backup target on a schedule, not just
checking freshness.
- **Multi-agent delegation model** — more than one agent acting
concurrently; needs the ledger's `agent` field to carry real identity
(age pubkey, not just hostname) consistently, which it mostly does
already but hasn't been stress-tested with concurrent writers.
- **Grafana** — only if the Console's own Prometheus-backed sparklines
turn out to be insufficient once Prometheus ships.
- **"Generalize later" extraction** — the original decision was personal-
first, generalize-later (see Week 1). Once patterns stabilize, extract
a config-driven Oikos core with no `hubris.network`/`hubris`/`strong`
hardcoding, so it's installable on a different homelab.

View File

@@ -1862,7 +1862,7 @@ def main() -> int:
ap_req.set_defaults(func=cmd_approval_request)
ap_list = apsub.add_parser("list")
ap_list.add_argument("--state", choices=["pending", "approved", "denied", "expired"])
ap_list.add_argument("--state", choices=["pending", "approved", "denied", "expired", "executed"])
ap_list.set_defaults(func=cmd_approval_list)
ap_reply = apsub.add_parser("reply")

View File

@@ -44,8 +44,9 @@ The app repo at `/opt/<thing>` is the working tree, but the deploy tooling (`web
| `dtoro/Homelab-Docs` → homelab-mcp | [apps (105)](../containers/105-apps.md) `/opt/homelab-mcp/` | B | `http://192.168.8.205:9811/deploy` | 10 | reinstalls `homelab-mcp.service` + restart |
| `dtoro/Homelab-Docs` → secrets-issuance | [apps (105)](../containers/105-apps.md) `/opt/secrets-issuance/` | B | `http://192.168.8.205:9821/deploy` | 11 | reinstalls `secrets-issuance.service` + restart |
| `dtoro/terminalito` | [trmnl (128)](../containers/128-trmnl.md) `/opt/terminalito/` | B | `http://192.168.8.211:9797/deploy` | 12 | reinstalls units + `systemctl restart trmnl-plugins` |
| `dtoro/Homelab-Docs` → oikos-console | [apps (105)](../containers/105-apps.md) `/opt/oikos-console/` | B | `http://192.168.8.205:9831/deploy` | (not yet registered) | reinstalls `oikos-console.service` + restart — see [oikos/console/deploy/README.md](../oikos/console/deploy/README.md) |
> Note: `dtoro/Homelab-Docs` has **two webhooks** firing on the same push.
> Note: `dtoro/Homelab-Docs` has **three webhooks** firing on the same push.
> Each owns its own clone on LXC 105. They don't conflict because each
> deploy.sh only touches its own service unit + venv.

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

View File

@@ -14,7 +14,7 @@ Run from the [hubris host](../hosts/hubris.md) as root. When working from `/root
| `pvesm status` | Storage pools status |
| `pvesh get /nodes --output-format json` | Node summary as JSON |
| `pvesh get /nodes/hubris/lxc/<id>/status/current` | Live container status |
| `pvesh get /cluster/resources --type vm --output-format json` | Bulk per-LXC CPU/mem/disk (used by [claudio-monitor](../infrastructure/monitoring.md)) |
| `pvesh get /cluster/resources --type vm --output-format json` | Bulk per-LXC CPU/mem/disk (used by the `homelab-health-watchdog` Hermes cron — see [monitoring](../infrastructure/monitoring.md); the old `claudio-monitor` this once fed is deprecated) |
| `pveversion` | PVE version |
| `journalctl -u pve-cluster -n 100` | PVE service logs |
@@ -34,8 +34,9 @@ Run from the [hubris host](../hosts/hubris.md) as root. When working from `/root
## DNS
- Split-horizon entries: `/etc/dnsmasq.d/hubris-split.conf` on [LXC 124](../containers/124-authentik.md). Hard restart on edit: `pct exec 124 -- systemctl restart dnsmasq`. SIGHUP isn't reliable.
- Verify: `dig @192.168.8.180 +short <host>.hubris.network`.
- Split-horizon authority: [Technitium DNS](https://technitium.com) on [dns (107)](../containers/107-dns.md) at `192.168.8.2:53`. Web UI at `http://192.168.8.2`. (Formerly dnsmasq on the now-destroyed LXC 124 — decommissioned 2026-06-04.)
- Add/edit records in the Technitium UI; the NetBird managed zone sync (`scripts/dns-sync.py` cron on 107) picks changes up within ~10 minutes.
- Verify: `dig @192.168.8.2 +short <host>.hubris.network`.
- See [DNS](../infrastructure/dns.md).
## Web access
@@ -65,6 +66,22 @@ Two `homelab` subcommands wrap the common patterns; both fan out to hubris + eve
PVE/kernel deferral on hubris: `homelab apt-upgrade --target hubris` will try every upgrade, including kernel + `pve-*`. To skip those, `apt-mark hold` the relevant packages on hubris first; `homelab apt-audit` shows held packages so you can confirm.
## Oikos (agent OS layer)
See [OIKOS.md](../OIKOS.md) for the operating model. Quick reference:
| Command | What it does |
| --- | --- |
| `homelab service <name> explain\|health\|docs\|log\|actions\|history` | Service Console v0 — context card, cached health (`--live` to force a probe), docs, logs, safe actions + risk class, ledger history |
| `homelab node <name> relations` | Ontology blast-radius query: what this host/service impacts, is affected by, and its full transitive blast radius |
| `homelab change preflight <service>` | Dry-run report before mutating: risk class, current health, config repo, verification command |
| `homelab decide <action> <entity>` | Decision classifier: risk × blast radius × confidence → auto-act or escalate |
| `homelab signal list\|raise\|ack\|resolve\|mute` | The attention layer — pending updates, thresholds, drift, anything needing attention |
| `homelab approval request\|list\|reply\|check` | Escalate-route grants (Matrix-delivered via Hermes, or the Oikos Console's `/approvals` page) |
| `homelab restart <service> [--approval-id <id>]` | `--approval-id` is required whenever the service's risk class needs approval (e.g. `caddy`, `dns`) — refuses mechanically without a valid grant |
Oikos Console (read-mostly dashboard): `oikos.hubris.network` once deployed — see [oikos/console/deploy/README.md](../oikos/console/deploy/README.md).
## Related
- [Hubris host](../hosts/hubris.md)
- [Containers index](../containers/index.md)