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
|
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
|
||||||
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
||||||
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
|
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)
|
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.
|
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ from pydantic import BaseModel
|
|||||||
from services import (
|
from services import (
|
||||||
aufraeumen,
|
aufraeumen,
|
||||||
backup,
|
backup,
|
||||||
|
box_updates,
|
||||||
llamaswap,
|
llamaswap,
|
||||||
maintenance,
|
|
||||||
modell_nutzung,
|
modell_nutzung,
|
||||||
system,
|
system,
|
||||||
update_verlauf,
|
update_verlauf,
|
||||||
@@ -36,64 +36,6 @@ from services import (
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["boxwart"])
|
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:
|
def _zeit_kurz(ts: float | None) -> str:
|
||||||
if not ts:
|
if not ts:
|
||||||
return "–"
|
return "–"
|
||||||
@@ -152,12 +94,12 @@ def _lampen(stand: dict, u: dict) -> list[dict]:
|
|||||||
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
|
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
|
||||||
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
|
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())
|
fest = len(update_verlauf.festgehalten())
|
||||||
unklar = len(_unklar(u))
|
unklar = len(box_updates.unklar(u))
|
||||||
if fest:
|
if fest:
|
||||||
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
|
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"))
|
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
|
||||||
elif n:
|
elif n:
|
||||||
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
|
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
|
||||||
@@ -183,7 +125,7 @@ def _box() -> dict:
|
|||||||
@router.get("/start")
|
@router.get("/start")
|
||||||
def start() -> dict:
|
def start() -> dict:
|
||||||
stand = waechter.lese_stand()
|
stand = waechter.lese_stand()
|
||||||
u = _updates_gecacht()
|
u = box_updates.gecacht()
|
||||||
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
|
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
|
||||||
anzahl = len(stand["hinweise"])
|
anzahl = len(stand["hinweise"])
|
||||||
wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
|
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],
|
"verlauf": stand["verlauf"][:20],
|
||||||
"lampen": _lampen(stand, u),
|
"lampen": _lampen(stand, u),
|
||||||
"box": _box(),
|
"box": _box(),
|
||||||
"updates": {"bausteine": _bausteine(u), "unklar": _unklar(u),
|
"updates": {"bausteine": box_updates.bausteine(u), "unklar": box_updates.unklar(u),
|
||||||
"festgehalten": update_verlauf.festgehalten(),
|
"festgehalten": update_verlauf.festgehalten(),
|
||||||
"gelesen": _updates_cache["daten"] is not None},
|
"gelesen": box_updates.gelesen()},
|
||||||
"flugplan": zeitplan.flugplan(),
|
"flugplan": zeitplan.flugplan(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,3 +195,10 @@ def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
|
|||||||
if not ergebnis.get("ok"):
|
if not ergebnis.get("ok"):
|
||||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
|
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
|
||||||
return ergebnis
|
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")
|
@router.post("/maintenance/hermes-update")
|
||||||
def hermes_update() -> dict:
|
def hermes_update(verlauf: bool = True) -> dict:
|
||||||
return maintenance.hermes_update_job()
|
"""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")
|
@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.
|
# Nur im Speicher (nicht in der Akte): Fortschritt eines Downloads.
|
||||||
_LAUFZEIT = {"progress", "done_bytes", "rate_bps", "eta_s"}
|
_LAUFZEIT = {"progress", "done_bytes", "rate_bps", "eta_s"}
|
||||||
# Nicht an die Oberfläche: Verwaltung des Auftrags.
|
# 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.
|
# 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",
|
_UMGEBUNG_OHNE = {"INVOCATION_ID", "JOURNAL_STREAM", "SYSTEMD_EXEC_PID", "MANAGERPID", "NOTIFY_SOCKET",
|
||||||
"LISTEN_PID", "LISTEN_FDS", "LISTEN_FDNAMES", "WATCHDOG_PID", "WATCHDOG_USEC", "MAINPID",
|
"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"')
|
'"$@"; code=$?; echo "$code" > "$akte.exit.tmp" && mv -f "$akte.exit.tmp" "$akte.exit"')
|
||||||
|
|
||||||
_NACHARBEITEN: dict[str, Callable[..., None]] = {}
|
_NACHARBEITEN: dict[str, Callable[..., None]] = {}
|
||||||
|
_ABSCHLUESSE: dict[str, Callable[[dict], None]] = {}
|
||||||
_geladen = False
|
_geladen = False
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +86,16 @@ def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None]
|
|||||||
return anmelden
|
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 ------------------------------------------------------------------------
|
# --- Ablage ------------------------------------------------------------------------
|
||||||
|
|
||||||
def _jobs_dir() -> Path:
|
def _jobs_dir() -> Path:
|
||||||
@@ -144,6 +156,11 @@ def _protokoll_lesen(job_id: str) -> list[str]:
|
|||||||
return zeilen_aus(text)
|
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:
|
def _exit_code(job_id: str) -> int | None:
|
||||||
try:
|
try:
|
||||||
return int(_pfad(job_id, ".exit").read_text(encoding="utf-8").strip())
|
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)
|
job.pop("_schliesst", None)
|
||||||
_PROCS.pop(job["id"], None)
|
_PROCS.pop(job["id"], None)
|
||||||
_speichern(job)
|
_speichern(job)
|
||||||
|
_abschluss_ausfuehren(job)
|
||||||
|
|
||||||
|
|
||||||
def _nacharbeit_ausfuehren(job: dict) -> None:
|
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)
|
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:
|
def _beobachten(job_id: str) -> None:
|
||||||
"""Wartet auf den Exit-Code einer systemd-Einheit. Endet sie ohne einen (Abbruch, Zeitlimit,
|
"""Wartet auf den Exit-Code einer systemd-Einheit. Endet sie ohne einen (Abbruch, Zeitlimit,
|
||||||
Absturz), schließt er den Auftrag trotzdem ab."""
|
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,
|
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)."""
|
"""Neue Akte anlegen (unter _LOCK aufrufen)."""
|
||||||
if nacharbeit_name and nacharbeit_name not in _NACHARBEITEN:
|
if nacharbeit_name and nacharbeit_name not in _NACHARBEITEN:
|
||||||
raise ValueError(f"Unbekannte Nacharbeit: {nacharbeit_name}")
|
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 = uuid.uuid4().hex[:12]
|
||||||
job = {
|
job = {
|
||||||
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(),
|
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(),
|
||||||
"returncode": None, "started_at": time.time(), "finished_at": None,
|
"returncode": None, "started_at": time.time(), "finished_at": None,
|
||||||
"zeitlimit_s": int(zeitlimit_s or STANDARD_ZEITLIMIT_S),
|
"zeitlimit_s": int(zeitlimit_s or STANDARD_ZEITLIMIT_S),
|
||||||
"nacharbeit": nacharbeit_name, "nacharbeit_daten": nacharbeit_daten or {},
|
"nacharbeit": nacharbeit_name, "nacharbeit_daten": nacharbeit_daten or {},
|
||||||
|
"abschluss": abschluss_name, "abschluss_daten": abschluss_daten or {},
|
||||||
}
|
}
|
||||||
JOBS[job_id] = job
|
JOBS[job_id] = job
|
||||||
_protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args))
|
_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,
|
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,
|
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:
|
with _LOCK:
|
||||||
_laden()
|
_laden()
|
||||||
_aufraeumen()
|
_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)
|
_starten(job, list(args), env)
|
||||||
return job["id"]
|
return job["id"]
|
||||||
|
|
||||||
|
|
||||||
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None,
|
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None,
|
||||||
zeitlimit_s: int | None = None, nacharbeit: str | 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
|
"""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)."""
|
Sperre. Rückgabe: (neue Auftrags-ID, None) oder (None, der schon laufende Auftrag)."""
|
||||||
with _LOCK:
|
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:
|
if (laeuft := _aktiv_in(group)) is not None:
|
||||||
return None, laeuft
|
return None, laeuft
|
||||||
_aufraeumen()
|
_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)
|
_starten(job, list(args), env)
|
||||||
return job["id"], None
|
return job["id"], None
|
||||||
|
|
||||||
@@ -479,6 +521,8 @@ def wiederaufnehmen() -> int:
|
|||||||
offen = [j for j in JOBS.values() if j["state"] in ("queued", "running")]
|
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")
|
nacharbeit_offen = [j for j in JOBS.values() if j["state"] == "done" and j.get("nacharbeit")
|
||||||
and not j.get("nacharbeit_erledigt")]
|
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:
|
for job in offen:
|
||||||
if job.get("art") == "systemd":
|
if job.get("art") == "systemd":
|
||||||
threading.Thread(target=_beobachten, args=(job["id"],), daemon=True).start()
|
threading.Thread(target=_beobachten, args=(job["id"],), daemon=True).start()
|
||||||
@@ -492,6 +536,8 @@ def wiederaufnehmen() -> int:
|
|||||||
_abschliessen(job, None)
|
_abschliessen(job, None)
|
||||||
for job in nacharbeit_offen:
|
for job in nacharbeit_offen:
|
||||||
_nacharbeit_ausfuehren(job)
|
_nacharbeit_ausfuehren(job)
|
||||||
|
for job in abschluss_offen:
|
||||||
|
_abschluss_ausfuehren(job)
|
||||||
if offen:
|
if offen:
|
||||||
log.info("jobengine: %d laufende Aufträge wieder aufgenommen", len(offen))
|
log.info("jobengine: %d laufende Aufträge wieder aufgenommen", len(offen))
|
||||||
return len(offen)
|
return len(offen)
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
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-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||||
SYSTEM_SERVICES = {"llama-swap"}
|
SYSTEM_SERVICES = {"llama-swap"}
|
||||||
@@ -261,6 +262,40 @@ def updates() -> dict:
|
|||||||
return daten
|
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")
|
@jobengine.nacharbeit("wartung:nach_update")
|
||||||
def _nach_update() -> None:
|
def _nach_update() -> None:
|
||||||
"""Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen."""
|
"""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": r["ok"], "text": r["out"] or r["err"]}
|
||||||
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
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
|
"""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
|
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."""
|
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,
|
job_id, laeuft = jobengine.start_job_exklusiv(
|
||||||
nacharbeit="wartung:nach_update")
|
"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:
|
if job_id is None:
|
||||||
return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"),
|
return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"),
|
||||||
"detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."}
|
"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 && "
|
cmd = ("sudo apt-get update && "
|
||||||
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
||||||
f"&& bash {STACK_POSTCHECK}")
|
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:
|
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
|
# 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.
|
# 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)",
|
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:
|
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
|
# 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.
|
# 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)",
|
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:
|
def _hermes_update_cmd() -> str:
|
||||||
@@ -677,12 +714,13 @@ def _hermes_update_cmd() -> str:
|
|||||||
f"exit $RC")
|
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
|
"""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
|
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",
|
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:
|
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')}."}
|
"detail": f"Es läuft schon: {laeuft.get('label', 'ein Update')}."}
|
||||||
upd = updates()
|
upd = updates()
|
||||||
parts: list[tuple[str, str]] = []
|
parts: list[tuple[str, str]] = []
|
||||||
|
bausteine: list[str] = []
|
||||||
if upd.get("engine") and ENGINE_UPDATE_CMD:
|
if upd.get("engine") and ENGINE_UPDATE_CMD:
|
||||||
parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD))
|
parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD))
|
||||||
|
bausteine.append("engine")
|
||||||
if upd.get("swap") and SWAP_UPDATE_CMD:
|
if upd.get("swap") and SWAP_UPDATE_CMD:
|
||||||
parts.append(("Router (llama-swap)", 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 []):
|
if any(c.get("update") is True for c in upd.get("components") or []):
|
||||||
parts.append(("Hermes-Agent", _hermes_update_cmd()))
|
parts.append(("Hermes-Agent", _hermes_update_cmd()))
|
||||||
|
bausteine.append("hermes")
|
||||||
os_pending = (upd.get("os") or 0) > 0
|
os_pending = (upd.get("os") or 0) > 0
|
||||||
if os_pending:
|
if os_pending:
|
||||||
if err := check_sudo_needs_password():
|
if err := check_sudo_needs_password():
|
||||||
@@ -716,6 +758,7 @@ def update_all_job() -> dict:
|
|||||||
"sudo apt-get update && "
|
"sudo apt-get update && "
|
||||||
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
||||||
f"&& bash {STACK_POSTCHECK}")))
|
f"&& bash {STACK_POSTCHECK}")))
|
||||||
|
bausteine.append("os")
|
||||||
if not parts:
|
if not parts:
|
||||||
return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."}
|
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)
|
labels = " → ".join(label for label, _ in parts)
|
||||||
ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
||||||
zeitlimit_s=3 * 3600)
|
zeitlimit_s=3 * 3600, bausteine=bausteine)
|
||||||
if ergebnis.get("ok"):
|
if ergebnis.get("ok"):
|
||||||
ergebnis["parts"] = [label for label, _ in parts]
|
ergebnis["parts"] = [label for label, _ in parts]
|
||||||
return ergebnis
|
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
|
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.
|
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
|
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.
|
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
|
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 datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
from kern.zeit import LOCAL_TZ
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
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"))
|
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
|
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
|
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"}
|
"neustart": "info", "eingespielt": "gruen", "aktuell": "grau"}
|
||||||
_RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0}
|
_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_lock = threading.Lock()
|
||||||
_cache: dict = {"schluessel": None, "laeufe": []}
|
_cache: dict = {"schluessel": None, "laeufe": []}
|
||||||
|
_struktur_cache: dict = {"schluessel": None, "laeufe": []}
|
||||||
|
_schreib_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
# --- Protokoll lesen ----------------------------------------------------------------
|
# --- 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.
|
# Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt.
|
||||||
zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste)))
|
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")
|
stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau")
|
||||||
arten = {z["ergebnis"] for z in zeilen}
|
arten = {z["ergebnis"] for z in zeilen}
|
||||||
if stufe == "rot":
|
if stufe == "rot":
|
||||||
@@ -143,11 +166,63 @@ def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
|
|||||||
titel = "Neu gestartet"
|
titel = "Neu gestartet"
|
||||||
else:
|
else:
|
||||||
titel = "Alles aktuell"
|
titel = "Alles aktuell"
|
||||||
start, ende = gruppe[0][0], gruppe[-1][0]
|
return {"stufe": stufe, "titel": titel}
|
||||||
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",
|
# --- Strukturierter Verlauf (seit 24.09.2026) ----------------------------------------
|
||||||
"stufe": stufe, "titel": titel, "zeilen": zeilen}
|
|
||||||
|
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]:
|
def laeufe_aus(text: str) -> list[dict]:
|
||||||
@@ -182,18 +257,30 @@ def _protokoll_ende() -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def laeufe(anzahl: int = 8) -> list[dict]:
|
def _schluessel(pfad: Path) -> tuple[int, int] | None:
|
||||||
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs."""
|
|
||||||
try:
|
try:
|
||||||
st = NOTIFY_LOG.stat()
|
st = pfad.stat()
|
||||||
schluessel = (st.st_mtime_ns, st.st_size)
|
|
||||||
except OSError:
|
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:
|
with _cache_lock:
|
||||||
|
schluessel = _schluessel(NOTIFY_LOG)
|
||||||
if _cache["schluessel"] != schluessel:
|
if _cache["schluessel"] != schluessel:
|
||||||
_cache["laeufe"] = laeufe_aus(_protokoll_ende())
|
_cache["laeufe"] = laeufe_aus(_protokoll_ende()) if schluessel else []
|
||||||
_cache["schluessel"] = schluessel
|
_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) -----------------------
|
# --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) -----------------------
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
|
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
from services import update_verlauf
|
from services import update_verlauf
|
||||||
|
|
||||||
# Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen.
|
# 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 = tmp_path / "mc2-notify.log"
|
||||||
datei.write_text(PROTOKOLL, encoding="utf-8")
|
datei.write_text(PROTOKOLL, encoding="utf-8")
|
||||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei)
|
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, "_cache", {"schluessel": None, "laeufe": []})
|
||||||
|
monkeypatch.setattr(update_verlauf, "_struktur_cache", {"schluessel": None, "laeufe": []})
|
||||||
assert len(update_verlauf.laeufe(3)) == 3
|
assert len(update_verlauf.laeufe(3)) == 3
|
||||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
|
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
|
||||||
assert update_verlauf.laeufe() == []
|
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):
|
def test_festgehalten_und_freigeben(tmp_path, monkeypatch):
|
||||||
pins = tmp_path / "mc2-pins.json"
|
pins = tmp_path / "mc2-pins.json"
|
||||||
pins.write_text(json.dumps({
|
pins.write_text(json.dumps({
|
||||||
@@ -154,3 +197,33 @@ def test_zweitweg_zaehlt_wie_telegram():
|
|||||||
"Stack-Check grün, alles läuft.\n")
|
"Stack-Check grün, alles läuft.\n")
|
||||||
laeufe = update_verlauf.laeufe_aus(text)
|
laeufe = update_verlauf.laeufe_aus(text)
|
||||||
assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [("llama-swap", "eingespielt")]
|
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()
|
info = maintenance.hermes_update_details()
|
||||||
assert info["installed_version"] == "0.21.4"
|
assert info["installed_version"] == "0.21.4"
|
||||||
assert "error" in info
|
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
|
||||||
+43
-19
@@ -31,6 +31,24 @@ notify_dringend(){ bash "$SRC_DIR/notify.sh" -d -s "[Box-Update]" "$1" || true;
|
|||||||
|
|
||||||
SUMMARY=() # eine Zeile je Ebene für die Abschluss-Meldung
|
SUMMARY=() # eine Zeile je Ebene für die Abschluss-Meldung
|
||||||
|
|
||||||
|
# ── Strukturierter Verlauf (seit 24.09.2026) ─────────────────────────────────
|
||||||
|
# Je Baustein eine JSON-Zeile; die Oberfläche liest den Update-Verlauf daraus statt aus den
|
||||||
|
# Telegram-Texten (services/update_verlauf.py). Die Meldungen selbst bleiben, wie sie sind.
|
||||||
|
VERLAUF="${MC_UPDATE_VERLAUF:-/srv/models/mc2-update-verlauf.jsonl}"
|
||||||
|
LAUF="$(date -Iseconds)"
|
||||||
|
ANLASS="${MC_UPDATE_ANLASS:-Update von Hand}"
|
||||||
|
verlauf(){ # $1 baustein (swap|engine|hermes|neustart|lauf) $2 ergebnis $3 text
|
||||||
|
jq -cn --arg lauf "$LAUF" --arg anlass "$ANLASS" --arg ts "$(date -Iseconds)" \
|
||||||
|
--arg b "$1" --arg e "$2" --arg t "$3" \
|
||||||
|
'{lauf: $lauf, anlass: $anlass, ts: $ts, baustein: $b, ergebnis: $e, text: $t}' \
|
||||||
|
>> "$VERLAUF" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
# Ergebnis eines Bausteins: eine Zeile für die Abschlussmeldung und eine für den Verlauf.
|
||||||
|
ergebnis(){ # $1 baustein $2 anzeigename $3 ergebnis $4 text
|
||||||
|
SUMMARY+=("$2: $4")
|
||||||
|
verlauf "$1" "$3" "$4"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Pin-Register ─────────────────────────────────────────────────────────────
|
# ── Pin-Register ─────────────────────────────────────────────────────────────
|
||||||
pins_init(){ [ -f "$PINS" ] || echo '{}' > "$PINS"; }
|
pins_init(){ [ -f "$PINS" ] || echo '{}' > "$PINS"; }
|
||||||
is_pinned(){ jq -e --arg k "$1" '.[$k].pinned == true' "$PINS" >/dev/null 2>&1; }
|
is_pinned(){ jq -e --arg k "$1" '.[$k].pinned == true' "$PINS" >/dev/null 2>&1; }
|
||||||
@@ -55,12 +73,12 @@ run_root_update(){ # $1 komponente(swap|engine) $2 skript $3 anzeigename $4
|
|||||||
say "$name: Update $installed → $latest wird eingespielt…"
|
say "$name: Update $installed → $latest wird eingespielt…"
|
||||||
sudo -n /usr/bin/bash "$SRC_DIR/$script"; rc=$?
|
sudo -n /usr/bin/bash "$SRC_DIR/$script"; rc=$?
|
||||||
case "$rc" in
|
case "$rc" in
|
||||||
0) SUMMARY+=("$name: $installed → $latest eingespielt, Stack-Check grün.")
|
0) ergebnis "$comp" "$name" eingespielt "$installed → $latest eingespielt, Stack-Check grün."
|
||||||
notify "$name aktualisiert: $installed → $latest. Stack-Check grün, alles läuft." ;;
|
notify "$name aktualisiert: $installed → $latest. Stack-Check grün, alles läuft." ;;
|
||||||
1) set_pin "$comp" "$installed" "Update auf $latest zerschoss den Stack-Check; Rollback grün"
|
1) set_pin "$comp" "$installed" "Update auf $latest zerschoss den Stack-Check; Rollback grün"
|
||||||
SUMMARY+=("$name: $latest FEHLGESCHLAGEN → zurückgerollt auf $installed + GEPINNT.")
|
ergebnis "$comp" "$name" zurueckgerollt "$latest FEHLGESCHLAGEN → zurückgerollt auf $installed + GEPINNT."
|
||||||
notify "$name-Update auf $latest fehlgeschlagen (Stack-Check rot). Automatisch zurückgerollt auf $installed — läuft wieder. Ebene ist jetzt GEPINNT, künftige Läufe überspringen sie." ;;
|
notify "$name-Update auf $latest fehlgeschlagen (Stack-Check rot). Automatisch zurückgerollt auf $installed — läuft wieder. Ebene ist jetzt GEPINNT, künftige Läufe überspringen sie." ;;
|
||||||
*) SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!")
|
*) ergebnis "$comp" "$name" fehler "KRITISCH — Update UND Rollback fehlgeschlagen!"
|
||||||
notify_dringend "KRITISCH: $name-Update auf $latest UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. Bitte melden, ich brauche Hilfe (Backup liegt bereit, restore.sh existiert)." ;;
|
notify_dringend "KRITISCH: $name-Update auf $latest UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. Bitte melden, ich brauche Hilfe (Backup liegt bereit, restore.sh existiert)." ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -69,25 +87,25 @@ check_layer_root(){ # $1 komponente $2 skript $3 anzeigename $4 details-kind
|
|||||||
local comp="$1" script="$2" name="$3" kind="$4" det installed latest
|
local comp="$1" script="$2" name="$3" kind="$4" det installed latest
|
||||||
if is_pinned "$comp"; then
|
if is_pinned "$comp"; then
|
||||||
say "$name: GEPINNT ($(pin_info "$comp")) — übersprungen."
|
say "$name: GEPINNT ($(pin_info "$comp")) — übersprungen."
|
||||||
SUMMARY+=("$name: gepinnt, übersprungen.")
|
ergebnis "$comp" "$name" festgehalten "gepinnt, übersprungen."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
det="$(details "$kind")" || { say "$name: Update-Check nicht erreichbar."; SUMMARY+=("$name: Check fehlgeschlagen."); return; }
|
det="$(details "$kind")" || { say "$name: Update-Check nicht erreichbar."; ergebnis "$comp" "$name" offen "Check fehlgeschlagen."; return; }
|
||||||
installed="$(jq -r '.installed_build // empty' <<<"$det")"
|
installed="$(jq -r '.installed_build // empty' <<<"$det")"
|
||||||
latest="$(jq -r '.latest_build // empty' <<<"$det")"
|
latest="$(jq -r '.latest_build // empty' <<<"$det")"
|
||||||
if [ -z "$installed" ] || [ -z "$latest" ]; then
|
if [ -z "$installed" ] || [ -z "$latest" ]; then
|
||||||
say "$name: Versionen nicht ermittelbar (installed='$installed' latest='$latest')."
|
say "$name: Versionen nicht ermittelbar (installed='$installed' latest='$latest')."
|
||||||
SUMMARY+=("$name: Versions-Check unklar, nichts getan.")
|
ergebnis "$comp" "$name" offen "Versions-Check unklar, nichts getan."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
if [ "$latest" -le "$installed" ] 2>/dev/null; then
|
if [ "$latest" -le "$installed" ] 2>/dev/null; then
|
||||||
say "$name: aktuell ($installed)."
|
say "$name: aktuell ($installed)."
|
||||||
SUMMARY+=("$name: aktuell ($installed).")
|
ergebnis "$comp" "$name" aktuell "aktuell ($installed)."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
if ! sudo_ok "$script"; then
|
if ! sudo_ok "$script"; then
|
||||||
say "$name: Update $installed → $latest verfügbar, aber sudo-Freischaltung fehlt."
|
say "$name: Update $installed → $latest verfügbar, aber sudo-Freischaltung fehlt."
|
||||||
SUMMARY+=("$name: Update $installed → $latest wartet — sudo-Freischaltung fehlt (einmalig deploy/sudoers-mc2-autonomie installieren).")
|
ergebnis "$comp" "$name" offen "Update $installed → $latest wartet — sudo-Freischaltung fehlt (einmalig deploy/sudoers-mc2-autonomie installieren)."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
run_root_update "$comp" "$script" "$name" "$installed" "$latest"
|
run_root_update "$comp" "$script" "$name" "$installed" "$latest"
|
||||||
@@ -98,23 +116,23 @@ check_hermes(){
|
|||||||
local name="Hermes-Agent" det behind old_head resp job_id state t
|
local name="Hermes-Agent" det behind old_head resp job_id state t
|
||||||
if is_pinned hermes; then
|
if is_pinned hermes; then
|
||||||
say "$name: GEPINNT ($(pin_info hermes)) — übersprungen."
|
say "$name: GEPINNT ($(pin_info hermes)) — übersprungen."
|
||||||
SUMMARY+=("$name: gepinnt, übersprungen.")
|
ergebnis hermes "$name" festgehalten "gepinnt, übersprungen."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
det="$(details hermes)" || { SUMMARY+=("$name: Check fehlgeschlagen."); return; }
|
det="$(details hermes)" || { ergebnis hermes "$name" offen "Check fehlgeschlagen."; return; }
|
||||||
behind="$(jq -r '.behind // 0' <<<"$det")"
|
behind="$(jq -r '.behind // 0' <<<"$det")"
|
||||||
if [ "${behind:-0}" -eq 0 ] 2>/dev/null; then
|
if [ "${behind:-0}" -eq 0 ] 2>/dev/null; then
|
||||||
say "$name: aktuell."
|
say "$name: aktuell."
|
||||||
SUMMARY+=("$name: aktuell.")
|
ergebnis hermes "$name" aktuell "aktuell."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
old_head="$(git -C "$HERMES_DIR" rev-parse HEAD 2>/dev/null)"
|
old_head="$(git -C "$HERMES_DIR" rev-parse HEAD 2>/dev/null)"
|
||||||
say "$name: $behind Commits hinterher — Update-Job startet (Backup→update→doctor→Restart→Gehirn-Check)…"
|
say "$name: $behind Commits hinterher — Update-Job startet (Backup→update→doctor→Restart→Gehirn-Check)…"
|
||||||
resp="$(curl -sf --max-time 30 -X POST "$API/api/maintenance/hermes-update")" || { SUMMARY+=("$name: Job-Start fehlgeschlagen."); return; }
|
resp="$(curl -sf --max-time 30 -X POST "$API/api/maintenance/hermes-update?verlauf=0")" || { ergebnis hermes "$name" offen "Job-Start fehlgeschlagen."; return; }
|
||||||
job_id="$(jq -r '.job_id // empty' <<<"$resp")"
|
job_id="$(jq -r '.job_id // empty' <<<"$resp")"
|
||||||
if [ -z "$job_id" ]; then
|
if [ -z "$job_id" ]; then
|
||||||
say "$name: kein Job gestartet: $resp"
|
say "$name: kein Job gestartet: $resp"
|
||||||
SUMMARY+=("$name: Job-Start abgelehnt ($(jq -r '.error // .detail // "unbekannt"' <<<"$resp" 2>/dev/null)).")
|
ergebnis hermes "$name" offen "Job-Start abgelehnt ($(jq -r '.error // .detail // "unbekannt"' <<<"$resp" 2>/dev/null))."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
t=0
|
t=0
|
||||||
@@ -124,7 +142,7 @@ check_hermes(){
|
|||||||
sleep 10; t=$((t + 10))
|
sleep 10; t=$((t + 10))
|
||||||
done
|
done
|
||||||
if [ "$state" = "done" ]; then
|
if [ "$state" = "done" ]; then
|
||||||
SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.")
|
ergebnis hermes "$name" eingespielt "$behind Commits eingespielt, Gehirn-Check grün."
|
||||||
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Patches, Tools, Voice) grün — alles läuft."
|
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Patches, Tools, Voice) grün — alles läuft."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
@@ -135,7 +153,7 @@ check_hermes(){
|
|||||||
sr_out="$(bash "$SRC_DIR/self-repair.sh" 2>&1)"; sr_rc=$?
|
sr_out="$(bash "$SRC_DIR/self-repair.sh" 2>&1)"; sr_rc=$?
|
||||||
echo "$sr_out"
|
echo "$sr_out"
|
||||||
if [ "$sr_rc" -eq 0 ]; then
|
if [ "$sr_rc" -eq 0 ]; then
|
||||||
SUMMARY+=("$name: Config-Bruch nach Update SELBST repariert — neue Version läuft (kein Rollback).")
|
ergebnis hermes "$name" eingespielt "Config-Bruch nach Update SELBST repariert — neue Version läuft (kein Rollback)."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
sugg="$(echo "$sr_out" | sed -n 's/^SELFREPAIR_SUGGESTION=//p' | tail -1)"
|
sugg="$(echo "$sr_out" | sed -n 's/^SELFREPAIR_SUGGESTION=//p' | tail -1)"
|
||||||
@@ -156,14 +174,14 @@ check_hermes(){
|
|||||||
systemctl --user restart hermes-gateway; sleep 6
|
systemctl --user restart hermes-gateway; sleep 6
|
||||||
if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then
|
if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then
|
||||||
set_pin hermes "$old_head" "Update ($behind Commits) riss den Gehirn-Check; Rollback grün"
|
set_pin hermes "$old_head" "Update ($behind Commits) riss den Gehirn-Check; Rollback grün"
|
||||||
SUMMARY+=("$name: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}.")
|
ergebnis hermes "$name" zurueckgerollt "Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}."
|
||||||
notify "Hermes-Update fehlgeschlagen (Gehirn-Check rot). Selbstreparatur der Config griff nicht, deshalb automatisch zurückgerollt auf ${old_head:0:9} — läuft wieder. Hermes ist jetzt GEPINNT.${sugg:+
|
notify "Hermes-Update fehlgeschlagen (Gehirn-Check rot). Selbstreparatur der Config griff nicht, deshalb automatisch zurückgerollt auf ${old_head:0:9} — läuft wieder. Hermes ist jetzt GEPINNT.${sugg:+
|
||||||
|
|
||||||
Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
|
Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!")
|
ergebnis hermes "$name" fehler "KRITISCH — Update UND Rollback fehlgeschlagen!"
|
||||||
notify_dringend "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
|
notify_dringend "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +205,7 @@ reboot_wenn_noetig(){
|
|||||||
[ "${MC_AUTOUPDATE_REBOOT:-1}" = "1" ] || { say "Neustart per Env abgeschaltet."; return 0; }
|
[ "${MC_AUTOUPDATE_REBOOT:-1}" = "1" ] || { say "Neustart per Env abgeschaltet."; return 0; }
|
||||||
[ -f /var/run/reboot-required ] || { say "Kein Neustart noetig."; return 0; }
|
[ -f /var/run/reboot-required ] || { say "Kein Neustart noetig."; return 0; }
|
||||||
|
|
||||||
local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr "
|
local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr '\n' ' ')"
|
||||||
" " ")"
|
|
||||||
|
|
||||||
# Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster, sonntags 04:00-06:59.
|
# Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster, sonntags 04:00-06:59.
|
||||||
# Anlass: Ein nachgeholter Lauf (Persistent=true beim Einschalten des Timers) startete die Box
|
# Anlass: Ein nachgeholter Lauf (Persistent=true beim Einschalten des Timers) startete die Box
|
||||||
@@ -197,6 +214,7 @@ reboot_wenn_noetig(){
|
|||||||
tag="$(date +%u)"; stunde="$((10#$(date +%H)))"
|
tag="$(date +%u)"; stunde="$((10#$(date +%H)))"
|
||||||
if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && { [ "$tag" != "7" ] || [ "$stunde" -lt 4 ] || [ "$stunde" -ge 7 ]; }; then
|
if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && { [ "$tag" != "7" ] || [ "$stunde" -lt 4 ] || [ "$stunde" -ge 7 ]; }; then
|
||||||
notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster am Sonntag frueh."
|
notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster am Sonntag frueh."
|
||||||
|
verlauf neustart offen "Neustart steht an (${pakete:-Kernel/libc}), erst im Wartungsfenster am Sonntag früh."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -206,6 +224,7 @@ reboot_wenn_noetig(){
|
|||||||
local nutzer; nutzer="$(id -un)"
|
local nutzer; nutzer="$(id -un)"
|
||||||
if [ "$(loginctl show-user "$nutzer" -p Linger --value 2>/dev/null)" != "yes" ]; then
|
if [ "$(loginctl show-user "$nutzer" -p Linger --value 2>/dev/null)" != "yes" ]; then
|
||||||
notify "Neustart NICHT ausgefuehrt: die Box laeuft ohne linger — nach einem Neustart wuerden Steuerpult, Gateway und Waechter nicht von selbst starten. Erst 'loginctl enable-linger $nutzer' setzen, dann von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
|
notify "Neustart NICHT ausgefuehrt: die Box laeuft ohne linger — nach einem Neustart wuerden Steuerpult, Gateway und Waechter nicht von selbst starten. Erst 'loginctl enable-linger $nutzer' setzen, dann von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
|
||||||
|
verlauf neustart offen "Neustart nicht ausgeführt: Linger fehlt."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -214,6 +233,7 @@ reboot_wenn_noetig(){
|
|||||||
laufend="$(curl -sf --max-time 20 "$API/api/jobs" | jq -r '[.jobs[]? | select(.state=="running")] | length' 2>/dev/null || echo 0)"
|
laufend="$(curl -sf --max-time 20 "$API/api/jobs" | jq -r '[.jobs[]? | select(.state=="running")] | length' 2>/dev/null || echo 0)"
|
||||||
if [ "${laufend:-0}" -gt 0 ]; then
|
if [ "${laufend:-0}" -gt 0 ]; then
|
||||||
notify "Neustart verschoben: es laufen noch $laufend Job(s). Ausstehend: ${pakete:-Kernel/libc}. Naechster Versuch beim Lauf in einer Woche."
|
notify "Neustart verschoben: es laufen noch $laufend Job(s). Ausstehend: ${pakete:-Kernel/libc}. Naechster Versuch beim Lauf in einer Woche."
|
||||||
|
verlauf neustart offen "Neustart verschoben: es liefen noch $laufend Aufträge."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -224,14 +244,17 @@ reboot_wenn_noetig(){
|
|||||||
done
|
done
|
||||||
if [ -n "$fehlend" ]; then
|
if [ -n "$fehlend" ]; then
|
||||||
notify "Neustart NICHT ausgefuehrt: diese Dienste sind nicht fuer den Autostart eingetragen —$fehlend. Erst 'systemctl --user enable' nachholen. Ausstehend: ${pakete:-Kernel/libc}"
|
notify "Neustart NICHT ausgefuehrt: diese Dienste sind nicht fuer den Autostart eingetragen —$fehlend. Erst 'systemctl --user enable' nachholen. Ausstehend: ${pakete:-Kernel/libc}"
|
||||||
|
verlauf neustart offen "Neustart nicht ausgeführt: Dienste nicht im Autostart ($fehlend)."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
notify "Die Box startet jetzt neu — das OS verlangt es nach dem Update (${pakete:-Kernel/libc}). Sie ist ein paar Minuten weg und meldet sich, sobald alles wieder laeuft. Die Modelle muessen danach neu geladen werden, die erste Anfrage dauert also laenger."
|
notify "Die Box startet jetzt neu — das OS verlangt es nach dem Update (${pakete:-Kernel/libc}). Sie ist ein paar Minuten weg und meldet sich, sobald alles wieder laeuft. Die Modelle muessen danach neu geladen werden, die erste Anfrage dauert also laenger."
|
||||||
say "Neustart wird ausgeloest (ausstehend: ${pakete:-Kernel/libc})."
|
say "Neustart wird ausgeloest (ausstehend: ${pakete:-Kernel/libc})."
|
||||||
|
verlauf neustart neustart "Die Box startet jetzt neu (${pakete:-Kernel/libc})."
|
||||||
sleep 20 # der Meldung Zeit lassen, rauszugehen
|
sleep 20 # der Meldung Zeit lassen, rauszugehen
|
||||||
sudo -n systemctl reboot || {
|
sudo -n systemctl reboot || {
|
||||||
notify "Neustart FEHLGESCHLAGEN: 'sudo systemctl reboot' wurde abgelehnt. Bitte von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
|
notify "Neustart FEHLGESCHLAGEN: 'sudo systemctl reboot' wurde abgelehnt. Bitte von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
|
||||||
|
verlauf neustart fehler "Neustart fehlgeschlagen: sudo systemctl reboot wurde abgelehnt."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +263,7 @@ say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))."
|
|||||||
pins_init
|
pins_init
|
||||||
if ! curl -sf --max-time 20 "$API/api/health" >/dev/null; then
|
if ! curl -sf --max-time 20 "$API/api/health" >/dev/null; then
|
||||||
notify "Auto-Update abgebrochen: MC2-API ($API) nicht erreichbar — bitte Box prüfen."
|
notify "Auto-Update abgebrochen: MC2-API ($API) nicht erreichbar — bitte Box prüfen."
|
||||||
|
verlauf lauf fehler "Abgebrochen: MC2 war nicht erreichbar."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ fi
|
|||||||
# autoupdate.sh meldet waehrend des Laufs selbst (inkl. der KRITISCH-Faelle, in
|
# autoupdate.sh meldet waehrend des Laufs selbst (inkl. der KRITISCH-Faelle, in
|
||||||
# denen ein Bericht am Ende zu spaet kaeme). Wir fangen hier nur den Fall ab,
|
# denen ein Bericht am Ende zu spaet kaeme). Wir fangen hier nur den Fall ab,
|
||||||
# dass es gar nicht erst durchlaeuft — sonst waere das Schweigen zweideutig.
|
# dass es gar nicht erst durchlaeuft — sonst waere das Schweigen zweideutig.
|
||||||
|
# Anlass für den strukturierten Update-Verlauf (seit 24.09.2026); von Hand heißt es „Update von Hand“.
|
||||||
|
export MC_UPDATE_ANLASS="Updates am Sonntag"
|
||||||
AUSGABE="$(bash "$AUTOUPDATE" 2>&1)"
|
AUSGABE="$(bash "$AUTOUPDATE" 2>&1)"
|
||||||
CODE=$?
|
CODE=$?
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user