phase2a: Kern fuer zwei Instanzen (Rolle, Partner, Telegram-Zweitweg)

- kern/einstellungen.py: MC_ROLLE (box|homelab), Instanzname, Datenordner, Partner-URL
- kern/zeit.py: eine Zeitzone statt sechs Kopien
- app.py/steward.py: Box-Router und Warm-Set nur in der Rolle box; der Homelab-Teil
  laedt nichts von der Box
- /api/health nennt Rolle und Instanz; Engine/Gateway/Hirn nur auf der Box
- /api/partner (Lebenszeichen) und /api/partner/<pfad> (Durchreiche, ohne Schleifen und SSE)
- Waechter prueft die andere Instanz (rot erst nach 5 Takten, ein Neustart ist kein Alarm)
- notify.sh: Zweitweg direkt an die Telegram-Bot-API, wenn hermes send scheitert oder fehlt;
  Token nicht in der Prozessliste, Tests greifen nie auf die echte .env
- update_verlauf erkennt "OK telegram direkt"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 15:41:08 +02:00
co-authored by Claude Opus 5.5
parent 99ba6a78ab
commit 75611be9a9
19 changed files with 637 additions and 98 deletions
+204
View File
@@ -0,0 +1,204 @@
"""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"}
@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()