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:
co-authored by
Claude Opus 5.5
parent
5e9227c4a3
commit
209549f0f3
@@ -4,6 +4,7 @@
|
|||||||
POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete)
|
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}/os-update Pakete im Gast einspielen
|
||||||
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
|
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)
|
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/laeufe Die letzten Update-Läufe mit ihren Schritten
|
||||||
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
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"))
|
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")
|
@router.post("/ziele/pve/neustart")
|
||||||
def host_neustart() -> dict:
|
def host_neustart() -> dict:
|
||||||
return _antwort(updates.starten("pve", "neustart"))
|
return _antwort(updates.starten("pve", "neustart"))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,7 +20,7 @@ from kern.github import neueste_version
|
|||||||
from kern.zeit import LOCAL_TZ
|
from kern.zeit import LOCAL_TZ
|
||||||
from kern.ziele import Aktion, BausteinStand, ZielStand
|
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
|
BERICHT_ALT_S = 30 * 60
|
||||||
LISTEN_ALT_S = 14 * 24 * 3600
|
LISTEN_ALT_S = 14 * 24 * 3600
|
||||||
@@ -117,6 +117,42 @@ def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
|
|||||||
return stand
|
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:
|
def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
|
||||||
art = "container" if gast["art"] == "lxc" else "vm"
|
art = "container" if gast["art"] == "lxc" else "vm"
|
||||||
zid = f"{'ct' if art == 'container' else 'vm'}-{gast['vmid']}"
|
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",
|
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
|
||||||
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
|
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
|
||||||
return ziel
|
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"):
|
if not gast.get("erlaubt"):
|
||||||
ziel.bausteine.append(BausteinStand(
|
ziel.bausteine.append(BausteinStand(
|
||||||
id="freigabe", name="Freigabe", zustand="unbekannt",
|
id="freigabe", name="Freigabe", zustand="unbekannt",
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ def zuletzt() -> float | None:
|
|||||||
# --- Bericht ------------------------------------------------------------------------------
|
# --- Bericht ------------------------------------------------------------------------------
|
||||||
|
|
||||||
def bericht_speichern(daten: dict) -> None:
|
def bericht_speichern(daten: dict) -> None:
|
||||||
|
with _lock:
|
||||||
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten})
|
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten})
|
||||||
|
|
||||||
|
|
||||||
def bericht() -> dict | None:
|
def bericht() -> dict | None:
|
||||||
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
|
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
|
||||||
|
with _lock:
|
||||||
daten = _json_lesen(_dir() / "pve-bericht.json", None)
|
daten = _json_lesen(_dir() / "pve-bericht.json", None)
|
||||||
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
|
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
|
||||||
|
|
||||||
@@ -152,6 +154,9 @@ def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def auftrag(auftrag_id: str) -> dict | None:
|
def auftrag(auftrag_id: str) -> dict | 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)
|
return next((a for a in _alle() if a["id"] == auftrag_id), None)
|
||||||
|
|
||||||
|
|
||||||
@@ -167,4 +172,5 @@ def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | N
|
|||||||
|
|
||||||
|
|
||||||
def liste() -> list[dict]:
|
def liste() -> list[dict]:
|
||||||
|
with _lock:
|
||||||
return list(reversed(_alle()))
|
return list(reversed(_alle()))
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from kern.einstellungen import einstellungen
|
|||||||
from kern.zeit import LOCAL_TZ
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
from services import announce, update_verlauf
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ ZEITLIMIT_UPDATE_S = 45 * 60
|
|||||||
ZEITLIMIT_KURZ_S = 15 * 60
|
ZEITLIMIT_KURZ_S = 15 * 60
|
||||||
BETREFF = "[Homelab-Update]"
|
BETREFF = "[Homelab-Update]"
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
|
||||||
|
|
||||||
|
|
||||||
def _pfad() -> Path:
|
def _pfad() -> Path:
|
||||||
@@ -44,6 +44,7 @@ def _pfad() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _laeufe() -> list[dict]:
|
def _laeufe() -> list[dict]:
|
||||||
|
with _lock:
|
||||||
try:
|
try:
|
||||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -164,6 +165,30 @@ def _aufraeumen(lauf: dict, vmid: int, behalten: str | None) -> None:
|
|||||||
lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.")
|
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:
|
def _host_lauf(lauf: dict) -> None:
|
||||||
try:
|
try:
|
||||||
if lauf["baustein"] == "neustart":
|
if lauf["baustein"] == "neustart":
|
||||||
@@ -188,9 +213,16 @@ def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
|
|||||||
_melden(text, dringend=dringend)
|
_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:
|
def starten(ziel_id: str, baustein: str) -> dict:
|
||||||
"""Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
|
"""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."}
|
return {"ok": False, "detail": "Unbekannter Baustein."}
|
||||||
if any(x.get("ziel") == ziel_id and x.get("status") == "laeuft" for x in _laeufe()):
|
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."}
|
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)
|
gast = inventar.gast(ziel_id)
|
||||||
if not gast:
|
if not gast:
|
||||||
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
|
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"):
|
if not gast.get("erlaubt"):
|
||||||
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
|
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"))
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
name = app.name if app else str(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",
|
lauf = _neuer_lauf(ziel_id, baustein, name)
|
||||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []}
|
|
||||||
_merken(lauf)
|
|
||||||
ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast))
|
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()
|
threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
|
||||||
return {"ok": True, "lauf": lauf["id"]}
|
return {"ok": True, "lauf": lauf["id"]}
|
||||||
|
|||||||
@@ -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"),
|
"tag": neueste.get(repo, "2026-07-24-r1"), "version": neueste.get(repo, "2026-07-24-r1"),
|
||||||
"datum": "2026-07-24"})
|
"datum": "2026-07-24"})
|
||||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
|
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
|
yield tmp_path
|
||||||
einstellungen_mod.einstellungen.cache_clear()
|
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
|
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“.
|
# NetBird hat keine Weboberfläche: erreichbar heißt hier „läuft“.
|
||||||
assert z["ct-102"]["erreichbar"] is True
|
assert z["ct-102"]["erreichbar"] is True
|
||||||
arcane = z["vm-106"]
|
arcane = {b["id"]: b for b in z["vm-106"]["bausteine"]}
|
||||||
assert arcane["name"] == "Arcane (Docker)" and arcane["bausteine"][0]["id"] == "freigabe"
|
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):
|
def test_ohne_bericht_leer_und_ehrlich(daten):
|
||||||
@@ -252,3 +259,21 @@ def test_ausfuehrer_lehnt_fremdes_ab(monkeypatch):
|
|||||||
assert ausgefuehrt == []
|
assert ausgefuehrt == []
|
||||||
assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": 104}})["code"] == 0
|
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]
|
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
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ flowchart LR
|
|||||||
und Snapshot da → automatisch zurück + dringende Meldung; grün → ältere `mc2-`-Snapshots weg + Meldung. Läufe in
|
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,
|
`/var/lib/mc2/homelab-laeufe.json` und im strukturierten Update-Verlauf. Host: Pakete per Knopf mit Warnung,
|
||||||
Neustart als eigener Knopf.
|
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
|
- **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.
|
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
|
- **Oberfläche**: Die Seite „Homelab“ zeigt alle Geräte als Karten — die KI-Box (`/api/ziele`) und alles aus
|
||||||
|
|||||||
Reference in New Issue
Block a user