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:
Hitonabi
2026-09-24 18:14:42 +02:00
co-authored by Claude Opus 5.5
parent 22bb8c672b
commit 589c14d5e9
12 changed files with 649 additions and 116 deletions
+59
View File
@@ -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
View File
@@ -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()]}
+3 -2
View File
@@ -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")
+156
View File
@@ -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
+52 -6
View File
@@ -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)
+54 -11
View File
@@ -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
+99 -12
View File
@@ -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) -----------------------
+73
View File
@@ -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"
+26
View File
@@ -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 == []
+67
View File
@@ -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