Welle 1 · Totmannschalter: Wächter meldet Abläufe, die gar nicht erst laufen; Seite Dienste zeigt die Zeitpläne

services/ablaeufe.py: je systemd-Timer der Box der erwartete Takt plus Karenz
(LastTriggerUSec über --timestamp=unix), Hermes-Cron-Jobs mit verpasstem
next_run_at, der Runner der Gitea-Ampel (Admin-API, Token aus
~/.git-credentials, nie ausgegeben). Überfällig oder gestoppt → Hinweis nach
fünf Takten, die Sonntags-Updates rot. GET /api/system/ablaeufe; Panel
„Zeitpläne“ unter den Diensten. Tests fragen Gitea nie echt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:01:21 +02:00
co-authored by Claude Opus 5.5
parent 4b57a12257
commit 34360c9644
9 changed files with 492 additions and 40 deletions
+247
View File
@@ -0,0 +1,247 @@
"""Abläufe der KI-Box — der Totmannschalter (Ausbauplan Welle 1, Punkt 1, seit 25.09.2026).
Der Wächter sah bisher nur, wenn ein geplanter Ablauf SCHEITERTE (pruefe_timer_dienste, pruefe_hermes_jobs). Ein Ablauf,
der gar nicht erst läuft, fiel niemandem auf: Vom 06. bis 17.09.2026 stand die Sonntags-Update-Kette still, bis es
jemand zufällig bemerkte. Hier steht deshalb je Ablauf, wie lange er höchstens schweigen darf (Takt + Karenz), und was
darüber liegt, wird ein Hinweis:
systemd-Timer letzter Start (LastTriggerUSec) älter als erlaubt → überfällig; der Timer selbst aus
(eingetragen, aber nicht aktiv) → aus. Bewusst abgeschaltet (inaktiv und ausgetragen, z. B. das
Radar in den Einstellungen) ist kein Befund.
Hermes-Cron-Jobs der nächste Lauf (next_run_at) liegt mehr als eine Stunde zurück → der Planer von Hermes lief
nicht (Gateway hing oder war aus).
Gitea-Ampel der Actions-Runner meldet sich in Gitea nicht als online (seit dem 28.08. stand er still, weil
er von Hand gestartet war; seit 25.09. ein Dienst auf arcane).
Nur lesend. Die Liste für die Oberfläche (Seite „Dienste und Protokolle“ → Zeitpläne) kommt aus ablaeufe().
"""
import logging
import os
import re
import subprocess
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import httpx
from kern.zeit import LOCAL_TZ
log = logging.getLogger(__name__)
STUNDE = 3600
TAG = 24 * STUNDE
@dataclass(frozen=True)
class Zeitplan:
unit: str # systemd-User-Timer ohne „.timer“
name: str
erlaubt_s: int # so lange darf er höchstens schweigen (Takt + Karenz)
takt: str # in Worten, für die Oberfläche
# Takt aus deploy/*.timer; Karenz: täglich 3 h, wöchentlich 6 h (RandomizedDelaySec, Neustart am Sonntag), monatlich
# fünf Wochen (erster Montag im Monat).
ZEITPLAENE: tuple[Zeitplan, ...] = (
Zeitplan("mc2-autoupdate", "Updates am Sonntag", 7 * TAG + 6 * STUNDE, "sonntags 04:30"),
Zeitplan("mc2-backup", "Sicherung der Box", TAG + 3 * STUNDE, "täglich 03:30"),
Zeitplan("pbs-backup", "Sicherung auf den PBS", TAG + 3 * STUNDE, "täglich 03:50"),
Zeitplan("mc2-morgenmeldung", "Morgenmeldung", TAG + 3 * STUNDE, "täglich 07:00"),
Zeitplan("mc2-radar", "Modell-Radar", TAG + 3 * STUNDE, "täglich 00:30"),
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
)
HERMES_KARENZ_S = STUNDE
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
GITEA_URL = os.environ.get("MC_GITEA_URL", "http://192.168.178.153:3000").rstrip("/")
GIT_ZUGANG = Path(os.environ.get("MC_GIT_ZUGANG", str(Path.home() / ".git-credentials")))
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
def _gemerkt(schluessel: str, holen):
with _lock:
eintrag = _cache.get(schluessel)
if eintrag and time.monotonic() - eintrag[0] < CACHE_S:
return eintrag[1]
wert = holen()
with _lock:
_cache[schluessel] = (time.monotonic(), wert)
return wert
def cache_leeren() -> None:
with _lock:
_cache.clear()
# --- systemd --------------------------------------------------------------------------------------------------------
def _unix(wert: str | None) -> float | None:
"""„@1790300009“ (systemctl --timestamp=unix) → Sekunden; leer oder „n/a“ → None."""
m = re.fullmatch(r"@(\d+(?:\.\d+)?)", (wert or "").strip())
return float(m.group(1)) if m else None
def _timer_zustand(unit: str) -> dict[str, str]:
"""Zustand eines User-Timers. Ohne systemd (Windows, Tests): leer."""
try:
out = subprocess.run(
["systemctl", "--user", "show", f"{unit}.timer", "--timestamp=unix", "-p",
"LoadState,ActiveState,UnitFileState,ActiveEnterTimestamp,LastTriggerUSec,NextElapseUSecRealtime"],
capture_output=True, text=True, timeout=10).stdout
except Exception:
return {}
return dict(zeile.split("=", 1) for zeile in out.splitlines() if "=" in zeile)
def bewerte_timer(z: Zeitplan, zustand: dict[str, str], jetzt: float) -> dict | None:
"""Ein Eintrag der Liste: Zustand in Worten und ob es ein Befund ist. None = gibt es hier nicht (kein systemd)."""
if not zustand or zustand.get("LoadState") in ("", "not-found"):
return None
letzter = _unix(zustand.get("LastTriggerUSec"))
naechster = _unix(zustand.get("NextElapseUSecRealtime"))
eintrag = {"id": f"timer:{z.unit}", "name": z.name, "art": "zeitplan", "takt": z.takt,
"letzter": letzter, "naechster": naechster, "status": "ok", "text": None}
aktiv = zustand.get("ActiveState") == "active"
eingetragen = zustand.get("UnitFileState") in ("enabled", "static", "enabled-runtime")
if not aktiv and not eingetragen:
return {**eintrag, "status": "aus", "text": "Bewusst abgeschaltet."}
if not aktiv:
return {**eintrag, "status": "gestoppt",
"text": f"Der Zeitplan ist eingetragen, läuft aber nicht — {z.name} startet so nie."}
# Nie gestartet: gezählt wird ab dem Moment, seit dem der Timer läuft (nach einer Neuinstallation ist das normal).
bezug = letzter or _unix(zustand.get("ActiveEnterTimestamp"))
if bezug is not None and jetzt - bezug > z.erlaubt_s:
seit = _dauer(jetzt - bezug)
text = (f"Zuletzt vor {seit} gestartet ({z.takt} erwartet)." if letzter
else f"Seit {seit} kein einziger Start ({z.takt} erwartet).")
return {**eintrag, "status": "ueberfaellig", "text": text}
return eintrag
def _dauer(s: float) -> str:
if s < 2 * TAG:
return f"{int(s // STUNDE)} Stunden"
return f"{int(s // TAG)} Tagen"
# --- Hermes ---------------------------------------------------------------------------------------------------------
def _zeit(wert) -> float | None:
if not wert:
return None
try:
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
except ValueError:
return None
return (dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)).timestamp()
def bewerte_job(job: dict, jetzt: float) -> dict | None:
if not job.get("enabled", True):
return None
naechster, letzter = _zeit(job.get("next_run_at")), _zeit(job.get("last_run_at"))
takt = str((job.get("schedule") or {}).get("display") or (job.get("schedule") or {}).get("expr") or "")
eintrag = {"id": f"hermes:{job.get('id')}", "name": str(job.get("name") or "Hermes-Job"), "art": "hermes",
"takt": f"Hermes-Cron {takt}".strip(), "letzter": letzter, "naechster": naechster, "status": "ok",
"text": None}
if naechster is not None and jetzt - naechster > HERMES_KARENZ_S:
return {**eintrag, "status": "ueberfaellig",
"text": f"Sollte vor {_dauer(jetzt - naechster)} laufen — der Planer von Hermes hat ihn nicht gestartet."}
return eintrag
# --- Gitea-Ampel ------------------------------------------------------------------------------------------------------
def _gitea_token() -> str | None:
"""Das Token aus ~/.git-credentials (Zeile für die Gitea-Adresse). Erscheint nie in einer Ausgabe."""
try:
zeilen = GIT_ZUGANG.read_text(encoding="utf-8").splitlines()
except OSError:
return None
host = GITEA_URL.split("//", 1)[-1].split(":", 1)[0]
for z in zeilen:
m = re.match(r"https?://[^:/@]+:([^@]+)@([^/:]+)", z.strip())
if m and (m.group(2) == host or "ddnsfree" in m.group(2)):
return m.group(1)
return None
def _runner() -> list[dict] | None:
token = _gitea_token()
if not token:
return None
try:
r = httpx.get(f"{GITEA_URL}/api/v1/admin/actions/runners", headers={"Authorization": f"token {token}"},
timeout=10)
r.raise_for_status()
daten = r.json()
except Exception:
log.info("ablaeufe: Gitea-Runner nicht abfragbar", exc_info=True)
return None
return [x for x in (daten.get("runners") or []) if isinstance(x, dict)] if isinstance(daten, dict) else None
def bewerte_runner(runner: list[dict] | None) -> list[dict]:
if runner is None:
return []
if not runner:
return [{"id": "gitea:runner", "name": "Gitea-Ampel", "art": "ampel", "takt": "bei jedem Push",
"letzter": None, "naechster": None, "status": "gestoppt",
"text": "In Gitea ist kein Runner eingetragen — die Ampel prüft nichts."}]
liste = []
for x in runner:
online = x.get("status") == "online" and not x.get("disabled")
liste.append({"id": f"gitea:runner:{x.get('id')}", "name": f"Gitea-Ampel ({x.get('name')})", "art": "ampel",
"takt": "bei jedem Push", "letzter": None, "naechster": None,
"status": "ok" if online else "gestoppt",
"text": None if online else (f"Der Runner {x.get('name')} meldet sich in Gitea als "
f"„{x.get('status')}“ — Prüfläufe bleiben stehen.")})
return liste
# --- Zusammen -------------------------------------------------------------------------------------------------------
def _jobs() -> list[dict]:
from services import waechter # waechter importiert dieses Modul
return waechter._hermes_jobs()
def ablaeufe(jetzt: float | None = None) -> list[dict]:
"""Alle Abläufe mit Zustand, Probleme zuerst — für die Oberfläche und den Wächter."""
jetzt = time.time() if jetzt is None else jetzt
liste: list[dict] = []
for z in ZEITPLAENE:
if (e := bewerte_timer(z, _gemerkt(f"timer:{z.unit}", lambda u=z.unit: _timer_zustand(u)), jetzt)):
liste.append(e)
for job in _jobs():
if (e := bewerte_job(job, jetzt)):
liste.append(e)
liste += bewerte_runner(_gemerkt("gitea", _runner))
rang = {"ueberfaellig": 0, "gestoppt": 0, "ok": 1, "aus": 2}
return sorted(liste, key=lambda e: rang.get(e["status"], 1))
def pruefe_ueberfaellig():
"""Befunde für den Wächter: überfällige oder gestoppte Abläufe (gelb). Die Sonntags-Updates rot — deren Stillstand
kostete im September elf Tage."""
from services.waechter import Befund, _aktion
befunde = []
for e in ablaeufe():
if e["status"] not in ("ueberfaellig", "gestoppt"):
continue
wichtig = e["id"] == "timer:mc2-autoupdate"
titel = f"{e['name']} ist überfällig" if e["status"] == "ueberfaellig" else f"{e['name']} läuft nicht"
aktionen = []
if e["art"] == "zeitplan":
unit = e["id"].split(":", 1)[1]
aktionen = [_aktion("protokoll", "Protokoll", dienst=unit)]
befunde.append(Befund(id=f"ablauf:{e['id']}", stufe="rot" if wichtig else "gelb", titel=titel,
text=e["text"] or "", quelle=e["id"], aktionen=aktionen, nach_takten=5))
return befunde