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
@@ -1,7 +1,12 @@
|
||||
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from services import update_verlauf
|
||||
|
||||
# Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen.
|
||||
@@ -107,12 +112,50 @@ def test_laeufe_liest_die_datei_und_merkt_sich_den_stand(tmp_path, monkeypatch):
|
||||
datei = tmp_path / "mc2-notify.log"
|
||||
datei.write_text(PROTOKOLL, encoding="utf-8")
|
||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei)
|
||||
monkeypatch.setattr(update_verlauf, "VERLAUF_FILE", tmp_path / "kein-verlauf.jsonl")
|
||||
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
|
||||
monkeypatch.setattr(update_verlauf, "_struktur_cache", {"schluessel": None, "laeufe": []})
|
||||
assert len(update_verlauf.laeufe(3)) == 3
|
||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
|
||||
assert update_verlauf.laeufe() == []
|
||||
|
||||
|
||||
def test_strukturierter_verlauf_loest_das_protokoll_ab(tmp_path, monkeypatch):
|
||||
"""Ab dem ersten strukturierten Eintrag gilt er; derselbe Sonntag aus dem Protokoll fällt weg."""
|
||||
protokoll = tmp_path / "mc2-notify.log"
|
||||
protokoll.write_text(PROTOKOLL + (
|
||||
"2026-10-04 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:\n"
|
||||
"• Router (llama-swap): 256 → 257 eingespielt, Stack-Check grün.\n"), encoding="utf-8")
|
||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", protokoll)
|
||||
monkeypatch.setattr(update_verlauf, "VERLAUF_FILE", tmp_path / "verlauf.jsonl")
|
||||
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
|
||||
monkeypatch.setattr(update_verlauf, "_struktur_cache", {"schluessel": None, "laeufe": []})
|
||||
|
||||
lauf = "2026-10-04T04:30:00+02:00"
|
||||
update_verlauf.eintragen(lauf, "Updates am Sonntag", "swap", "eingespielt", "256 → 257 eingespielt, Stack-Check grün.")
|
||||
update_verlauf.eintragen(lauf, "Updates am Sonntag", "engine", "zurueckgerollt",
|
||||
"11200 fehlgeschlagen → zurückgerollt auf 11172 und festgehalten.")
|
||||
update_verlauf.eintragen(lauf, "Updates am Sonntag", "hermes", "aktuell", "aktuell.")
|
||||
with pytest.raises(ValueError):
|
||||
update_verlauf.eintragen(lauf, "x", "swap", "super", "gibt es nicht")
|
||||
|
||||
laeufe = update_verlauf.laeufe()
|
||||
assert laeufe[0]["start"] == lauf and laeufe[0]["anlass"] == "Updates am Sonntag"
|
||||
assert (laeufe[0]["stufe"], laeufe[0]["titel"]) == ("gelb", "Zurückgerollt")
|
||||
assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [
|
||||
("llama-swap", "eingespielt"), ("Motor", "zurueckgerollt"), ("Hermes", "aktuell")]
|
||||
# Der gleiche Sonntag aus dem Protokoll (04:31) taucht nicht doppelt auf, ältere Läufe bleiben.
|
||||
assert [lauf["start"][:10] for lauf in laeufe[:3]] == ["2026-10-04", "2026-09-27", "2026-09-20"]
|
||||
|
||||
|
||||
def test_kaputte_zeilen_im_verlauf_stoeren_nicht():
|
||||
text = ('kein json\n{"lauf": "keine zeit", "ergebnis": "aktuell"}\n'
|
||||
'{"lauf": "2026-10-04T04:30:00+02:00", "baustein": "os", "ergebnis": "unbekannt", "text": "x"}\n'
|
||||
'{"lauf": "2026-10-04T04:30:00+02:00", "baustein": "os", "ergebnis": "eingespielt", "text": "10 Pakete."}\n')
|
||||
laeufe = update_verlauf.strukturierte_laeufe(text)
|
||||
assert len(laeufe) == 1 and [z["baustein"] for z in laeufe[0]["zeilen"]] == ["Betriebssystem"]
|
||||
|
||||
|
||||
def test_festgehalten_und_freigeben(tmp_path, monkeypatch):
|
||||
pins = tmp_path / "mc2-pins.json"
|
||||
pins.write_text(json.dumps({
|
||||
@@ -154,3 +197,33 @@ def test_zweitweg_zaehlt_wie_telegram():
|
||||
"Stack-Check grün, alles läuft.\n")
|
||||
laeufe = update_verlauf.laeufe_aus(text)
|
||||
assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [("llama-swap", "eingespielt")]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (shutil.which("bash") and shutil.which("jq")), reason="braucht bash und jq (auf der Box da)")
|
||||
def test_autoupdate_schreibt_was_der_verlauf_liest(tmp_path):
|
||||
"""Vertrag zwischen deploy/autoupdate.sh (Schreiber) und diesem Modul (Leser): Die Helfer
|
||||
verlauf() und ergebnis() werden aus dem echten Skript geholt und in einer bash ausgeführt."""
|
||||
skript = (Path(__file__).resolve().parents[2] / "deploy" / "autoupdate.sh").read_text(encoding="utf-8")
|
||||
anfang = skript.index("VERLAUF=")
|
||||
ende = skript.index("# ── Pin-Register")
|
||||
helfer = skript[anfang:ende]
|
||||
ziel = tmp_path / "verlauf.jsonl"
|
||||
probe = helfer + (
|
||||
'SUMMARY=()\n'
|
||||
'name="Router (llama-swap)"; comp=swap; installed=256; latest=257\n'
|
||||
'ergebnis "$comp" "$name" eingespielt "$installed → $latest eingespielt, Stack-Check grün."\n'
|
||||
'ergebnis hermes "Hermes-Agent" zurueckgerollt "Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf abc."\n'
|
||||
'verlauf neustart neustart "Die Box startet jetzt neu (linux-image)."\n'
|
||||
'printf "%s\n" "${SUMMARY[@]}"\n')
|
||||
lauf = subprocess.run(["bash", "-c", probe], capture_output=True, text=True, timeout=30,
|
||||
env={**os.environ, "MC_UPDATE_VERLAUF": str(ziel), "MC_UPDATE_ANLASS": "Updates am Sonntag"})
|
||||
assert lauf.returncode == 0, lauf.stderr
|
||||
# Die Abschlussmeldung bleibt wie vorher („Name: Text“).
|
||||
assert lauf.stdout.splitlines()[0] == "Router (llama-swap): 256 → 257 eingespielt, Stack-Check grün."
|
||||
laeufe = update_verlauf.strukturierte_laeufe(ziel.read_text(encoding="utf-8"))
|
||||
assert len(laeufe) == 1 and laeufe[0]["anlass"] == "Updates am Sonntag"
|
||||
assert [(z["baustein"], z["ergebnis"], z["text"]) for z in laeufe[0]["zeilen"]] == [
|
||||
("llama-swap", "eingespielt", "256 → 257 eingespielt, Stack-Check grün."),
|
||||
("Hermes", "zurueckgerollt", "Update fehlgeschlagen → zurückgerollt und festgehalten auf abc."),
|
||||
("Neustart", "neustart", "Die Box startet jetzt neu (linux-image).")]
|
||||
assert laeufe[0]["titel"] == "Zurückgerollt"
|
||||
|
||||
@@ -61,3 +61,29 @@ def test_hermes_version_auch_wenn_der_abruf_haengt(monkeypatch, tmp_path):
|
||||
info = maintenance.hermes_update_details()
|
||||
assert info["installed_version"] == "0.21.4"
|
||||
assert "error" in info
|
||||
|
||||
|
||||
def test_update_per_knopf_landet_im_verlauf(monkeypatch):
|
||||
"""Einzel-Updates und „Alle aktualisieren“ schreiben ins strukturierte Verlauf-Format; bei einer
|
||||
gescheiterten Kette zeigt die letzte Teil-Marke, welcher Teil scheiterte."""
|
||||
from services import jobengine, update_verlauf
|
||||
|
||||
zeilen: list[tuple] = []
|
||||
monkeypatch.setattr(update_verlauf, "eintragen", lambda *a: zeilen.append(a))
|
||||
job = {"id": "x", "state": "done", "returncode": 0, "started_at": 1790000000.0,
|
||||
"abschluss_daten": {"bausteine": ["engine"]}}
|
||||
maintenance._update_im_verlauf(job)
|
||||
assert zeilen[-1][1:] == ("Update von Hand", "engine", "eingespielt", "Per Knopf eingespielt, Prüfung grün.")
|
||||
|
||||
zeilen.clear()
|
||||
monkeypatch.setattr(jobengine, "protokoll", lambda job_id: [
|
||||
"$ bash -c …", "════════ [1/3] Engine (llama.cpp) ════════", "ok",
|
||||
"════════ [2/3] Router (llama-swap) ════════", "Stack-Check rot"])
|
||||
maintenance._update_im_verlauf({**job, "state": "failed", "returncode": 1,
|
||||
"abschluss_daten": {"bausteine": ["engine", "swap", "hermes"]}})
|
||||
assert [(z[2], z[3]) for z in zeilen] == [
|
||||
("engine", "eingespielt"), ("swap", "zurueckgerollt"), ("hermes", "offen")]
|
||||
|
||||
zeilen.clear()
|
||||
maintenance._update_im_verlauf({**job, "abschluss_daten": {}})
|
||||
assert zeilen == []
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Gemeinsames Ziel-Modell (Phase 2, 24.09.2026) und die KI-Box als erster Adapter."""
|
||||
|
||||
import pytest
|
||||
from kern.ziele import BausteinStand, ZielStand
|
||||
from services import box_updates, maintenance, system, update_verlauf
|
||||
|
||||
|
||||
def test_als_dict_ordnet_nach_dringlichkeit_und_zaehlt():
|
||||
ziel = ZielStand(id="ct-104", name="Gitea", art="container", instanz="homelab", bausteine=[
|
||||
BausteinStand(id="os", name="Betriebssystem", zustand="aktuell"),
|
||||
BausteinStand(id="app", name="Gitea", zustand="neu", kurz="1.27.2 → 1.27.3"),
|
||||
BausteinStand(id="docker", name="Docker", zustand="unbekannt", grund="nicht erreichbar"),
|
||||
])
|
||||
daten = ziel.als_dict()
|
||||
assert [b["id"] for b in daten["bausteine"]] == ["docker", "app", "os"]
|
||||
assert daten["zaehlung"] == {"aktuell": 1, "neu": 1, "unbekannt": 1} and daten["offen"] == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def box(monkeypatch):
|
||||
monkeypatch.setattr(maintenance, "_installed_engine_build", lambda: 11160)
|
||||
monkeypatch.setattr(maintenance, "_installed_swap_version", lambda: 257)
|
||||
monkeypatch.setattr(maintenance, "_latest_engine_asset_release", lambda: {"tag_name": "b11172"})
|
||||
monkeypatch.setattr(maintenance, "_github_json", lambda pfad, timeout=8: {"tag_name": "v258"})
|
||||
monkeypatch.setattr(maintenance, "_hermes_version", lambda pfad: "0.21.4")
|
||||
monkeypatch.setattr(system, "find_hermes_agent_git", lambda: {"path": "/x", "hash": "abc1234"})
|
||||
monkeypatch.setattr(update_verlauf, "festgehalten", list)
|
||||
monkeypatch.setattr(box_updates, "gelesen", lambda: True)
|
||||
|
||||
def setzen(daten: dict) -> None:
|
||||
monkeypatch.setattr(box_updates, "gecacht", lambda: daten)
|
||||
|
||||
return setzen
|
||||
|
||||
|
||||
def _nach_id(ziel: ZielStand) -> dict[str, BausteinStand]:
|
||||
return {b.id: b for b in ziel.bausteine}
|
||||
|
||||
|
||||
def test_ki_box_neu_aktuell_und_knoepfe(box):
|
||||
box({"os": 10, "engine": 1, "swap": 0, "pruef_fehler": {},
|
||||
"components": [{"key": "hermes_agent", "update": True, "behind": 743, "current": "abc1234"}]})
|
||||
b = _nach_id(box_updates.ki_box_ziel())
|
||||
assert (b["os"].zustand, b["os"].kurz) == ("neu", "10 Pakete")
|
||||
assert (b["engine"].installiert, b["engine"].verfuegbar) == ("b11160", "b11172")
|
||||
assert (b["swap"].zustand, b["swap"].installiert, b["swap"].verfuegbar) == ("aktuell", "v257", None)
|
||||
assert (b["hermes"].installiert, b["hermes"].verfuegbar) == ("0.21.4", "+743 Änderungen")
|
||||
assert b["engine"].aktion.pfad == "/api/maintenance/engine-update"
|
||||
assert b["swap"].aktion is None
|
||||
|
||||
|
||||
def test_ki_box_unbekannt_und_festgehalten_ohne_knopf(box, monkeypatch):
|
||||
monkeypatch.setattr(update_verlauf, "festgehalten", lambda: [
|
||||
{"baustein": "engine", "name": "Motor", "version": "10819", "seit": "13.09.", "grund": "Check rot"}])
|
||||
box({"os": 0, "engine": 1, "swap": 0, "pruef_fehler": {"swap": "GitHub nicht erreichbar"}, "components": []})
|
||||
b = _nach_id(box_updates.ki_box_ziel())
|
||||
assert b["engine"].zustand == "festgehalten" and b["engine"].aktion is None
|
||||
assert b["engine"].grund == "Seit 13.09. auf 10819: Check rot"
|
||||
assert (b["swap"].zustand, b["swap"].grund) == ("unbekannt", "GitHub nicht erreichbar")
|
||||
assert b["os"].zustand == "aktuell"
|
||||
|
||||
|
||||
def test_ki_box_vor_der_ersten_pruefung(box, monkeypatch):
|
||||
monkeypatch.setattr(box_updates, "gelesen", lambda: False)
|
||||
box({})
|
||||
ziel = box_updates.ki_box_ziel()
|
||||
assert {b.zustand for b in ziel.bausteine} == {"wird-geprueft"} and len(ziel.bausteine) == 4
|
||||
Reference in New Issue
Block a user