From 589c14d5e9b3f9a0a57e071d3f00c6d357c910a6 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 18:14:42 +0200 Subject: [PATCH] phase2d: gemeinsames Ziel-Modell, KI-Box als erster Adapter, strukturierter Update-Verlauf - kern/ziele.py: Ziel -> Bausteine mit Stand (neu/aktuell/unbekannt/festgehalten/wird-geprueft), Versionen und dem Knopf, der das Update anstoesst; gleiches Modell fuer Box und Homelab - services/box_updates.py: Update-Zwischenspeicher aus dem Router geholt, ki_box_ziel() als Box-Adapter; GET /api/ziele - Update-Verlauf strukturiert: autoupdate.sh und die Update-Knoepfe schreiben je Baustein eine JSON-Zeile (mc2-update-verlauf.jsonl); die Meldungstexte bleiben gleich, aeltere Laeufe kommen weiter aus dem Meldeprotokoll. Updates per Knopf erscheinen jetzt auch im Verlauf. - jobengine: Abschluss-Haken fuer jedes Ende (neben der Nacharbeit fuer den Erfolg) - Sonntags-Lauf setzt seinen Anlass; Hermes-Job aus dem Lauf schreibt nicht doppelt - Vertragstest Bash-Schreiber gegen Python-Leser (laeuft auf der Box mit jq) Co-Authored-By: Claude Opus 5.5 --- backend/kern/ziele.py | 59 ++++++++++ backend/routers/boxwart.py | 81 +++----------- backend/routers/maintenance.py | 5 +- backend/services/box_updates.py | 156 +++++++++++++++++++++++++++ backend/services/jobengine.py | 58 ++++++++-- backend/services/maintenance.py | 65 +++++++++-- backend/services/update_verlauf.py | 111 ++++++++++++++++--- backend/tests/test_update_verlauf.py | 73 +++++++++++++ backend/tests/test_wartung.py | 26 +++++ backend/tests/test_ziele.py | 67 ++++++++++++ deploy/autoupdate.sh | 62 +++++++---- deploy/jobs/sonntags-update.sh | 2 + 12 files changed, 649 insertions(+), 116 deletions(-) create mode 100644 backend/kern/ziele.py create mode 100644 backend/services/box_updates.py create mode 100644 backend/tests/test_ziele.py diff --git a/backend/kern/ziele.py b/backend/kern/ziele.py new file mode 100644 index 0000000..f1493b0 --- /dev/null +++ b/backend/kern/ziele.py @@ -0,0 +1,59 @@ +"""Ziele und Bausteine (Phase 2, 24.09.2026): ein Modell für alles, was Updates bekommt. + +Ein Ziel ist ein Gerät: die KI-Box, der Proxmox-Host, ein Container, die Arcane-VM. Es besteht aus +Bausteinen (Betriebssystem, Motor, die App im Container, Docker-Images …). Jeder Baustein sagt, wie +sein Stand ist — neu, aktuell, unbekannt (mit Grund) oder festgehalten — und wie man ihn einspielt. + +Die KI-Box meldet ihre Bausteine über den Box-Adapter (services/box_updates.py), der Homelab-Teil +ab Phase 3 über Proxmox- und Arcane-Adapter. So wird aus beiden Instanzen EINE Liste offener Updates +(User-Entscheid 24.09.: eine Anlaufstelle für Updates im ganzen Homelab). +""" + +from dataclasses import asdict, dataclass, field +from typing import Literal + +Zustand = Literal["neu", "aktuell", "unbekannt", "festgehalten", "wird-geprueft"] +# Reihenfolge für die Anzeige: was Aufmerksamkeit braucht, zuerst. +_RANG = {"unbekannt": 0, "festgehalten": 1, "neu": 2, "wird-geprueft": 3, "aktuell": 4} + + +@dataclass +class Aktion: + """Wie ein Update angestoßen wird: eine Schnittstelle der Instanz, die das Ziel pflegt.""" + methode: str # "POST" + pfad: str # "/api/maintenance/engine-update" + frage: str # Rückfrage vor dem Klick + + +@dataclass +class BausteinStand: + id: str # "engine" + name: str # "Motor (llama.cpp)" + zustand: Zustand + installiert: str | None = None + verfuegbar: str | None = None + kurz: str = "" # „10 Pakete", „b11160 → b11172", „743 Änderungen" + grund: str | None = None # bei „unbekannt" und „festgehalten": warum + aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt) + + +@dataclass +class ZielStand: + id: str # "ki-box" + name: str # "KI-Box" + art: str # "ki-box" | "proxmox-host" | "container" | "vm" + instanz: str # welche Instanz es pflegt: "box" | "homelab" + erreichbar: bool | None = None # None = nicht geprüft + bausteine: list[BausteinStand] = field(default_factory=list) + geprueft: float | None = None # Unix-Sekunden der letzten Prüfung + rueckweg: str | None = None # „Sicherung vor jedem Lauf", „Snapshot", „nur Backup" + + def als_dict(self) -> dict: + daten = asdict(self) + daten["bausteine"].sort(key=lambda b: _RANG.get(b["zustand"], 9)) + zaehlung: dict[str, int] = {} + for b in self.bausteine: + zaehlung[b.zustand] = zaehlung.get(b.zustand, 0) + 1 + daten["zaehlung"] = zaehlung + daten["offen"] = zaehlung.get("neu", 0) + return daten diff --git a/backend/routers/boxwart.py b/backend/routers/boxwart.py index 7ec2929..af58fb0 100644 --- a/backend/routers/boxwart.py +++ b/backend/routers/boxwart.py @@ -8,12 +8,12 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt + GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2) POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck) Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py. """ -import threading import time from datetime import datetime @@ -25,8 +25,8 @@ from pydantic import BaseModel from services import ( aufraeumen, backup, + box_updates, llamaswap, - maintenance, modell_nutzung, system, update_verlauf, @@ -36,64 +36,6 @@ from services import ( router = APIRouter(prefix="/api", tags=["boxwart"]) -_updates_lock = threading.Lock() -_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1} -UPDATES_CACHE_S = 600 - - -def _updates_holen() -> None: - stand = maintenance.update_stand - try: - daten = maintenance.updates() - except Exception: - daten = None - with _updates_lock: - if daten is not None or _updates_cache["daten"] is None: - _updates_cache["daten"] = daten or {} - _updates_cache["ts"] = time.time() - _updates_cache["stand"] = stand - _updates_cache["laeuft"] = False - - -def _updates_gecacht() -> dict: - """maintenance.updates() fragt GitHub, apt und Hugging Face und braucht nach einem Neustart - gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder - zunächst nichts), frisch geholt wird im Hintergrund.""" - with _updates_lock: - # Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen, - # statt bis zu 10 Minuten „Aktualisieren“ für schon eingespielte Updates zu zeigen (24.09.2026). - veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S - or _updates_cache["stand"] != maintenance.update_stand) - if veraltet and not _updates_cache["laeuft"]: - _updates_cache["laeuft"] = True - threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start() - return _updates_cache["daten"] or {} - - -def _bausteine(u: dict) -> list[dict]: - """Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft.""" - liste = [] - if u.get("os"): - liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"}) - if u.get("engine"): - liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"}) - if u.get("swap"): - liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"}) - for c in u.get("components") or []: - if c.get("key") == "hermes_agent" and c.get("update"): - liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"}) - return liste - - -NAMEN_UNKLAR = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"} - - -def _unklar(u: dict) -> list[dict]: - """Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt“ statt „aktuell“.""" - return [{"id": k, "name": NAMEN_UNKLAR.get(k, k), "grund": grund} - for k, grund in (u.get("pruef_fehler") or {}).items() if grund] - - def _zeit_kurz(ts: float | None) -> str: if not ts: return "–" @@ -152,12 +94,12 @@ def _lampen(stand: dict, u: dict) -> list[dict]: "warn" if platte < waechter.DISK_ROT_PCT else "fehler") lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–")) - n = len(_bausteine(u)) + n = len(box_updates.bausteine(u)) fest = len(update_verlauf.festgehalten()) - unklar = len(_unklar(u)) + unklar = len(box_updates.unklar(u)) if fest: lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten")) - elif _updates_cache["daten"] is None: + elif not box_updates.gelesen(): lampen.append(lampe("updates", "Updates", "aus", "wird geprüft")) elif n: lampen.append(lampe("updates", "Updates", "info", f"{n} bereit")) @@ -183,7 +125,7 @@ def _box() -> dict: @router.get("/start") def start() -> dict: stand = waechter.lese_stand() - u = _updates_gecacht() + u = box_updates.gecacht() rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot") anzahl = len(stand["hinweise"]) wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60 @@ -199,9 +141,9 @@ def start() -> dict: "verlauf": stand["verlauf"][:20], "lampen": _lampen(stand, u), "box": _box(), - "updates": {"bausteine": _bausteine(u), "unklar": _unklar(u), + "updates": {"bausteine": box_updates.bausteine(u), "unklar": box_updates.unklar(u), "festgehalten": update_verlauf.festgehalten(), - "gelesen": _updates_cache["daten"] is not None}, + "gelesen": box_updates.gelesen()}, "flugplan": zeitplan.flugplan(), } @@ -253,3 +195,10 @@ def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict: if not ergebnis.get("ok"): raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.") return ergebnis + + +@router.get("/ziele") +def ziele() -> dict: + """Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab + Phase 3 unter /api/homelab/ziele; die Oberfläche legt beide zu einer Update-Liste zusammen.""" + return {"ziele": [box_updates.ki_box_ziel().als_dict()]} diff --git a/backend/routers/maintenance.py b/backend/routers/maintenance.py index 61051d9..abd39a7 100644 --- a/backend/routers/maintenance.py +++ b/backend/routers/maintenance.py @@ -69,8 +69,9 @@ def swap_update() -> dict: @router.post("/maintenance/hermes-update") -def hermes_update() -> dict: - return maintenance.hermes_update_job() +def hermes_update(verlauf: bool = True) -> dict: + """verlauf=false: Der Sonntags-Lauf trägt das Ergebnis selbst in seinen Update-Verlauf ein.""" + return maintenance.hermes_update_job(verlauf=verlauf) @router.post("/maintenance/update-all") diff --git a/backend/services/box_updates.py b/backend/services/box_updates.py new file mode 100644 index 0000000..0f66548 --- /dev/null +++ b/backend/services/box_updates.py @@ -0,0 +1,156 @@ +"""Updates der KI-Box: Zwischenspeicher für den Start-Bildschirm und der Box-Adapter (Phase 2). + +maintenance.updates() fragt GitHub, apt und git und braucht nach einem Neustart gern zehn Sekunden +und mehr. Der Start-Bildschirm wartet darauf nie: Er bekommt den letzten Stand (oder zunächst +nichts), frisch geholt wird im Hintergrund. Seit 24.09.2026 liegt das hier statt im Router, weil +auch der Box-Adapter (ki_box_ziel) davon liest. + +ki_box_ziel() beschreibt die KI-Box im gemeinsamen Modell aus kern/ziele.py: vier Bausteine mit +Stand, Versionen und dem Knopf, der das jeweilige Update anstößt. +""" + +import platform +import threading +import time + +from kern.ziele import Aktion, BausteinStand, ZielStand + +from services import maintenance, system, update_verlauf + +_updates_lock = threading.Lock() +_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1} +UPDATES_CACHE_S = 600 + +NAMEN = {"os": "Betriebssystem", "engine": "Motor (llama.cpp)", "swap": "llama-swap", "hermes": "Hermes"} +NAMEN_KURZ = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"} + +AKTIONEN = { + "os": Aktion("POST", "/api/maintenance/os-update", + "Betriebssystem-Updates jetzt einspielen? Danach wird der Stack geprüft."), + "engine": Aktion("POST", "/api/maintenance/engine-update", + "Den Motor jetzt aktualisieren? Die Modelle sind dabei kurz weg; scheitert die Prüfung, " + "geht es zurück."), + "swap": Aktion("POST", "/api/maintenance/swap-update", + "llama-swap jetzt aktualisieren? Laufende Antworten brechen ab; scheitert die Prüfung, " + "geht es zurück."), + "hermes": Aktion("POST", "/api/maintenance/hermes-update", + "Hermes jetzt aktualisieren? Lucy ist dabei ein paar Minuten weg."), +} + + +def _holen() -> None: + stand = maintenance.update_stand + try: + daten = maintenance.updates() + except Exception: + daten = None + with _updates_lock: + if daten is not None or _updates_cache["daten"] is None: + _updates_cache["daten"] = daten or {} + _updates_cache["ts"] = time.time() + _updates_cache["stand"] = stand + _updates_cache["laeuft"] = False + + +def gecacht() -> dict: + """Letzter Stand der Update-Prüfung; ist er veraltet, wird im Hintergrund neu geholt.""" + with _updates_lock: + # Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen, + # statt bis zu 10 Minuten „Aktualisieren" für schon eingespielte Updates zu zeigen (24.09.2026). + veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S + or _updates_cache["stand"] != maintenance.update_stand) + if veraltet and not _updates_cache["laeuft"]: + _updates_cache["laeuft"] = True + threading.Thread(target=_holen, name="updates-holen", daemon=True).start() + return _updates_cache["daten"] or {} + + +def gelesen() -> bool: + """Liegt schon ein Ergebnis vor (nach einem Neustart dauert die erste Prüfung)?""" + return _updates_cache["daten"] is not None + + +def bausteine(u: dict) -> list[dict]: + """Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft.""" + liste = [] + if u.get("os"): + liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"}) + if u.get("engine"): + liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"}) + if u.get("swap"): + liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"}) + for c in u.get("components") or []: + if c.get("key") == "hermes_agent" and c.get("update"): + liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"}) + return liste + + +def unklar(u: dict) -> list[dict]: + """Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt" statt „aktuell".""" + return [{"id": k, "name": NAMEN_KURZ.get(k, k), "grund": grund} + for k, grund in (u.get("pruef_fehler") or {}).items() if grund] + + +# --- Box-Adapter ------------------------------------------------------------------------ + +def _ubuntu() -> str | None: + try: + info = platform.freedesktop_os_release() + except OSError: + return None + return " ".join(x for x in (info.get("NAME"), info.get("VERSION_ID")) if x) or None + + +def _versionen(u: dict) -> dict[str, tuple[str | None, str | None]]: + """(installiert, verfügbar) je Baustein. „Verfügbar" nur, wenn es Neues gibt — die Abfrage + dafür ist bei GitHub zwischengespeichert (maintenance._github_json, 15 Minuten).""" + engine = maintenance._installed_engine_build() + swap = maintenance._installed_swap_version() + werte: dict[str, tuple[str | None, str | None]] = { + "os": (_ubuntu(), f"{u['os']} Pakete" if u.get("os") else None), + "engine": (f"b{engine}" if engine else None, None), + "swap": (f"v{swap}" if swap else None, None), + } + try: + if u.get("engine") and (rel := maintenance._latest_engine_asset_release()): + werte["engine"] = (werte["engine"][0], str(rel.get("tag_name") or "") or None) + if u.get("swap"): + rel = maintenance._github_json(f"repos/{maintenance.SWAP_REPO}/releases/latest") + tag = str(rel.get("tag_name") or "") if isinstance(rel, dict) else "" + werte["swap"] = (werte["swap"][0], tag or None) + except Exception: + pass + hermes = next((c for c in u.get("components") or [] if c.get("key") == "hermes_agent"), {}) + git = system.find_hermes_agent_git() or {} + version = maintenance._hermes_version(git["path"]) if git.get("path") else None + werte["hermes"] = (version or hermes.get("current"), + f"+{hermes.get('behind')} Änderungen" if hermes.get("update") else None) + return werte + + +def ki_box_ziel() -> ZielStand: + """Die KI-Box als Ziel mit ihren vier Bausteinen.""" + u = gecacht() + ziel = ZielStand(id="ki-box", name="KI-Box", art="ki-box", instanz="box", erreichbar=True, + geprueft=_updates_cache["ts"] or None, + rueckweg="Sicherung vor jedem Sonntags-Lauf; Motor, llama-swap und Hermes rollen " + "bei rotem Check selbst zurück, das Betriebssystem nicht.") + if not gelesen(): + ziel.bausteine = [BausteinStand(id=k, name=n, zustand="wird-geprueft") for k, n in NAMEN.items()] + return ziel + offen = {b["id"]: b["neu"] for b in bausteine(u)} + fehler = {b["id"]: b["grund"] for b in unklar(u)} + fest = {p["baustein"]: p for p in update_verlauf.festgehalten()} + versionen = _versionen(u) + for k, name in NAMEN.items(): + installiert, verfuegbar = versionen.get(k, (None, None)) + stand = BausteinStand(id=k, name=name, zustand="aktuell", installiert=installiert, verfuegbar=verfuegbar) + if k in fest: + p = fest[k] + stand.zustand, stand.grund = "festgehalten", f"Seit {p['seit']} auf {p['version']}: {p['grund']}" + elif k in fehler: + stand.zustand, stand.grund = "unbekannt", fehler[k] + elif k in offen: + stand.zustand, stand.kurz, stand.aktion = "neu", offen[k], AKTIONEN[k] + ziel.bausteine.append(stand) + return ziel diff --git a/backend/services/jobengine.py b/backend/services/jobengine.py index ac2ddf4..caca656 100644 --- a/backend/services/jobengine.py +++ b/backend/services/jobengine.py @@ -56,7 +56,8 @@ _EINHEIT_PRUEFEN_S = 5.0 # wie oft er zusätzlich fragt, ob die Einheit noc # Nur im Speicher (nicht in der Akte): Fortschritt eines Downloads. _LAUFZEIT = {"progress", "done_bytes", "rate_bps", "eta_s"} # Nicht an die Oberfläche: Verwaltung des Auftrags. -_INTERN = {"art", "nacharbeit", "nacharbeit_daten", "nacharbeit_erledigt", "fortschritt", "zeitlimit_s"} +_INTERN = {"art", "nacharbeit", "nacharbeit_daten", "nacharbeit_erledigt", "abschluss", "abschluss_daten", + "abschluss_erledigt", "fortschritt", "zeitlimit_s"} # Nicht in die Umgebung des Auftrags: gehört zur Einheit von MC2 oder ist in bash schreibgeschützt. _UMGEBUNG_OHNE = {"INVOCATION_ID", "JOURNAL_STREAM", "SYSTEMD_EXEC_PID", "MANAGERPID", "NOTIFY_SOCKET", "LISTEN_PID", "LISTEN_FDS", "LISTEN_FDNAMES", "WATCHDOG_PID", "WATCHDOG_USEC", "MAINPID", @@ -72,6 +73,7 @@ _HUELLE = ('akte="$1"; shift; ' '"$@"; code=$?; echo "$code" > "$akte.exit.tmp" && mv -f "$akte.exit.tmp" "$akte.exit"') _NACHARBEITEN: dict[str, Callable[..., None]] = {} +_ABSCHLUESSE: dict[str, Callable[[dict], None]] = {} _geladen = False @@ -84,6 +86,16 @@ def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None] return anmelden +def abschluss(name: str) -> Callable[[Callable[[dict], None]], Callable[[dict], None]]: + """Einen Abschluss unter festem Namen anmelden. Anders als die Nacharbeit läuft er bei JEDEM Ende + (fertig, gescheitert, abgebrochen) und bekommt eine Kopie der Akte — etwa um das Ergebnis in den + Update-Verlauf zu schreiben.""" + def anmelden(fn: Callable[[dict], None]) -> Callable[[dict], None]: + _ABSCHLUESSE[name] = fn + return fn + return anmelden + + # --- Ablage ------------------------------------------------------------------------ def _jobs_dir() -> Path: @@ -144,6 +156,11 @@ def _protokoll_lesen(job_id: str) -> list[str]: return zeilen_aus(text) +def protokoll(job_id: str) -> list[str]: + """Das Protokoll eines Auftrags (Ende, Fortschrittszeilen zusammengefasst).""" + return _protokoll_lesen(job_id) + + def _exit_code(job_id: str) -> int | None: try: return int(_pfad(job_id, ".exit").read_text(encoding="utf-8").strip()) @@ -278,6 +295,7 @@ def _abschliessen(job: dict, code: int | None) -> None: job.pop("_schliesst", None) _PROCS.pop(job["id"], None) _speichern(job) + _abschluss_ausfuehren(job) def _nacharbeit_ausfuehren(job: dict) -> None: @@ -296,6 +314,22 @@ def _nacharbeit_ausfuehren(job: dict) -> None: log.warning("jobengine: Nacharbeit %s von %s gescheitert", name, job["id"], exc_info=True) +def _abschluss_ausfuehren(job: dict) -> None: + name = job.get("abschluss") + if not name or job.get("abschluss_erledigt"): + return + job["abschluss_erledigt"] = True + _speichern(job) + try: + fn = _ABSCHLUESSE.get(name) + if fn is None: + raise RuntimeError(f"Abschluss „{name}“ ist in dieser Instanz unbekannt") + fn({k: v for k, v in job.items() if not k.startswith("_")}) + except Exception as exc: + _protokoll(job["id"], f"[mc] Abschluss-Fehler: {exc}") + log.warning("jobengine: Abschluss %s von %s gescheitert", name, job["id"], exc_info=True) + + def _beobachten(job_id: str) -> None: """Wartet auf den Exit-Code einer systemd-Einheit. Endet sie ohne einen (Abbruch, Zeitlimit, Absturz), schließt er den Auftrag trotzdem ab.""" @@ -413,16 +447,20 @@ def _aufraeumen() -> None: def _eintragen(args: list[str], label: str, group: str | None, zeitlimit_s: int | None, - nacharbeit_name: str | None, nacharbeit_daten: dict | None) -> dict: + nacharbeit_name: str | None, nacharbeit_daten: dict | None, + abschluss_name: str | None = None, abschluss_daten: dict | None = None) -> dict: """Neue Akte anlegen (unter _LOCK aufrufen).""" if nacharbeit_name and nacharbeit_name not in _NACHARBEITEN: raise ValueError(f"Unbekannte Nacharbeit: {nacharbeit_name}") + if abschluss_name and abschluss_name not in _ABSCHLUESSE: + raise ValueError(f"Unbekannter Abschluss: {abschluss_name}") job_id = uuid.uuid4().hex[:12] job = { "id": job_id, "label": label, "state": "queued", "group": group, "art": _art(), "returncode": None, "started_at": time.time(), "finished_at": None, "zeitlimit_s": int(zeitlimit_s or STANDARD_ZEITLIMIT_S), "nacharbeit": nacharbeit_name, "nacharbeit_daten": nacharbeit_daten or {}, + "abschluss": abschluss_name, "abschluss_daten": abschluss_daten or {}, } JOBS[job_id] = job _protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args)) @@ -439,18 +477,21 @@ def _aktiv_in(group: str) -> dict | None: def start_job(args: list[str], label: str, env: dict | None = None, group: str | None = None, zeitlimit_s: int | None = None, nacharbeit: str | None = None, - nacharbeit_daten: dict | None = None) -> str: + nacharbeit_daten: dict | None = None, abschluss: str | None = None, + abschluss_daten: dict | None = None) -> str: with _LOCK: _laden() _aufraeumen() - job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten) + job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten, + abschluss, abschluss_daten) _starten(job, list(args), env) return job["id"] def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None, zeitlimit_s: int | None = None, nacharbeit: str | None = None, - nacharbeit_daten: dict | None = None) -> tuple[str | None, dict | None]: + nacharbeit_daten: dict | None = None, abschluss: str | None = None, + abschluss_daten: dict | None = None) -> tuple[str | None, dict | None]: """Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer Sperre. Rückgabe: (neue Auftrags-ID, None) oder (None, der schon laufende Auftrag).""" with _LOCK: @@ -458,7 +499,8 @@ def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None if (laeuft := _aktiv_in(group)) is not None: return None, laeuft _aufraeumen() - job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten) + job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten, + abschluss, abschluss_daten) _starten(job, list(args), env) return job["id"], None @@ -479,6 +521,8 @@ def wiederaufnehmen() -> int: offen = [j for j in JOBS.values() if j["state"] in ("queued", "running")] nacharbeit_offen = [j for j in JOBS.values() if j["state"] == "done" and j.get("nacharbeit") and not j.get("nacharbeit_erledigt")] + abschluss_offen = [j for j in JOBS.values() if j["state"] in _ENDE and j.get("abschluss") + and not j.get("abschluss_erledigt")] for job in offen: if job.get("art") == "systemd": threading.Thread(target=_beobachten, args=(job["id"],), daemon=True).start() @@ -492,6 +536,8 @@ def wiederaufnehmen() -> int: _abschliessen(job, None) for job in nacharbeit_offen: _nacharbeit_ausfuehren(job) + for job in abschluss_offen: + _abschluss_ausfuehren(job) if offen: log.info("jobengine: %d laufende Aufträge wieder aufgenommen", len(offen)) return len(offen) diff --git a/backend/services/maintenance.py b/backend/services/maintenance.py index 586b8e3..6c34460 100644 --- a/backend/services/maintenance.py +++ b/backend/services/maintenance.py @@ -14,8 +14,9 @@ from datetime import datetime from pathlib import Path import httpx +from kern.zeit import LOCAL_TZ -from services import jobengine, system +from services import jobengine, system, update_verlauf # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). SYSTEM_SERVICES = {"llama-swap"} @@ -261,6 +262,40 @@ def updates() -> dict: return daten +# „════════ [2/3] Router (llama-swap) ════════" — so markiert update_all_job jeden Teil der Kette. +_TEIL_MARKE = re.compile(r"^═+ \[(\d+)/(\d+)\] ") + + +@jobengine.abschluss("wartung:verlauf") +def _update_im_verlauf(job: dict) -> None: + """Updates per Knopf landen im strukturierten Update-Verlauf (seit 24.09.2026). Vorher sah man sie + dort gar nicht: Nur der Sonntags-Lauf schrieb Meldungen, aus denen der Verlauf gelesen wurde.""" + bausteine = list((job.get("abschluss_daten") or {}).get("bausteine") or []) + if not bausteine: + return + lauf = datetime.fromtimestamp(job["started_at"], LOCAL_TZ).isoformat(timespec="seconds") + zustand, code = job.get("state"), job.get("returncode") + # Scheitert „Alle aktualisieren“, zeigt die letzte Teil-Marke im Protokoll, wo die Kette stand. + erreicht = len(bausteine) + if zustand != "done" and len(bausteine) > 1: + marken = [int(m.group(1)) for z in jobengine.protokoll(job["id"]) if (m := _TEIL_MARKE.match(z.strip()))] + erreicht = marken[-1] if marken else 1 + for nr, baustein in enumerate(bausteine, start=1): + if zustand == "done" or nr < erreicht: + ergebnis, text = "eingespielt", "Per Knopf eingespielt, Prüfung grün." + elif nr > erreicht: + ergebnis, text = "offen", "Nicht mehr gelaufen, weil ein Teil davor scheiterte." + elif zustand == "canceled": + ergebnis, text = "offen", "Abgebrochen." + elif job.get("zeitlimit"): + ergebnis, text = "fehler", "Zeitlimit überschritten." + elif baustein in ("engine", "swap") and code == 1: + ergebnis, text = "zurueckgerollt", "Prüfung rot, automatisch zurückgerollt." + else: + ergebnis, text = "fehler", f"Fehlgeschlagen (Exit {code}). Das Protokoll steht beim Auftrag." + update_verlauf.eintragen(lauf, "Update von Hand", baustein, ergebnis, text) + + @jobengine.nacharbeit("wartung:nach_update") def _nach_update() -> None: """Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen.""" @@ -582,12 +617,14 @@ def logs(service: str, lines: int = 200) -> dict: return {"ok": r["ok"], "text": r["out"] or r["err"]} return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} -def _starten(args: list[str], label: str, zeitlimit_s: int | None = None) -> dict: +def _starten(args: list[str], label: str, zeitlimit_s: int | None = None, + bausteine: list[str] | None = None) -> dict: """Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Job gleichzeitig. Prüfen und Eintragen passieren unter einer Sperre (jobengine.start_job_exklusiv) — vorher lag zwischen Prüfung und Start ein langsamer Aufruf, und zwei Klicks konnten zwei Updates starten.""" - job_id, laeuft = jobengine.start_job_exklusiv("maintenance", args, label, zeitlimit_s=zeitlimit_s, - nacharbeit="wartung:nach_update") + job_id, laeuft = jobengine.start_job_exklusiv( + "maintenance", args, label, zeitlimit_s=zeitlimit_s, nacharbeit="wartung:nach_update", + abschluss="wartung:verlauf" if bausteine else None, abschluss_daten={"bausteine": bausteine or []}) if job_id is None: return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"), "detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."} @@ -612,7 +649,7 @@ def os_update_job() -> dict: cmd = ("sudo apt-get update && " "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " f"&& bash {STACK_POSTCHECK}") - return _starten(["bash", "-c", cmd], "OS-Update (apt)", zeitlimit_s=90 * 60) + return _starten(["bash", "-c", cmd], "OS-Update (apt)", zeitlimit_s=90 * 60, bausteine=["os"]) def engine_update_job() -> dict | None: @@ -622,7 +659,7 @@ def engine_update_job() -> dict | None: # Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem # neuen Build. KEIN sudo-Passwort-Gate: das Skript ist per sudoers NOPASSWD freigegeben. return _starten(["bash", "-c", ENGINE_UPDATE_CMD], "Engine-Update (llama.cpp Vulkan)", - zeitlimit_s=60 * 60) + zeitlimit_s=60 * 60, bausteine=["engine"]) def swap_update_job() -> dict | None: @@ -631,7 +668,7 @@ def swap_update_job() -> dict | None: # update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack # und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer Version. return _starten(["bash", "-c", SWAP_UPDATE_CMD], "Router-Update (llama-swap)", - zeitlimit_s=30 * 60) + zeitlimit_s=30 * 60, bausteine=["swap"]) def _hermes_update_cmd() -> str: @@ -677,12 +714,13 @@ def _hermes_update_cmd() -> str: f"exit $RC") -def hermes_update_job() -> dict: +def hermes_update_job(verlauf: bool = True) -> dict: """Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo - (alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern).""" + (alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern). + verlauf=False: autoupdate.sh startet den Job und trägt das Ergebnis selbst in seinen Lauf ein.""" return _starten(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update", - zeitlimit_s=45 * 60) + zeitlimit_s=45 * 60, bausteine=["hermes"] if verlauf else None) def update_all_job() -> dict: @@ -696,12 +734,16 @@ def update_all_job() -> dict: "detail": f"Es läuft schon: {laeuft.get('label', 'ein Update')}."} upd = updates() parts: list[tuple[str, str]] = [] + bausteine: list[str] = [] if upd.get("engine") and ENGINE_UPDATE_CMD: parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD)) + bausteine.append("engine") if upd.get("swap") and SWAP_UPDATE_CMD: parts.append(("Router (llama-swap)", SWAP_UPDATE_CMD)) + bausteine.append("swap") if any(c.get("update") is True for c in upd.get("components") or []): parts.append(("Hermes-Agent", _hermes_update_cmd())) + bausteine.append("hermes") os_pending = (upd.get("os") or 0) > 0 if os_pending: if err := check_sudo_needs_password(): @@ -716,6 +758,7 @@ def update_all_job() -> dict: "sudo apt-get update && " "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " f"&& bash {STACK_POSTCHECK}"))) + bausteine.append("os") if not parts: return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."} @@ -727,7 +770,7 @@ def update_all_job() -> dict: labels = " → ".join(label for label, _ in parts) ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})", - zeitlimit_s=3 * 3600) + zeitlimit_s=3 * 3600, bausteine=bausteine) if ergebnis.get("ok"): ergebnis["parts"] = [label for label, _ in parts] return ergebnis diff --git a/backend/services/update_verlauf.py b/backend/services/update_verlauf.py index 001b7c6..2a88887 100644 --- a/backend/services/update_verlauf.py +++ b/backend/services/update_verlauf.py @@ -6,6 +6,11 @@ Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — Baustein („255 → 256 eingespielt", „zurückgerollt + GEPINNT" …). notify.sh schreibt jede Meldung mit Zeitstempel in ~/mc2-notify.log; der Verlauf wird von dort gelesen, nur lesend. +Seit 24.09.2026 gibt es dazu einen strukturierten Verlauf (mc2-update-verlauf.jsonl im Datenordner): +autoupdate.sh und die Update-Knöpfe schreiben je Baustein eine JSON-Zeile mit Lauf, Anlass, Ergebnis +und Text. Ab dem ersten solchen Eintrag gilt nur noch er; ältere Läufe kommen weiter aus dem +Meldeprotokoll. So bricht keine Umformulierung einer Telegram-Meldung mehr den Verlauf. + Das Pin-Register (/srv/models/mc2-pins.json) führt autoupdate.sh: Besteht ein Update den Check nicht, wird zurückgerollt und der Baustein festgehalten — künftige Sonntage überspringen ihn. Vom 06. bis 17.09.2026 hat das niemand gesehen, die Box bekam elf Tage keine Updates. Darum @@ -19,10 +24,13 @@ import threading from datetime import datetime from pathlib import Path +from kern.einstellungen import einstellungen 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")) +VERLAUF_FILE = Path(os.environ.get("MC_UPDATE_VERLAUF", + str(einstellungen().daten_dir / "mc2-update-verlauf.jsonl"))) LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate LAUF_LUECKE_S = 45 * 60 # Meldungen mit größerem Abstand gehören zu verschiedenen Läufen @@ -66,8 +74,14 @@ STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "g "neustart": "info", "eingespielt": "gruen", "aktuell": "grau"} _RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0} +# Bausteine im strukturierten Verlauf → Name im Cockpit. +BAUSTEIN_NAME = {"swap": "llama-swap", "engine": "Motor", "hermes": "Hermes", "os": "Betriebssystem", + "neustart": "Neustart", "lauf": "Update-Lauf"} + _cache_lock = threading.Lock() _cache: dict = {"schluessel": None, "laeufe": []} +_struktur_cache: dict = {"schluessel": None, "laeufe": []} +_schreib_lock = threading.Lock() # --- Protokoll lesen ---------------------------------------------------------------- @@ -130,6 +144,15 @@ def _lauf(gruppe: list[tuple[datetime, str]]) -> dict: # Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt. zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste))) + start, ende = gruppe[0][0], gruppe[-1][0] + sonntag = start.weekday() == 6 and 4 <= start.hour < 6 + return {"start": start.isoformat(), "ende": ende.isoformat(), + "anlass": "Updates am Sonntag" if sonntag else "Update von Hand", + **_bewertung(zeilen), "zeilen": zeilen} + + +def _bewertung(zeilen: list[dict]) -> dict: + """Stufe und Titel eines Laufs aus seinen Zeilen: das Schlimmste zählt.""" stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau") arten = {z["ergebnis"] for z in zeilen} if stufe == "rot": @@ -143,11 +166,63 @@ def _lauf(gruppe: list[tuple[datetime, str]]) -> dict: titel = "Neu gestartet" else: titel = "Alles aktuell" - start, ende = gruppe[0][0], gruppe[-1][0] - sonntag = start.weekday() == 6 and 4 <= start.hour < 6 - return {"start": start.isoformat(), "ende": ende.isoformat(), - "anlass": "Updates am Sonntag" if sonntag else "Update von Hand", - "stufe": stufe, "titel": titel, "zeilen": zeilen} + return {"stufe": stufe, "titel": titel} + + +# --- Strukturierter Verlauf (seit 24.09.2026) ---------------------------------------- + +def eintragen(lauf: str, anlass: str, baustein: str, ergebnis: str, text: str) -> None: + """Eine Zeile in den strukturierten Verlauf (dasselbe Format schreibt autoupdate.sh).""" + if ergebnis not in STUFE_DER_ZEILE: + raise ValueError(f"Unbekanntes Ergebnis: {ergebnis}") + zeile = json.dumps({"lauf": lauf, "anlass": anlass, "ts": datetime.now(LOCAL_TZ).isoformat(timespec="seconds"), + "baustein": baustein, "ergebnis": ergebnis, "text": text}, ensure_ascii=False) + with _schreib_lock: + VERLAUF_FILE.parent.mkdir(parents=True, exist_ok=True) + with VERLAUF_FILE.open("a", encoding="utf-8", newline="\n") as f: + f.write(zeile + "\n") + + +def _zeitpunkt(wert: str) -> datetime | None: + try: + zeit = datetime.fromisoformat(wert) + except (TypeError, ValueError): + return None + return zeit if zeit.tzinfo else zeit.replace(tzinfo=LOCAL_TZ) + + +def strukturierte_laeufe(text: str) -> list[dict]: + """Läufe aus dem strukturierten Verlauf (eine JSON-Zeile je Baustein), neueste zuerst.""" + gruppen: dict[str, list[dict]] = {} + for zeile in text.splitlines(): + try: + eintrag = json.loads(zeile) + except ValueError: + continue + if isinstance(eintrag, dict) and _zeitpunkt(str(eintrag.get("lauf"))): + gruppen.setdefault(str(eintrag["lauf"]), []).append(eintrag) + laeufe = [] + for lauf, eintraege in gruppen.items(): + zeilen = [{"baustein": BAUSTEIN_NAME.get(str(e.get("baustein")), str(e.get("baustein") or "Update-Lauf")), + "ergebnis": e["ergebnis"], "stufe": STUFE_DER_ZEILE[e["ergebnis"]], + "text": _lesbar(str(e.get("text") or ""))} + for e in eintraege if e.get("ergebnis") in STUFE_DER_ZEILE] + start = _zeitpunkt(lauf) + zeiten = [t for e in eintraege if (t := _zeitpunkt(str(e.get("ts"))))] + ende = max([start, *zeiten]) + laeufe.append({"start": start.isoformat(), "ende": ende.isoformat(), + "anlass": str(eintraege[0].get("anlass") or "Update von Hand"), + **_bewertung(zeilen), "zeilen": zeilen}) + return sorted(laeufe, key=lambda lauf: _zeitpunkt(lauf["start"]), reverse=True) + + +def zusammenfuehren(struktur: list[dict], alt: list[dict]) -> list[dict]: + """Ab dem ersten strukturierten Lauf gilt nur noch der strukturierte Verlauf; davor das + Meldeprotokoll (dort stünde derselbe Sonntag sonst doppelt).""" + if struktur: + grenze = min(_zeitpunkt(lauf["start"]) for lauf in struktur) + alt = [lauf for lauf in alt if _zeitpunkt(lauf["start"]) < grenze] + return sorted(struktur + alt, key=lambda lauf: _zeitpunkt(lauf["start"]), reverse=True) def laeufe_aus(text: str) -> list[dict]: @@ -182,18 +257,30 @@ def _protokoll_ende() -> str: return text -def laeufe(anzahl: int = 8) -> list[dict]: - """Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs.""" +def _schluessel(pfad: Path) -> tuple[int, int] | None: try: - st = NOTIFY_LOG.stat() - schluessel = (st.st_mtime_ns, st.st_size) + st = pfad.stat() except OSError: - return [] + return None + return (st.st_mtime_ns, st.st_size) + + +def laeufe(anzahl: int = 8) -> list[dict]: + """Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn eine Quelle wuchs.""" with _cache_lock: + schluessel = _schluessel(NOTIFY_LOG) if _cache["schluessel"] != schluessel: - _cache["laeufe"] = laeufe_aus(_protokoll_ende()) + _cache["laeufe"] = laeufe_aus(_protokoll_ende()) if schluessel else [] _cache["schluessel"] = schluessel - return _cache["laeufe"][:anzahl] + schluessel = _schluessel(VERLAUF_FILE) + if _struktur_cache["schluessel"] != schluessel: + try: + text = VERLAUF_FILE.read_text(encoding="utf-8") if schluessel else "" + except OSError: + text = "" + _struktur_cache["laeufe"] = strukturierte_laeufe(text) + _struktur_cache["schluessel"] = schluessel + return zusammenfuehren(_struktur_cache["laeufe"], _cache["laeufe"])[:anzahl] # --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) ----------------------- diff --git a/backend/tests/test_update_verlauf.py b/backend/tests/test_update_verlauf.py index 063e875..94e2a42 100644 --- a/backend/tests/test_update_verlauf.py +++ b/backend/tests/test_update_verlauf.py @@ -1,7 +1,12 @@ """Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026).""" import json +import os +import shutil +import subprocess +from pathlib import Path +import pytest from services import update_verlauf # Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen. @@ -107,12 +112,50 @@ def test_laeufe_liest_die_datei_und_merkt_sich_den_stand(tmp_path, monkeypatch): datei = tmp_path / "mc2-notify.log" datei.write_text(PROTOKOLL, encoding="utf-8") monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei) + monkeypatch.setattr(update_verlauf, "VERLAUF_FILE", tmp_path / "kein-verlauf.jsonl") monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []}) + monkeypatch.setattr(update_verlauf, "_struktur_cache", {"schluessel": None, "laeufe": []}) assert len(update_verlauf.laeufe(3)) == 3 monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log") assert update_verlauf.laeufe() == [] +def test_strukturierter_verlauf_loest_das_protokoll_ab(tmp_path, monkeypatch): + """Ab dem ersten strukturierten Eintrag gilt er; derselbe Sonntag aus dem Protokoll fällt weg.""" + protokoll = tmp_path / "mc2-notify.log" + protokoll.write_text(PROTOKOLL + ( + "2026-10-04 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:\n" + "• Router (llama-swap): 256 → 257 eingespielt, Stack-Check grün.\n"), encoding="utf-8") + monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", protokoll) + monkeypatch.setattr(update_verlauf, "VERLAUF_FILE", tmp_path / "verlauf.jsonl") + monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []}) + monkeypatch.setattr(update_verlauf, "_struktur_cache", {"schluessel": None, "laeufe": []}) + + lauf = "2026-10-04T04:30:00+02:00" + update_verlauf.eintragen(lauf, "Updates am Sonntag", "swap", "eingespielt", "256 → 257 eingespielt, Stack-Check grün.") + update_verlauf.eintragen(lauf, "Updates am Sonntag", "engine", "zurueckgerollt", + "11200 fehlgeschlagen → zurückgerollt auf 11172 und festgehalten.") + update_verlauf.eintragen(lauf, "Updates am Sonntag", "hermes", "aktuell", "aktuell.") + with pytest.raises(ValueError): + update_verlauf.eintragen(lauf, "x", "swap", "super", "gibt es nicht") + + laeufe = update_verlauf.laeufe() + assert laeufe[0]["start"] == lauf and laeufe[0]["anlass"] == "Updates am Sonntag" + assert (laeufe[0]["stufe"], laeufe[0]["titel"]) == ("gelb", "Zurückgerollt") + assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [ + ("llama-swap", "eingespielt"), ("Motor", "zurueckgerollt"), ("Hermes", "aktuell")] + # Der gleiche Sonntag aus dem Protokoll (04:31) taucht nicht doppelt auf, ältere Läufe bleiben. + assert [lauf["start"][:10] for lauf in laeufe[:3]] == ["2026-10-04", "2026-09-27", "2026-09-20"] + + +def test_kaputte_zeilen_im_verlauf_stoeren_nicht(): + text = ('kein json\n{"lauf": "keine zeit", "ergebnis": "aktuell"}\n' + '{"lauf": "2026-10-04T04:30:00+02:00", "baustein": "os", "ergebnis": "unbekannt", "text": "x"}\n' + '{"lauf": "2026-10-04T04:30:00+02:00", "baustein": "os", "ergebnis": "eingespielt", "text": "10 Pakete."}\n') + laeufe = update_verlauf.strukturierte_laeufe(text) + assert len(laeufe) == 1 and [z["baustein"] for z in laeufe[0]["zeilen"]] == ["Betriebssystem"] + + def test_festgehalten_und_freigeben(tmp_path, monkeypatch): pins = tmp_path / "mc2-pins.json" pins.write_text(json.dumps({ @@ -154,3 +197,33 @@ def test_zweitweg_zaehlt_wie_telegram(): "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")] + + +@pytest.mark.skipif(not (shutil.which("bash") and shutil.which("jq")), reason="braucht bash und jq (auf der Box da)") +def test_autoupdate_schreibt_was_der_verlauf_liest(tmp_path): + """Vertrag zwischen deploy/autoupdate.sh (Schreiber) und diesem Modul (Leser): Die Helfer + verlauf() und ergebnis() werden aus dem echten Skript geholt und in einer bash ausgeführt.""" + skript = (Path(__file__).resolve().parents[2] / "deploy" / "autoupdate.sh").read_text(encoding="utf-8") + anfang = skript.index("VERLAUF=") + ende = skript.index("# ── Pin-Register") + helfer = skript[anfang:ende] + ziel = tmp_path / "verlauf.jsonl" + probe = helfer + ( + 'SUMMARY=()\n' + 'name="Router (llama-swap)"; comp=swap; installed=256; latest=257\n' + 'ergebnis "$comp" "$name" eingespielt "$installed → $latest eingespielt, Stack-Check grün."\n' + 'ergebnis hermes "Hermes-Agent" zurueckgerollt "Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf abc."\n' + 'verlauf neustart neustart "Die Box startet jetzt neu (linux-image)."\n' + 'printf "%s\n" "${SUMMARY[@]}"\n') + lauf = subprocess.run(["bash", "-c", probe], capture_output=True, text=True, timeout=30, + env={**os.environ, "MC_UPDATE_VERLAUF": str(ziel), "MC_UPDATE_ANLASS": "Updates am Sonntag"}) + assert lauf.returncode == 0, lauf.stderr + # Die Abschlussmeldung bleibt wie vorher („Name: Text“). + assert lauf.stdout.splitlines()[0] == "Router (llama-swap): 256 → 257 eingespielt, Stack-Check grün." + laeufe = update_verlauf.strukturierte_laeufe(ziel.read_text(encoding="utf-8")) + assert len(laeufe) == 1 and laeufe[0]["anlass"] == "Updates am Sonntag" + assert [(z["baustein"], z["ergebnis"], z["text"]) for z in laeufe[0]["zeilen"]] == [ + ("llama-swap", "eingespielt", "256 → 257 eingespielt, Stack-Check grün."), + ("Hermes", "zurueckgerollt", "Update fehlgeschlagen → zurückgerollt und festgehalten auf abc."), + ("Neustart", "neustart", "Die Box startet jetzt neu (linux-image).")] + assert laeufe[0]["titel"] == "Zurückgerollt" diff --git a/backend/tests/test_wartung.py b/backend/tests/test_wartung.py index b67c923..3ddd971 100644 --- a/backend/tests/test_wartung.py +++ b/backend/tests/test_wartung.py @@ -61,3 +61,29 @@ def test_hermes_version_auch_wenn_der_abruf_haengt(monkeypatch, tmp_path): info = maintenance.hermes_update_details() assert info["installed_version"] == "0.21.4" assert "error" in info + + +def test_update_per_knopf_landet_im_verlauf(monkeypatch): + """Einzel-Updates und „Alle aktualisieren“ schreiben ins strukturierte Verlauf-Format; bei einer + gescheiterten Kette zeigt die letzte Teil-Marke, welcher Teil scheiterte.""" + from services import jobengine, update_verlauf + + zeilen: list[tuple] = [] + monkeypatch.setattr(update_verlauf, "eintragen", lambda *a: zeilen.append(a)) + job = {"id": "x", "state": "done", "returncode": 0, "started_at": 1790000000.0, + "abschluss_daten": {"bausteine": ["engine"]}} + maintenance._update_im_verlauf(job) + assert zeilen[-1][1:] == ("Update von Hand", "engine", "eingespielt", "Per Knopf eingespielt, Prüfung grün.") + + zeilen.clear() + monkeypatch.setattr(jobengine, "protokoll", lambda job_id: [ + "$ bash -c …", "════════ [1/3] Engine (llama.cpp) ════════", "ok", + "════════ [2/3] Router (llama-swap) ════════", "Stack-Check rot"]) + maintenance._update_im_verlauf({**job, "state": "failed", "returncode": 1, + "abschluss_daten": {"bausteine": ["engine", "swap", "hermes"]}}) + assert [(z[2], z[3]) for z in zeilen] == [ + ("engine", "eingespielt"), ("swap", "zurueckgerollt"), ("hermes", "offen")] + + zeilen.clear() + maintenance._update_im_verlauf({**job, "abschluss_daten": {}}) + assert zeilen == [] diff --git a/backend/tests/test_ziele.py b/backend/tests/test_ziele.py new file mode 100644 index 0000000..81e28fb --- /dev/null +++ b/backend/tests/test_ziele.py @@ -0,0 +1,67 @@ +"""Gemeinsames Ziel-Modell (Phase 2, 24.09.2026) und die KI-Box als erster Adapter.""" + +import pytest +from kern.ziele import BausteinStand, ZielStand +from services import box_updates, maintenance, system, update_verlauf + + +def test_als_dict_ordnet_nach_dringlichkeit_und_zaehlt(): + ziel = ZielStand(id="ct-104", name="Gitea", art="container", instanz="homelab", bausteine=[ + BausteinStand(id="os", name="Betriebssystem", zustand="aktuell"), + BausteinStand(id="app", name="Gitea", zustand="neu", kurz="1.27.2 → 1.27.3"), + BausteinStand(id="docker", name="Docker", zustand="unbekannt", grund="nicht erreichbar"), + ]) + daten = ziel.als_dict() + assert [b["id"] for b in daten["bausteine"]] == ["docker", "app", "os"] + assert daten["zaehlung"] == {"aktuell": 1, "neu": 1, "unbekannt": 1} and daten["offen"] == 1 + + +@pytest.fixture +def box(monkeypatch): + monkeypatch.setattr(maintenance, "_installed_engine_build", lambda: 11160) + monkeypatch.setattr(maintenance, "_installed_swap_version", lambda: 257) + monkeypatch.setattr(maintenance, "_latest_engine_asset_release", lambda: {"tag_name": "b11172"}) + monkeypatch.setattr(maintenance, "_github_json", lambda pfad, timeout=8: {"tag_name": "v258"}) + monkeypatch.setattr(maintenance, "_hermes_version", lambda pfad: "0.21.4") + monkeypatch.setattr(system, "find_hermes_agent_git", lambda: {"path": "/x", "hash": "abc1234"}) + monkeypatch.setattr(update_verlauf, "festgehalten", list) + monkeypatch.setattr(box_updates, "gelesen", lambda: True) + + def setzen(daten: dict) -> None: + monkeypatch.setattr(box_updates, "gecacht", lambda: daten) + + return setzen + + +def _nach_id(ziel: ZielStand) -> dict[str, BausteinStand]: + return {b.id: b for b in ziel.bausteine} + + +def test_ki_box_neu_aktuell_und_knoepfe(box): + box({"os": 10, "engine": 1, "swap": 0, "pruef_fehler": {}, + "components": [{"key": "hermes_agent", "update": True, "behind": 743, "current": "abc1234"}]}) + b = _nach_id(box_updates.ki_box_ziel()) + assert (b["os"].zustand, b["os"].kurz) == ("neu", "10 Pakete") + assert (b["engine"].installiert, b["engine"].verfuegbar) == ("b11160", "b11172") + assert (b["swap"].zustand, b["swap"].installiert, b["swap"].verfuegbar) == ("aktuell", "v257", None) + assert (b["hermes"].installiert, b["hermes"].verfuegbar) == ("0.21.4", "+743 Änderungen") + assert b["engine"].aktion.pfad == "/api/maintenance/engine-update" + assert b["swap"].aktion is None + + +def test_ki_box_unbekannt_und_festgehalten_ohne_knopf(box, monkeypatch): + monkeypatch.setattr(update_verlauf, "festgehalten", lambda: [ + {"baustein": "engine", "name": "Motor", "version": "10819", "seit": "13.09.", "grund": "Check rot"}]) + box({"os": 0, "engine": 1, "swap": 0, "pruef_fehler": {"swap": "GitHub nicht erreichbar"}, "components": []}) + b = _nach_id(box_updates.ki_box_ziel()) + assert b["engine"].zustand == "festgehalten" and b["engine"].aktion is None + assert b["engine"].grund == "Seit 13.09. auf 10819: Check rot" + assert (b["swap"].zustand, b["swap"].grund) == ("unbekannt", "GitHub nicht erreichbar") + assert b["os"].zustand == "aktuell" + + +def test_ki_box_vor_der_ersten_pruefung(box, monkeypatch): + monkeypatch.setattr(box_updates, "gelesen", lambda: False) + box({}) + ziel = box_updates.ki_box_ziel() + assert {b.zustand for b in ziel.bausteine} == {"wird-geprueft"} and len(ziel.bausteine) == 4 diff --git a/deploy/autoupdate.sh b/deploy/autoupdate.sh index 1500ee8..661b3e2 100644 --- a/deploy/autoupdate.sh +++ b/deploy/autoupdate.sh @@ -31,6 +31,24 @@ notify_dringend(){ bash "$SRC_DIR/notify.sh" -d -s "[Box-Update]" "$1" || true; SUMMARY=() # eine Zeile je Ebene für die Abschluss-Meldung +# ── Strukturierter Verlauf (seit 24.09.2026) ───────────────────────────────── +# Je Baustein eine JSON-Zeile; die Oberfläche liest den Update-Verlauf daraus statt aus den +# Telegram-Texten (services/update_verlauf.py). Die Meldungen selbst bleiben, wie sie sind. +VERLAUF="${MC_UPDATE_VERLAUF:-/srv/models/mc2-update-verlauf.jsonl}" +LAUF="$(date -Iseconds)" +ANLASS="${MC_UPDATE_ANLASS:-Update von Hand}" +verlauf(){ # $1 baustein (swap|engine|hermes|neustart|lauf) $2 ergebnis $3 text + jq -cn --arg lauf "$LAUF" --arg anlass "$ANLASS" --arg ts "$(date -Iseconds)" \ + --arg b "$1" --arg e "$2" --arg t "$3" \ + '{lauf: $lauf, anlass: $anlass, ts: $ts, baustein: $b, ergebnis: $e, text: $t}' \ + >> "$VERLAUF" 2>/dev/null || true +} +# Ergebnis eines Bausteins: eine Zeile für die Abschlussmeldung und eine für den Verlauf. +ergebnis(){ # $1 baustein $2 anzeigename $3 ergebnis $4 text + SUMMARY+=("$2: $4") + verlauf "$1" "$3" "$4" +} + # ── Pin-Register ───────────────────────────────────────────────────────────── pins_init(){ [ -f "$PINS" ] || echo '{}' > "$PINS"; } is_pinned(){ jq -e --arg k "$1" '.[$k].pinned == true' "$PINS" >/dev/null 2>&1; } @@ -55,12 +73,12 @@ run_root_update(){ # $1 komponente(swap|engine) $2 skript $3 anzeigename $4 say "$name: Update $installed → $latest wird eingespielt…" sudo -n /usr/bin/bash "$SRC_DIR/$script"; rc=$? case "$rc" in - 0) SUMMARY+=("$name: $installed → $latest eingespielt, Stack-Check grün.") + 0) ergebnis "$comp" "$name" eingespielt "$installed → $latest eingespielt, Stack-Check grün." notify "$name aktualisiert: $installed → $latest. Stack-Check grün, alles läuft." ;; 1) set_pin "$comp" "$installed" "Update auf $latest zerschoss den Stack-Check; Rollback grün" - SUMMARY+=("$name: $latest FEHLGESCHLAGEN → zurückgerollt auf $installed + GEPINNT.") + ergebnis "$comp" "$name" zurueckgerollt "$latest FEHLGESCHLAGEN → zurückgerollt auf $installed + GEPINNT." notify "$name-Update auf $latest fehlgeschlagen (Stack-Check rot). Automatisch zurückgerollt auf $installed — läuft wieder. Ebene ist jetzt GEPINNT, künftige Läufe überspringen sie." ;; - *) SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!") + *) ergebnis "$comp" "$name" fehler "KRITISCH — Update UND Rollback fehlgeschlagen!" notify_dringend "KRITISCH: $name-Update auf $latest UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. Bitte melden, ich brauche Hilfe (Backup liegt bereit, restore.sh existiert)." ;; esac } @@ -69,25 +87,25 @@ check_layer_root(){ # $1 komponente $2 skript $3 anzeigename $4 details-kind local comp="$1" script="$2" name="$3" kind="$4" det installed latest if is_pinned "$comp"; then say "$name: GEPINNT ($(pin_info "$comp")) — übersprungen." - SUMMARY+=("$name: gepinnt, übersprungen.") + ergebnis "$comp" "$name" festgehalten "gepinnt, übersprungen." return fi - det="$(details "$kind")" || { say "$name: Update-Check nicht erreichbar."; SUMMARY+=("$name: Check fehlgeschlagen."); return; } + det="$(details "$kind")" || { say "$name: Update-Check nicht erreichbar."; ergebnis "$comp" "$name" offen "Check fehlgeschlagen."; return; } installed="$(jq -r '.installed_build // empty' <<<"$det")" latest="$(jq -r '.latest_build // empty' <<<"$det")" if [ -z "$installed" ] || [ -z "$latest" ]; then say "$name: Versionen nicht ermittelbar (installed='$installed' latest='$latest')." - SUMMARY+=("$name: Versions-Check unklar, nichts getan.") + ergebnis "$comp" "$name" offen "Versions-Check unklar, nichts getan." return fi if [ "$latest" -le "$installed" ] 2>/dev/null; then say "$name: aktuell ($installed)." - SUMMARY+=("$name: aktuell ($installed).") + ergebnis "$comp" "$name" aktuell "aktuell ($installed)." return fi if ! sudo_ok "$script"; then say "$name: Update $installed → $latest verfügbar, aber sudo-Freischaltung fehlt." - SUMMARY+=("$name: Update $installed → $latest wartet — sudo-Freischaltung fehlt (einmalig deploy/sudoers-mc2-autonomie installieren).") + ergebnis "$comp" "$name" offen "Update $installed → $latest wartet — sudo-Freischaltung fehlt (einmalig deploy/sudoers-mc2-autonomie installieren)." return fi run_root_update "$comp" "$script" "$name" "$installed" "$latest" @@ -98,23 +116,23 @@ check_hermes(){ local name="Hermes-Agent" det behind old_head resp job_id state t if is_pinned hermes; then say "$name: GEPINNT ($(pin_info hermes)) — übersprungen." - SUMMARY+=("$name: gepinnt, übersprungen.") + ergebnis hermes "$name" festgehalten "gepinnt, übersprungen." return fi - det="$(details hermes)" || { SUMMARY+=("$name: Check fehlgeschlagen."); return; } + det="$(details hermes)" || { ergebnis hermes "$name" offen "Check fehlgeschlagen."; return; } behind="$(jq -r '.behind // 0' <<<"$det")" if [ "${behind:-0}" -eq 0 ] 2>/dev/null; then say "$name: aktuell." - SUMMARY+=("$name: aktuell.") + ergebnis hermes "$name" aktuell "aktuell." return fi old_head="$(git -C "$HERMES_DIR" rev-parse HEAD 2>/dev/null)" say "$name: $behind Commits hinterher — Update-Job startet (Backup→update→doctor→Restart→Gehirn-Check)…" - resp="$(curl -sf --max-time 30 -X POST "$API/api/maintenance/hermes-update")" || { SUMMARY+=("$name: Job-Start fehlgeschlagen."); return; } + resp="$(curl -sf --max-time 30 -X POST "$API/api/maintenance/hermes-update?verlauf=0")" || { ergebnis hermes "$name" offen "Job-Start fehlgeschlagen."; return; } job_id="$(jq -r '.job_id // empty' <<<"$resp")" if [ -z "$job_id" ]; then say "$name: kein Job gestartet: $resp" - SUMMARY+=("$name: Job-Start abgelehnt ($(jq -r '.error // .detail // "unbekannt"' <<<"$resp" 2>/dev/null)).") + ergebnis hermes "$name" offen "Job-Start abgelehnt ($(jq -r '.error // .detail // "unbekannt"' <<<"$resp" 2>/dev/null))." return fi t=0 @@ -124,7 +142,7 @@ check_hermes(){ sleep 10; t=$((t + 10)) done if [ "$state" = "done" ]; then - SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.") + ergebnis hermes "$name" eingespielt "$behind Commits eingespielt, Gehirn-Check grün." notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Patches, Tools, Voice) grün — alles läuft." return fi @@ -135,7 +153,7 @@ check_hermes(){ sr_out="$(bash "$SRC_DIR/self-repair.sh" 2>&1)"; sr_rc=$? echo "$sr_out" if [ "$sr_rc" -eq 0 ]; then - SUMMARY+=("$name: Config-Bruch nach Update SELBST repariert — neue Version läuft (kein Rollback).") + ergebnis hermes "$name" eingespielt "Config-Bruch nach Update SELBST repariert — neue Version läuft (kein Rollback)." return fi sugg="$(echo "$sr_out" | sed -n 's/^SELFREPAIR_SUGGESTION=//p' | tail -1)" @@ -156,14 +174,14 @@ check_hermes(){ systemctl --user restart hermes-gateway; sleep 6 if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then set_pin hermes "$old_head" "Update ($behind Commits) riss den Gehirn-Check; Rollback grün" - SUMMARY+=("$name: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}.") + ergebnis hermes "$name" zurueckgerollt "Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}." notify "Hermes-Update fehlgeschlagen (Gehirn-Check rot). Selbstreparatur der Config griff nicht, deshalb automatisch zurückgerollt auf ${old_head:0:9} — läuft wieder. Hermes ist jetzt GEPINNT.${sugg:+ Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}" return fi fi - SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!") + ergebnis hermes "$name" fehler "KRITISCH — Update UND Rollback fehlgeschlagen!" notify_dringend "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)" } @@ -187,8 +205,7 @@ reboot_wenn_noetig(){ [ "${MC_AUTOUPDATE_REBOOT:-1}" = "1" ] || { say "Neustart per Env abgeschaltet."; return 0; } [ -f /var/run/reboot-required ] || { say "Kein Neustart noetig."; return 0; } - local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr " -" " ")" + local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr '\n' ' ')" # Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster, sonntags 04:00-06:59. # Anlass: Ein nachgeholter Lauf (Persistent=true beim Einschalten des Timers) startete die Box @@ -197,6 +214,7 @@ reboot_wenn_noetig(){ tag="$(date +%u)"; stunde="$((10#$(date +%H)))" if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && { [ "$tag" != "7" ] || [ "$stunde" -lt 4 ] || [ "$stunde" -ge 7 ]; }; then notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster am Sonntag frueh." + verlauf neustart offen "Neustart steht an (${pakete:-Kernel/libc}), erst im Wartungsfenster am Sonntag früh." return 0 fi @@ -206,6 +224,7 @@ reboot_wenn_noetig(){ local nutzer; nutzer="$(id -un)" if [ "$(loginctl show-user "$nutzer" -p Linger --value 2>/dev/null)" != "yes" ]; then notify "Neustart NICHT ausgefuehrt: die Box laeuft ohne linger — nach einem Neustart wuerden Steuerpult, Gateway und Waechter nicht von selbst starten. Erst 'loginctl enable-linger $nutzer' setzen, dann von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}" + verlauf neustart offen "Neustart nicht ausgeführt: Linger fehlt." return 0 fi @@ -214,6 +233,7 @@ reboot_wenn_noetig(){ laufend="$(curl -sf --max-time 20 "$API/api/jobs" | jq -r '[.jobs[]? | select(.state=="running")] | length' 2>/dev/null || echo 0)" if [ "${laufend:-0}" -gt 0 ]; then notify "Neustart verschoben: es laufen noch $laufend Job(s). Ausstehend: ${pakete:-Kernel/libc}. Naechster Versuch beim Lauf in einer Woche." + verlauf neustart offen "Neustart verschoben: es liefen noch $laufend Aufträge." return 0 fi @@ -224,14 +244,17 @@ reboot_wenn_noetig(){ done if [ -n "$fehlend" ]; then notify "Neustart NICHT ausgefuehrt: diese Dienste sind nicht fuer den Autostart eingetragen —$fehlend. Erst 'systemctl --user enable' nachholen. Ausstehend: ${pakete:-Kernel/libc}" + verlauf neustart offen "Neustart nicht ausgeführt: Dienste nicht im Autostart ($fehlend)." return 0 fi notify "Die Box startet jetzt neu — das OS verlangt es nach dem Update (${pakete:-Kernel/libc}). Sie ist ein paar Minuten weg und meldet sich, sobald alles wieder laeuft. Die Modelle muessen danach neu geladen werden, die erste Anfrage dauert also laenger." say "Neustart wird ausgeloest (ausstehend: ${pakete:-Kernel/libc})." + verlauf neustart neustart "Die Box startet jetzt neu (${pakete:-Kernel/libc})." sleep 20 # der Meldung Zeit lassen, rauszugehen sudo -n systemctl reboot || { notify "Neustart FEHLGESCHLAGEN: 'sudo systemctl reboot' wurde abgelehnt. Bitte von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}" + verlauf neustart fehler "Neustart fehlgeschlagen: sudo systemctl reboot wurde abgelehnt." } } @@ -240,6 +263,7 @@ say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))." pins_init if ! curl -sf --max-time 20 "$API/api/health" >/dev/null; then notify "Auto-Update abgebrochen: MC2-API ($API) nicht erreichbar — bitte Box prüfen." + verlauf lauf fehler "Abgebrochen: MC2 war nicht erreichbar." exit 1 fi diff --git a/deploy/jobs/sonntags-update.sh b/deploy/jobs/sonntags-update.sh index fb920d1..a12bfb9 100644 --- a/deploy/jobs/sonntags-update.sh +++ b/deploy/jobs/sonntags-update.sh @@ -27,6 +27,8 @@ fi # autoupdate.sh meldet waehrend des Laufs selbst (inkl. der KRITISCH-Faelle, in # denen ein Bericht am Ende zu spaet kaeme). Wir fangen hier nur den Fall ab, # dass es gar nicht erst durchlaeuft — sonst waere das Schweigen zweideutig. +# Anlass für den strukturierten Update-Verlauf (seit 24.09.2026); von Hand heißt es „Update von Hand“. +export MC_UPDATE_ANLASS="Updates am Sonntag" AUSGABE="$(bash "$AUTOUPDATE" 2>&1)" CODE=$?