From 75611be9a9f50f463ff6d83b3b666b424344597f Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 15:41:08 +0200 Subject: [PATCH] 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/ (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 --- backend/app.py | 122 +++++++++------- backend/kern/__init__.py | 3 + backend/kern/einstellungen.py | 40 ++++++ backend/kern/partner.py | 53 +++++++ backend/kern/zeit.py | 7 + backend/routers/boxwart.py | 4 +- backend/routers/health.py | 39 +++-- backend/routers/partner.py | 43 ++++++ backend/services/modell_nutzung.py | 4 +- backend/services/radar.py | 3 +- backend/services/reminders.py | 3 +- backend/services/update_verlauf.py | 6 +- backend/services/waechter.py | 49 +++++-- backend/services/zeitplan.py | 5 +- backend/steward.py | 4 +- backend/tests/test_nachtmeldung.py | 90 ++++++++++++ backend/tests/test_partner.py | 204 +++++++++++++++++++++++++++ backend/tests/test_update_verlauf.py | 8 ++ deploy/notify.sh | 48 ++++++- 19 files changed, 637 insertions(+), 98 deletions(-) create mode 100644 backend/kern/__init__.py create mode 100644 backend/kern/einstellungen.py create mode 100644 backend/kern/partner.py create mode 100644 backend/kern/zeit.py create mode 100644 backend/routers/partner.py create mode 100644 backend/tests/test_partner.py diff --git a/backend/app.py b/backend/app.py index 6805dc3..cf2f75d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,13 +1,16 @@ """ -Mission Control 2.0 — dünner FastAPI-Einstieg. +Homelab Orchestrator (vormals Mission Control 2.0) — dünner FastAPI-Einstieg. Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev- Server (proxyt /api hierher), daher CORS für localhost offen. -Seit dem Box-Wart-Umbau (09/2026) ist MC2 nur noch Updater, Wächter und Modell-Radar. -Ideen, Wissen, Chronik, Skills, Verbinden und die Konsole sind raus; die Schnittstellen, -die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge), bleiben. +Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026): + box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen, + die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge) + homelab — der Proxmox-PC: der Homelab-Teil +Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter +/api/partner/… durch. """ import asyncio @@ -23,25 +26,13 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from routers import ( - boxwart, - events, - gateway_proxy, - health, - hermes_ui, - maintenance, - models, - radar, - routing, - system, - voice, - zeitmaschine, -) -from routers import reminders as reminders_router -from services import reminders, warmer +from kern.einstellungen import einstellungen +from routers import health, partner from services.herkunft import erlaubt from starlette.requests import Request +ROLLE = einstellungen().rolle + # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # für alle Module (logging.getLogger(__name__)). logging.basicConfig( @@ -51,11 +42,11 @@ logging.basicConfig( log = logging.getLogger(__name__) -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: - """Hintergrund-Tasks an den App-Lebenszyklus binden. Der Wächter läuft NICHT hier, - sondern im mc2-steward (eigener Prozess, steward.py) — ein totes MC2 könnte sich - sonst nicht selbst melden.""" +def _box_hintergrund() -> list[asyncio.Task[Any]]: + """Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward + (eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden.""" + from services import reminders, warmer + tasks: list[asyncio.Task[Any]] = [] if warmer.ENABLED: tasks.append(asyncio.create_task(warmer.rewarm_loop())) @@ -69,6 +60,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: tasks.append(asyncio.create_task(reminders.reminders_loop())) else: log.info("Probelauf: Erinnerungen bleiben beim echten MC2.") + return tasks + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Hintergrund-Tasks an den App-Lebenszyklus binden.""" + tasks = _box_hintergrund() if ROLLE == "box" else [] # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo). app.state.gw_client = httpx.AsyncClient( @@ -83,7 +81,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await app.state.gw_client.aclose() -app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan) +app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan) # Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf. app.add_middleware( @@ -99,7 +97,7 @@ app.add_middleware( @app.middleware("http") async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]): if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")): - return JSONResponse({"detail": "Diese Aktion geht nur von der Box-Wart-Seite selbst aus."}, status_code=403) + return JSONResponse({"detail": "Diese Aktion geht nur von der Orchestrator-Seite selbst aus."}, status_code=403) return await call_next(request) @@ -113,34 +111,50 @@ async def no_cache( return resp -app.include_router(health.router) -app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan -app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen -app.include_router(models.router) -app.include_router(routing.router) -app.include_router(system.router) +def _box_router(app: FastAPI) -> None: + """Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen).""" + from routers import ( + boxwart, + events, + gateway_proxy, + hermes_ui, + maintenance, + models, + radar, + routing, + system, + voice, + zeitmaschine, + ) + from routers import reminders as reminders_router -app.include_router( - voice.router -) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) -app.include_router( - reminders_router.router -) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten -# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als -# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit -# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten -# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst. -if V1_UPSTREAM: - from routers import gateway_forward - app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway -else: - app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) -app.include_router(maintenance.router) -app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation -app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached) -app.include_router( - hermes_ui.router -) # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all + app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan + app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen + app.include_router(models.router) + app.include_router(routing.router) + app.include_router(system.router) + app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat + app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten + # /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als + # eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit + # LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten + # Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst. + if V1_UPSTREAM: + from routers import gateway_forward + app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway + else: + app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) + app.include_router(maintenance.router) + app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation + app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached) + # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all + app.include_router(hermes_ui.router) + + +app.include_router(health.router) +app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche +if ROLLE == "box": + _box_router(app) # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. diff --git a/backend/kern/__init__.py b/backend/kern/__init__.py new file mode 100644 index 0000000..98347c0 --- /dev/null +++ b/backend/kern/__init__.py @@ -0,0 +1,3 @@ +"""Kern des Homelab Orchestrators (Phase 2, ab 24.09.2026): was beide Instanzen teilen — +Einstellungen, Zeitzone, Partner-Instanz. Neue Module lesen ihre Einstellungen hier; ältere +Module ziehen nach und nach um.""" diff --git a/backend/kern/einstellungen.py b/backend/kern/einstellungen.py new file mode 100644 index 0000000..d905008 --- /dev/null +++ b/backend/kern/einstellungen.py @@ -0,0 +1,40 @@ +"""Zentrale Einstellungen einer Instanz des Homelab Orchestrators. + +Zwei Rollen, derselbe Code (User-Entscheid 24.09.2026, „zwei Instanzen, eine Oberfläche“): + box — auf der KI-Box: Box-Wart (Updates, Wächter, Modelle, Radar, Gateway für Lucy) + homelab — auf dem Proxmox-PC: Homelab-Teil (Proxmox-Host, Container, Arcane) +Beide kennen die jeweils andere Instanz als Partner: die Oberfläche zeigt beide Bereiche, und +jede Instanz prüft, ob die andere noch antwortet. +""" + +import os +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +ROLLEN = ("box", "homelab") + + +@dataclass(frozen=True) +class Einstellungen: + rolle: str # "box" | "homelab" + instanz: str # Anzeigename dieser Instanz + daten_dir: Path # Zustandsdateien dieser Instanz + partner_url: str # Basis-URL der anderen Instanz, leer = keine + partner_name: str # Anzeigename der anderen Instanz + + +@lru_cache(maxsize=1) +def einstellungen() -> Einstellungen: + rolle = os.environ.get("MC_ROLLE", "box").strip().lower() or "box" + if rolle not in ROLLEN: + raise ValueError(f"MC_ROLLE muss eine von {ROLLEN} sein, nicht {rolle!r}.") + box = rolle == "box" + standard_daten = os.environ.get("MC_MODELS_DIR", "/srv/models") if box else "/var/lib/mc2" + return Einstellungen( + rolle=rolle, + instanz=os.environ.get("MC_INSTANZ", "Box-Wart" if box else "Homelab"), + daten_dir=Path(os.environ.get("MC_DATEN_DIR", standard_daten)), + partner_url=os.environ.get("MC_PARTNER_URL", "").strip().rstrip("/"), + partner_name=os.environ.get("MC_PARTNER_NAME", "Homelab" if box else "Box-Wart"), + ) diff --git a/backend/kern/partner.py b/backend/kern/partner.py new file mode 100644 index 0000000..7956258 --- /dev/null +++ b/backend/kern/partner.py @@ -0,0 +1,53 @@ +"""Die Partner-Instanz (User-Entscheid 24.09.2026: zwei Instanzen, eine Oberfläche). + +Box-Wart läuft auf der KI-Box, der Homelab-Teil in einem Container auf dem Proxmox-PC. Jede +Instanz kennt die andere über MC_PARTNER_URL. Sie fragt deren Lebenszeichen ab (für den Wächter +und die Kopfzeile der Oberfläche) und reicht Anfragen der Oberfläche unter /api/partner/… durch, +damit eine Seite beide Bereiche zeigt — egal, auf welcher Instanz man sie öffnet. +""" + +import threading +import time + +import httpx + +from kern.einstellungen import einstellungen + +ZEITLIMIT_S = 5.0 +CACHE_S = 15.0 + +_lock = threading.Lock() +_cache: dict = {"ts": 0.0, "daten": None} + + +def _kurz(exc: Exception) -> str: + if isinstance(exc, httpx.TimeoutException): + return "antwortet nicht rechtzeitig" + if isinstance(exc, httpx.ConnectError): + return "nicht erreichbar" + if isinstance(exc, httpx.HTTPStatusError): + return f"antwortet mit HTTP {exc.response.status_code}" + return f"{exc.__class__.__name__}: {exc}"[:160] + + +def status(frisch: bool = False) -> dict: + """Lebenszeichen der Partner-Instanz. Ohne MC_PARTNER_URL: {"eingerichtet": False}.""" + e = einstellungen() + if not e.partner_url: + return {"eingerichtet": False} + with _lock: + if not frisch and _cache["daten"] and time.time() - _cache["ts"] < CACHE_S: + return dict(_cache["daten"]) + daten: dict = {"eingerichtet": True, "name": e.partner_name, "url": e.partner_url, + "erreichbar": False, "rolle": None, "instanz": None, "version": None, "fehler": None} + try: + r = httpx.get(f"{e.partner_url}/api/health", timeout=ZEITLIMIT_S) + r.raise_for_status() + h = r.json() + daten.update(erreichbar=True, rolle=h.get("rolle"), instanz=h.get("instanz"), version=h.get("version")) + except Exception as exc: + daten["fehler"] = _kurz(exc) + daten["geprueft"] = time.time() + with _lock: + _cache.update(ts=time.time(), daten=daten) + return dict(daten) diff --git a/backend/kern/zeit.py b/backend/kern/zeit.py new file mode 100644 index 0000000..788c22a --- /dev/null +++ b/backend/kern/zeit.py @@ -0,0 +1,7 @@ +"""Die eine Zeitzone für alles, was Zeiten anzeigt oder naive Zeiten deutet (Projektregel: +nie datetime.now() ohne Zone). Vorher stand dieselbe Zeile in sieben Modulen.""" + +import os +from zoneinfo import ZoneInfo + +LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) diff --git a/backend/routers/boxwart.py b/backend/routers/boxwart.py index 9d5ed8f..7ec2929 100644 --- a/backend/routers/boxwart.py +++ b/backend/routers/boxwart.py @@ -13,15 +13,14 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py. """ -import os import threading import time from datetime import datetime -from zoneinfo import ZoneInfo import psutil from config import MODELS_DIR from fastapi import APIRouter, HTTPException +from kern.zeit import LOCAL_TZ from pydantic import BaseModel from services import ( aufraeumen, @@ -36,7 +35,6 @@ from services import ( ) router = APIRouter(prefix="/api", tags=["boxwart"]) -LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) _updates_lock = threading.Lock() _updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1} diff --git a/backend/routers/health.py b/backend/routers/health.py index 644cf25..433a63d 100644 --- a/backend/routers/health.py +++ b/backend/routers/health.py @@ -1,9 +1,12 @@ -"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0.""" +"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check einer Instanz. + +Seit 24.09.2026 nennt er auch Rolle und Namen der Instanz: Die Partner-Instanz liest daran ab, +wer ihr antwortet. Engine, Gateway und Hirn gibt es nur auf der KI-Box.""" from config import VERSION from fastapi import APIRouter +from kern.einstellungen import einstellungen from pydantic import BaseModel -from services import gateway, llamaswap router = APIRouter(prefix="/api") @@ -11,19 +14,25 @@ router = APIRouter(prefix="/api") class HealthResponse(BaseModel): status: str version: str - engine_reachable: bool - gateway_reachable: bool - brain: dict + rolle: str + instanz: str + engine_reachable: bool | None = None + gateway_reachable: bool | None = None + brain: dict | None = None -@router.get("/health", response_model=HealthResponse) +@router.get("/health", response_model=HealthResponse, response_model_exclude_none=True) def health() -> dict: - return { - "status": "ok", - "version": VERSION, - "engine_reachable": llamaswap.engine_reachable(), - "gateway_reachable": gateway.gateway_reachable(), - # Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot - # (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status). - "brain": llamaswap.brain_status(), - } + e = einstellungen() + antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz} + if e.rolle == "box": + from services import gateway, llamaswap + + antwort.update( + engine_reachable=llamaswap.engine_reachable(), + gateway_reachable=gateway.gateway_reachable(), + # Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn aber tot + # (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail. + brain=llamaswap.brain_status(), + ) + return antwort diff --git a/backend/routers/partner.py b/backend/routers/partner.py new file mode 100644 index 0000000..7907c07 --- /dev/null +++ b/backend/routers/partner.py @@ -0,0 +1,43 @@ +"""Partner-Instanz: Lebenszeichen und Durchreiche (zwei Instanzen, eine Oberfläche, 24.09.2026). + +Die Oberfläche fragt den Bereich der anderen Instanz über /api/partner/ ab; hier wird +daraus /api/. Die Herkunftsprüfung (services/herkunft.py) greift wie bei jeder +anderen schreibenden Anfrage schon hier, bevor etwas weitergereicht wird. +""" + +import httpx +from fastapi import APIRouter, HTTPException, Request, Response +from kern import partner +from kern.einstellungen import einstellungen + +router = APIRouter(prefix="/api", tags=["partner"]) + +# Nicht durchreichen: den Partner-Weg des Partners (sonst reichen sich zwei Instanzen eine Anfrage +# endlos zu) und den Live-Strom (SSE hält die Verbindung offen; der Partner-Bereich fragt ab). +_GESPERRT = ("partner", "stream") +_ZEITLIMIT = httpx.Timeout(30.0, connect=5.0) + + +@router.get("/partner") +def partner_status() -> dict: + return partner.status() + + +@router.api_route("/partner/{pfad:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False) +async def weiterreichen(pfad: str, request: Request) -> Response: + e = einstellungen() + if not e.partner_url: + raise HTTPException(status_code=404, detail="Es ist keine zweite Instanz eingerichtet.") + if pfad.split("/", 1)[0] in _GESPERRT: + raise HTTPException(status_code=404, detail="Diese Schnittstelle wird nicht weitergereicht.") + kopf = {"X-MC-Von": e.instanz} + if request.headers.get("content-type"): + kopf["Content-Type"] = request.headers["content-type"] + try: + async with httpx.AsyncClient(timeout=_ZEITLIMIT) as c: + r = await c.request(request.method, f"{e.partner_url}/api/{pfad}", + params=request.query_params, content=await request.body(), headers=kopf) + except httpx.HTTPError: + raise HTTPException(status_code=502, detail=f"{e.partner_name} ist gerade nicht erreichbar.") from None + return Response(content=r.content, status_code=r.status_code, + media_type=r.headers.get("content-type", "application/json")) diff --git a/backend/services/modell_nutzung.py b/backend/services/modell_nutzung.py index 8036860..7ff0c30 100644 --- a/backend/services/modell_nutzung.py +++ b/backend/services/modell_nutzung.py @@ -20,11 +20,11 @@ import threading import time from collections import Counter, defaultdict from datetime import datetime, timedelta -from zoneinfo import ZoneInfo + +from kern.zeit import LOCAL_TZ CACHE_S = 600 TAGE = 7 -LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) _STANDARD_NAMEN = { "127.0.0.1": "Lucy und OpenChamber über MC2", diff --git a/backend/services/radar.py b/backend/services/radar.py index b63c50c..8118408 100644 --- a/backend/services/radar.py +++ b/backend/services/radar.py @@ -45,11 +45,11 @@ from datetime import date, datetime, timedelta from datetime import time as uhrzeit from pathlib import Path from urllib.parse import quote -from zoneinfo import ZoneInfo import httpx import psutil from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR +from kern.zeit import LOCAL_TZ from ruamel.yaml.scalarstring import LiteralScalarString from services import discover, geheimnisse, hf, llamaswap @@ -64,7 +64,6 @@ log = logging.getLogger(__name__) # --- Grenzen (jede mit Env-Override) ---------------------------------------------------- -LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) FENSTER_START = os.environ.get("MC_RADAR_FENSTER_START", "00:30") # Tests nur nachts … FENSTER_ENDE = os.environ.get("MC_RADAR_FENSTER_ENDE", "02:30") # … um 03:00 kommt NerdQuiz KANDIDATEN_PRO_WOCHE = int(os.environ.get("MC_RADAR_PRO_WOCHE", "1")) diff --git a/backend/services/reminders.py b/backend/services/reminders.py index 0fac64b..612a34e 100644 --- a/backend/services/reminders.py +++ b/backend/services/reminders.py @@ -21,16 +21,15 @@ import threading import time from datetime import datetime, timedelta from pathlib import Path -from zoneinfo import ZoneInfo from config import MODELS_DIR +from kern.zeit import LOCAL_TZ from services import announce log = logging.getLogger(__name__) STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json"))) -LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden) MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100")) REPEATS = ("", "daily", "weekdays", "weekly") diff --git a/backend/services/update_verlauf.py b/backend/services/update_verlauf.py index d61c772..001b7c6 100644 --- a/backend/services/update_verlauf.py +++ b/backend/services/update_verlauf.py @@ -18,9 +18,9 @@ import re import threading from datetime import datetime from pathlib import Path -from zoneinfo import ZoneInfo -LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) +from kern.zeit import LOCAL_TZ + NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log"))) PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json")) LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate @@ -39,7 +39,7 @@ _BAUSTEIN_IM_TEXT = ( # Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht. _EINTRAG = re.compile( r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) " - r"(?:OK telegram|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$") + r"(?:OK telegram(?: direkt)?|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$") _ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch") _UPDATE_MELDUNG = re.compile( diff --git a/backend/services/waechter.py b/backend/services/waechter.py index a9ead32..06097db 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -42,11 +42,15 @@ from pathlib import Path import httpx import psutil -from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL +from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL +from kern import partner +from kern.einstellungen import einstellungen from services import announce, llamaswap, maintenance, update_verlauf log = logging.getLogger(__name__) +ROLLE = einstellungen().rolle +DATEN_DIR = einstellungen().daten_dir # MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn. ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0" @@ -57,10 +61,13 @@ REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2")) DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90")) DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80")) -STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(MODELS_DIR / "mc2-waechter.json"))) +STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(DATEN_DIR / "mc2-waechter.json"))) # „Ausblenden bis zum nächsten Lauf“ (24.09.2026): Hinweis-ID → Start des Laufs, der ausgeblendet ist. # Schreibt nur MC2 (Knopf), der Steward liest. Ein neuer Lauf macht den Eintrag wirkungslos. -QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(MODELS_DIR / "mc2-quittiert.json"))) +QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(DATEN_DIR / "mc2-quittiert.json"))) +# Die andere Instanz darf einen Neustart lang schweigen (Sonntags-Update der Box, Container-Update +# auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird. +PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5")) VERLAUF_MAX = 200 # Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber # nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter. @@ -112,6 +119,7 @@ class Befund: aktionen: list[dict] = field(default_factory=list) auto: str | None = None # Name der Selbstreparatur, falls erlaubt sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde) + nach_takten: int = 0 # eigene Wartezeit statt FAIL_AFTER (0 = Standard) def _aktion(aid: str, label: str, **extra: str) -> dict: @@ -407,7 +415,7 @@ def pruefe_kern() -> list[Befund]: def pruefe_platte() -> list[Befund]: try: - pct = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent + pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent except Exception: return [] if pct >= DISK_ROT_PCT: @@ -433,8 +441,31 @@ def pruefe_festgehalten() -> list[Befund]: for p in update_verlauf.festgehalten()] -PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte, - pruefe_festgehalten) +def pruefe_partner() -> list[Befund]: + """Die andere Instanz (User-Entscheid 24.09.2026): Box und Homelab prüfen sich gegenseitig — + fällt eine aus, meldet die andere es.""" + stand = partner.status(frisch=True) + if not stand.get("eingerichtet") or stand.get("erreichbar"): + return [] + grund = stand.get("fehler") or "nicht erreichbar" + if ROLLE == "homelab": + text = f"Die KI-Box ({stand['url']}) ist {grund}. Lucy, die Modelle und der Box-Wart sind so lange weg." + else: + text = (f"Der Homelab-Teil auf dem Proxmox-PC ({stand['url']}) ist {grund}. " + "Updates im Homelab lassen sich so lange nicht anstoßen.") + return [Befund(id="partner", stufe="rot", titel=f"{stand['name']} antwortet nicht", text=text, + quelle="partner", nach_takten=PARTNER_TAKTE)] + + +# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil +# bekommt seine Prüfungen mit den Homelab-Bausteinen. +PRUEFUNGEN = { + "box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte, + pruefe_festgehalten, pruefe_partner), + "homelab": (pruefe_platte, pruefe_partner), +}[ROLLE] +BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"), + "homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE] # --- Zustand, Takt, Selbstreparatur ----------------------------------------------- @@ -525,7 +556,7 @@ def takt() -> None: k["takte"] += 1 if not update and b.auto: _selbst_beheben(b, jetzt) - if not (b.sofort or k["takte"] >= FAIL_AFTER): + if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER)): continue h = hinweise.get(b.id) neu = h is None @@ -537,7 +568,7 @@ def takt() -> None: _verlauf("neu", b.id, b.titel) if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S): vorsatz = "" if neu else "Immer noch: " - _telegram("[Box-Problem]", f"{vorsatz}{b.titel}. {b.text}") + _telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}") h["gemeldet"] = jetzt # Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und @@ -550,7 +581,7 @@ def takt() -> None: h = hinweise.pop(hid) _verlauf("erledigt", hid, h.get("titel", hid)) if h.get("stufe") == "rot" and h.get("gemeldet"): - _telegram("[Box wieder ok]", f"Erledigt: {h.get('titel', hid)}.") + _telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.") for kid in list(kandidaten): if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))): kandidaten.pop(kid) diff --git a/backend/services/zeitplan.py b/backend/services/zeitplan.py index 713f4ed..4b12717 100644 --- a/backend/services/zeitplan.py +++ b/backend/services/zeitplan.py @@ -7,16 +7,15 @@ Anfragen). Alles nur lesend. """ import json -import os import subprocess from datetime import datetime, timedelta -from zoneinfo import ZoneInfo + +from kern.zeit import LOCAL_TZ from services import modell_nutzung, waechter TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar", "mc2-morgenmeldung.timer": "Morgenmeldung", "mc2-autoupdate.timer": "Updates am Sonntag"} -LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) def _parse_iso(wert) -> datetime | None: diff --git a/backend/steward.py b/backend/steward.py index b29621d..5e31767 100644 --- a/backend/steward.py +++ b/backend/steward.py @@ -31,6 +31,7 @@ import logging import os from config import CONFIG_PATH +from kern.einstellungen import einstellungen from services import waechter, warmer logging.basicConfig( @@ -61,7 +62,8 @@ async def config_watch() -> None: async def main() -> None: tasks: list[asyncio.Task] = [] - if warmer.ENABLED: + # Warm-Set und llama-swap gibt es nur auf der KI-Box; der Homelab-Teil hat nur den Wächter. + if einstellungen().rolle == "box" and warmer.ENABLED: tasks.append(asyncio.create_task(warmer.rewarm_loop())) tasks.append(asyncio.create_task(config_watch())) log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)", diff --git a/backend/tests/test_nachtmeldung.py b/backend/tests/test_nachtmeldung.py index ba723a1..d111681 100644 --- a/backend/tests/test_nachtmeldung.py +++ b/backend/tests/test_nachtmeldung.py @@ -37,6 +37,11 @@ def umgebung(tmp_path): "MC_NIGHT_QUEUE": (tmp_path / "night-queue.txt").as_posix(), "MC_NOTIFY_HERMES": stub.as_posix(), "MC_NOTIFY_NO_ANNOUNCE": "1", + "MC_NOTIFY_OHNE_WALL": "1", + # Auf der Box läge unter ~/.hermes/.env das echte Bot-Token — der Zweitweg darf in Tests + # nie dorthin greifen, sonst schickt der Deploy-Testlauf echte Telegram-Nachrichten. + "MC_TELEGRAM_ENV": (tmp_path / "keine.env").as_posix(), + "MC_TELEGRAM_API": "http://127.0.0.1:9", "STUB_PROTOKOLL": protokoll.as_posix(), "MC_MORGENMELDUNG_PAUSE": "0", } @@ -118,3 +123,88 @@ def test_morgenmeldung_verliert_nichts_wenn_telegram_klemmt(umgebung): assert "2 Meldungen" in gesendet assert gesendet.index("Erste Nacht") < gesendet.index("Zweite Nacht") assert not (tmp / "night-queue.txt.senden").exists() + + +# ── Zweitweg direkt an die Bot-API (seit 24.09.2026) ────────────────────────────────────────────── + +@pytest.fixture +def bot_api(): + """Ein Ersatz für api.telegram.org, der mitschreibt, was ankommt.""" + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from urllib.parse import parse_qs + + empfangen: list[dict] = [] + antwort = {"code": 200} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + laenge = int(self.headers.get("Content-Length", 0)) + felder = parse_qs(self.rfile.read(laenge).decode("utf-8")) + empfangen.append({"pfad": self.path, **{k: v[0] for k, v in felder.items()}}) + self.send_response(antwort["code"]) + self.end_headers() + self.wfile.write(b'{"ok": 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, antwort + server.shutdown() + + +def _env_datei(tmp: Path, inhalt: str) -> str: + datei = tmp / "hermes.env" + datei.write_text(inhalt, encoding="utf-8", newline="\n") + return datei.as_posix() + + +def test_zweitweg_springt_ein_wenn_hermes_scheitert(umgebung, bot_api): + tmp, env, _ = umgebung + url, empfangen, _ = bot_api + env = {**env, "STUB_EXIT": "1", "MC_TELEGRAM_API": url, "MC_TELEGRAM_ENV": _env_datei( + tmp, 'OPENAI_API_KEY=egal\nexport TELEGRAM_BOT_TOKEN="123:abc"\nTELEGRAM_HOME_CHANNEL=4711\n' + "TELEGRAM_HOME_CHANNEL_THREAD_ID=\n")} + lauf = _notify(env, "-s", "[Alarm]", "Hermes steht", stunde="12") + assert lauf.returncode == 0 + assert empfangen == [{"pfad": "/bot123:abc/sendMessage", "chat_id": "4711", + "text": "[Alarm]\nHermes steht"}] + assert "OK telegram direkt: Hermes steht" in (tmp / "notify.log").read_text(encoding="utf-8") + + +def test_zweitweg_nimmt_den_thread_mit(umgebung, bot_api): + tmp, env, _ = umgebung + url, empfangen, _ = bot_api + env = {**env, "STUB_EXIT": "1", "MC_TELEGRAM_API": url, "MC_TELEGRAM_ENV": _env_datei( + tmp, "TELEGRAM_BOT_TOKEN='9:x'\r\nTELEGRAM_HOME_CHANNEL=-100\r\nTELEGRAM_HOME_CHANNEL_THREAD_ID=7\r\n")} + assert _notify(env, "Ohne Betreff", stunde="12").returncode == 0 + assert empfangen == [{"pfad": "/bot9:x/sendMessage", "chat_id": "-100", "text": "Ohne Betreff", + "message_thread_id": "7"}] + + +def test_hermes_zuerst_zweitweg_bleibt_still(umgebung, bot_api): + tmp, env, protokoll = umgebung + url, empfangen, _ = bot_api + env = {**env, "MC_TELEGRAM_API": url, + "MC_TELEGRAM_ENV": _env_datei(tmp, "TELEGRAM_BOT_TOKEN=1:a\nTELEGRAM_HOME_CHANNEL=1\n")} + assert _notify(env, "Normal", stunde="12").returncode == 0 + assert "Normal" in _gesendet(protokoll) + assert empfangen == [] + + +@pytest.mark.parametrize("inhalt, code", [ + ("", 200), # keine Zugangsdaten + ("TELEGRAM_BOT_TOKEN=1:a\n", 200), # Chat fehlt + ("TELEGRAM_BOT_TOKEN=1:a\nTELEGRAM_HOME_CHANNEL=1\n", 500), # Telegram lehnt ab +]) +def test_letzter_ausweg_wenn_auch_der_zweitweg_scheitert(umgebung, bot_api, inhalt, code): + tmp, env, _ = umgebung + url, _, antwort = bot_api + antwort["code"] = code + env = {**env, "STUB_EXIT": "1", "MC_NOTIFY_STRICT": "1", "MC_TELEGRAM_API": url, + "MC_TELEGRAM_ENV": _env_datei(tmp, inhalt)} + lauf = _notify(env, "-s", "[Test]", "Geht nirgends hin", stunde="12") + assert lauf.returncode == 1 + assert "FALLBACK (telegram fehlgeschlagen" in (tmp / "notify.log").read_text(encoding="utf-8") diff --git a/backend/tests/test_partner.py b/backend/tests/test_partner.py new file mode 100644 index 0000000..0c1e7a3 --- /dev/null +++ b/backend/tests/test_partner.py @@ -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() diff --git a/backend/tests/test_update_verlauf.py b/backend/tests/test_update_verlauf.py index 73b7954..063e875 100644 --- a/backend/tests/test_update_verlauf.py +++ b/backend/tests/test_update_verlauf.py @@ -146,3 +146,11 @@ def test_waechter_meldet_festgehaltene_bausteine(tmp_path, monkeypatch): assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True) assert "Seit 13.09. auf 10819 festgehalten" in b.text assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine" + + +def test_zweitweg_zaehlt_wie_telegram(): + """notify.sh schreibt „OK telegram direkt“, wenn Hermes stand und die Bot-API direkt ansprang.""" + text = ("2026-09-27 04:40:00 OK telegram direkt: Router (llama-swap) aktualisiert: 256 → 257. " + "Stack-Check grün, alles läuft.\n") + laeufe = update_verlauf.laeufe_aus(text) + assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [("llama-swap", "eingespielt")] diff --git a/deploy/notify.sh b/deploy/notify.sh index bf1a2ef..7b4df4f 100644 --- a/deploy/notify.sh +++ b/deploy/notify.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Meldeweg der Box (Autonomie E1): eine Nachricht an den User schicken. # Primär: hermes send → Telegram (nutzt Gateway-Credentials, kein LLM nötig). -# Fallback: Logfile + wall (falls Telegram/hermes nicht erreichbar). +# Zweitweg (seit 24.09.2026): direkt an die Telegram-Bot-API mit den Zugangsdaten aus Hermes' .env — +# greift, wenn Hermes steht oder fehlt (die Homelab-Instanz hat gar kein Hermes). +# Letzter Ausweg: Logfile + wall. # # Nutzung: notify.sh "Nachricht" (oder via stdin: echo msg | notify.sh) # notify.sh -s "[Update]" "Text" (Betreffzeile voranstellen) @@ -15,12 +17,16 @@ # # Exit 0 = zugestellt (Telegram, Fallback-Log oder Nacht-Warteschlange). # MC_NOTIFY_STRICT=1: Exit 1, wenn Telegram scheitert (für morgenmeldung.sh, damit nichts verloren geht). -# Test-Schalter: MC_NOTIFY_STUNDE (Stunde vortäuschen), MC_NIGHT_QUEUE, MC_NOTIFY_HERMES (hermes-Befehl). +# Test-Schalter: MC_NOTIFY_STUNDE (Stunde vortäuschen), MC_NIGHT_QUEUE, MC_NOTIFY_HERMES (hermes-Befehl), +# MC_TELEGRAM_ENV (Datei mit TELEGRAM_BOT_TOKEN/TELEGRAM_HOME_CHANNEL), MC_TELEGRAM_API (Bot-API-Adresse), +# MC_NOTIFY_OHNE_WALL=1 (kein wall im letzten Ausweg). set -uo pipefail LOG="${MC_NOTIFY_LOG:-$HOME/mc2-notify.log}" NIGHT_QUEUE="${MC_NIGHT_QUEUE:-$HOME/.hermes/night-queue.txt}" HERMES_BIN="${MC_NOTIFY_HERMES:-hermes}" +TG_ENV="${MC_TELEGRAM_ENV:-$HOME/.hermes/.env}" +TG_API="${MC_TELEGRAM_API:-https://api.telegram.org}" SUBJECT="" DRINGEND=0 @@ -85,9 +91,43 @@ if [ $? -eq 0 ]; then exit 0 fi -# Fallback: Logfile + wall — Meldung darf nie verloren gehen. +# Zweitweg: direkt an die Bot-API. Das Token geht über --config auf stdin an curl, damit es nicht +# in der Prozessliste steht. +env_wert() { + local wert + wert="$(grep -E "^(export +)?$1=" "$TG_ENV" 2>/dev/null | tail -n 1)" + wert="${wert#*=}" + wert="${wert%$'\r'}" + wert="${wert%\"}"; wert="${wert#\"}" + wert="${wert%\'}"; wert="${wert#\'}" + printf '%s' "$wert" +} +telegram_direkt() { + local token chat thread text + [ -r "$TG_ENV" ] || return 1 + token="$(env_wert TELEGRAM_BOT_TOKEN)" + chat="$(env_wert TELEGRAM_HOME_CHANNEL)" + thread="$(env_wert TELEGRAM_HOME_CHANNEL_THREAD_ID)" + [ -n "$token" ] && [ -n "$chat" ] || return 1 + text="$MSG" + [ -n "$SUBJECT" ] && text="$SUBJECT +$MSG" + printf 'url = "%s/bot%s/sendMessage"\n' "$TG_API" "$token" \ + | curl -sf -m 15 -o /dev/null --config - \ + --data-urlencode "chat_id=$chat" --data-urlencode "text=$text" \ + ${thread:+--data-urlencode "message_thread_id=$thread"} +} +if telegram_direkt; then + # „OK telegram direkt“: services/update_verlauf.py erkennt beide Wege. + echo "$TS OK telegram direkt: $MSG" >> "$LOG" + exit 0 +fi + +# Letzter Ausweg: Logfile + wall — Meldung darf nie verloren gehen. echo "$TS FALLBACK (telegram fehlgeschlagen: $SEND_OUT): $MSG" >> "$LOG" -printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true +if [ "${MC_NOTIFY_OHNE_WALL:-0}" != "1" ]; then + printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true +fi echo "WARN: Telegram fehlgeschlagen, in $LOG protokolliert" >&2 [ "${MC_NOTIFY_STRICT:-0}" = "1" ] && exit 1 exit 0