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
+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