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:
Hitonabi
2026-09-25 13:27:35 +02:00
co-authored by Claude Opus 5.5
parent 44f33c31d3
commit deaf35e953
4 changed files with 559 additions and 0 deletions
@@ -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)."
+13
View File
@@ -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 `/api`-Pfade liefern einen Fehler (bei `GET` 404) statt der Startseite. Die MCP-Server in `mcp/` sprechen dieselben
Routen. 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`, **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 `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 `Origin` (Skripte, `curl`), `Origin: null` bzw. `file://` (Lucy-Desktop), dieselbe Adresse wie die Oberfläche und die
+90
View File
@@ -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()
+335
View File
@@ -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)