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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
22bb8c672b
commit
589c14d5e9
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) -----------------------
|
||||
|
||||
Reference in New Issue
Block a user