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>
248 lines
11 KiB
Python
248 lines
11 KiB
Python
"""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
|