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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Regenerated every scheduler run (every 10 min); no audit value in the
|
# Regenerated every scheduler run; ephemeral health-probe cache.
|
||||||
# diff. Signals (signals/*.jsonl) ARE tracked — this is just the ephemeral
|
|
||||||
# health-probe cache. See oikos/scheduler.py.
|
|
||||||
oikos/state.json
|
oikos/state.json
|
||||||
|
|
||||||
.worktrees/bin/
|
# Compiled binaries (Go rewrite — bin/oikos, bin/hermes)
|
||||||
bin/oikos
|
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 |
|
| 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
|
## Done
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user