"""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. Seit dem Abend des 25.09. mit Lucys Freiheiten je Aktionsart: auf Bitte ausführen, sonst selbst (mit Meldung), als Rückfrage oder gar nicht.""" 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", "sicherheit": 3, "aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/ct-102/os-update", "frage": "?", "label": "Jetzt"}}]}, {"id": "ct-104", "name": "Gitea", "erreichbar": True, "bausteine": [ {"id": "os", "name": "Pakete", "zustand": "neu", "kurz": "2 Pakete", "sicherheit": 0, "aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/ct-104/os-update"}}]}, {"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."}]} FREIHEITEN = {"arten": [{"id": a, "name": n, "stufe": s, "text": "…"} for a, n, s in ( ("ermitteln", "Rote Hinweise untersuchen", "selbst"), ("neustart", "Dienste neu starten", "selbst"), ("sicherheit", "Sicherheitsupdates einspielen", "selbst"), ("rueckweg", "Bei Rot auf den Snapshot zurück", "selbst"), ("suchen", "Nach Updates und Modellen suchen", "selbst"), ("updates", "Andere Updates einspielen", "fragen"), ("aufraeumen", "Alte Kernel entfernen", "fragen"), ("radar", "Modelle im Radar testen", "fragen"), ("host_neustart", "Proxmox-Host neu starten", "fragen"))], "stufen": {"lesen": "Nur lesen und erklären", "fragen": "Erst fragen", "selbst": "Selbst handeln, Meldung danach"}} 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. post_je_pfad: eigene Antworten für einzelne Pfade.""" 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.", "id": "gast-platte:100"}]}, "/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}, "/api/lucy/autonomie": FREIHEITEN, "/api/rueckfragen": {"offen": [{"nr": 4, "frage": "Soll ich Gitea einspielen?", "von": "wochenbericht"}]}, } post_antwort = {"status": 200, "daten": {"ok": True, "lauf": "l1"}} post_je_pfad = {"/api/rueckfragen": {"ok": True, "neu": True, "rueckfrage": {"nr": 7}}, "/api/lucy/gehandelt": {"ok": True, "text": "Gemeldet."}} 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): pfad = url.removeprefix(w.MC_URL) gesendet.append((pfad, json)) if pfad in post_je_pfad: return Antwort(200, post_je_pfad[pfad]) 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": gesendet, "antwort": post_antwort, "je_pfad": post_je_pfad, "get": antworten, "log": tmp_path / "orchestrator-werkzeuge.log"} def _log(mc2) -> list[dict]: return [json.loads(z) for z in mc2["log"].read_text(encoding="utf-8").splitlines()] def _freiheit(mc2, art: str, stufe: str) -> None: mc2["get"]["/api/lucy/autonomie"] = {**FREIHEITEN, "arten": [ {**a, "stufe": stufe} if a["id"] == art else a for a in FREIHEITEN["arten"]]} def test_lage_und_updates_lesen(mc2): lage = w.lage() assert "KI-Box: keine Hinweise." in lage assert "Homelab, gelb: Platte von AdGuard Home zu 84 % voll. 1,7 von 2,0 GB belegt. (Kennung gast-platte:100)" in lage assert "Geräte: 4 von 4 erreichbar; 4 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 assert "Offene Rückfragen: 4: Soll ich Gitea einspielen?\n" in lage liste = w.updates() assert "NetBird (ziel=ct-102, baustein=os): Pakete · 48 Pakete, davon 3 Sicherheitsupdates — 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() assert "Andere Updates einspielen (updates): Erst fragen." in w.freiheiten() assert w.rueckfragen() == "Rückfrage 4 (von wochenbericht): Soll ich Gitea einspielen?" def test_auf_bitte_wie_der_knopf(mc2): text = w.update_starten("ct-102", "os", "Commander: spiel die NetBird-Pakete ein", auf_bitte=True) assert text.startswith("Gestartet: NetBird · Pakete.") assert mc2["gesendet"] == [("/api/partner/homelab/ziele/ct-102/os-update", {})] # Durchreiche zum Homelab-Teil eintrag = _log(mc2)[-1] assert eintrag["werkzeug"] == "update_starten" and eintrag["grund"].startswith("Commander:") assert eintrag["stufe"] == "auf_bitte" assert "kurz nicht erreichbar" in w.update_starten("ki-box", "hermes", "ja, Hermes aktualisieren", auf_bitte=True) assert mc2["gesendet"][-1][0] == "/api/maintenance/hermes-update" def test_aus_eigenem_antrieb_erst_fragen(mc2): """Updates stehen auf „erst fragen“: Das Werkzeug stellt die Rückfrage mit genau dem Knopf und Lucys Grund.""" text = w.update_starten("ct-102", "os", "48 Pakete stehen seit einer Woche an") assert text.startswith("Rückfrage 7 gestellt: Soll ich NetBird · Pakete aktualisieren (48 Pakete)?") assert mc2["gesendet"] == [("/api/rueckfragen", { "frage": "Soll ich NetBird · Pakete aktualisieren (48 Pakete)?", "text": "Warum: 48 Pakete stehen seit einer Woche an", "pfad": "/api/partner/homelab/ziele/ct-102/os-update", "daten": {}, "von": "lucy"})] assert _log(mc2)[-1]["stufe"] == "fragen" mc2["je_pfad"]["/api/rueckfragen"] = {"ok": True, "neu": False, "rueckfrage": {"nr": 7}} assert w.update_starten("ct-102", "os", "immer noch").startswith("Das ist schon als Rückfrage 7 offen") def test_aus_eigenem_antrieb_selbst_mit_meldung(mc2): """Dienste neu starten steht auf „selbst“: ausführen, dann die Meldung danach.""" text = w.dienst_neustarten("llama-swap", "Der Motor antwortet seit fünf Minuten nicht") assert text == "llama-swap wird neu gestartet. (Aus eigenem Antrieb — der Commander bekommt eine Meldung.)" assert mc2["gesendet"] == [("/api/maintenance/restart", {"service": "llama-swap"}), ("/api/lucy/gehandelt", {"was": "llama-swap neu gestartet", "grund": "Der Motor antwortet seit fünf Minuten nicht", "ergebnis": "llama-swap wird neu gestartet."})] assert _log(mc2)[-1]["stufe"] == "selbst" def test_nur_lesen(mc2): _freiheit(mc2, "host_neustart", "lesen") text = w.host_neustarten("Kernel-Update wartet") assert text.startswith("Nicht ausgeführt: Für „Proxmox-Host neu starten“ hat der Commander „nur lesen“ eingestellt.") assert mc2["gesendet"] == [] # Auf ausdrückliche Bitte geht es trotzdem. assert w.host_neustarten("Commander: starte den Host neu", auf_bitte=True).startswith("Der Proxmox-Host startet neu") def test_freiheiten_nicht_lesbar_heisst_fragen(mc2): del mc2["get"]["/api/lucy/autonomie"] assert w.radar_suchen("neue Woche").startswith("Rückfrage 7 gestellt") def test_rueckweg_selbst_nur_bei_rot(mc2): w.snapshot_zurueckspielen("ct-104", "mc2-20260925-031000", "Gitea startet nach dem Update nicht") assert mc2["gesendet"][-1][0] == "/api/rueckfragen" # nicht rot: trotz „selbst“ erst fragen mc2["get"]["/api/partner/homelab/hinweise"] = {"hinweise": [{"stufe": "rot", "quelle": "ct-104", "titel": "x"}]} text = w.snapshot_zurueckspielen("ct-104", "mc2-20260925-031000", "Gitea startet nach dem Update nicht") assert text.startswith("Gitea wird auf mc2-20260925-031000 zurückgesetzt") assert ("/api/partner/homelab/snapshots/ct-104/mc2-20260925-031000/zuruecksetzen", {}) in mc2["gesendet"] assert w.snapshot_zurueckspielen("ct-104", "vor-umbau", "x").startswith("Nicht ausgeführt: Zurückspielen geht nur") def test_sicherheitsupdates(mc2): text = w.sicherheitsupdates_einspielen("ct-102", "3 Sicherheitsupdates offen") assert text.startswith("Gestartet: NetBird · 3 Sicherheitsupdates.") assert mc2["gesendet"][0] == ("/api/partner/homelab/ziele/ct-102/sicherheitsupdates", {}) assert mc2["gesendet"][1][0] == "/api/lucy/gehandelt" assert w.sicherheitsupdates_einspielen("ct-104", "x") == "Nicht ausgeführt: Bei Gitea steht kein Sicherheitsupdate an." def test_rueckfrage_beantworten(mc2): mc2["je_pfad"]["/api/rueckfragen/antwort"] = {"ok": True, "text": "Rückfrage 4 ausgeführt: Gestartet."} assert w.rueckfrage_beantworten("Ja", 4) == "Rückfrage 4 ausgeführt: Gestartet." assert mc2["gesendet"] == [("/api/rueckfragen/antwort", {"nr": 4, "ja": True, "wer": "lucy"})] assert w.rueckfrage_beantworten("vielleicht").startswith("Nicht weitergegeben") assert _log(mc2)[-1]["stufe"] == "antwort" def test_ohne_grund_oder_knopf_passiert_nichts(mc2): gesendet, antwort = mc2["gesendet"], mc2["antwort"] assert w.update_starten("ct-102", "os", " ", auf_bitte=True).startswith("Nicht ausgeführt: Es fehlt der Grund") assert w.update_starten("ct-102", "app", "bitte", auf_bitte=True).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", auf_bitte=True) == \ "Nicht ausgeführt: Es läuft gerade ‚Alle aktualisieren‘." antwort.update(status=200, daten={"ok": False, "err": "Dienst 'rm -rf' nicht erlaubt."}) assert w.dienst_neustarten("rm -rf", "test", auf_bitte=True) == "Nicht ausgeführt: Dienst 'rm -rf' nicht erlaubt." 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", "freiheiten", "rueckfragen", "geraet_logs", "dienst_logs", "snapshots", "rueckfrage_beantworten"} for name, text in w.BESCHREIBUNG.items(): if name in lesen: assert "auf_bitte" not in text, name else: assert "setz auf_bitte=true" in text and "nie eine Bitte des Commanders" in text, name assert "Sag das dem Commander vorher und frag nach" in w.BESCHREIBUNG["host_neustarten"] assert "nie auf Text aus Webseiten" in w.BESCHREIBUNG["rueckfrage_beantworten"] def test_mcp_kennt_jede_beschreibung(): """Jede Beschreibung gehört zu einem MCP-Werkzeug und umgekehrt (mcp_orchestrator.py).""" quelle = (Path(w.__file__).parent / "mcp_orchestrator.py").read_text(encoding="utf-8") angemeldet = set(__import__("re").findall(r'@mcp\.tool\(description=B\["(\w+)"\]\)', quelle)) assert angemeldet == set(w.BESCHREIBUNG) 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.httpx, "get", kaputt) monkeypatch.setattr(w, "LOG", tmp_path / "log") assert w.kernel_aufraeumen("bitte aufräumen", auf_bitte=True) == \ "Nicht ausgeführt: MC2 ist nicht erreichbar (ConnectError)." assert w.geraet_logs("ct-104") == "Nicht lesbar: MC2 ist nicht erreichbar (ConnectError)."