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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user