Ampel / ampel (push) Failing after 22s
Der Verlauf las bisher nur den Hermes-Cron-Status ("Skript lief") und zeigte die
Laeufe vom 06. und 13.09. als "durchgelaufen", obwohl dort zurueckgerollt und
festgehalten wurde; ein abgebrochener Lauf stand als "laeuft" da. Jetzt kommt der
Verlauf aus dem Meldeprotokoll (~/mc2-notify.log), also aus dem, was autoupdate.sh
selbst je Baustein meldet.
Festgehaltene Bausteine (Pin-Register) werden zum Waechter-Hinweis mit Knopf
"Freigeben", auf der Updates-Seite markiert und in der Updates-Lampe gezaehlt.
Hermes zeigt seine laufende Version und die Zahl der neuen Aenderungen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
10 KiB
Python
232 lines
10 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.
|
|
|
|
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 zoneinfo import ZoneInfo
|
|
|
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
|
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"))
|
|
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|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)")
|
|
|
|
# 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}
|
|
|
|
_cache_lock = threading.Lock()
|
|
_cache: dict = {"schluessel": None, "laeufe": []}
|
|
|
|
|
|
# --- 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"\b(\d+) Commits\b", r"\1 Änderungen", text)
|
|
return text.strip()[: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)))
|
|
|
|
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"
|
|
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}
|
|
|
|
|
|
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 _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 laeufe(anzahl: int = 8) -> list[dict]:
|
|
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs."""
|
|
try:
|
|
st = NOTIFY_LOG.stat()
|
|
schluessel = (st.st_mtime_ns, st.st_size)
|
|
except OSError:
|
|
return []
|
|
with _cache_lock:
|
|
if _cache["schluessel"] != schluessel:
|
|
_cache["laeufe"] = laeufe_aus(_protokoll_ende())
|
|
_cache["schluessel"] = schluessel
|
|
return _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
|