Files
mission-control-v2/backend/tests/test_partner.py
T
HitonabiandClaude Opus 5.5 5e9227c4a3 phase3+4: Homelab-Teil (Ausfuehrer, Ziele, Jetzt updaten mit Rueckweg) und Seite Homelab fuer alle Geraete
- deploy/homelab/ausfuehrer.py: laeuft als root auf dem Proxmox-Host, holt Auftraege beim Homelab-Teil
  ab (Pull, kein offener Port), feste Aktionsliste, prueft Etiketten selbst; Bericht gegen den echten
  Host erprobt (nur lesend). Kein Proxmox-Schluessel im Container noetig.
- services/homelab: Kanal mit gemeinsamem Geheimnis, App-Katalog, Inventar -> Ziele im gemeinsamen
  Modell (GitHub-Versionen, Webpruefung, alte Paketlisten = unklar), Jetzt updaten: Snapshot ->
  Update -> Pruefung -> bei Rot zurueck + dringende Meldung
- Waechter in der Rolle homelab: Ausfuehrer schweigt, Gaeste antworten nicht
- kern/github.py fuer beide Rollen (auch untagged Releases mit Version im Namen)
- Oberflaeche: Seite Homelab zeigt alle Geraete als Karten (KI-Box ueber /api/ziele, Homelab ueber
  /api/homelab/ziele) mit Stand, Rueckweg und Knopf samt Rueckfrage
- Einrichtung als Skripte (container-anlegen, ausrollen, ausfuehrer-einrichten, box-partner) — noch
  nicht ausgefuehrt, jeder Schritt braucht das User-OK
- frontend-bauen-box.sh: Frontend-Pruefung und Build auf der Box, wenn der PC keinen Speicher hat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:39:59 +02:00

226 lines
8.8 KiB
Python

