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:
co-authored by
Claude Opus 5.5
parent
4b57a12257
commit
34360c9644
@@ -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
|
||||
Reference in New Issue
Block a user