lucy: Werkzeuge für den Orchestrator (MCP homelab-orchestrator)
User 25.09.: „Lucy ist hier der komplette Systemadmin + Helfer“ – „ja, bau das mit Lucy und dem Orchestrator“. Lucy bekommt die Knöpfe der Oberfläche als MCP-Werkzeuge: lesen (lage, updates, speicher, protokoll, radar) und handeln (update_starten, alle_aktualisieren, nach_updates_suchen, dienst_neustarten, kernel_aufraeumen, host_neustarten, radar_testen, radar_suchen) – über dieselben MC2-Routen mit Snapshot, Prüfung, Rückweg und Sperren. Jede Aktion braucht einen Grund (die Bitte des Commanders) und wird protokolliert; die Beschreibungen verbieten Aktionen auf eigene Initiative (Reparatur-Neustart ausgenommen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
44f33c31d3
commit
deaf35e953
@@ -0,0 +1,121 @@
|
||||
"""Lucys Knöpfe für den Orchestrator (mcp/orchestrator_werkzeuge.py, seit 25.09.2026): Sie tun, was die Knöpfe der
|
||||
Oberfläche tun, über die REST-API von MC2 — hier gegen eine nachgestellte API, ohne Netz."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "mcp"))
|
||||
import orchestrator_werkzeuge as w
|
||||
|
||||
ZIELE_BOX = {"ziele": [{"id": "ki-box", "name": "KI-Box", "erreichbar": True, "bausteine": [
|
||||
{"id": "hermes", "name": "Hermes", "zustand": "neu", "kurz": "+120 Änderungen",
|
||||
"aktion": {"methode": "POST", "pfad": "/api/maintenance/hermes-update", "frage": "?", "label": "Aktualisieren"}},
|
||||
{"id": "os", "name": "Betriebssystem", "zustand": "aktuell"}]}]}
|
||||
ZIELE_HL = {"ziele": [
|
||||
{"id": "ct-102", "name": "NetBird", "erreichbar": True, "bausteine": [
|
||||
{"id": "app", "name": "NetBird", "zustand": "neu", "verfuegbar": "0.79.0", "aktion": None,
|
||||
"grund": "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 08:06."},
|
||||
{"id": "os", "name": "Pakete", "zustand": "neu", "kurz": "48 Pakete",
|
||||
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/ct-102/os-update", "frage": "?", "label": "Jetzt"}}]},
|
||||
{"id": "pve", "name": "Proxmox-Host", "erreichbar": True, "bausteine": []}]}
|
||||
SPEICHER = {"pools": [{"speicher": "local-lvm", "anteil": 0.5, "stufe": "ok"}],
|
||||
"systemplatte": {"anteil": 0.32, "stufe": "ok"},
|
||||
"nas": [{"server": "192.168.178.62", "anteil": 0.39, "stufe": "ok", "problem": None}],
|
||||
"sicherungen": {"stufe": "ok", "fehlend": []},
|
||||
"empfehlungen": [{"titel": "Arcane (Docker): 125 GB an den Pool zurückgeben", "platz": 125e9,
|
||||
"text": "Discard einschalten."}]}
|
||||
|
||||
|
||||
class Antwort:
|
||||
def __init__(self, status: int, daten: object):
|
||||
self.status_code, self._daten = status, daten
|
||||
self.content = json.dumps(daten).encode()
|
||||
|
||||
def json(self):
|
||||
return self._daten
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mc2(monkeypatch, tmp_path):
|
||||
"""Nachgestellte MC2-API; merkt sich jeden POST."""
|
||||
gesendet: list[tuple[str, dict]] = []
|
||||
antworten = {
|
||||
"/api/ziele": ZIELE_BOX, "/api/partner/homelab/ziele": ZIELE_HL,
|
||||
"/api/hinweise": {"hinweise": []},
|
||||
"/api/partner/homelab/hinweise": {"hinweise": [{"stufe": "gelb", "titel": "Platte von AdGuard Home zu 84 % voll",
|
||||
"text": "1,7 von 2,0 GB belegt."}]},
|
||||
"/api/partner/homelab/speicher": SPEICHER,
|
||||
"/api/jobs": {"jobs": [{"label": "Nach Updates suchen", "state": "running"}]},
|
||||
"/api/partner/homelab/laeufe": {"laeufe": []}, "/api/partner/homelab/sammellauf": {"sammellauf": None},
|
||||
}
|
||||
post_antwort = {"status": 200, "daten": {"ok": True, "lauf": "l1"}}
|
||||
|
||||
def get(url, params=None, timeout=None):
|
||||
pfad = url.removeprefix(w.MC_URL)
|
||||
return Antwort(200, antworten[pfad]) if pfad in antworten else Antwort(404, {"detail": "gibt es nicht"})
|
||||
|
||||
def post(url, json=None, timeout=None):
|
||||
gesendet.append((url.removeprefix(w.MC_URL), json))
|
||||
return Antwort(post_antwort["status"], post_antwort["daten"])
|
||||
|
||||
monkeypatch.setattr(w.httpx, "get", get)
|
||||
monkeypatch.setattr(w.httpx, "post", post)
|
||||
monkeypatch.setattr(w, "LOG", tmp_path / "orchestrator-werkzeuge.log")
|
||||
return gesendet, post_antwort, tmp_path / "orchestrator-werkzeuge.log"
|
||||
|
||||
|
||||
def test_lage_und_updates_lesen(mc2):
|
||||
lage = w.lage()
|
||||
assert "KI-Box: keine Hinweise." in lage and "Homelab, gelb: Platte von AdGuard Home zu 84 % voll." in lage
|
||||
assert "Geräte: 3 von 3 erreichbar; 3 Updates stehen an" in lage
|
||||
assert "Speicherpool local-lvm: 50 % belegt (ok)." in lage and "Läuft auf der KI-Box: Nach Updates suchen." in lage
|
||||
liste = w.updates()
|
||||
assert "NetBird (ziel=ct-102, baustein=os): Pakete · 48 Pakete — startbar." in liste
|
||||
assert "kein Knopf: Das Update-Skript wurde am 24.09. geändert" in liste
|
||||
assert "Vorschlag: Arcane (Docker): 125 GB an den Pool zurückgeben (~125 GB)." in w.speicher()
|
||||
|
||||
|
||||
def test_update_starten_wie_der_knopf(mc2):
|
||||
gesendet, _, log = mc2
|
||||
text = w.update_starten("ct-102", "os", "Commander: spiel die NetBird-Pakete ein")
|
||||
assert text.startswith("Gestartet: NetBird · Pakete.")
|
||||
assert gesendet == [("/api/partner/homelab/ziele/ct-102/os-update", {})] # Durchreiche zum Homelab-Teil
|
||||
eintrag = json.loads(log.read_text(encoding="utf-8").splitlines()[-1])
|
||||
assert eintrag["werkzeug"] == "update_starten" and eintrag["grund"].startswith("Commander:")
|
||||
assert "kurz nicht erreichbar" in w.update_starten("ki-box", "hermes", "ja, Hermes aktualisieren")
|
||||
assert gesendet[-1][0] == "/api/maintenance/hermes-update"
|
||||
|
||||
|
||||
def test_ohne_grund_oder_knopf_passiert_nichts(mc2):
|
||||
gesendet, antwort, _ = mc2
|
||||
assert w.update_starten("ct-102", "os", " ").startswith("Nicht ausgeführt: Es fehlt der Grund")
|
||||
assert w.update_starten("ct-102", "app", "bitte").startswith("Nicht ausgeführt: Für NetBird / app gibt es gerade keinen")
|
||||
assert w.update_starten("ct-999", "os", "bitte").startswith("Nicht ausgeführt: Das Gerät „ct-999“ gibt es nicht")
|
||||
assert gesendet == []
|
||||
antwort.update(status=409, daten={"detail": "Es läuft gerade ‚Alle aktualisieren‘."})
|
||||
assert w.alle_aktualisieren("homelab", "alles bitte") == "Nicht ausgeführt: Es läuft gerade ‚Alle aktualisieren‘."
|
||||
antwort.update(status=200, daten={"ok": False, "detail": "Unbekannter Dienst"})
|
||||
assert w.dienst_neustarten("rm -rf", "test") == "Nicht ausgeführt: Unbekannter Dienst"
|
||||
assert w.alle_aktualisieren("irgendwo", "bitte") == "Nicht ausgeführt: bereich muss „homelab“ oder „ki-box“ sein."
|
||||
|
||||
|
||||
def test_aktionen_tragen_die_leitplanke():
|
||||
lesen = {"lage", "updates", "speicher", "protokoll", "radar"}
|
||||
for name, text in w.BESCHREIBUNG.items():
|
||||
if name in lesen or name == "dienst_neustarten": # Reparatur darf sie selbst (wie der Wächter)
|
||||
assert "Nur aufrufen, wenn der Commander" not in text, name
|
||||
else:
|
||||
assert "Nur aufrufen, wenn der Commander" in text, name
|
||||
assert "Sag das dem Commander vorher und frag nach" in w.BESCHREIBUNG["host_neustarten"]
|
||||
|
||||
|
||||
def test_mc2_nicht_erreichbar(monkeypatch, tmp_path):
|
||||
def kaputt(*a, **k):
|
||||
raise httpx.ConnectError("weg")
|
||||
monkeypatch.setattr(w.httpx, "post", kaputt)
|
||||
monkeypatch.setattr(w, "LOG", tmp_path / "log")
|
||||
assert w.kernel_aufraeumen("bitte aufräumen") == "Nicht ausgeführt: MC2 ist nicht erreichbar (ConnectError)."
|
||||
@@ -211,6 +211,19 @@ Dazu `/v1/*` (Weiterleitung an den Gateway), `/hermes-ui/` und die Auslieferung
|
||||
`/api`-Pfade liefern einen Fehler (bei `GET` 404) statt der Startseite. Die MCP-Server in `mcp/` sprechen dieselben
|
||||
Routen.
|
||||
|
||||
**Lucy bedient den Orchestrator** (seit 25.09.2026, User-Ja: „Lucy ist die Systemadministratorin und Assistentin“):
|
||||
MCP-Server `homelab-orchestrator` (`mcp/mcp_orchestrator.py`, Logik ohne MCP testbar in
|
||||
`mcp/orchestrator_werkzeuge.py`), in Hermes eingetragen mit `hermes mcp add` (Werkzeuge heißen dort
|
||||
`mcp__homelab_orchestrator__…`). Lesen: `lage`, `updates`, `speicher`, `protokoll`, `radar`. Handeln — genau die Knöpfe
|
||||
der Oberfläche über dieselben Routen, mit ihren Leitplanken: `update_starten` (nimmt den Pfad aus der Aktion des
|
||||
Bausteins, Homelab über `/api/partner/homelab/…`), `alle_aktualisieren`, `nach_updates_suchen`, `dienst_neustarten`
|
||||
(Allowlist in `services.maintenance`), `kernel_aufraeumen`, `host_neustarten`, `radar_testen`, `radar_suchen`. Jede
|
||||
Aktion braucht einen `grund` (die Bitte des Commanders) und steht in `~/.hermes/logs/orchestrator-werkzeuge.log`; die
|
||||
Beschreibungen sagen dem Modell: nur auf ausdrücklichen Wunsch, nie auf eigene Initiative (außer einem Dienst-Neustart
|
||||
als Reparatur). Nichts davon geht an Proxmox oder die Gäste vorbei. Warum nicht „alle Meldungen an Hermes“: Alarme
|
||||
blieben aus, wenn Box oder Hermes selbst hängen — Meldungen kommen deshalb weiter aus Skripten (in Lucys Stimme,
|
||||
siehe BETRIEB.md) und landen zusätzlich in ihrem Briefkasten.
|
||||
|
||||
**Herkunftsprüfung** (`backend/services/herkunft.py`, statt Anmeldung): Schreibende Aufrufe (`POST`, `PUT`, `PATCH`,
|
||||
`DELETE`) auf `/api/` mit dem `Origin` einer fremden Webseite lehnt MC2 mit 403 ab. Erlaubt bleiben Aufrufe ohne
|
||||
`Origin` (Skripte, `curl`), `Origin: null` bzw. `file://` (Lucy-Desktop), dieselbe Adresse wie die Oberfläche und die
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Homelab Orchestrator — MCP-Server für Lucy (Hermes), seit 25.09.2026.
|
||||
|
||||
Stdio-MCP-Server, von Hermes Gateway als Unterprozess gestartet (auf der KI-Box, mit dem venv von MC2). Gibt Lucy die
|
||||
Knöpfe des Orchestrators: Lage lesen, Updates starten, „Alle aktualisieren“, Dienste neu starten, Kernel aufräumen,
|
||||
Host neu starten, Modell-Radar. Die Logik steht in orchestrator_werkzeuge.py (ohne MCP testbar); alles geht über die
|
||||
REST-API von MC2 mit denselben Leitplanken wie die Knöpfe der Oberfläche.
|
||||
|
||||
Die Beschreibungen sind bewusst als Leitplanken formuliert (Lesen jederzeit, Handeln nur auf Bitte des Commanders),
|
||||
damit das Modell nicht auf eigene Faust Updates oder Neustarts auslöst — der Grund jeder Aktion wird protokolliert.
|
||||
FastMCP liest die Beschreibung beim Anmelden, deshalb steht sie in description= (orchestrator_werkzeuge.BESCHREIBUNG).
|
||||
|
||||
Einrichten auf der Box (einmal): hermes mcp add homelab-orchestrator --command <venv>/bin/python --args <dieses Skript>
|
||||
"""
|
||||
|
||||
import orchestrator_werkzeuge as w
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("homelab-orchestrator")
|
||||
B = w.BESCHREIBUNG
|
||||
|
||||
|
||||
@mcp.tool(description=B["lage"])
|
||||
def lage() -> str:
|
||||
return w.lage()
|
||||
|
||||
|
||||
@mcp.tool(description=B["updates"])
|
||||
def updates() -> str:
|
||||
return w.updates()
|
||||
|
||||
|
||||
@mcp.tool(description=B["speicher"])
|
||||
def speicher() -> str:
|
||||
return w.speicher()
|
||||
|
||||
|
||||
@mcp.tool(description=B["protokoll"])
|
||||
def protokoll(anzahl: int = 15) -> str:
|
||||
return w.protokoll(anzahl)
|
||||
|
||||
|
||||
@mcp.tool(description=B["radar"])
|
||||
def radar() -> str:
|
||||
return w.radar()
|
||||
|
||||
|
||||
@mcp.tool(description=B["update_starten"])
|
||||
def update_starten(ziel: str, baustein: str, grund: str) -> str:
|
||||
return w.update_starten(ziel, baustein, grund)
|
||||
|
||||
|
||||
@mcp.tool(description=B["alle_aktualisieren"])
|
||||
def alle_aktualisieren(bereich: str, grund: str) -> str:
|
||||
return w.alle_aktualisieren(bereich, grund)
|
||||
|
||||
|
||||
@mcp.tool(description=B["nach_updates_suchen"])
|
||||
def nach_updates_suchen(grund: str) -> str:
|
||||
return w.nach_updates_suchen(grund)
|
||||
|
||||
|
||||
@mcp.tool(description=B["dienst_neustarten"])
|
||||
def dienst_neustarten(dienst: str, grund: str) -> str:
|
||||
return w.dienst_neustarten(dienst, grund)
|
||||
|
||||
|
||||
@mcp.tool(description=B["kernel_aufraeumen"])
|
||||
def kernel_aufraeumen(grund: str) -> str:
|
||||
return w.kernel_aufraeumen(grund)
|
||||
|
||||
|
||||
@mcp.tool(description=B["host_neustarten"])
|
||||
def host_neustarten(grund: str) -> str:
|
||||
return w.host_neustarten(grund)
|
||||
|
||||
|
||||
@mcp.tool(description=B["radar_testen"])
|
||||
def radar_testen(kandidat: str, grund: str) -> str:
|
||||
return w.radar_testen(kandidat, grund)
|
||||
|
||||
|
||||
@mcp.tool(description=B["radar_suchen"])
|
||||
def radar_suchen(grund: str) -> str:
|
||||
return w.radar_suchen(grund)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Die Knöpfe des Homelab Orchestrators für Lucy (seit 25.09.2026, User-Wunsch: „Lucy ist die Systemadministratorin
|
||||
und Assistentin — bau das mit Lucy und dem Orchestrator“).
|
||||
|
||||
Reine Funktionen über die REST-API von MC2 auf der KI-Box (:9001); den Homelab-Teil erreicht sie über die
|
||||
Durchreiche /api/partner/homelab/…. Sie tun genau, was die Knöpfe der Oberfläche tun, mit deren Leitplanken: Snapshot
|
||||
oder Sicherung vorher, Prüfung danach, bei Rot zurück, Sperren gegen gleichzeitige Läufe, die Neustart-Allowlist.
|
||||
Nichts hier geht an Proxmox oder die Gäste vorbei. mcp_orchestrator.py macht MCP-Werkzeuge daraus.
|
||||
|
||||
Antworten sind kurze deutsche Texte statt JSON — Lucy soll sie lesen und dem Commander berichten, nicht parsen.
|
||||
Jede Aktion steht mit dem Grund (der Bitte des Commanders) in ~/.hermes/logs/orchestrator-werkzeuge.log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
|
||||
ORTSZEIT = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) # wie kern/zeit.py (AGENTS.md: Zeit)
|
||||
HL = "/api/partner/homelab"
|
||||
LOG = Path(os.environ.get("MC_ORCH_LOG", str(Path.home() / ".hermes" / "logs" / "orchestrator-werkzeuge.log")))
|
||||
KI_BOX_ALLE = "/api/maintenance/update-all"
|
||||
|
||||
|
||||
# Die Beschreibungen der MCP-Werkzeuge (mcp_orchestrator.py) — als Leitplanken formuliert. Hier, damit ein Test prüft,
|
||||
# dass jede Aktion die Leitplanke trägt.
|
||||
_HANDELN = (" Nur aufrufen, wenn der Commander es in diesem Gespräch ausdrücklich verlangt oder auf deine Rückfrage "
|
||||
"bestätigt hat — nie auf eigene Initiative, auch nicht in geplanten Läufen. grund: seine Bitte in seinen "
|
||||
"Worten (wird protokolliert).")
|
||||
BESCHREIBUNG = {
|
||||
"lage": ("Kurzbericht über KI-Box und Homelab: Hinweise beider Wächter, erreichbare Geräte, Zahl offener Updates, "
|
||||
"Speicherpool, Systemplatte, NAS, Sicherungen und was gerade läuft. Nutze das, wenn der Commander wissen "
|
||||
"will, wie es steht, oder bevor du etwas startest."),
|
||||
"updates": ("Offene Updates je Gerät (KI-Box und Homelab) mit den Kennungen ziel und baustein für update_starten — "
|
||||
"und bei denen ohne Knopf der Grund (etwa die 48-Stunden-Wartezeit nach einer Skriptänderung)."),
|
||||
"speicher": ("Speicherpool, Systemplatte des Proxmox-Hosts, NAS und nächtliche Sicherungen, dazu Vorschläge, was "
|
||||
"Platz bringt."),
|
||||
"protokoll": "Was im Homelab zuletzt geschah — Updates, Meldungen, Hinweise —, neueste zuerst. anzahl: höchstens 60.",
|
||||
"radar": "Kandidaten des Modell-Radars mit Status und ob „Jetzt testen“ gerade geht (sonst der Grund).",
|
||||
"update_starten": ("Ein einzelnes Update starten, genau wie der Knopf auf der Update-Seite: vorher Snapshot oder "
|
||||
"Sicherung, danach Prüfung, bei Rot automatisch zurück. ziel und baustein aus updates() (etwa "
|
||||
"ziel=\"ct-104\", baustein=\"os\"; KI-Box: ziel=\"ki-box\", baustein=\"os\"/\"engine\"/\"swap\"/"
|
||||
"\"hermes\" — bei \"hermes\" bist du kurz nicht erreichbar)." + _HANDELN),
|
||||
"alle_aktualisieren": ("„Alle aktualisieren“ für bereich=\"homelab\" (Gäste nacheinander, Host-Pakete zuletzt, nie "
|
||||
"Neustart, hält bei Rot an) oder bereich=\"ki-box\" (Motor, llama-swap, Hermes, "
|
||||
"Betriebssystem; beim Hermes-Schritt bist du kurz weg)." + _HANDELN),
|
||||
"nach_updates_suchen": "Die KI-Box nach neuen Updates suchen lassen (Paketlisten, Motor, llama-swap, Hermes)." + _HANDELN,
|
||||
"dienst_neustarten": ("Einen Dienst der KI-Box neu starten (etwa \"llama-swap\", \"hermes-builtin-ui\", "
|
||||
"\"mission-control-2\"). Erlaubt ist nur, was die Neustart-Liste von MC2 kennt. Einen "
|
||||
"hängenden Dienst darfst du auch ohne Bitte neu starten (Reparatur) — sag es dem Commander "
|
||||
"danach. grund: warum (wird protokolliert)."),
|
||||
"kernel_aufraeumen": ("Alte Kernel vom Proxmox-Host entfernen — nur, was Proxmox selbst für entbehrlich hält; der "
|
||||
"laufende und der für den nächsten Start bleiben. Die Gäste merken nichts." + _HANDELN),
|
||||
"host_neustarten": ("Den Proxmox-Host neu starten. ALLE Container und die Arcane-VM sind dann ein paar Minuten weg, "
|
||||
"auch AdGuard (die Namensauflösung im Heimnetz). Sag das dem Commander vorher und frag nach, auch "
|
||||
"wenn er es verlangt hat." + _HANDELN),
|
||||
"radar_testen": ("Einen Kandidaten des Modell-Radars jetzt testen (id aus radar()). Geht nur, wenn er samt Coder in "
|
||||
"den Speicher passt und nicht zwischen 00:00 und 03:30; Lucy und NerdQuiz sind währenddessen etwas "
|
||||
"langsamer." + _HANDELN),
|
||||
"radar_suchen": "Das Modell-Radar jetzt nach neuen Modellen suchen lassen (Merkliste und Hugging Face)." + _HANDELN,
|
||||
}
|
||||
|
||||
|
||||
class Fehler(Exception):
|
||||
"""MC2 hat abgelehnt oder war nicht erreichbar — der Text geht so an Lucy."""
|
||||
|
||||
|
||||
def _get(pfad: str, **params: Any) -> Any:
|
||||
try:
|
||||
r = httpx.get(f"{MC_URL}{pfad}", params=params or None, timeout=30)
|
||||
except httpx.HTTPError as exc:
|
||||
raise Fehler(f"MC2 ist nicht erreichbar ({exc.__class__.__name__}).") from exc
|
||||
if r.status_code >= 400:
|
||||
raise Fehler(_grund(r))
|
||||
return r.json()
|
||||
|
||||
|
||||
def _post(pfad: str, daten: dict | None = None) -> dict:
|
||||
"""POST wie ein Knopf der Oberfläche. MC2 antwortet mit ok/detail oder HTTP-Fehler samt detail."""
|
||||
try:
|
||||
r = httpx.post(f"{MC_URL}{pfad}", json=daten or {}, timeout=60)
|
||||
except httpx.HTTPError as exc:
|
||||
raise Fehler(f"MC2 ist nicht erreichbar ({exc.__class__.__name__}).") from exc
|
||||
if r.status_code >= 400:
|
||||
raise Fehler(_grund(r))
|
||||
antwort = r.json() if r.content else {}
|
||||
if isinstance(antwort, dict) and antwort.get("ok") is False:
|
||||
raise Fehler(str(antwort.get("detail") or antwort.get("text") or "MC2 hat abgelehnt."))
|
||||
return antwort if isinstance(antwort, dict) else {}
|
||||
|
||||
|
||||
def _grund(r: httpx.Response) -> str:
|
||||
try:
|
||||
detail = r.json().get("detail")
|
||||
except (ValueError, AttributeError):
|
||||
detail = None
|
||||
return str(detail or f"MC2 antwortete mit HTTP {r.status_code}.")
|
||||
|
||||
|
||||
def _homelab_pfad(pfad: str) -> str:
|
||||
"""Pfade aus den Knöpfen des Homelab-Teils (/api/homelab/…) laufen auf der Box über die Durchreiche."""
|
||||
return HL + pfad[len("/api/homelab"):] if pfad.startswith("/api/homelab") else pfad
|
||||
|
||||
|
||||
def _protokolliere(werkzeug: str, argumente: dict, grund: str, ergebnis: str) -> None:
|
||||
try:
|
||||
LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"zeit": datetime.now().isoformat(timespec="seconds"), "werkzeug": werkzeug,
|
||||
"argumente": argumente, "grund": grund, "ergebnis": ergebnis},
|
||||
ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _handeln(werkzeug: str, argumente: dict, grund: str, tun) -> str:
|
||||
"""Eine Aktion ausführen und protokollieren. Ohne Grund keine Aktion — er ist der Nachweis, dass der Commander
|
||||
darum gebeten hat."""
|
||||
if not str(grund or "").strip():
|
||||
text = ("Nicht ausgeführt: Es fehlt der Grund, also die Bitte des Commanders in seinen Worten. Aktionen nur, "
|
||||
"wenn er sie verlangt oder bestätigt hat.")
|
||||
else:
|
||||
try:
|
||||
text = tun()
|
||||
except Fehler as exc:
|
||||
text = f"Nicht ausgeführt: {exc}"
|
||||
_protokolliere(werkzeug, argumente, str(grund or ""), text)
|
||||
return text
|
||||
|
||||
|
||||
# --- Lesen ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def _ziele() -> list[dict]:
|
||||
"""KI-Box und Homelab als Ziele mit Bausteinen — dasselbe, was die Update-Seiten zeigen."""
|
||||
ziele = list(_get("/api/ziele").get("ziele") or [])
|
||||
try:
|
||||
ziele += list(_get(f"{HL}/ziele").get("ziele") or [])
|
||||
except Fehler:
|
||||
pass
|
||||
return ziele
|
||||
|
||||
|
||||
def _offen(z: dict) -> list[dict]:
|
||||
return [b for b in z.get("bausteine") or [] if b.get("zustand") == "neu"]
|
||||
|
||||
|
||||
def lage() -> str:
|
||||
"""Kurzbericht: Hinweise beider Wächter, offene Updates, Speicher/NAS/Sicherungen, was gerade läuft."""
|
||||
zeilen: list[str] = []
|
||||
for wo, pfad in (("KI-Box", "/api/hinweise"), ("Homelab", f"{HL}/hinweise")):
|
||||
try:
|
||||
hinweise = _get(pfad).get("hinweise") or []
|
||||
except Fehler as exc:
|
||||
zeilen.append(f"{wo}: nicht erreichbar ({exc}).")
|
||||
continue
|
||||
if hinweise:
|
||||
zeilen += [f"{wo}, {h.get('stufe')}: {h.get('titel')}. {h.get('text') or ''}".strip() for h in hinweise]
|
||||
else:
|
||||
zeilen.append(f"{wo}: keine Hinweise.")
|
||||
ziele = _ziele()
|
||||
offen = [(z, b) for z in ziele for b in _offen(z)]
|
||||
zeilen.append(f"Geräte: {sum(1 for z in ziele if z.get('erreichbar') is not False)} von {len(ziele)} erreichbar; "
|
||||
f"{len(offen)} Updates stehen an" + (" (Liste mit updates())." if offen else "."))
|
||||
try:
|
||||
zeilen += _speicher_zeilen(_get(f"{HL}/speicher"))
|
||||
except Fehler:
|
||||
pass
|
||||
zeilen += _laeuft()
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
def _speicher_zeilen(s: dict) -> list[str]:
|
||||
zeilen = []
|
||||
for p in s.get("pools") or []:
|
||||
zeilen.append(f"Speicherpool {p['speicher']}: {round(p['anteil'] * 100)} % belegt ({p['stufe']}).")
|
||||
if sp := s.get("systemplatte"):
|
||||
zeilen.append(f"Systemplatte des Proxmox-Hosts: {round(sp['anteil'] * 100)} % ({sp['stufe']}).")
|
||||
for n in s.get("nas") or []:
|
||||
zeilen.append(f"NAS {n.get('server')}: " + (n.get("problem") or f"{round((n.get('anteil') or 0) * 100)} % "
|
||||
f"belegt ({n.get('stufe')})."))
|
||||
si = s.get("sicherungen") or {}
|
||||
if si.get("stufe") not in (None, "keine"):
|
||||
fehlend = ", ".join(f["name"] for f in si.get("fehlend") or [])
|
||||
zeilen.append(f"Sicherungen: {si.get('stufe')}" + (f", fehlend: {fehlend}" if fehlend else "") + ".")
|
||||
return zeilen
|
||||
|
||||
|
||||
def _laeuft() -> list[str]:
|
||||
zeilen = []
|
||||
try:
|
||||
for j in _get("/api/jobs").get("jobs") or []:
|
||||
if j.get("state") in ("running", "queued"):
|
||||
zeilen.append(f"Läuft auf der KI-Box: {j.get('label')}.")
|
||||
except Fehler:
|
||||
pass
|
||||
try:
|
||||
for lauf in _get(f"{HL}/laeufe").get("laeufe") or []:
|
||||
if lauf.get("status") == "laeuft":
|
||||
zeilen.append(f"Läuft im Homelab: {lauf.get('name')} ({lauf.get('baustein')}), "
|
||||
f"zuletzt: {(lauf.get('schritte') or ['–'])[-1]}.")
|
||||
sl = (_get(f"{HL}/sammellauf") or {}).get("sammellauf") or {}
|
||||
if sl.get("status") == "laeuft":
|
||||
zeilen.append("Im Homelab läuft „Alle aktualisieren“.")
|
||||
except Fehler:
|
||||
pass
|
||||
return zeilen
|
||||
|
||||
|
||||
def updates() -> str:
|
||||
"""Offene Updates je Gerät mit der Kennung für update_starten — und warum eines keinen Knopf hat."""
|
||||
zeilen = []
|
||||
for z in _ziele():
|
||||
for b in _offen(z):
|
||||
was = b.get("kurz") or b.get("verfuegbar") or "neu"
|
||||
if b.get("aktion"):
|
||||
zeilen.append(f"{z['name']} (ziel={z['id']}, baustein={b['id']}): {b.get('name')} · {was} — startbar.")
|
||||
else:
|
||||
zeilen.append(f"{z['name']} ({z['id']}): {b.get('name')} · {was} — kein Knopf: "
|
||||
f"{b.get('grund') or 'wartet'}.")
|
||||
return "\n".join(zeilen) or "Keine offenen Updates, KI-Box und Homelab sind aktuell."
|
||||
|
||||
|
||||
def speicher() -> str:
|
||||
"""Speicherpool, Systemplatte, NAS und Sicherungen samt Vorschlägen, was Platz bringt."""
|
||||
s = _get(f"{HL}/speicher")
|
||||
zeilen = _speicher_zeilen(s)
|
||||
for e in s.get("empfehlungen") or []:
|
||||
platz = f" (~{round(e['platz'] / 1e9)} GB)" if e.get("platz") else ""
|
||||
zeilen.append(f"Vorschlag: {e.get('titel')}{platz}. {e.get('text')}")
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
def protokoll(anzahl: int = 15) -> str:
|
||||
"""Was im Homelab zuletzt geschah (Läufe, Meldungen, Hinweise), neueste zuerst."""
|
||||
eintraege = _get(f"{HL}/protokoll", grenze=max(1, min(int(anzahl), 60)), berichte=0).get("eintraege") or []
|
||||
zeilen = []
|
||||
for e in eintraege:
|
||||
zeit = datetime.fromtimestamp(float(e.get("zeit") or 0), ORTSZEIT).strftime("%d.%m. %H:%M")
|
||||
zeilen.append(f"{zeit} {e.get('titel')}" + (f" — {e.get('text')}" if e.get("text") else ""))
|
||||
return "\n".join(zeilen) or "Das Protokoll ist leer."
|
||||
|
||||
|
||||
def radar() -> str:
|
||||
"""Kandidaten des Modell-Radars: Status, ob „Jetzt testen“ geht, und warum nicht."""
|
||||
zeilen = []
|
||||
for k in _get("/api/radar").get("kandidaten") or []:
|
||||
teile = [f"{k.get('name')} (id={k.get('id')}, {k.get('rolle')}): {k.get('status')}"]
|
||||
if k.get("testbar_jetzt"):
|
||||
teile.append("jetzt testbar")
|
||||
elif k.get("testbar_grund"):
|
||||
teile.append(str(k["testbar_grund"]))
|
||||
zeilen.append(" — ".join(teile))
|
||||
return "\n".join(zeilen) or "Das Radar hat gerade keine Kandidaten."
|
||||
|
||||
|
||||
# --- Handeln (nur auf Bitte des Commanders; der Grund wird protokolliert) --------------------------------------
|
||||
|
||||
def update_starten(ziel: str, baustein: str, grund: str) -> str:
|
||||
def tun() -> str:
|
||||
z = next((x for x in _ziele() if x.get("id") == ziel), None)
|
||||
if not z:
|
||||
raise Fehler(f"Das Gerät „{ziel}“ gibt es nicht. updates() zeigt die Kennungen.")
|
||||
b = next((x for x in z.get("bausteine") or [] if x.get("id") == baustein), None)
|
||||
if not b or not b.get("aktion"):
|
||||
raise Fehler(f"Für {z['name']} / {baustein} gibt es gerade keinen Update-Knopf"
|
||||
+ (f": {b.get('grund')}" if b and b.get("grund") else "."))
|
||||
antwort = _post(_homelab_pfad(b["aktion"]["pfad"]))
|
||||
hinweis = (" Ich bin dabei kurz nicht erreichbar." if baustein == "hermes" else "")
|
||||
return (f"Gestartet: {z['name']} · {b.get('name')}. Vorher wird gesichert, danach geprüft; das Ergebnis kommt als "
|
||||
f"Meldung.{hinweis}" + (f" (Lauf {antwort.get('lauf')})" if antwort.get("lauf") else ""))
|
||||
return _handeln("update_starten", {"ziel": ziel, "baustein": baustein}, grund, tun)
|
||||
|
||||
|
||||
def alle_aktualisieren(bereich: str, grund: str) -> str:
|
||||
def tun() -> str:
|
||||
if bereich == "homelab":
|
||||
_post(f"{HL}/alle")
|
||||
return ("„Alle aktualisieren“ im Homelab läuft: Gäste nacheinander, der Proxmox-Host zuletzt, ohne Neustart; "
|
||||
"bei Rot hält es an. Am Ende kommt eine Sammelmeldung.")
|
||||
if bereich == "ki-box":
|
||||
_post(KI_BOX_ALLE)
|
||||
return ("„Alle aktualisieren“ auf der KI-Box läuft (Motor, llama-swap, Hermes, Betriebssystem). Beim Hermes-"
|
||||
"Schritt bin ich kurz nicht erreichbar.")
|
||||
raise Fehler("bereich muss „homelab“ oder „ki-box“ sein.")
|
||||
return _handeln("alle_aktualisieren", {"bereich": bereich}, grund, tun)
|
||||
|
||||
|
||||
def nach_updates_suchen(grund: str) -> str:
|
||||
def tun() -> str:
|
||||
_post("/api/maintenance/check-updates")
|
||||
return "Die KI-Box sucht nach Updates; in ein, zwei Minuten zeigt updates() das Ergebnis."
|
||||
return _handeln("nach_updates_suchen", {}, grund, tun)
|
||||
|
||||
|
||||
def dienst_neustarten(dienst: str, grund: str) -> str:
|
||||
def tun() -> str:
|
||||
antwort = _post("/api/maintenance/restart", {"service": dienst})
|
||||
return str(antwort.get("text") or f"{dienst} wird neu gestartet.")
|
||||
return _handeln("dienst_neustarten", {"dienst": dienst}, grund, tun)
|
||||
|
||||
|
||||
def kernel_aufraeumen(grund: str) -> str:
|
||||
def tun() -> str:
|
||||
_post(f"{HL}/ziele/pve/kernel-aufraeumen")
|
||||
return ("Alte Kernel werden entfernt (was Proxmox für entbehrlich hält; der laufende und der nächste bleiben). "
|
||||
"Das Ergebnis kommt als Meldung.")
|
||||
return _handeln("kernel_aufraeumen", {}, grund, tun)
|
||||
|
||||
|
||||
def host_neustarten(grund: str) -> str:
|
||||
def tun() -> str:
|
||||
_post(f"{HL}/ziele/pve/neustart")
|
||||
return ("Der Proxmox-Host startet neu. Alle Container und die Arcane-VM sind ein paar Minuten weg, auch AdGuard "
|
||||
"(DNS im Heimnetz). Die KI-Box prüft danach, ob alles wiederkommt.")
|
||||
return _handeln("host_neustarten", {}, grund, tun)
|
||||
|
||||
|
||||
def radar_testen(kandidat: str, grund: str) -> str:
|
||||
def tun() -> str:
|
||||
antwort = _post(f"/api/radar/{kandidat}/testen")
|
||||
return str(antwort.get("text") or "Der Test läuft; das Ergebnis kommt als Meldung.")
|
||||
return _handeln("radar_testen", {"kandidat": kandidat}, grund, tun)
|
||||
|
||||
|
||||
def radar_suchen(grund: str) -> str:
|
||||
def tun() -> str:
|
||||
antwort = _post("/api/radar/suche")
|
||||
return str(antwort.get("text") or "Das Radar sucht nach neuen Modellen.")
|
||||
return _handeln("radar_suchen", {}, grund, tun)
|
||||
Reference in New Issue
Block a user