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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 18:48:28 +02:00
co-authored by Claude Opus 5.5
parent 5e9227c4a3
commit 209549f0f3
7 changed files with 236 additions and 17 deletions
+7
View File
@@ -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"))
+99
View File
@@ -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
+41 -1
View File
@@ -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",
+10 -4
View File
@@ -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()))
+48 -10
View File
@@ -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"]}
+27 -2
View File
@@ -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
+4
View File
@@ -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