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,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
|
||||
+15
-66
@@ -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()]}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) -----------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user