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
+68 -54
View File
@@ -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 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- 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. 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. Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026):
Ideen, Wissen, Chronik, Skills, Verbinden und die Konsole sind raus; die Schnittstellen, 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), bleiben. 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 import asyncio
@@ -23,25 +26,13 @@ from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from routers import ( from kern.einstellungen import einstellungen
boxwart, from routers import health, partner
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 services.herkunft import erlaubt from services.herkunft import erlaubt
from starlette.requests import Request from starlette.requests import Request
ROLLE = einstellungen().rolle
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)). # für alle Module (logging.getLogger(__name__)).
logging.basicConfig( logging.basicConfig(
@@ -51,11 +42,11 @@ logging.basicConfig(
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@asynccontextmanager def _box_hintergrund() -> list[asyncio.Task[Any]]:
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward
"""Hintergrund-Tasks an den App-Lebenszyklus binden. Der Wächter läuft NICHT hier, (eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden."""
sondern im mc2-steward (eigener Prozess, steward.py) — ein totes MC2 könnte sich from services import reminders, warmer
sonst nicht selbst melden."""
tasks: list[asyncio.Task[Any]] = [] tasks: list[asyncio.Task[Any]] = []
if warmer.ENABLED: if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop())) 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())) tasks.append(asyncio.create_task(reminders.reminders_loop()))
else: else:
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.") 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 # 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). # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient( app.state.gw_client = httpx.AsyncClient(
@@ -83,7 +81,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await app.state.gw_client.aclose() 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. # Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware( app.add_middleware(
@@ -99,7 +97,7 @@ app.add_middleware(
@app.middleware("http") @app.middleware("http")
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]): 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")): 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) return await call_next(request)
@@ -113,34 +111,50 @@ async def no_cache(
return resp return resp
app.include_router(health.router) def _box_router(app: FastAPI) -> None:
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan """Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen from routers import (
app.include_router(models.router) boxwart,
app.include_router(routing.router) events,
app.include_router(system.router) gateway_proxy,
hermes_ui,
maintenance,
models,
radar,
routing,
system,
voice,
zeitmaschine,
)
from routers import reminders as reminders_router
app.include_router( app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
voice.router app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) app.include_router(models.router)
app.include_router( app.include_router(routing.router)
reminders_router.router app.include_router(system.router)
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit # /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten # eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst. # LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
if V1_UPSTREAM: # Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
from routers import gateway_forward if V1_UPSTREAM:
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway from routers import gateway_forward
else: app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) else:
app.include_router(maintenance.router) app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation app.include_router(maintenance.router)
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached) app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
app.include_router( app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
hermes_ui.router # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
) # 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. # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
+3
View File
@@ -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."""
+40
View File
@@ -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"),
)
+53
View File
@@ -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)
+7
View File
@@ -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"))
+1 -3
View File
@@ -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. Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
""" """
import os
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo
import psutil import psutil
from config import MODELS_DIR from config import MODELS_DIR
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from kern.zeit import LOCAL_TZ
from pydantic import BaseModel from pydantic import BaseModel
from services import ( from services import (
aufraeumen, aufraeumen,
@@ -36,7 +35,6 @@ from services import (
) )
router = APIRouter(prefix="/api", tags=["boxwart"]) router = APIRouter(prefix="/api", tags=["boxwart"])
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
_updates_lock = threading.Lock() _updates_lock = threading.Lock()
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1} _updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
+24 -15
View File
@@ -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 config import VERSION
from fastapi import APIRouter from fastapi import APIRouter
from kern.einstellungen import einstellungen
from pydantic import BaseModel from pydantic import BaseModel
from services import gateway, llamaswap
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -11,19 +14,25 @@ router = APIRouter(prefix="/api")
class HealthResponse(BaseModel): class HealthResponse(BaseModel):
status: str status: str
version: str version: str
engine_reachable: bool rolle: str
gateway_reachable: bool instanz: str
brain: dict 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: def health() -> dict:
return { e = einstellungen()
"status": "ok", antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz}
"version": VERSION, if e.rolle == "box":
"engine_reachable": llamaswap.engine_reachable(), from services import gateway, llamaswap
"gateway_reachable": gateway.gateway_reachable(),
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot antwort.update(
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status). engine_reachable=llamaswap.engine_reachable(),
"brain": llamaswap.brain_status(), 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
+43
View File
@@ -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/<pfad> ab; hier wird
daraus <partner>/api/<pfad>. 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"))
+2 -2
View File
@@ -20,11 +20,11 @@ import threading
import time import time
from collections import Counter, defaultdict from collections import Counter, defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from kern.zeit import LOCAL_TZ
CACHE_S = 600 CACHE_S = 600
TAGE = 7 TAGE = 7
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
_STANDARD_NAMEN = { _STANDARD_NAMEN = {
"127.0.0.1": "Lucy und OpenChamber über MC2", "127.0.0.1": "Lucy und OpenChamber über MC2",
+1 -2
View File
@@ -45,11 +45,11 @@ from datetime import date, datetime, timedelta
from datetime import time as uhrzeit from datetime import time as uhrzeit
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from zoneinfo import ZoneInfo
import httpx import httpx
import psutil import psutil
from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR
from kern.zeit import LOCAL_TZ
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
from services import discover, geheimnisse, hf, llamaswap from services import discover, geheimnisse, hf, llamaswap
@@ -64,7 +64,6 @@ log = logging.getLogger(__name__)
# --- Grenzen (jede mit Env-Override) ---------------------------------------------------- # --- 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_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 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")) KANDIDATEN_PRO_WOCHE = int(os.environ.get("MC_RADAR_PRO_WOCHE", "1"))
+1 -2
View File
@@ -21,16 +21,15 @@ import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from config import MODELS_DIR from config import MODELS_DIR
from kern.zeit import LOCAL_TZ
from services import announce from services import announce
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json"))) 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) INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100")) MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
REPEATS = ("", "daily", "weekdays", "weekly") REPEATS = ("", "daily", "weekdays", "weekly")
+3 -3
View File
@@ -18,9 +18,9 @@ import re
import threading import threading
from datetime import datetime from datetime import datetime
from pathlib import Path 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"))) 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")) 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 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. # Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
_EINTRAG = re.compile( _EINTRAG = re.compile(
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) " 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") _ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
_UPDATE_MELDUNG = re.compile( _UPDATE_MELDUNG = re.compile(
+40 -9
View File
@@ -42,11 +42,15 @@ from pathlib import Path
import httpx import httpx
import psutil 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 from services import announce, llamaswap, maintenance, update_verlauf
log = logging.getLogger(__name__) 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. # 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" 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")) 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_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80")) 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. # „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. # 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 VERLAUF_MAX = 200
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber # 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. # 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) aktionen: list[dict] = field(default_factory=list)
auto: str | None = None # Name der Selbstreparatur, falls erlaubt auto: str | None = None # Name der Selbstreparatur, falls erlaubt
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde) 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: def _aktion(aid: str, label: str, **extra: str) -> dict:
@@ -407,7 +415,7 @@ def pruefe_kern() -> list[Befund]:
def pruefe_platte() -> list[Befund]: def pruefe_platte() -> list[Befund]:
try: 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: except Exception:
return [] return []
if pct >= DISK_ROT_PCT: if pct >= DISK_ROT_PCT:
@@ -433,8 +441,31 @@ def pruefe_festgehalten() -> list[Befund]:
for p in update_verlauf.festgehalten()] for p in update_verlauf.festgehalten()]
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte, def pruefe_partner() -> list[Befund]:
pruefe_festgehalten) """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 ----------------------------------------------- # --- Zustand, Takt, Selbstreparatur -----------------------------------------------
@@ -525,7 +556,7 @@ def takt() -> None:
k["takte"] += 1 k["takte"] += 1
if not update and b.auto: if not update and b.auto:
_selbst_beheben(b, jetzt) _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 continue
h = hinweise.get(b.id) h = hinweise.get(b.id)
neu = h is None neu = h is None
@@ -537,7 +568,7 @@ def takt() -> None:
_verlauf("neu", b.id, b.titel) _verlauf("neu", b.id, b.titel)
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S): if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
vorsatz = "" if neu else "Immer noch: " 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 h["gemeldet"] = jetzt
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und # Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
@@ -550,7 +581,7 @@ def takt() -> None:
h = hinweise.pop(hid) h = hinweise.pop(hid)
_verlauf("erledigt", hid, h.get("titel", hid)) _verlauf("erledigt", hid, h.get("titel", hid))
if h.get("stufe") == "rot" and h.get("gemeldet"): 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): for kid in list(kandidaten):
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))): if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
kandidaten.pop(kid) kandidaten.pop(kid)
+2 -3
View File
@@ -7,16 +7,15 @@ Anfragen). Alles nur lesend.
""" """
import json import json
import os
import subprocess import subprocess
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from kern.zeit import LOCAL_TZ
from services import modell_nutzung, waechter from services import modell_nutzung, waechter
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar", TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar",
"mc2-morgenmeldung.timer": "Morgenmeldung", "mc2-autoupdate.timer": "Updates am Sonntag"} "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: def _parse_iso(wert) -> datetime | None:
+3 -1
View File
@@ -31,6 +31,7 @@ import logging
import os import os
from config import CONFIG_PATH from config import CONFIG_PATH
from kern.einstellungen import einstellungen
from services import waechter, warmer from services import waechter, warmer
logging.basicConfig( logging.basicConfig(
@@ -61,7 +62,8 @@ async def config_watch() -> None:
async def main() -> None: async def main() -> None:
tasks: list[asyncio.Task] = [] 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(warmer.rewarm_loop()))
tasks.append(asyncio.create_task(config_watch())) tasks.append(asyncio.create_task(config_watch()))
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)", log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
+90
View File
@@ -37,6 +37,11 @@ def umgebung(tmp_path):
"MC_NIGHT_QUEUE": (tmp_path / "night-queue.txt").as_posix(), "MC_NIGHT_QUEUE": (tmp_path / "night-queue.txt").as_posix(),
"MC_NOTIFY_HERMES": stub.as_posix(), "MC_NOTIFY_HERMES": stub.as_posix(),
"MC_NOTIFY_NO_ANNOUNCE": "1", "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(), "STUB_PROTOKOLL": protokoll.as_posix(),
"MC_MORGENMELDUNG_PAUSE": "0", "MC_MORGENMELDUNG_PAUSE": "0",
} }
@@ -118,3 +123,88 @@ def test_morgenmeldung_verliert_nichts_wenn_telegram_klemmt(umgebung):
assert "2 Meldungen" in gesendet assert "2 Meldungen" in gesendet
assert gesendet.index("Erste Nacht") < gesendet.index("Zweite Nacht") assert gesendet.index("Erste Nacht") < gesendet.index("Zweite Nacht")
assert not (tmp / "night-queue.txt.senden").exists() 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")
+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()
+8
View File
@@ -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 (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True)
assert "Seit 13.09. auf 10819 festgehalten" in b.text assert "Seit 13.09. auf 10819 festgehalten" in b.text
assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine" 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")]
+44 -4
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Meldeweg der Box (Autonomie E1): eine Nachricht an den User schicken. # Meldeweg der Box (Autonomie E1): eine Nachricht an den User schicken.
# Primär: hermes send → Telegram (nutzt Gateway-Credentials, kein LLM nötig). # 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) # Nutzung: notify.sh "Nachricht" (oder via stdin: echo msg | notify.sh)
# notify.sh -s "[Update]" "Text" (Betreffzeile voranstellen) # notify.sh -s "[Update]" "Text" (Betreffzeile voranstellen)
@@ -15,12 +17,16 @@
# #
# Exit 0 = zugestellt (Telegram, Fallback-Log oder Nacht-Warteschlange). # 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). # 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 set -uo pipefail
LOG="${MC_NOTIFY_LOG:-$HOME/mc2-notify.log}" LOG="${MC_NOTIFY_LOG:-$HOME/mc2-notify.log}"
NIGHT_QUEUE="${MC_NIGHT_QUEUE:-$HOME/.hermes/night-queue.txt}" NIGHT_QUEUE="${MC_NIGHT_QUEUE:-$HOME/.hermes/night-queue.txt}"
HERMES_BIN="${MC_NOTIFY_HERMES:-hermes}" HERMES_BIN="${MC_NOTIFY_HERMES:-hermes}"
TG_ENV="${MC_TELEGRAM_ENV:-$HOME/.hermes/.env}"
TG_API="${MC_TELEGRAM_API:-https://api.telegram.org}"
SUBJECT="" SUBJECT=""
DRINGEND=0 DRINGEND=0
@@ -85,9 +91,43 @@ if [ $? -eq 0 ]; then
exit 0 exit 0
fi 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" 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 echo "WARN: Telegram fehlgeschlagen, in $LOG protokolliert" >&2
[ "${MC_NOTIFY_STRICT:-0}" = "1" ] && exit 1 [ "${MC_NOTIFY_STRICT:-0}" = "1" ] && exit 1
exit 0 exit 0