From deaf35e95326700ecc7c4c7d45928e421f045600 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 13:27:35 +0200 Subject: [PATCH] =?UTF-8?q?lucy:=20Werkzeuge=20f=C3=BCr=20den=20Orchestrat?= =?UTF-8?q?or=20(MCP=20homelab-orchestrator)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/tests/test_orchestrator_werkzeuge.py | 121 +++++++ docs/ARCHITEKTUR.md | 13 + mcp/mcp_orchestrator.py | 90 +++++ mcp/orchestrator_werkzeuge.py | 335 +++++++++++++++++++ 4 files changed, 559 insertions(+) create mode 100644 backend/tests/test_orchestrator_werkzeuge.py create mode 100644 mcp/mcp_orchestrator.py create mode 100644 mcp/orchestrator_werkzeuge.py diff --git a/backend/tests/test_orchestrator_werkzeuge.py b/backend/tests/test_orchestrator_werkzeuge.py new file mode 100644 index 0000000..ccc972c --- /dev/null +++ b/backend/tests/test_orchestrator_werkzeuge.py @@ -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)." diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md index 1708ea6..0bee1bc 100644 --- a/docs/ARCHITEKTUR.md +++ b/docs/ARCHITEKTUR.md @@ -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 diff --git a/mcp/mcp_orchestrator.py b/mcp/mcp_orchestrator.py new file mode 100644 index 0000000..1344b79 --- /dev/null +++ b/mcp/mcp_orchestrator.py @@ -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 /bin/python --args +""" + +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() diff --git a/mcp/orchestrator_werkzeuge.py b/mcp/orchestrator_werkzeuge.py new file mode 100644 index 0000000..7b586d8 --- /dev/null +++ b/mcp/orchestrator_werkzeuge.py @@ -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)