"""Zwei Instanzen, eine Oberfläche (24.09.2026): Partner-Lebenszeichen, Durchreiche, Wächter, Rollen."""
import json
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from kern import partner
from routers import partner as partner_router
from services import waechter
BACKEND = Path(__file__).resolve().parent.parent
@pytest.fixture
def partner_server():
"""Eine Partner-Instanz zum Anfassen: /api/health antwortet, alles andere wird mitgeschrieben."""
empfangen: list[dict] = []
class Handler(BaseHTTPRequestHandler):
def _antworte(self, code: int, daten: dict) -> None:
roh = json.dumps(daten).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(roh)))
self.end_headers()
self.wfile.write(roh)
def _merke(self) -> None:
laenge = int(self.headers.get("Content-Length") or 0)
empfangen.append({"methode": self.command, "pfad": self.path,
"koerper": self.rfile.read(laenge).decode() if laenge else "",
"von": self.headers.get("X-MC-Von")})
def do_GET(self):
if self.path == "/api/health":
self._antworte(200, {"status": "ok", "version": "t", "rolle": "homelab", "instanz": "Homelab"})
return
self._merke()
self._antworte(200, {"pfad": self.path})
def do_POST(self):
self._merke()
self._antworte(202, {"angenommen": True})
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}", empfangen
server.shutdown()
@pytest.fixture
def mit_partner(monkeypatch):
"""Setzt MC_PARTNER_URL und leert die Caches davor und danach."""
def setzen(url: str) -> None:
monkeypatch.setenv("MC_PARTNER_URL", url)
einstellungen_mod.einstellungen.cache_clear()
partner._cache.update(ts=0.0, daten=None)
yield setzen
monkeypatch.delenv("MC_PARTNER_URL", raising=False)
einstellungen_mod.einstellungen.cache_clear()
partner._cache.update(ts=0.0, daten=None)
def test_ohne_partner_nichts_eingerichtet(mit_partner):
mit_partner("")
assert partner.status() == {"eingerichtet": False}
def test_partner_antwortet(mit_partner, partner_server):
url, _ = partner_server
mit_partner(url)
stand = partner.status()
assert stand["erreichbar"] is True and stand["rolle"] == "homelab" and stand["name"] == "Homelab"
def test_partner_weg(mit_partner):
mit_partner("http://127.0.0.1:9")
stand = partner.status()
assert stand["erreichbar"] is False and stand["fehler"] == "nicht erreichbar"
def _client() -> TestClient:
app = FastAPI()
app.include_router(partner_router.router)
return TestClient(app)
def test_durchreiche_get_und_post(mit_partner, partner_server):
url, empfangen = partner_server
mit_partner(url)
c = _client()
r = c.get("/api/partner/homelab/ziele", params={"frisch": "1"})
assert r.status_code == 200 and r.json() == {"pfad": "/api/homelab/ziele?frisch=1"}
r = c.post("/api/partner/homelab/update", json={"ziel": "ct:101"})
assert r.status_code == 202
letzte = empfangen[-1]
assert (letzte["methode"], letzte["pfad"], letzte["von"]) == ("POST", "/api/homelab/update", "Box-Wart")
assert json.loads(letzte["koerper"]) == {"ziel": "ct:101"}
def test_instanz_ohne_netz(mit_partner):
mit_partner("http://127.0.0.1:9")
assert _client().get("/api/instanz").json() == {
"rolle": "box", "instanz": "Box-Wart", "partner": {"eingerichtet": True, "name": "Homelab"}}
@pytest.mark.parametrize("pfad", ["partner/health", "partner", "stream"])
def test_durchreiche_sperrt_schleifen_und_strom(mit_partner, partner_server, pfad):
url, empfangen = partner_server
mit_partner(url)
assert _client().get(f"/api/partner/{pfad}").status_code == 404
assert empfangen == []
def test_durchreiche_ohne_partner_und_bei_ausfall(mit_partner):
mit_partner("")
assert _client().get("/api/partner/health").status_code == 404
mit_partner("http://127.0.0.1:9")
r = _client().get("/api/partner/health")
assert r.status_code == 502 and "nicht erreichbar" in r.json()["detail"]
def test_waechter_meldet_den_partner_erst_nach_geduld(monkeypatch, tmp_path):
"""Ein Neustart der anderen Instanz (Sonntags-Update) ist kein Alarm; erst nach PARTNER_TAKTE."""
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
monkeypatch.setattr(waechter, "PRUEFUNGEN", (waechter.pruefe_partner,))
monkeypatch.setattr(waechter, "PARTNER_TAKTE", 3)
meldungen: list[str] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(text))
erreichbar = [False]
monkeypatch.setattr(partner, "status", lambda frisch=False: {
"eingerichtet": True, "name": "Homelab", "url": "http://pve:9001",
"erreichbar": erreichbar[0], "fehler": None if erreichbar[0] else "nicht erreichbar"})
waechter.takt()
waechter.takt()
assert waechter._stand["hinweise"] == {} and meldungen == []
waechter.takt()
assert waechter._stand["hinweise"]["partner"]["titel"] == "Homelab antwortet nicht"
assert len(meldungen) == 1 and "Proxmox-PC (http://pve:9001) ist nicht erreichbar" in meldungen[0]
erreichbar[0] = True
waechter.takt()
assert waechter._stand["hinweise"] == {}
assert meldungen[-1] == "Erledigt: Homelab antwortet nicht."
def test_ohne_partner_kein_befund(monkeypatch):
monkeypatch.setattr(partner, "status", lambda frisch=False: {"eingerichtet": False})
assert waechter.pruefe_partner() == []
def _starte(rolle: str, tmp_path: Path, code: str) -> dict:
"""Die App frisch in einem eigenen Interpreter laden — die Rolle wird beim Import festgelegt."""
env = {**os.environ, "MC_ROLLE": rolle, "MC_MODELS_DIR": str(tmp_path), "MC_DATEN_DIR": str(tmp_path),
"MC_PARTNER_URL": ""}
lauf = subprocess.run([sys.executable, "-c", code], cwd=BACKEND, env=env, capture_output=True, text=True,
timeout=120)
assert lauf.returncode == 0, lauf.stderr[-2000:]
return json.loads(lauf.stdout.strip().splitlines()[-1])
_PROBE = """
import json, sys
from fastapi.testclient import TestClient
import app
pfade = sorted(app.app.openapi()["paths"])
gesund = TestClient(app.app).get("/api/health").json()
box_module = [m for m in ("services.llamaswap", "services.maintenance", "routers.boxwart") if m in sys.modules]
print(json.dumps({"pfade": pfade, "gesund": gesund, "box_module": box_module}))
"""
def test_homelab_rolle_laedt_nichts_von_der_box(tmp_path):
ergebnis = _starte("homelab", tmp_path, _PROBE)
assert ergebnis["gesund"] == {"status": "ok", "version": ergebnis["gesund"]["version"],
"rolle": "homelab", "instanz": "Homelab"}
assert "/api/start" not in ergebnis["pfade"] and "/api/partner" in ergebnis["pfade"]
assert ergebnis["box_module"] == []
def test_box_rolle_bleibt_wie_sie_war(tmp_path):
ergebnis = _starte("box", tmp_path, _PROBE.replace('.get("/api/health").json()', '.get("/api/partner").json()'))
assert {"/api/start", "/api/health", "/api/partner"} <= set(ergebnis["pfade"])
assert ergebnis["gesund"] == {"eingerichtet": False}
def test_unbekannte_rolle_bricht_ab(monkeypatch):
monkeypatch.setenv("MC_ROLLE", "keller")
einstellungen_mod.einstellungen.cache_clear()
try:
with pytest.raises(ValueError, match="MC_ROLLE"):
einstellungen_mod.einstellungen()
finally:
monkeypatch.delenv("MC_ROLLE")
einstellungen_mod.einstellungen.cache_clear()
def test_homelab_waechter_prueft_ausfuehrer_und_gaeste(tmp_path):
ergebnis = _starte("homelab", tmp_path, """
import json
import steward
from services import waechter
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
""")
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
ergebnis = _starte("homelab", tmp_path, _PROBE)
assert "/api/homelab/ziele" in ergebnis["pfade"] and "/api/ziele" not in ergebnis["pfade"]