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.
|
||||
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.
|
||||
|
||||
|
||||
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 ×
|
||||
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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
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
|
||||
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
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 |
|
||||
| `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)
|
||||
|
||||
Reference in New Issue
Block a user