- 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 <noreply@anthropic.com>
324 lines
15 KiB
Python
324 lines
15 KiB
Python
"""
|
|
Update-Verlauf und festgehaltene Bausteine (Box-Wart, Umbau 09/2026).
|
|
|
|
Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — der sagt nur
|
|
„Skript lief". Die Wahrheit ist das, was deploy/autoupdate.sh am Ende meldet: eine Zeile je
|
|
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
|
|
zeigt der Wächter jeden festgehaltenen Baustein als Hinweis, und hier lässt er sich freigeben.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
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
|
|
|
|
NAMEN = {"swap": "llama-swap", "engine": "Motor (llama.cpp)", "hermes": "Hermes"}
|
|
FREIGEGEBEN_TEXT = "Freigegeben. Der nächste Sonntags-Lauf versucht das Update erneut."
|
|
|
|
# So heißen die Bausteine in den Meldungen von autoupdate.sh → kurzer Name fürs Cockpit.
|
|
_BAUSTEIN_IM_TEXT = (
|
|
(re.compile(r"Router \(llama-swap\)"), "llama-swap"),
|
|
(re.compile(r"Engine \(llama\.cpp\)"), "Motor"),
|
|
(re.compile(r"Hermes(-Agent)?\b"), "Hermes"),
|
|
)
|
|
|
|
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
|
|
_EINTRAG = re.compile(
|
|
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
|
r"(?:OK telegram(?: direkt)?|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
|
|
|
|
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
|
|
_UPDATE_MELDUNG = re.compile(
|
|
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
|
|
r"|^Auto-Update abgebrochen|^Sonntags-Update")
|
|
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
|
|
# Die Morgenmeldung (07:00) wiederholt die Nachtmeldungen gesammelt — sie ist kein eigener Lauf.
|
|
_MORGENMELDUNG = re.compile(r"^Guten Morgen, Commander\. Heute Nacht")
|
|
|
|
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
|
|
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
|
|
_EINORDNUNG = (
|
|
("fehler", re.compile(r"kritisch", re.IGNORECASE)),
|
|
("zurueckgerollt", re.compile(r"zurückgerollt", re.IGNORECASE)),
|
|
("festgehalten", re.compile(r"gepinnt", re.IGNORECASE)),
|
|
("offen", re.compile(r"wartet|unklar|check fehlgeschlagen|job-start|nicht ausgefuehrt|verschoben",
|
|
re.IGNORECASE)),
|
|
("fehler", re.compile(r"fehlgeschlagen|abgebrochen|ausgefallen|mit fehler", re.IGNORECASE)),
|
|
("neustart", re.compile(r"startet jetzt neu", re.IGNORECASE)),
|
|
("eingespielt", re.compile(r"eingespielt|aktualisiert|repariert", re.IGNORECASE)),
|
|
("aktuell", re.compile(r"aktuell", re.IGNORECASE)),
|
|
)
|
|
STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "gelb", "offen": "gelb",
|
|
"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 ----------------------------------------------------------------
|
|
|
|
def eintraege(text: str) -> list[tuple[datetime, str]]:
|
|
"""Protokoll → (Zeitpunkt, Nachricht). Zeilen ohne Zeitstempel gehören zur Nachricht davor
|
|
(mehrzeilige Meldungen wie die Wochen-Zusammenfassung oder die Nachrichten-Übersicht)."""
|
|
ergebnis: list[tuple[datetime, str]] = []
|
|
for zeile in text.splitlines():
|
|
if m := _EINTRAG.match(zeile):
|
|
try:
|
|
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ)
|
|
except ValueError:
|
|
continue
|
|
ergebnis.append((zeit, m.group(2)))
|
|
elif ergebnis:
|
|
zeit, bisher = ergebnis[-1]
|
|
ergebnis[-1] = (zeit, f"{bisher}\n{zeile}")
|
|
return ergebnis
|
|
|
|
|
|
def _einordnen(text: str) -> str:
|
|
return next((art for art, muster in _EINORDNUNG if muster.search(text)), "offen")
|
|
|
|
|
|
def _baustein_aus(text: str) -> str:
|
|
return next((name for muster, name in _BAUSTEIN_IM_TEXT if muster.search(text)), "Update-Lauf")
|
|
|
|
|
|
def _lesbar(text: str) -> str:
|
|
"""Meldungstext fürs Cockpit: Fachwörter der Skripte in Alltagssprache."""
|
|
text = re.sub(r"\s*\+\s*GEPINNT", " und festgehalten", text)
|
|
text = re.sub(r"gepinnt, übersprungen", "festgehalten, übersprungen", text, flags=re.IGNORECASE)
|
|
text = text.replace("GEPINNT", "festgehalten").replace("FEHLGESCHLAGEN", "fehlgeschlagen")
|
|
text = re.sub(r"\b1 Commits\b", "1 Änderung", text)
|
|
text = re.sub(r"\b(\d+) Commits\b", r"\1 Änderungen", text)
|
|
# Nur der erste Satz: der Rest der Meldungen ist Begleittext für Telegram.
|
|
return re.split(r"(?<=[.!])\s", text.strip(), maxsplit=1)[0][:240]
|
|
|
|
|
|
def _zeile(baustein: str, text: str) -> dict:
|
|
ergebnis = _einordnen(text)
|
|
return {"baustein": baustein, "ergebnis": ergebnis, "stufe": STUFE_DER_ZEILE[ergebnis], "text": _lesbar(text)}
|
|
|
|
|
|
def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
|
|
zusammenfassung = next((t for _, t in reversed(gruppe) if _ZUSAMMENFASSUNG.search(t)), None)
|
|
zeilen: list[dict] = []
|
|
if zusammenfassung:
|
|
for z in zusammenfassung.splitlines()[1:]:
|
|
if m := re.match(r"^\s*•\s*(.+?):\s*(.+)$", z):
|
|
zeilen.append(_zeile(_baustein_aus(m.group(1)), m.group(2)))
|
|
for _, text in gruppe:
|
|
erste = text.splitlines()[0] if text else ""
|
|
if _NEUSTART.search(erste):
|
|
zeilen.append(_zeile("Neustart", erste))
|
|
elif erste.startswith(("Sonntags-Update", "Auto-Update abgebrochen")):
|
|
zeilen.append(_zeile("Update-Lauf", erste))
|
|
elif not zusammenfassung and _UPDATE_MELDUNG.search(erste):
|
|
# 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":
|
|
titel = "Fehler"
|
|
elif stufe == "gelb":
|
|
titel = ("Zurückgerollt" if "zurueckgerollt" in arten
|
|
else "Festgehalten" if "festgehalten" in arten else "Unvollständig")
|
|
elif stufe == "gruen":
|
|
titel = "Eingespielt"
|
|
elif stufe == "info":
|
|
titel = "Neu gestartet"
|
|
else:
|
|
titel = "Alles aktuell"
|
|
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]:
|
|
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
|
|
relevant = [(z, t) for z, t in eintraege(text)
|
|
if not _MORGENMELDUNG.match(t)
|
|
and (_ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
|
|
or _NEUSTART.search(t.splitlines()[0] if t else ""))]
|
|
gruppen: list[list[tuple[datetime, str]]] = []
|
|
for eintrag in relevant:
|
|
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
|
|
gruppen[-1].append(eintrag)
|
|
else:
|
|
gruppen.append([eintrag])
|
|
# Ein Neustart allein ist kein Update-Lauf (z. B. von Hand ausgelöst).
|
|
laeufe = [_lauf(g) for g in gruppen if any(not _NEUSTART.search(t.splitlines()[0]) for _, t in g)]
|
|
return list(reversed(laeufe))
|
|
|
|
|
|
def _protokoll_ende() -> str:
|
|
try:
|
|
with NOTIFY_LOG.open("rb") as f:
|
|
f.seek(0, os.SEEK_END)
|
|
groesse = f.tell()
|
|
f.seek(max(0, groesse - LESE_MAX))
|
|
roh = f.read()
|
|
except OSError:
|
|
return ""
|
|
text = roh.decode("utf-8", errors="replace")
|
|
if groesse > LESE_MAX:
|
|
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
|
|
return text
|
|
|
|
|
|
def _schluessel(pfad: Path) -> tuple[int, int] | None:
|
|
try:
|
|
st = pfad.stat()
|
|
except OSError:
|
|
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()) if schluessel else []
|
|
_cache["schluessel"] = schluessel
|
|
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) -----------------------
|
|
|
|
def _pins_lesen() -> dict:
|
|
try:
|
|
daten = json.loads(PINS_FILE.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return daten if isinstance(daten, dict) else {}
|
|
|
|
|
|
def festgehalten() -> list[dict]:
|
|
"""Bausteine, die der Sonntags-Lauf nach einem gescheiterten Update überspringt."""
|
|
ergebnis = []
|
|
for baustein, eintrag in _pins_lesen().items():
|
|
if not (isinstance(eintrag, dict) and eintrag.get("pinned")):
|
|
continue
|
|
datum = str(eintrag.get("datum") or "")
|
|
m = re.fullmatch(r"\d{4}-(\d{2})-(\d{2})", datum)
|
|
seit = f"{m.group(2)}.{m.group(1)}." if m else (datum or "?")
|
|
ergebnis.append({"baustein": baustein, "name": NAMEN.get(baustein, baustein),
|
|
"version": str(eintrag.get("version") or "?"), "seit": seit,
|
|
"grund": _lesbar(str(eintrag.get("grund") or ""))})
|
|
return ergebnis
|
|
|
|
|
|
def freigeben(baustein: str) -> bool:
|
|
"""Pin lösen (atomar schreiben). False, wenn der Baustein gar nicht festgehalten war."""
|
|
daten = _pins_lesen()
|
|
if baustein not in daten:
|
|
return False
|
|
daten.pop(baustein)
|
|
tmp = PINS_FILE.with_suffix(".tmp")
|
|
try:
|
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
os.replace(tmp, PINS_FILE)
|
|
except OSError:
|
|
return False
|
|
return True
|