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:
28
AGENTS.md
28
AGENTS.md
@@ -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.
|
- `/opt/homelab-context/infrastructure/mesh.md` — Tailscale → Netbird state.
|
||||||
Both meshes are accepted today; Netbird is preferred for new traffic.
|
Both meshes are accepted today; Netbird is preferred for new traffic.
|
||||||
- `/opt/homelab-context/infrastructure/dns.md` — split-horizon DNS via
|
- `/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
|
Technitium on [dns (107)](../containers/107-dns.md). `*.hubris.network`
|
||||||
and to mesh addresses off-LAN.
|
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
|
- `/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
|
## 3. The MCP server
|
||||||
|
|
||||||
@@ -49,8 +50,15 @@ Available tools:
|
|||||||
get_service_status(service), tail_log(service, lines=200),
|
get_service_status(service), tail_log(service, lines=200),
|
||||||
list_lxcs(), get_lxc_state(lxc), ping_service(service)
|
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
|
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
|
**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.
|
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
|
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
|
available in this system — `list_my_secrets()` (MCP) shows what you can
|
||||||
decrypt.
|
decrypt.
|
||||||
- **Mutations** (restart, edit configs, etc.): the `homelab` CLI's mutating
|
- **Mutations** (restart, edit configs, etc.): classify against
|
||||||
subcommands ask for confirmation. For ad-hoc work, SSH and edit directly —
|
`oikos/policy.yaml` first (`homelab decide <action> <entity>`).
|
||||||
but commit changes that touch tracked configs (caddy, gitea custom,
|
`reversible_low` actions just need the interactive confirmation prompt;
|
||||||
artifacto, mule-image, etc.; see `infrastructure/auto-deploy.md`).
|
`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
|
- **Wiki updates**: same-session rule applies to any meaningful state change
|
||||||
this client makes.
|
this client makes.
|
||||||
|
|
||||||
|
|||||||
93
OIKOS.md
93
OIKOS.md
@@ -24,8 +24,9 @@ one pass through **Observe → Orient → Decide → Act**:
|
|||||||
3. **Decide** — the classifier scores **risk class × blast radius ×
|
3. **Decide** — the classifier scores **risk class × blast radius ×
|
||||||
confidence** and routes:
|
confidence** and routes:
|
||||||
- **auto-act**: within autonomy policy, high confidence, contained radius
|
- **auto-act**: within autonomy policy, high confidence, contained radius
|
||||||
- **escalate**: operator approval via Matrix (✅/❌ reaction; destructive
|
- **escalate**: operator approval via Matrix (✅/❌ reaction) or the
|
||||||
actions additionally need a typed confirmation phrase)
|
Oikos Console's `/approvals` page (destructive actions additionally
|
||||||
|
need a typed confirmation phrase either way)
|
||||||
- **queue**: informational — console + reports
|
- **queue**: informational — console + reports
|
||||||
The classifier can only *lower* autonomy relative to policy, never raise
|
The classifier can only *lower* autonomy relative to policy, never raise
|
||||||
it. When in doubt, escalate.
|
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.
|
deprecation completes only when inbound edges reach zero.
|
||||||
|
|
||||||
Generated views: [infrastructure/topology.md](infrastructure/topology.md)
|
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
|
## 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
|
temperature isn't probed at all yet (no confirmed sensor path on
|
||||||
hubris/strong). DNS-vs-inventory and generic tracked-config-cleanliness
|
hubris/strong). DNS-vs-inventory and generic tracked-config-cleanliness
|
||||||
drift checks are also deferred (see `oikos/drift.py` docstring).
|
drift checks are also deferred (see `oikos/drift.py` docstring).
|
||||||
- **Week 4**: Oikos Console (oikos.hubris.network, behind Authentik with
|
- **Week 4**: Oikos Console v0 shipped — signals landing page, service
|
||||||
step-up re-auth on approvals), per-agent age-key-signed approval
|
grid + detail, node/blast-radius view, live Mermaid graph, drift view,
|
||||||
requests (upgrading from Week 3's shared-HMAC), docs pass, 60/90-day
|
approvals queue (approve/deny, destructive confirmation-phrase
|
||||||
backlog.
|
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)
|
### 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
|
- Three `lifecycle-pve-id-reuse` info findings (100, 106, 107 each shared
|
||||||
between an active host and an archaeology entry) — expected/benign ID
|
between an active host and an archaeology entry) — expected/benign ID
|
||||||
reuse after destroy, no action needed.
|
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.
|
||||||
|
|||||||
@@ -1862,7 +1862,7 @@ def main() -> int:
|
|||||||
ap_req.set_defaults(func=cmd_approval_request)
|
ap_req.set_defaults(func=cmd_approval_request)
|
||||||
|
|
||||||
ap_list = apsub.add_parser("list")
|
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_list.set_defaults(func=cmd_approval_list)
|
||||||
|
|
||||||
ap_reply = apsub.add_parser("reply")
|
ap_reply = apsub.add_parser("reply")
|
||||||
|
|||||||
@@ -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` → 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/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/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
|
> Each owns its own clone on LXC 105. They don't conflict because each
|
||||||
> deploy.sh only touches its own service unit + venv.
|
> deploy.sh only touches its own service unit + venv.
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ APPROVALS_DIR = REPO / "approvals"
|
|||||||
HMAC_SECRET = REPO / "secrets" / "oikos-approval-hmac.yaml"
|
HMAC_SECRET = REPO / "secrets" / "oikos-approval-hmac.yaml"
|
||||||
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
|
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
|
REQUEST_TTL_HOURS = 24
|
||||||
GRANT_TTL_MINUTES = 15
|
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]:
|
def check_grant(request_id: str, entity: str, action: str) -> tuple[bool, str]:
|
||||||
"""Verify a request carries a valid, unexpired, matching grant. Returns
|
"""Verify a request carries a valid, unexpired, matching grant, AND
|
||||||
(ok, reason). Callers (homelab CLI mutating commands) must call this
|
consume it — a grant is exact-bound (this exact request id + entity +
|
||||||
immediately before executing — grants are single-use in spirit (the
|
action) and single-use: this call both checks and marks it "executed"
|
||||||
60/90-day backlog adds exact command+target binding + replay
|
in the same step, so a second call for the same request_id fails with
|
||||||
prevention; today re-checking the same still-valid grant twice is
|
"not approved" even if the grant's TTL hasn't expired yet. Callers
|
||||||
possible, so keep grant TTLs short)."""
|
(homelab CLI mutating commands) must call this immediately before
|
||||||
|
executing, exactly once."""
|
||||||
entry = current(request_id)
|
entry = current(request_id)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
return False, "unknown approval id"
|
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"])
|
expected = _sign(request_id, entity, action, entry["grant_expires"])
|
||||||
if not hmac.compare_digest(expected, entry.get("grant_token", "")):
|
if not hmac.compare_digest(expected, entry.get("grant_token", "")):
|
||||||
return False, "grant signature invalid"
|
return False, "grant signature invalid"
|
||||||
|
_append({**entry, "ts": now_s, "state": "executed"})
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
oikos/console/__init__.py
Normal file
0
oikos/console/__init__.py
Normal file
226
oikos/console/app.py
Normal file
226
oikos/console/app.py
Normal 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)
|
||||||
66
oikos/console/deploy/README.md
Normal file
66
oikos/console/deploy/README.md
Normal 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
49
oikos/console/deploy/deploy.sh
Executable 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."
|
||||||
24
oikos/console/deploy/oikos-console.service
Normal file
24
oikos/console/deploy/oikos-console.service
Normal 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
|
||||||
31
oikos/console/deploy/webhook/install.sh
Executable file
31
oikos/console/deploy/webhook/install.sh
Executable 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
|
||||||
13
oikos/console/deploy/webhook/oikos-console-deploy.service
Normal file
13
oikos/console/deploy/webhook/oikos-console-deploy.service
Normal 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
|
||||||
95
oikos/console/deploy/webhook/webhook.py
Normal file
95
oikos/console/deploy/webhook/webhook.py
Normal 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())
|
||||||
124
oikos/console/static/style.css
Normal file
124
oikos/console/static/style.css
Normal 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; }
|
||||||
32
oikos/console/templates/approvals.html
Normal file
32
oikos/console/templates/approvals.html
Normal 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 %}
|
||||||
23
oikos/console/templates/base.html
Normal file
23
oikos/console/templates/base.html
Normal 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>
|
||||||
22
oikos/console/templates/drift.html
Normal file
22
oikos/console/templates/drift.html
Normal 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 %}
|
||||||
11
oikos/console/templates/graph.html
Normal file
11
oikos/console/templates/graph.html
Normal 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 %}
|
||||||
37
oikos/console/templates/landing.html
Normal file
37
oikos/console/templates/landing.html
Normal 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
|
||||||
|
· 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 %}
|
||||||
35
oikos/console/templates/node_detail.html
Normal file
35
oikos/console/templates/node_detail.html
Normal 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 %}
|
||||||
44
oikos/console/templates/service_detail.html
Normal file
44
oikos/console/templates/service_detail.html
Normal 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 %}
|
||||||
25
oikos/console/templates/services.html
Normal file
25
oikos/console/templates/services.html
Normal 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 %}
|
||||||
@@ -36,6 +36,7 @@ except ImportError: # pragma: no cover
|
|||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(REPO))
|
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 ledger as oikos_ledger # noqa: E402
|
||||||
from oikos import policy as oikos_policy # noqa: E402
|
from oikos import policy as oikos_policy # noqa: E402
|
||||||
from oikos import relations as oikos_relations # 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"
|
"<!-- Do NOT edit by hand - your changes will be overwritten. -->\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# View/graph logic lives in oikos/gen_topology_lib.py (importable — this
|
||||||
def node_id(name: str) -> str:
|
# file's hyphenated name can't be). Re-exported here so existing call
|
||||||
"""Mermaid-safe node id."""
|
# sites in this module don't need a rename.
|
||||||
return name.replace("-", "_").replace(".", "_").replace("/", "_").strip("_")
|
node_id = lib.node_id
|
||||||
|
guest_label = lib.guest_label
|
||||||
|
compute_view = lib.compute_view
|
||||||
def guest_label(name: str, entry: dict) -> str:
|
storage_view = lib.storage_view
|
||||||
pve = entry.get("pve_id")
|
archaeology_table = lib.archaeology_table
|
||||||
role = entry.get("role", "")
|
|
||||||
tag = f"LXC {pve}" if entry.get("kind") == "lxc" and pve else \
|
|
||||||
f"VM {pve}" if entry.get("kind") == "vm" and pve else entry.get("kind", "")
|
|
||||||
ip = entry.get("lan_ip", "")
|
|
||||||
parts = [name, tag, role, ip]
|
|
||||||
return "<br/>".join(str(p) for p in parts if p)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_view(inv: dict) -> list[str]:
|
|
||||||
hosts = inv.get("hosts", {})
|
|
||||||
services = inv.get("services", {})
|
|
||||||
lines = ["```mermaid", "flowchart LR"]
|
|
||||||
|
|
||||||
hypervisors = {n: e for n, e in hosts.items() if e.get("kind") == "proxmox-host"}
|
|
||||||
guests = {n: e for n, e in hosts.items() if e.get("kind") in ("lxc", "vm")}
|
|
||||||
others = {n: e for n, e in hosts.items()
|
|
||||||
if e.get("kind") in ("workstation", "external")}
|
|
||||||
|
|
||||||
for hv in hypervisors:
|
|
||||||
lines.append(f' subgraph {node_id(hv)}_sub["{hv} (Proxmox)"]')
|
|
||||||
for g, e in guests.items():
|
|
||||||
if e.get("host") == hv:
|
|
||||||
lines.append(f' {node_id(g)}["{guest_label(g, e)}"]')
|
|
||||||
lines.append(" end")
|
|
||||||
|
|
||||||
# guests without a parent hypervisor recorded (e.g. rclone)
|
|
||||||
for g, e in guests.items():
|
|
||||||
if e.get("host") not in hypervisors:
|
|
||||||
lines.append(f' {node_id(g)}["{guest_label(g, e)}"]')
|
|
||||||
|
|
||||||
for n, e in others.items():
|
|
||||||
shape = "([{}])" if e.get("kind") == "workstation" else "[[{}]]"
|
|
||||||
lines.append(f' {node_id(n)}{shape.format(guest_label(n, e))}')
|
|
||||||
|
|
||||||
# ingress: public URL -> backend (routes-to)
|
|
||||||
for svc, e in sorted(services.items()):
|
|
||||||
if not isinstance(e, dict):
|
|
||||||
continue
|
|
||||||
backend = e.get("backend")
|
|
||||||
url = e.get("url") or (
|
|
||||||
f'https://{e["public_host"]}' if e.get("public_host") else None)
|
|
||||||
if backend and url and backend in hosts:
|
|
||||||
host = url.removeprefix("https://").removeprefix("http://")
|
|
||||||
# hypervisors are rendered as subgraphs; point edges at the subgraph id
|
|
||||||
target = node_id(backend) + ("_sub" if backend in hypervisors else "")
|
|
||||||
lines.append(
|
|
||||||
f' {node_id("url_" + svc)}(["{host}"]) -->|routes-to| {target}')
|
|
||||||
|
|
||||||
lines.append("```")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def storage_view(inv: dict) -> list[str]:
|
|
||||||
hosts = inv.get("hosts", {})
|
|
||||||
lines = ["```mermaid", "flowchart LR"]
|
|
||||||
pools: set[str] = set()
|
|
||||||
edges: list[str] = []
|
|
||||||
|
|
||||||
for name, e in hosts.items():
|
|
||||||
for mount in e.get("mounts", []):
|
|
||||||
pools.add(mount)
|
|
||||||
edges.append(f' {node_id(name)}["{name}"] -->|mounts| {node_id(mount)}')
|
|
||||||
|
|
||||||
for pool in sorted(pools):
|
|
||||||
lines.append(f' {node_id(pool)}[("{pool}")]')
|
|
||||||
lines.extend(sorted(set(edges)))
|
|
||||||
lines.append("```")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def archaeology_table(inv: dict) -> list[str]:
|
|
||||||
arch = inv.get("archaeology", {})
|
|
||||||
if not arch:
|
|
||||||
return []
|
|
||||||
lines = ["| Node | ID | Destroyed | Reason |", "|---|---|---|---|"]
|
|
||||||
entries = sorted(arch.items(), key=lambda kv: str(kv[1].get("destroyed", "")),
|
|
||||||
reverse=True)
|
|
||||||
for name, e in entries:
|
|
||||||
lines.append(
|
|
||||||
f'| {name} | {e.get("pve_id", "")} | {e.get("destroyed", "")} '
|
|
||||||
f'| {e.get("reason", "")} |')
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _host_card(name: str, entry: dict, inv: dict) -> str:
|
def _host_card(name: str, entry: dict, inv: dict) -> str:
|
||||||
|
|||||||
112
oikos/gen_topology_lib.py
Normal file
112
oikos/gen_topology_lib.py
Normal 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
|
||||||
@@ -14,7 +14,7 @@ Run from the [hubris host](../hosts/hubris.md) as root. When working from `/root
|
|||||||
| `pvesm status` | Storage pools status |
|
| `pvesm status` | Storage pools status |
|
||||||
| `pvesh get /nodes --output-format json` | Node summary as JSON |
|
| `pvesh get /nodes --output-format json` | Node summary as JSON |
|
||||||
| `pvesh get /nodes/hubris/lxc/<id>/status/current` | Live container status |
|
| `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 |
|
| `pveversion` | PVE version |
|
||||||
| `journalctl -u pve-cluster -n 100` | PVE service logs |
|
| `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
|
## 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.
|
- 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.)
|
||||||
- Verify: `dig @192.168.8.180 +short <host>.hubris.network`.
|
- 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).
|
- See [DNS](../infrastructure/dns.md).
|
||||||
|
|
||||||
## Web access
|
## 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.
|
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
|
## Related
|
||||||
- [Hubris host](../hosts/hubris.md)
|
- [Hubris host](../hosts/hubris.md)
|
||||||
- [Containers index](../containers/index.md)
|
- [Containers index](../containers/index.md)
|
||||||
|
|||||||
Reference in New Issue
Block a user