From 209549f0f318e7751537173681201fe6256ab3da Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 18:48:28 +0200 Subject: [PATCH] homelab: Arcane und Docker (eigene Version oeffentlich, Images mit Schluessel, Updates zuerst als Probelauf); Datei-Sperren gegen Windows-Wettlauf Co-Authored-By: Claude Opus 5.5 --- backend/routers/homelab.py | 7 ++ backend/services/homelab/arcane.py | 99 ++++++++++++++++++++++++++++ backend/services/homelab/inventar.py | 42 +++++++++++- backend/services/homelab/kanal.py | 14 ++-- backend/services/homelab/updates.py | 58 +++++++++++++--- backend/tests/test_homelab.py | 29 +++++++- docs/ARCHITEKTUR.md | 4 ++ 7 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 backend/services/homelab/arcane.py diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index a1fad27..8ce2546 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -4,6 +4,7 @@ POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete) POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern + POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf) POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf) GET /api/homelab/laeufe Die letzten Update-Läufe mit ihren Schritten GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers @@ -50,6 +51,12 @@ def os_update(ziel_id: str) -> dict: return _antwort(updates.starten(ziel_id, "os")) +@router.post("/ziele/{ziel_id}/docker") +def docker(ziel_id: str) -> dict: + """Docker über Arcanes Updater — zuerst nur als Probelauf (MC_ARCANE_ECHT=1 macht es echt).""" + return _antwort(updates.starten(ziel_id, "docker")) + + @router.post("/ziele/pve/neustart") def host_neustart() -> dict: return _antwort(updates.starten("pve", "neustart")) diff --git a/backend/services/homelab/arcane.py b/backend/services/homelab/arcane.py new file mode 100644 index 0000000..90210ec --- /dev/null +++ b/backend/services/homelab/arcane.py @@ -0,0 +1,99 @@ +"""Arcane und Docker auf der Arcane-VM (Phase 3/4, 24.09.2026). + +Arcane (getarcaneapp/arcane) verwaltet die Docker-Container der VM (NerdQuiz, Rippy …) und hat eine +eigene Schnittstelle (OpenAPI unter /api/openapi.json). Öffentlich ist nur die eigene Version +(/api/app-version mit currentVersion, newestVersion, updateAvailable). Alles über Images braucht einen +API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt — MC_ARCANE_KEY in /etc/mc2/homelab.env. + +Docker-Updates laufen über Arcanes eigenen Updater (POST /environments/{id}/updater/run). User-Entscheid +24.09.: zuerst nur als Probelauf (dryRun); echte Updates erst mit MC_ARCANE_ECHT=1. +""" + +import os +import threading +import time + +import httpx + +ZEITLIMIT = httpx.Timeout(20.0, connect=5.0) +CACHE_S = 120 +_cache: dict[str, tuple[float, object]] = {} +_lock = threading.Lock() + + +def _gemerkt(schluessel: str, holen): + """Arcane merkt sich den Update-Stand selbst; die Übersicht fragt ihn höchstens alle zwei Minuten ab.""" + jetzt = time.time() + with _lock: + if (eintrag := _cache.get(schluessel)) and jetzt - eintrag[0] < CACHE_S: + return eintrag[1] + wert = holen() + with _lock: + _cache[schluessel] = (jetzt, wert) + return wert + + +def schluessel() -> str: + return os.environ.get("MC_ARCANE_KEY", "").strip() + + +def echt() -> bool: + return os.environ.get("MC_ARCANE_ECHT", "") == "1" + + +def app_version(url: str) -> dict | None: + """Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None.""" + return _gemerkt(f"version:{url}", lambda: _app_version(url)) + + +def _app_version(url: str) -> dict | None: + try: + d = httpx.get(f"{url}/api/app-version", timeout=ZEITLIMIT).json() + except (httpx.HTTPError, ValueError): + return None + if not isinstance(d, dict) or not d.get("currentVersion"): + return None + return {"installiert": str(d["currentVersion"]).lstrip("v"), "neu": str(d.get("newestVersion") or "").lstrip("v") or None, + "update": bool(d.get("updateAvailable"))} + + +def _get(url: str, pfad: str) -> object: + r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": schluessel()}, timeout=ZEITLIMIT) + r.raise_for_status() + return r.json() + + +def umgebungen(url: str) -> list[dict]: + daten = _get(url, "/environments?limit=50") + return [e for e in (daten or {}).get("data") or [] if isinstance(e, dict) and e.get("id") is not None] + + +def images_mit_update(url: str) -> list[dict]: + """Images mit einem neueren Stand in der Registry, über alle Umgebungen: {"name", "aktuell", "neu", "benutzt"}.""" + return _gemerkt(f"images:{url}", lambda: _images_mit_update(url)) + + +def _images_mit_update(url: str) -> list[dict]: + ergebnis = [] + for umgebung in umgebungen(url): + daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500") + for image in (daten or {}).get("data") or []: + info = image.get("updateInfo") or {} + if not info.get("hasUpdate"): + continue + name = f"{image.get('repo') or '?'}:{image.get('tag') or 'latest'}" + ergebnis.append({"name": name, "aktuell": info.get("currentVersion") or None, + "neu": info.get("latestVersion") or None, "umgebung": umgebung["id"], + "benutzt": [str(u.get("name") if isinstance(u, dict) else u) for u in image.get("usedBy") or []]}) + return ergebnis + + +def updater(url: str, probelauf: bool) -> dict: + """Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler).""" + ergebnisse = {} + for umgebung in umgebungen(url): + r = httpx.post(f"{url}/api/environments/{umgebung['id']}/updater/run", headers={"X-API-Key": schluessel()}, + json={"dryRun": probelauf}, timeout=httpx.Timeout(600.0, connect=5.0)) + r.raise_for_status() + ergebnisse[str(umgebung.get("name") or umgebung["id"])] = (r.json() or {}).get("data") or {} + return ergebnisse diff --git a/backend/services/homelab/inventar.py b/backend/services/homelab/inventar.py index ea9fa84..4adca52 100644 --- a/backend/services/homelab/inventar.py +++ b/backend/services/homelab/inventar.py @@ -20,7 +20,7 @@ from kern.github import neueste_version from kern.zeit import LOCAL_TZ from kern.ziele import Aktion, BausteinStand, ZielStand -from services.homelab import apps, kanal +from services.homelab import apps, arcane, kanal BERICHT_ALT_S = 30 * 60 LISTEN_ALT_S = 14 * 24 * 3600 @@ -117,6 +117,42 @@ def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand: return stand +def _arcane_bausteine(basis: str | None, zid: str) -> list[BausteinStand]: + """Arcane selbst (öffentliche Version) und die Docker-Images (mit API-Schlüssel).""" + app = arcane.app_version(basis) if basis else None + selbst = BausteinStand(id="arcane", name="Arcane", zustand="unbekannt", + installiert=app["installiert"] if app else None, verfuegbar=app["neu"] if app else None) + if not app: + selbst.grund = "Arcane antwortet nicht." + elif app["update"]: + selbst.zustand, selbst.kurz = "neu", f"{app['installiert']} → {app['neu']}" + selbst.grund = "Arcane spielt sein eigenes Update über die eigene Oberfläche ein (Einstellungen)." + else: + selbst.zustand = "aktuell" + docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt") + if not arcane.schluessel(): + docker.grund = ("Es fehlt ein Arcane-API-Schlüssel (MC_ARCANE_KEY): ohne ihn sieht der Orchestrator " + "die Images nicht.") + elif basis: + try: + images = arcane.images_mit_update(basis) + except Exception: + docker.grund = "Arcane verweigert die Auskunft oder antwortet nicht (Schlüssel prüfen)." + else: + if images: + docker.zustand, docker.kurz = "neu", f"{len(images)} Images" + docker.grund = ", ".join(i["name"] for i in images[:6]) + (" …" if len(images) > 6 else "") + probe = not arcane.echt() + docker.aktion = Aktion( + "POST", f"/api/homelab/ziele/{zid}/docker", + ("Arcane prüft im Probelauf, welche Container es aktualisieren würde — geändert wird noch nichts." + if probe else "Arcane aktualisiert jetzt die Container mit neuen Images; Daten-Volumes bleiben."), + label="Probelauf" if probe else "Jetzt updaten") + else: + docker.zustand = "aktuell" + return [selbst, docker] + + def _gast_ziel(gast: dict, jetzt: float) -> ZielStand: art = "container" if gast["art"] == "lxc" else "vm" zid = f"{'ct' if art == 'container' else 'vm'}-{gast['vmid']}" @@ -131,6 +167,10 @@ def _gast_ziel(gast: dict, jetzt: float) -> ZielStand: ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt", grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}.")) return ziel + if app and app.kennung == "arcane": + # Arcane läuft über seine eigene Schnittstelle, nicht über den Ausführer: kein Etikett nötig. + ziel.bausteine += _arcane_bausteine(url.rstrip("/") if url else None, zid) + return ziel if not gast.get("erlaubt"): ziel.bausteine.append(BausteinStand( id="freigabe", name="Freigabe", zustand="unbekannt", diff --git a/backend/services/homelab/kanal.py b/backend/services/homelab/kanal.py index 5c41185..e8f3f6a 100644 --- a/backend/services/homelab/kanal.py +++ b/backend/services/homelab/kanal.py @@ -84,12 +84,14 @@ def zuletzt() -> float | None: # --- Bericht ------------------------------------------------------------------------------ def bericht_speichern(daten: dict) -> None: - _json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten}) + with _lock: + _json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten}) def bericht() -> dict | None: """{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam.""" - daten = _json_lesen(_dir() / "pve-bericht.json", None) + with _lock: + daten = _json_lesen(_dir() / "pve-bericht.json", None) return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None @@ -152,7 +154,10 @@ def ergebnis(auftrag_id: str, code: int, text: str) -> bool: def auftrag(auftrag_id: str) -> dict | None: - return next((a for a in _alle() if a["id"] == auftrag_id), None) + # Lesen unter derselben Sperre wie das Schreiben: Unter Windows scheitert das atomare Ersetzen, + # solange ein anderer Thread die Datei offen hat. + with _lock: + return next((a for a in _alle() if a["id"] == auftrag_id), None) def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | None: @@ -167,4 +172,5 @@ def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | N def liste() -> list[dict]: - return list(reversed(_alle())) + with _lock: + return list(reversed(_alle())) diff --git a/backend/services/homelab/updates.py b/backend/services/homelab/updates.py index 6ef8c7f..f5677af 100644 --- a/backend/services/homelab/updates.py +++ b/backend/services/homelab/updates.py @@ -27,7 +27,7 @@ from kern.einstellungen import einstellungen from kern.zeit import LOCAL_TZ from services import announce, update_verlauf -from services.homelab import apps, inventar, kanal +from services.homelab import apps, arcane, inventar, kanal log = logging.getLogger(__name__) @@ -36,7 +36,7 @@ ZEITLIMIT_UPDATE_S = 45 * 60 ZEITLIMIT_KURZ_S = 15 * 60 BETREFF = "[Homelab-Update]" -_lock = threading.Lock() +_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre def _pfad() -> Path: @@ -44,10 +44,11 @@ def _pfad() -> Path: def _laeufe() -> list[dict]: - try: - daten = json.loads(_pfad().read_text(encoding="utf-8")) - except (OSError, ValueError): - return [] + with _lock: + try: + daten = json.loads(_pfad().read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] return daten if isinstance(daten, list) else [] @@ -164,6 +165,30 @@ def _aufraeumen(lauf: dict, vmid: int, behalten: str | None) -> None: lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.") +def _docker_lauf(lauf: dict, basis: str) -> None: + """Docker über Arcanes Updater — zuerst nur als Probelauf (User-Entscheid 24.09.2026).""" + probe = not arcane.echt() + try: + ergebnisse = arcane.updater(basis, probelauf=probe) + except Exception as exc: + _ende(lauf, "fehler", f"Arcane: Updater gescheitert — {exc}", dringend=not probe) + return + gesamt = {k: sum(int((e or {}).get(k) or 0) for e in ergebnisse.values()) for k in ("checked", "updated", "failed")} + namen = [str(i.get("resourceName")) for e in ergebnisse.values() for i in (e or {}).get("items") or [] + if isinstance(i, dict) and i.get("updateAvailable")] + if probe: + _ende(lauf, "offen", f"Arcane-Probelauf: {len(namen)} von {gesamt['checked']} Containern würden aktualisiert" + + (f" ({', '.join(namen[:8])})" if namen else "") + ". Geändert wurde nichts.") + elif gesamt["failed"]: + _ende(lauf, "fehler", f"Arcane: {gesamt['failed']} Container ließen sich nicht aktualisieren, " + f"{gesamt['updated']} schon.", dringend=True) + else: + ok = inventar.erreichbar_frisch(basis + "/") + _ende(lauf, "eingespielt" if ok else "fehler", + f"Arcane: {gesamt['updated']} Container aktualisiert" + ("." if ok else ", aber Arcane antwortet danach nicht."), + dringend=not ok) + + def _host_lauf(lauf: dict) -> None: try: if lauf["baustein"] == "neustart": @@ -188,9 +213,16 @@ def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None: _melden(text, dringend=dringend) +def _neuer_lauf(ziel_id: str, baustein: str, name: str) -> dict: + lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft", + "start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []} + _merken(lauf) + return lauf + + def starten(ziel_id: str, baustein: str) -> dict: """Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}.""" - if baustein not in ("app", "os", "pakete", "neustart"): + if baustein not in ("app", "os", "pakete", "neustart", "docker"): return {"ok": False, "detail": "Unbekannter Baustein."} if any(x.get("ziel") == ziel_id and x.get("status") == "laeuft" for x in _laeufe()): return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."} @@ -200,13 +232,19 @@ def starten(ziel_id: str, baustein: str) -> dict: gast = inventar.gast(ziel_id) if not gast: return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."} + if baustein == "docker": + app = apps.app_fuer(None, gast.get("name")) + basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/") + if not (app and app.kennung == "arcane" and basis and arcane.schluessel()): + return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."} + lauf = _neuer_lauf(ziel_id, baustein, app.name) + threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}", daemon=True).start() + return {"ok": True, "lauf": lauf["id"]} if not gast.get("erlaubt"): return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."} app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name")) name = app.name if app else str(gast.get("name")) - lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft", - "start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []} - _merken(lauf) + lauf = _neuer_lauf(ziel_id, baustein, name) ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast)) threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start() return {"ok": True, "lauf": lauf["id"]} diff --git a/backend/tests/test_homelab.py b/backend/tests/test_homelab.py index fd709ac..1225dd7 100644 --- a/backend/tests/test_homelab.py +++ b/backend/tests/test_homelab.py @@ -31,6 +31,10 @@ def daten(tmp_path, monkeypatch): "tag": neueste.get(repo, "2026-07-24-r1"), "version": neueste.get(repo, "2026-07-24-r1"), "datum": "2026-07-24"}) monkeypatch.setattr(inventar, "erreichbar", lambda url: True) + monkeypatch.setattr(inventar.arcane, "app_version", + lambda url: {"installiert": "2.12.0", "neu": "2.13.1", "update": True}) + monkeypatch.delenv("MC_ARCANE_KEY", raising=False) + monkeypatch.delenv("MC_ARCANE_ECHT", raising=False) yield tmp_path einstellungen_mod.einstellungen.cache_clear() @@ -106,8 +110,11 @@ def test_ziele_aus_dem_echten_bericht(daten): assert {b["id"]: b for b in z["ct-101"]["bausteine"]}["app"]["zustand"] == "aktuell" # Image jünger # NetBird hat keine Weboberfläche: erreichbar heißt hier „läuft“. assert z["ct-102"]["erreichbar"] is True - arcane = z["vm-106"] - assert arcane["name"] == "Arcane (Docker)" and arcane["bausteine"][0]["id"] == "freigabe" + arcane = {b["id"]: b for b in z["vm-106"]["bausteine"]} + assert z["vm-106"]["name"] == "Arcane (Docker)" + # Arcane läuft über seine eigene Schnittstelle: ohne Schlüssel sagt die Übersicht das ehrlich. + assert (arcane["arcane"]["zustand"], arcane["arcane"]["kurz"]) == ("neu", "2.12.0 → 2.13.1") + assert arcane["docker"]["zustand"] == "unbekannt" and "MC_ARCANE_KEY" in arcane["docker"]["grund"] def test_ohne_bericht_leer_und_ehrlich(daten): @@ -252,3 +259,21 @@ def test_ausfuehrer_lehnt_fremdes_ab(monkeypatch): assert ausgefuehrt == [] assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": 104}})["code"] == 0 assert ausgefuehrt[-1][:4] == ["pct", "exec", "104", "--"] and "PHS_SILENT=1" in ausgefuehrt[-1][-1] + + +def test_docker_ueber_arcane_erst_als_probelauf(ausfuehrer, monkeypatch): + _, _, meldungen, _ = ausfuehrer + monkeypatch.setenv("MC_ARCANE_KEY", "schluessel") + monkeypatch.setattr(inventar.arcane, "images_mit_update", lambda url: [ + {"name": "ghcr.io/tobi/nerdquiz:latest", "aktuell": None, "neu": None, "umgebung": 0, "benutzt": ["nerdquiz"]}]) + docker = {b["id"]: b for b in _ziele(None)["vm-106"]["bausteine"]}["docker"] + assert (docker["zustand"], docker["kurz"], docker["aktion"]["label"]) == ("neu", "1 Images", "Probelauf") + aufrufe: list[bool] = [] + monkeypatch.setattr(updates.arcane, "updater", lambda url, probelauf: aufrufe.append(probelauf) or { + "local": {"checked": 7, "updated": 0, "failed": 0, + "items": [{"resourceName": "nerdquiz", "updateAvailable": True}]}}) + lauf = _auf_ende(updates.starten("vm-106", "docker")["lauf"]) + assert aufrufe == [True] and lauf["ergebnis"] == "offen" + assert meldungen[-1][0] == "Arcane-Probelauf: 1 von 7 Containern würden aktualisiert (nerdquiz). Geändert wurde nichts." + monkeypatch.delenv("MC_ARCANE_KEY") + assert updates.starten("vm-106", "docker")["ok"] is False diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md index f4fa475..e9282ce 100644 --- a/docs/ARCHITEKTUR.md +++ b/docs/ARCHITEKTUR.md @@ -241,6 +241,10 @@ flowchart LR und Snapshot da → automatisch zurück + dringende Meldung; grün → ältere `mc2-`-Snapshots weg + Meldung. Läufe in `/var/lib/mc2/homelab-laeufe.json` und im strukturierten Update-Verlauf. Host: Pakete per Knopf mit Warnung, Neustart als eigener Knopf. +- **Arcane und Docker** (`services/homelab/arcane.py`): Arcanes eigene Version kommt öffentlich über + `/api/app-version`; die Docker-Images mit neuerem Stand und der Updater brauchen einen Arcane-API-Schlüssel + (`MC_ARCANE_KEY`, Kopfzeile `X-API-Key`). Docker-Updates laufen zuerst nur als Probelauf (`dryRun`); echt erst + mit `MC_ARCANE_ECHT=1`. Die Arcane-VM braucht dafür kein Etikett, weil der Ausführer nicht beteiligt ist. - **Wächter** in der Rolle `homelab`: Platte, Partner (die KI-Box), Ausführer (kein Bericht seit 30 min = rot) und jede Weboberfläche der freigegebenen Gäste. - **Oberfläche**: Die Seite „Homelab“ zeigt alle Geräte als Karten — die KI-Box (`/api/ziele`) und alles aus