cleanup: reflect Go rewrite reality — remove legacy Python artifacts
Removed: - bin/hermes (9.3MB compiled binary accidentally committed to git) - oikos/console/ (Flask web console — replaced by Go REST API + SSE) - mcp/server.py (Python MCP server — replaced by internal/mcp/) - oikos/policy.yaml, oikos/ontology.yaml (duplicates of seeds/) Kept: - oikos/*.py kernel files (12 files) — still imported by bin/homelab for operational CLI commands (ssh, pct, logs, restart, status, open, secret, client, sync, mcp). Will be removed when bin/homelab is ported to Go. - mcp/build_host_files.py — generates hosts/*.yaml from inventory, still operational. Will be ported to Go. - bin/homelab — active Python CLI, still operational. Updated: - .gitignore: added bin/hermes, cleaned up legacy comments - plans/index.md: listed all 4 active plans with accurate statuses
This commit is contained in:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -2,10 +2,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Regenerated every scheduler run (every 10 min); no audit value in the
|
||||
# diff. Signals (signals/*.jsonl) ARE tracked — this is just the ephemeral
|
||||
# health-probe cache. See oikos/scheduler.py.
|
||||
# Regenerated every scheduler run; ephemeral health-probe cache.
|
||||
oikos/state.json
|
||||
|
||||
.worktrees/bin/
|
||||
# Compiled binaries (Go rewrite — bin/oikos, bin/hermes)
|
||||
bin/oikos
|
||||
bin/hermes
|
||||
|
||||
# Legacy Python oikos (superseded by cmd/oikos Go binary — Phase 1-6 rewrite).
|
||||
# oikos/ kernel files are still imported by bin/homelab for operational CLI
|
||||
# commands (ssh, pct, logs, restart, status, open, secret, client, sync, mcp).
|
||||
# Remove oikos/* when bin/homelab is ported to Go.
|
||||
|
||||
BIN
bin/hermes
BIN
bin/hermes
Binary file not shown.
411
mcp/server.py
411
mcp/server.py
@@ -1,411 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Homelab MCP server.
|
||||
|
||||
Reads the canonical state from /opt/homelab-context/ (a git clone of
|
||||
dtoro/Homelab-Docs) and exposes structured tools to any MCP-capable agent.
|
||||
|
||||
Two tool groups:
|
||||
- Context (pure read of the clone, no shell-outs)
|
||||
- Management (read-only ssh/pct/systemctl/curl, NO mutations)
|
||||
|
||||
Mutations live in the `homelab` CLI on each client, behind operator
|
||||
confirmation. The MCP server never restarts, edits, or executes arbitrary
|
||||
commands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from mcp.server.fastmcp import FastMCP # noqa: E402 — must precede the sys.path
|
||||
# insert below: CONTEXT_DIR contains its
|
||||
# own top-level "mcp/" directory, which
|
||||
# would shadow the real `mcp` package if
|
||||
# inserted first.
|
||||
|
||||
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
|
||||
INVENTORY = CONTEXT_DIR / "inventory.yaml"
|
||||
HOSTS_DIR = CONTEXT_DIR / "hosts"
|
||||
CARDS_DIR = CONTEXT_DIR / "oikos" / "cards"
|
||||
|
||||
sys.path.insert(0, str(CONTEXT_DIR))
|
||||
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
|
||||
# All management tools proxy through hubris (the Proxmox host) via a single
|
||||
# restricted-shell SSH connection. The wrapper at mcp/mcp-reader-shell on
|
||||
# hubris validates each command against a strict read-only allowlist.
|
||||
SSH_IDENTITY = os.environ.get("HOMELAB_MCP_SSH_KEY", "/etc/homelab-mcp/mcp-reader.key")
|
||||
SSH_USER = os.environ.get("HOMELAB_MCP_SSH_USER", "root")
|
||||
SSH_KNOWN_HOSTS = os.environ.get("HOMELAB_MCP_SSH_KNOWN_HOSTS",
|
||||
"/etc/homelab-mcp/known_hosts")
|
||||
HUBRIS_HOST = os.environ.get("HOMELAB_MCP_HUBRIS_HOST", "192.168.8.77")
|
||||
SSH_TIMEOUT = int(os.environ.get("HOMELAB_MCP_SSH_TIMEOUT", "10"))
|
||||
|
||||
mcp = FastMCP("homelab")
|
||||
mcp.settings.host = os.environ.get("HOMELAB_MCP_HOST", "0.0.0.0")
|
||||
mcp.settings.port = int(os.environ.get("HOMELAB_MCP_PORT", "9810"))
|
||||
# FastMCP's DNS-rebinding protection only whitelists 127.0.0.1 / localhost / [::1]
|
||||
# by default, which breaks any LAN/mesh client. We're already mesh+LAN-gated at
|
||||
# nftables and the browser-attack threat doesn't apply to mesh-only services.
|
||||
mcp.settings.transport_security.enable_dns_rebinding_protection = False
|
||||
|
||||
|
||||
def _load_inventory() -> dict:
|
||||
if not INVENTORY.exists():
|
||||
raise RuntimeError(f"inventory not found: {INVENTORY}")
|
||||
return yaml.safe_load(INVENTORY.read_text())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _inventory_cache_key() -> int:
|
||||
"""Cache buster keyed on mtime so we re-read after each sync pull."""
|
||||
return INVENTORY.stat().st_mtime_ns
|
||||
|
||||
|
||||
def inventory() -> dict:
|
||||
_inventory_cache_key.cache_clear()
|
||||
_ = _inventory_cache_key() # warm
|
||||
return _load_inventory()
|
||||
|
||||
|
||||
def _run_via_hubris(remote_host: str, cmd: list[str],
|
||||
timeout: int | None = None) -> subprocess.CompletedProcess:
|
||||
"""SSH to hubris (single channel, restricted-shell key) and run `cmd`.
|
||||
If remote_host is an LXC, the command is wrapped in `pct exec <id> --`
|
||||
so hubris executes it inside the LXC. The wrapper at /usr/local/bin/
|
||||
mcp-reader-shell on hubris validates the final command against an
|
||||
allowlist before execution.
|
||||
"""
|
||||
inv_hosts = inventory().get("hosts", {})
|
||||
if remote_host == "hubris":
|
||||
full = cmd
|
||||
else:
|
||||
host_entry = inv_hosts.get(remote_host)
|
||||
if host_entry is None:
|
||||
raise ValueError(f"unknown host: {remote_host}")
|
||||
pve_id = host_entry.get("pve_id")
|
||||
if pve_id is None:
|
||||
raise ValueError(f"{remote_host} has no pve_id; can't pct-exec into it")
|
||||
full = ["pct", "exec", str(pve_id), "--", *cmd]
|
||||
# The restricted shell on hubris reads the joined command from
|
||||
# $SSH_ORIGINAL_COMMAND and validates it as a single string.
|
||||
joined = " ".join(full)
|
||||
ssh_args = [
|
||||
"ssh", "-i", SSH_IDENTITY, "-o", "BatchMode=yes",
|
||||
# The systemd unit runs with ProtectHome=true so ~/.ssh is unreachable.
|
||||
# Use a pre-populated known_hosts in /etc/homelab-mcp/.
|
||||
"-o", f"UserKnownHostsFile={SSH_KNOWN_HOSTS}",
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", f"ConnectTimeout={SSH_TIMEOUT}",
|
||||
f"{SSH_USER}@{HUBRIS_HOST}",
|
||||
joined,
|
||||
]
|
||||
proc = subprocess.run(
|
||||
ssh_args, capture_output=True, text=True,
|
||||
timeout=timeout or SSH_TIMEOUT * 3,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
import logging as _l
|
||||
_l.getLogger("homelab-mcp").warning(
|
||||
"ssh failed rc=%s host=%s cmd=%r stderr=%r",
|
||||
proc.returncode, remote_host, joined, proc.stderr.strip(),
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
def _service_to_host(service: str) -> str:
|
||||
"""Resolve a service name to its backend host name."""
|
||||
svc = inventory().get("services", {}).get(service)
|
||||
if not svc:
|
||||
raise ValueError(f"unknown service: {service}")
|
||||
return svc["backend"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def get_host(name: str) -> dict:
|
||||
"""Return the structured record for a host (LXC, VM, workstation, or hubris)."""
|
||||
inv = inventory()
|
||||
entry = inv.get("hosts", {}).get(name)
|
||||
if entry is None:
|
||||
raise ValueError(f"unknown host: {name}")
|
||||
out = {"name": name, **entry}
|
||||
yaml_path = HOSTS_DIR / f"{name}.yaml"
|
||||
if yaml_path.exists():
|
||||
out["host_yaml_path"] = str(yaml_path)
|
||||
return out
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_services() -> dict:
|
||||
"""List every service registered in inventory.yaml."""
|
||||
return inventory().get("services", {})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_service(query: str) -> list[dict]:
|
||||
"""Find services by name substring, role, or backend host name."""
|
||||
q = query.lower()
|
||||
inv = inventory()
|
||||
out = []
|
||||
for name, entry in inv.get("services", {}).items():
|
||||
haystack = " ".join([
|
||||
name,
|
||||
str(entry.get("backend", "")),
|
||||
str(entry.get("role", "")),
|
||||
str(entry.get("note", "")),
|
||||
]).lower()
|
||||
if q in haystack:
|
||||
out.append({"service": name, **entry})
|
||||
return out
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_topology() -> dict:
|
||||
"""Return the full inventory (hosts + services + mesh)."""
|
||||
return inventory()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def search_docs(query: str, max_results: int = 10) -> list[dict]:
|
||||
"""Ripgrep the markdown wiki for query, return file:line hits."""
|
||||
rg = subprocess.run(
|
||||
["rg", "--no-heading", "-n", "-i", "--type", "md", "-m", "5", query, str(CONTEXT_DIR)],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
results = []
|
||||
for line in rg.stdout.splitlines()[: max_results * 5]:
|
||||
m = re.match(r"^(.+?):(\d+):(.*)$", line)
|
||||
if m:
|
||||
results.append({
|
||||
"path": str(Path(m.group(1)).relative_to(CONTEXT_DIR)),
|
||||
"line": int(m.group(2)),
|
||||
"text": m.group(3).strip(),
|
||||
})
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_page(path: str) -> str:
|
||||
"""Return a markdown page verbatim. Path is relative to the repo root."""
|
||||
full = (CONTEXT_DIR / path).resolve()
|
||||
if not str(full).startswith(str(CONTEXT_DIR.resolve())):
|
||||
raise ValueError("path escapes repo")
|
||||
if not full.exists():
|
||||
raise ValueError(f"no such page: {path}")
|
||||
return full.read_text()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_changelog(path: str, since: str | None = None) -> str:
|
||||
"""Extract the trailing ## Changelog section from a page; optionally filter."""
|
||||
text = get_page(path)
|
||||
m = re.search(r"^##\s+Changelog\s*\n(.*)", text, re.MULTILINE | re.DOTALL)
|
||||
if not m:
|
||||
return ""
|
||||
body = m.group(1).strip()
|
||||
if since:
|
||||
kept = []
|
||||
for entry in re.split(r"^###\s+", body, flags=re.MULTILINE):
|
||||
if not entry.strip():
|
||||
continue
|
||||
head = entry.split("\n", 1)[0]
|
||||
date_m = re.match(r"(\d{4}-\d{2}-\d{2})", head)
|
||||
if not date_m or date_m.group(1) >= since:
|
||||
kept.append("### " + entry.rstrip())
|
||||
return "\n\n".join(kept).strip()
|
||||
return body
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def whoami(hostname: str) -> dict:
|
||||
"""Given a hostname, return that host's full yaml record from hosts/<name>.yaml."""
|
||||
candidate = HOSTS_DIR / f"{hostname}.yaml"
|
||||
if not candidate.exists():
|
||||
raise ValueError(f"no host record for {hostname}")
|
||||
return yaml.safe_load(candidate.read_text())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def explain(service: str) -> str:
|
||||
"""Return the compact context card for a service: identity, blast
|
||||
radius, safe actions + risk class, doc pointer, recent ledger history.
|
||||
Card-first — cheaper for agent orientation than search_docs + get_page.
|
||||
"""
|
||||
card = CARDS_DIR / f"service-{service}.md"
|
||||
if not card.exists():
|
||||
raise ValueError(f"no context card for {service} — has gen-topology.py run?")
|
||||
return card.read_text()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def preflight(service: str) -> dict:
|
||||
"""Dry-run report before mutating a service: risk class, approval
|
||||
requirement, current health, config repo, and the verification command
|
||||
to run after the change."""
|
||||
inv_svc = inventory().get("services", {}).get(service)
|
||||
if not inv_svc:
|
||||
raise ValueError(f"unknown service: {service}")
|
||||
risk = (oikos_policy.classify_action("tracked-config-edit", service)
|
||||
if inv_svc.get("config_repo")
|
||||
else oikos_policy.classify_action("service-restart", service)) or "config_mutation"
|
||||
url = inv_svc.get("url") or inv_svc.get("endpoint")
|
||||
return {
|
||||
"service": service,
|
||||
"risk_class": risk,
|
||||
"approval": oikos_policy.approval_for(risk),
|
||||
"config_repo": inv_svc.get("config_repo"),
|
||||
"risk_notes": inv_svc.get("risk_notes"),
|
||||
"verification": f"curl -sf {url}" if url else f"tail_log({service!r})",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_relations(entity: str) -> list[dict]:
|
||||
"""Walk the ontology graph both directions for a host or service name:
|
||||
what it impacts, what affects it, and its full transitive blast radius.
|
||||
"""
|
||||
return oikos_relations.relations_for_name(entity)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_state_snapshot() -> dict:
|
||||
"""The Week-3 scheduler's last Observe-pass snapshot (service health,
|
||||
host disk usage, drift-finding count, generated_at timestamp). This is
|
||||
what makes cache-first reads work from ANY client, not just the one the
|
||||
scheduler runs on: the CLI's local-file cache only helps on that host;
|
||||
agents elsewhere should call this tool instead of assuming a local
|
||||
oikos/state.json exists."""
|
||||
from oikos import scheduler as oikos_scheduler
|
||||
state = oikos_scheduler.read_state()
|
||||
if state is None:
|
||||
raise ValueError("no scheduler snapshot yet — has oikos-scheduler.timer run?")
|
||||
return state
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_change_history(entity: str, limit: int = 20) -> list[dict]:
|
||||
"""Ledger entries for `entity` (e.g. "service:jellyfin", "host:strong"),
|
||||
newest first."""
|
||||
return oikos_ledger.history(entity, limit=limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_my_secrets(caller_pubkey: str) -> list[str]:
|
||||
"""Return the names of secrets the caller (identified by age pubkey) can decrypt.
|
||||
|
||||
Metadata only — the server never returns plaintext. The client decrypts
|
||||
locally with its own /etc/age/key.txt.
|
||||
"""
|
||||
if not caller_pubkey:
|
||||
return []
|
||||
secrets_dir = CONTEXT_DIR / "secrets"
|
||||
if not secrets_dir.exists():
|
||||
return []
|
||||
out = []
|
||||
for path in secrets_dir.glob("*.yaml"):
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text()) or {}
|
||||
except yaml.YAMLError:
|
||||
continue
|
||||
recipients = (
|
||||
data.get("sops", {})
|
||||
.get("age", [])
|
||||
)
|
||||
for r in recipients:
|
||||
if r.get("recipient") == caller_pubkey:
|
||||
out.append(path.stem)
|
||||
break
|
||||
return sorted(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management tools (read-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def get_service_status(service: str) -> dict:
|
||||
"""systemctl is-active + is-enabled for the named service on its backend host."""
|
||||
host = _service_to_host(service)
|
||||
inv_svc = inventory()["services"][service]
|
||||
unit = inv_svc.get("systemd_unit", service)
|
||||
active = _run_via_hubris(host, ["systemctl", "is-active", unit])
|
||||
enabled = _run_via_hubris(host, ["systemctl", "is-enabled", unit])
|
||||
return {
|
||||
"service": service,
|
||||
"host": host,
|
||||
"unit": unit,
|
||||
"active": active.stdout.strip(),
|
||||
"enabled": enabled.stdout.strip(),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def tail_log(service: str, lines: int = 200) -> str:
|
||||
"""Last N journalctl lines for the named service on its backend host."""
|
||||
host = _service_to_host(service)
|
||||
inv_svc = inventory()["services"][service]
|
||||
unit = inv_svc.get("systemd_unit", service)
|
||||
proc = _run_via_hubris(host, ["journalctl", "-u", unit,
|
||||
"-n", str(lines), "--no-pager"])
|
||||
return proc.stdout
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_lxcs() -> str:
|
||||
"""Run `pct list` on hubris."""
|
||||
return _run_via_hubris("hubris", ["pct", "list"]).stdout
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_lxc_state(lxc: str) -> dict:
|
||||
"""`pct status` and a quick `pct config` snapshot for the named LXC."""
|
||||
inv = inventory().get("hosts", {})
|
||||
if lxc not in inv:
|
||||
raise ValueError(f"unknown lxc: {lxc}")
|
||||
pve_id = inv[lxc].get("pve_id")
|
||||
if pve_id is None:
|
||||
raise ValueError(f"{lxc} has no pve_id (is it actually an LXC?)")
|
||||
status = _run_via_hubris("hubris", ["pct", "status", str(pve_id)])
|
||||
cfg = _run_via_hubris("hubris", ["pct", "config", str(pve_id)])
|
||||
return {"lxc": lxc, "pve_id": pve_id,
|
||||
"status": status.stdout.strip(),
|
||||
"config": cfg.stdout}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ping_service(service: str) -> dict:
|
||||
"""HTTP check against the service's URL (from inventory)."""
|
||||
inv_svc = inventory().get("services", {}).get(service)
|
||||
if not inv_svc:
|
||||
raise ValueError(f"unknown service: {service}")
|
||||
url = inv_svc.get("url") or inv_svc.get("endpoint") or inv_svc.get("backend_url")
|
||||
if not url:
|
||||
return {"service": service, "ok": False, "reason": "no URL in inventory"}
|
||||
proc = subprocess.run(
|
||||
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code} %{time_total}",
|
||||
"--max-time", "5", url],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
code, t = (proc.stdout.strip().split() + ["", ""])[:2]
|
||||
return {"service": service, "url": url, "http_code": code, "time_s": t,
|
||||
"ok": code.startswith("2") or code.startswith("3")}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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](../../../knowledge/wiki/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.
|
||||
|
||||
## Status (2026-07-06)
|
||||
|
||||
- **Console deploy on apps (105): done.** Live at `/opt/oikos-console`,
|
||||
both systemd units enabled and active, verified locally
|
||||
(`127.0.0.1:8091` → `200`) and end-to-end
|
||||
(`https://oikos.hubris.network/` → `302`, the Authentik gate firing).
|
||||
- **Gitea webhook: registered but NOT currently working.** Webhook id
|
||||
**14** (`http://192.168.8.205:9831/deploy`, `push` events, `main`
|
||||
branch filter, active) exists, and its secret is synced correctly
|
||||
between Gitea and `/etc/oikos-console-deploy/secret` on apps (rotated
|
||||
once to fix a drift from an earlier partial-PATCH update) — but
|
||||
deliveries still 403 with a signature mismatch for a cause not yet
|
||||
found. **Until this is fixed, `git push` to `main` will NOT
|
||||
auto-redeploy the console** — run `deploy.sh` manually on apps after
|
||||
any change (see "One-time setup" below; it's idempotent, safe to
|
||||
re-run). Debugging this further needs either a git-committed (not
|
||||
ad-hoc SSH-edited) debug build of `webhook.py`, or checking Gitea's
|
||||
actual signing behavior against a captured raw request — both stalled
|
||||
on safety-classifier blocks around production code edits and
|
||||
credential handling this session, so left for a future pass.
|
||||
- **Caddy route: done.** Pushed to `dtoro/caddy-conf` (commit `c195142`),
|
||||
Authentik-gated matching `paperless.hubris.network`'s pattern, reload
|
||||
confirmed clean (an unrelated route stayed healthy through the reload).
|
||||
**Found and fixed a real bug while wiring this up:** `oikos-console.service`
|
||||
originally bound `127.0.0.1` only — since Caddy runs on a *different*
|
||||
host (LXC 121), that would have made the console completely
|
||||
unreachable once deployed. Now binds `0.0.0.0`, matching `homelab-mcp`'s
|
||||
convention (trust boundary is LAN/mesh + the Authentik gate, not the
|
||||
bind address).
|
||||
- **DNS entry: done.** `oikos.hubris.network` A record added via
|
||||
Technitium's REST API (login → createToken → zones/records/add, all
|
||||
in one in-memory call; the session/API token was never printed or
|
||||
written to disk, and wasn't persisted anywhere after the call
|
||||
completed). Verified: `dig @192.168.8.2 +short oikos.hubris.network`
|
||||
→ `192.168.8.175`.
|
||||
|
||||
## 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 a NEW secret by default —
|
||||
# see "Status" above before running this
|
||||
systemctl enable --now oikos-console.service oikos-console-deploy.service
|
||||
```
|
||||
|
||||
## Caddy route — done (2026-07-06), in `dtoro/caddy-conf`, not this repo
|
||||
|
||||
Live in `dtoro/caddy-conf` as of commit `c195142`, Authentik-gated
|
||||
(confirmed syntax against the live Caddyfile: `import authentik`, no
|
||||
parens in the import itself — the snippet is *defined* as `(authentik)`
|
||||
but *imported* as `authentik`), same pattern as `paperless.hubris.network`:
|
||||
|
||||
```caddyfile
|
||||
oikos.hubris.network {
|
||||
import authentik
|
||||
reverse_proxy 192.168.8.205:8091
|
||||
}
|
||||
```
|
||||
|
||||
Still needed: 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 — see the DNS section below.
|
||||
|
||||
## 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()`).
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/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
|
||||
|
||||
# signals/ and approvals/ are untracked-when-empty (git doesn't version
|
||||
# empty directories), so a fresh clone won't have them. The systemd unit's
|
||||
# ReadWritePaths need these to exist before the process starts (confirmed
|
||||
# the hard way on first deploy, 2026-07-06 — a missing dir here is a
|
||||
# 226/NAMESPACE crash-loop, not a graceful degradation).
|
||||
mkdir -p /opt/homelab-context/signals /opt/homelab-context/approvals
|
||||
|
||||
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."
|
||||
@@ -1,32 +0,0 @@
|
||||
[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 0.0.0.0 --port 8091
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Caddy runs on a DIFFERENT host (LXC 121) and reaches this over the LAN
|
||||
# at 192.168.8.205:8091 (dtoro/caddy-conf's oikos.hubris.network block) —
|
||||
# binding loopback-only would make this unreachable from Caddy entirely.
|
||||
# Same bind convention as homelab-mcp (0.0.0.0, trust boundary enforced
|
||||
# by LAN/mesh + the Authentik forward-auth gate in front, not by bind
|
||||
# address). No public exposure: only reachable via LAN/mesh.
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
ReadOnlyPaths=/opt/homelab-context /opt/oikos-console
|
||||
# "-" prefix marks each path optional — signals/ and approvals/ are
|
||||
# untracked-when-empty (git doesn't version empty dirs), so a fresh
|
||||
# /opt/homelab-context clone won't have them yet. Without "-", systemd
|
||||
# refuses to start at all (226/NAMESPACE) until something else creates
|
||||
# them first — confirmed the hard way on first deploy (2026-07-06).
|
||||
ReadWritePaths=-/opt/homelab-context/signals -/opt/homelab-context/approvals -/opt/homelab-context/ledger -/opt/homelab-context/oikos
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,13 +0,0 @@
|
||||
[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
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,124 +0,0 @@
|
||||
: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; }
|
||||
@@ -1,32 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,23 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,22 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,37 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,35 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,44 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,25 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,137 +0,0 @@
|
||||
# Oikos ontology — the systems model of the homelab.
|
||||
#
|
||||
# This file defines the closed vocabulary Oikos reasons with: entity
|
||||
# types (grouped into eight domains), typed relationships (with inverses),
|
||||
# and the node lifecycle. inventory.yaml holds the *instances*; this file
|
||||
# defines what those instances and their fields MEAN, so agents, the
|
||||
# decision classifier, and the topology generator interpret them
|
||||
# identically. See OIKOS.md for the operating model.
|
||||
#
|
||||
# Rule of completeness: if something can break, be changed, or hold data,
|
||||
# it has an entity type here and edges to the things it touches.
|
||||
|
||||
domains:
|
||||
physical:
|
||||
description: Hardware and environment.
|
||||
entity_types: [site, machine, ups, sensor, peripheral]
|
||||
compute:
|
||||
description: Things that execute workloads.
|
||||
entity_types: [proxmox-host, lxc, vm, workstation, external-host, device]
|
||||
network:
|
||||
description: How things reach each other.
|
||||
entity_types: [lan, mesh, dns-zone, dns-record, ingress-route, certificate, firewall-rule]
|
||||
storage:
|
||||
description: Where data lives and how it survives.
|
||||
entity_types: [storage-pool, volume, mount, backup-target, dataset]
|
||||
software:
|
||||
description: What runs and how it is configured and shipped.
|
||||
entity_types: [service, application, config-repo, package-set, deploy-pipeline]
|
||||
identity_access:
|
||||
description: Who and what may do which things.
|
||||
entity_types: [person, identity-provider, account, secret, key, access-grant]
|
||||
operations:
|
||||
description: The OS's own working objects.
|
||||
entity_types: [agent, runbook, plan, change, incident, signal, approval, report]
|
||||
external:
|
||||
description: Dependencies outside the lab's control.
|
||||
entity_types: [domain-registration, cloud-service, isp-link, vendor-dependency]
|
||||
|
||||
# Relationships. `source:` says which inventory/repo data expresses the edge
|
||||
# today (thin = not yet structured, derive from docs until backfilled).
|
||||
relationships:
|
||||
hosts:
|
||||
inverse: runs-on
|
||||
example: hubris hosts lxc:apps
|
||||
source: hosts.<lxc>.host + pve_id
|
||||
provides:
|
||||
inverse: provided-by
|
||||
example: lxc:apps provides service:homelab_mcp
|
||||
source: hosts.<name>.runs + services.<svc>.backend
|
||||
mounts:
|
||||
inverse: mounted-by
|
||||
example: lxc:jellyfin mounts /mnt/media_local from strong
|
||||
source: hosts.<name>.mounts (extend with from:)
|
||||
stores-on:
|
||||
inverse: stores-for
|
||||
example: lxc:jellyfin rootfs stores-on storage-pool:ludo-lvm
|
||||
source: hosts.<name>.storage (new field)
|
||||
routes-to:
|
||||
inverse: routed-via
|
||||
example: ingress-route:media.hubris.network routes-to service:jellyfin
|
||||
source: services.<svc>.url/public_host + dtoro/caddy-conf
|
||||
resolves-to:
|
||||
inverse: resolved-from
|
||||
example: dns-record:media.hubris.network resolves-to caddy lan_ip
|
||||
source: Technitium split-horizon zone (LXC 107) + dns-sync job
|
||||
secured-by:
|
||||
inverse: secures
|
||||
example: ingress-route:paperless secured-by identity-provider:authentik
|
||||
source: caddy-conf forward-auth blocks + service auth notes
|
||||
authenticates-via:
|
||||
inverse: authenticates
|
||||
example: service:jellyfin authenticates-via authentik (native OIDC)
|
||||
source: services.<svc>.auth (new field, from risk_notes/docs)
|
||||
connects-via:
|
||||
inverse: connects
|
||||
example: workstation:mac-mini connects-via mesh:netbird
|
||||
source: hosts.<name>.mesh
|
||||
can-decrypt:
|
||||
inverse: readable-by
|
||||
example: lxc:apps can-decrypt secret:gitea-pat
|
||||
source: .sops.yaml path rules + hosts.<name>.age_pubkey
|
||||
configured-by:
|
||||
inverse: configures
|
||||
example: lxc:caddy configured-by config-repo:dtoro/caddy-conf
|
||||
source: services.<svc>.config_repo (new field)
|
||||
deploys-to:
|
||||
inverse: deployed-from
|
||||
example: deploy-pipeline:webhook-10 deploys-to /opt/homelab-mcp on lxc:apps
|
||||
source: infrastructure/auto-deploy.md table
|
||||
monitors:
|
||||
inverse: monitored-by
|
||||
example: agent:scheduler monitors service:* (Week 3)
|
||||
source: oikos/scheduler config
|
||||
depends-on:
|
||||
inverse: dependency-of
|
||||
example: service:paperless depends-on service:authentik
|
||||
source: hosts/services depends_on (new field)
|
||||
backs-up-to:
|
||||
inverse: backup-of
|
||||
example: dataset:nextcloud-data backs-up-to backup-target:proton-drive
|
||||
source: infrastructure backups docs → structured field (thin)
|
||||
documents:
|
||||
inverse: documented-by
|
||||
example: containers/101-jellyfin.md documents lxc:jellyfin
|
||||
source: generated see_also / services.<svc>.doc_page
|
||||
powered-by:
|
||||
inverse: powers
|
||||
example: machine:hubris powered-by ups (future, thin record)
|
||||
source: physical domain (thin)
|
||||
registered-with:
|
||||
inverse: registrar-of
|
||||
example: domain-registration:hubris.network registered-with registrar
|
||||
source: external domain (thin)
|
||||
|
||||
# Node lifecycle. Stored as `state:` on each inventory host entry
|
||||
# (absent = active, for backward compatibility). Transitions are runbooks
|
||||
# (Week 2); drift detectors (Week 3) verify declared state matches reality.
|
||||
lifecycle:
|
||||
states: [planned, provisioning, active, migrating, deprecated, destroyed]
|
||||
default: active
|
||||
transitions:
|
||||
planned->provisioning:
|
||||
requires: [inventory-entry, ip-reserved, storage-pool-chosen, doc-page-stub]
|
||||
provisioning->active:
|
||||
requires: [age-key-enrolled-if-needed, mesh-joined-if-needed,
|
||||
ingress-live-if-public, health-check-answering,
|
||||
doc-page-complete, ledger-entry]
|
||||
active->migrating:
|
||||
requires: [preflight, backup-verified]
|
||||
migrating->active:
|
||||
requires: [post-verify, caddy-backends-checked, mounts-checked, docs-updated]
|
||||
active->deprecated:
|
||||
requires: [replacement-live-or-role-retired]
|
||||
complete_when: no inbound depends-on / routes-to edges remain
|
||||
deprecated->destroyed:
|
||||
requires: [backups-verified, secrets-revoked-and-rekeyed,
|
||||
ingress-and-dns-removed, archaeology-entry, ledger-entry]
|
||||
@@ -1,117 +0,0 @@
|
||||
# Oikos risk & approval policy — machine-readable safety model.
|
||||
#
|
||||
# Every operation an agent can perform maps to exactly one risk class.
|
||||
# The decision classifier (oikos/decide.py, Week 3) and the homelab CLI
|
||||
# consult this file before executing; agents consult it before proposing.
|
||||
# See OIKOS.md for the operating model.
|
||||
#
|
||||
# Autonomy default (operator decision 2026-07-05): unattended agents may
|
||||
# perform read_only and reversible_low actions; config_mutation and
|
||||
# destructive always require operator approval.
|
||||
|
||||
risk_classes:
|
||||
read_only:
|
||||
description: Observes state; cannot change anything.
|
||||
approval: none
|
||||
ledger: false
|
||||
reversible_low:
|
||||
description: >-
|
||||
Changes runtime state in a way a single follow-up command undoes
|
||||
(restart, cache clear, sync pull). No config or data changes.
|
||||
approval: none
|
||||
ledger: true # every mutation leaves a ledger entry
|
||||
config_mutation:
|
||||
description: >-
|
||||
Changes tracked configuration or deployed software: repo edit + push,
|
||||
deploy pipeline trigger, Caddy/Gitea/app config, package upgrades.
|
||||
Reversible via git, but affects other consumers.
|
||||
approval: operator # Matrix ✅/❌ reaction (Week 3 approval engine)
|
||||
ledger: true
|
||||
destructive:
|
||||
description: >-
|
||||
Destroys or irreversibly alters data/entities: container destroy,
|
||||
disk format, DB wipe, secret rotation, client revocation.
|
||||
approval: operator_confirmed # approval + typed confirmation phrase
|
||||
ledger: true
|
||||
|
||||
# Lifecycle gates (see ontology.yaml lifecycle):
|
||||
# provisioning: config_mutation downgraded to reversible_low (no dependents yet)
|
||||
# deprecated: adding new inbound edges (depends-on/routes-to) is refused
|
||||
# destroyed: any action targeting the entity raises a drift signal
|
||||
lifecycle_overrides:
|
||||
provisioning:
|
||||
config_mutation: reversible_low
|
||||
deprecated:
|
||||
refuse: [new-inbound-edges]
|
||||
destroyed:
|
||||
refuse: [all]
|
||||
|
||||
# homelab CLI subcommands → risk class
|
||||
commands:
|
||||
whoami: read_only
|
||||
list: read_only
|
||||
status: read_only
|
||||
logs: read_only
|
||||
open: read_only
|
||||
ssh-keyscan: read_only
|
||||
apt-audit: read_only
|
||||
mcp: read_only # MCP tools are individually classified below
|
||||
secret: read_only # decrypt-to-stdout; never write secrets to files/docs
|
||||
ssh: read_only # interactive shell itself; actions inside it carry
|
||||
# their own class — agents must not use raw ssh to
|
||||
# bypass policy (HERMES.md convention)
|
||||
sync: reversible_low
|
||||
refresh-creds: reversible_low
|
||||
ssh-config: reversible_low # rewrites ~/.ssh/config, regenerable
|
||||
apt-upgrade: config_mutation
|
||||
render-vps-configs: config_mutation
|
||||
client-add: config_mutation
|
||||
client-remove: destructive # revokes key + re-keys all secrets
|
||||
|
||||
# MCP tools → risk class (all currently read-only by design)
|
||||
mcp_tools:
|
||||
get_host: read_only
|
||||
list_services: read_only
|
||||
find_service: read_only
|
||||
get_topology: read_only
|
||||
search_docs: read_only
|
||||
get_page: read_only
|
||||
get_changelog: read_only
|
||||
whoami: read_only
|
||||
get_service_status: read_only
|
||||
tail_log: read_only
|
||||
list_lxcs: read_only
|
||||
get_lxc_state: read_only
|
||||
ping_service: read_only
|
||||
list_my_secrets: read_only
|
||||
|
||||
# Common operational actions (not yet CLI subcommands) → risk class.
|
||||
# Used by agents to classify ad-hoc work until Week 2/3 wraps them in
|
||||
# `homelab service` / runbooks.
|
||||
actions:
|
||||
service-restart: reversible_low
|
||||
cache-clear: reversible_low
|
||||
docker-compose-restart: reversible_low
|
||||
tracked-config-edit: config_mutation # commit+push to config repo, never local edit
|
||||
deploy-webhook-trigger: config_mutation
|
||||
lxc-create: config_mutation # new entity, state: provisioning
|
||||
lxc-migrate: config_mutation
|
||||
dns-record-change: config_mutation
|
||||
ingress-route-change: config_mutation
|
||||
secret-rotate: destructive
|
||||
lxc-destroy: destructive
|
||||
disk-format: destructive
|
||||
db-wipe: destructive
|
||||
storage-pool-change: destructive
|
||||
|
||||
# Per-service overrides (schema ready; populate as needs emerge).
|
||||
# Example:
|
||||
# jellyfin:
|
||||
# service-restart: reversible_low # default anyway
|
||||
# caddy:
|
||||
# service-restart: config_mutation # wide blast radius: all ingress
|
||||
service_overrides:
|
||||
caddy:
|
||||
service-restart: config_mutation # everything *.hubris.network rides on it
|
||||
dns:
|
||||
service-restart: config_mutation # LAN-wide resolver
|
||||
@@ -8,7 +8,10 @@ went sideways, open an investigation.
|
||||
|
||||
| Date | Title | Status |
|
||||
| ---- | ----- | ------ |
|
||||
| 2026-06-24 | [TRMNL plugins LXC (128) + middleware deploy pipeline](2026-06-24-trmnl-plugins-lxc.md) | In Progress |
|
||||
| 2026-06-24 | [TRMNL plugins LXC (128) + middleware deploy pipeline](2026-06-24-trmnl-plugins-lxc.md) | Planned |
|
||||
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned |
|
||||
| 2026-07-06 | [Adopt wiki-hq doc architecture](2026-07-06-adopt-wiki-hq-doc-architecture.md) | In Progress |
|
||||
| 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) | In Progress (Phase 1-6 implemented, pending cutover) |
|
||||
|
||||
## Done
|
||||
|
||||
@@ -26,4 +29,4 @@ See [`done/`](done/) for executed plans:
|
||||
- File name: `YYYY-MM-DD-<slug>.md`. Use the *target* date if known, otherwise the planning date.
|
||||
- Status: `Planned` → `In Progress` → `Done` (move to `done/` on completion).
|
||||
- When done: add a changelog entry on every affected node page, then move the file to `done/`.
|
||||
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.
|
||||
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.
|
||||
|
||||
Reference in New Issue
Block a user