phase3+4: Homelab-Teil (Ausfuehrer, Ziele, Jetzt updaten mit Rueckweg) und Seite Homelab fuer alle Geraete

- deploy/homelab/ausfuehrer.py: laeuft als root auf dem Proxmox-Host, holt Auftraege beim Homelab-Teil
  ab (Pull, kein offener Port), feste Aktionsliste, prueft Etiketten selbst; Bericht gegen den echten
  Host erprobt (nur lesend). Kein Proxmox-Schluessel im Container noetig.
- services/homelab: Kanal mit gemeinsamem Geheimnis, App-Katalog, Inventar -> Ziele im gemeinsamen
  Modell (GitHub-Versionen, Webpruefung, alte Paketlisten = unklar), Jetzt updaten: Snapshot ->
  Update -> Pruefung -> bei Rot zurueck + dringende Meldung
- Waechter in der Rolle homelab: Ausfuehrer schweigt, Gaeste antworten nicht
- kern/github.py fuer beide Rollen (auch untagged Releases mit Version im Namen)
- Oberflaeche: Seite Homelab zeigt alle Geraete als Karten (KI-Box ueber /api/ziele, Homelab ueber
  /api/homelab/ziele) mit Stand, Rueckweg und Knopf samt Rueckfrage
- Einrichtung als Skripte (container-anlegen, ausrollen, ausfuehrer-einrichten, box-partner) — noch
  nicht ausgefuehrt, jeder Schritt braucht das User-OK
- frontend-bauen-box.sh: Frontend-Pruefung und Build auf der Box, wenn der PC keinen Speicher hat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 18:39:59 +02:00
co-authored by Claude Opus 5.5
parent a47489fa85
commit 5e9227c4a3
66 changed files with 2472 additions and 170 deletions
+254
View File
@@ -0,0 +1,254 @@
"""Homelab-Teil (Phase 3/4, 24.09.2026): Kanal zum Ausführer, Ziele aus dem echten Bericht des
Proxmox-Hosts (tests/fixtures/pve-bericht.json, gelesen am 24.09.), „Jetzt updaten“ mit Rückweg."""
import importlib.util
import json
import os
import threading
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import inventar, kanal, updates
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
monkeypatch.delenv("MC_AUSFUEHRER_TOKEN", raising=False)
einstellungen_mod.einstellungen.cache_clear()
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
# Keine Netzabfragen: neueste Versionen und Weboberflächen sind fest.
neueste = {"AdguardTeam/AdGuardHome": "0.107.79", "go-gitea/gitea": "1.27.3", "netbirdio/netbird": "0.79.0",
"community-scripts/ProxmoxVE-Local": "1.2.1"}
monkeypatch.setattr(inventar, "neueste_version", lambda repo: {
"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)
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
# --- Kanal ---------------------------------------------------------------------------------
def test_token_wird_einmal_erzeugt_und_geprueft(daten):
wert = kanal.token()
assert len(wert) > 30 and kanal.token() == wert
assert kanal.token_gueltig(wert) and not kanal.token_gueltig("falsch") and not kanal.token_gueltig(None)
if os.name == "posix":
assert (daten / "ausfuehrer.token").stat().st_mode & 0o077 == 0
def test_auftraege_der_reihe_nach_und_verloren(daten, monkeypatch):
erster = kanal.anlegen("bericht")
zweiter = kanal.anlegen("suchen", {"vmid": 104})
with pytest.raises(ValueError):
kanal.anlegen("rm -rf")
assert kanal.naechster()["id"] == erster
assert kanal.ergebnis(erster, 0, "ok") and not kanal.ergebnis(erster, 0, "doppelt")
assert kanal.naechster() == {"id": zweiter, "aktion": "suchen", "parameter": {"vmid": 104}}
monkeypatch.setattr(kanal, "VERLOREN_S", -1)
assert kanal.naechster() is None
assert kanal.auftrag(zweiter)["status"] == "verloren"
def _client() -> TestClient:
from routers import homelab
app = FastAPI()
app.include_router(homelab.router)
return TestClient(app)
def test_nur_der_ausfuehrer_darf_in_den_kanal(daten):
c = _client()
assert c.get("/api/homelab/ausfuehrer/auftrag").status_code == 403
assert c.post("/api/homelab/ausfuehrer/bericht", json=BERICHT,
headers={"X-MC2-Ausfuehrer": "falsch"}).status_code == 403
kopf = {"X-MC2-Ausfuehrer": kanal.token()}
assert c.post("/api/homelab/ausfuehrer/bericht", json=BERICHT, headers=kopf).json() == {"ok": True}
assert c.get("/api/homelab/ausfuehrer/auftrag", headers=kopf).json() == {}
assert c.get("/api/homelab/ausfuehrer").json()["verbunden"] is True
# --- Ziele aus dem echten Bericht ---------------------------------------------------------------
def _ziele(daten) -> dict[str, dict]:
kanal.bericht_speichern(BERICHT)
return {z["id"]: z for z in inventar.ziele()["ziele"]}
def test_ziele_aus_dem_echten_bericht(daten):
z = _ziele(daten)
assert list(z) == ["pve", "ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"]
host = {b["id"]: b for b in z["pve"]["bausteine"]}
assert host["pakete"]["zustand"] == "neu" and host["pakete"]["aktion"]["pfad"] == "/api/homelab/ziele/pve/update"
gitea = {b["id"]: b for b in z["ct-104"]["bausteine"]}
assert (gitea["app"]["zustand"], gitea["app"]["kurz"]) == ("neu", "1.27.2 → 1.27.3")
assert z["ct-104"]["name"] == "Gitea" and z["ct-104"]["rueckweg"] == "Snapshot vor jedem Update"
adguard = {b["id"]: b for b in z["ct-100"]["bausteine"]}
assert adguard["app"]["zustand"] == "aktuell"
# Paketlisten vom Oktober 2025: ehrlich „unbekannt“ mit Knopf zum Suchen statt „aktuell“.
assert adguard["os"]["zustand"] == "unbekannt" and adguard["os"]["aktion"]["label"] == "Nach Updates suchen"
pbs = {b["id"]: b for b in z["ct-105"]["bausteine"]}
assert (pbs["os"]["zustand"], pbs["os"]["kurz"]) == ("neu", "58 Pakete")
assert "Bind-Mount" in z["ct-105"]["rueckweg"]
assert {b["id"]: b for b in z["ct-103"]["bausteine"]}["app"]["kurz"] == "0.5.8 → 1.2.1"
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"
def test_ohne_bericht_leer_und_ehrlich(daten):
stand = inventar.ziele()
assert stand["ziele"] == [] and stand["bericht"] is None and stand["ausfuehrer"]["verbunden"] is False
def test_wachter_sieht_stumme_gaeste(daten, monkeypatch):
from services import waechter
kanal.bericht_speichern(BERICHT)
monkeypatch.setattr(inventar, "erreichbar", lambda url: "153" not in url)
befunde = waechter.pruefe_gaeste()
assert [b.id for b in befunde] == ["gast:ct-104"] and befunde[0].titel == "Gitea antwortet nicht"
assert waechter.pruefe_ausfuehrer() == [] # Bericht ist frisch
# --- „Jetzt updaten“ mit einem nachgespielten Ausführer ----------------------------------------
def _ausfuehrer_spielen(stopp: threading.Event, neue_version: str, protokoll: list[str]) -> None:
while not stopp.is_set():
auftrag = kanal.naechster()
if not auftrag:
time.sleep(0.02)
continue
protokoll.append(auftrag["aktion"])
text = ""
if auftrag["aktion"] == "snapshot":
text = "snapshot=mc2-20260924-190000"
elif auftrag["aktion"] == "bericht":
neu = json.loads(json.dumps(BERICHT))
next(g for g in neu["gaeste"] if g["vmid"] == 104)["app"]["version"] = neue_version
text = json.dumps(neu)
kanal.ergebnis(auftrag["id"], 0, text)
@pytest.fixture
def ausfuehrer(daten, monkeypatch):
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
monkeypatch.setattr(kanal, "warten", _schnell_warten)
meldungen: list[tuple[str, bool]] = []
monkeypatch.setattr(updates, "_melden", lambda text, dringend=False: meldungen.append((text, dringend)))
verlauf: list[tuple] = []
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a))
kanal.bericht_speichern(BERICHT)
stopp, protokoll = threading.Event(), []
def starten(neue_version: str) -> None:
threading.Thread(target=_ausfuehrer_spielen, args=(stopp, neue_version, protokoll), daemon=True).start()
yield starten, protokoll, meldungen, verlauf
stopp.set()
_echtes_warten = kanal.warten
def _schnell_warten(auftrag_id, zeitlimit_s, takt_s=2.0):
return _echtes_warten(auftrag_id, min(zeitlimit_s, 10), 0.02)
def _auf_ende(lauf_id: str) -> dict:
ende = time.time() + 20
while time.time() < ende:
lauf = next((x for x in updates.laeufe() if x["id"] == lauf_id), {})
if lauf.get("status") == "fertig":
return lauf
time.sleep(0.05)
raise AssertionError(f"Lauf {lauf_id} wurde nicht fertig")
def test_update_gruen(ausfuehrer, monkeypatch):
starten, protokoll, meldungen, verlauf = ausfuehrer
starten("1.27.3")
antwort = updates.starten("ct-104", "app")
assert antwort["ok"]
assert updates.starten("ct-104", "app")["detail"] == "Für dieses Gerät läuft schon ein Update."
lauf = _auf_ende(antwort["lauf"])
assert lauf["ergebnis"] == "eingespielt" and protokoll == ["snapshot", "update", "bericht"]
assert meldungen == [("Gitea: eingespielt, Prüfung grün.", False)]
assert verlauf[0][2:4] == ("ct-104", "eingespielt")
def test_update_rot_geht_zurueck_und_meldet_dringend(ausfuehrer, monkeypatch):
starten, protokoll, meldungen, _ = ausfuehrer
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
starten("1.27.3")
lauf = _auf_ende(updates.starten("ct-104", "app")["lauf"])
assert lauf["ergebnis"] == "zurueckgerollt"
assert protokoll == ["snapshot", "update", "bericht", "zurueck"]
text, dringend = meldungen[0]
assert dringend and "zurück auf den Snapshot mc2-20260924-190000" in text and "antwortet nicht" in text
def test_update_ohne_neue_version_ist_rot(ausfuehrer):
starten, _, _, _ = ausfuehrer
starten("1.27.2")
lauf = _auf_ende(updates.starten("ct-104", "app")["lauf"])
assert lauf["ergebnis"] == "zurueckgerollt" and "unverändert" in lauf["text"]
def test_nicht_freigegeben_und_unbekannt(ausfuehrer):
assert updates.starten("vm-106", "app")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
assert updates.starten("ct-999", "app")["ok"] is False
assert updates.starten("ct-104", "boese")["ok"] is False
# --- Der Ausführer selbst (läuft auf dem Proxmox-Host) -------------------------------------------
def _ausfuehrer_modul():
spec = importlib.util.spec_from_file_location("ausfuehrer", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_ausfuehrer_entscheidet_selbst_ueber_etiketten():
a = _ausfuehrer_modul()
assert a.erlaubt(["community-script", "git"]) and a.erlaubt(["watcher"])
assert not a.erlaubt(["community-script", "watcher-aus"]) and not a.erlaubt([])
def test_ausfuehrer_snapshot_faehigkeit():
a = _ausfuehrer_modul()
arten = {"local-lvm": "lvmthin", "local": "dir"}
assert a.snapshot_moeglich({"rootfs": "local-lvm:vm-104-disk-0,size=8G"}, arten) == (True, None)
ok, grund = a.snapshot_moeglich({"rootfs": "local-lvm:vm-105-disk-0", "mp0": "/mnt/qnap,mp=/mnt/ds"}, arten)
assert not ok and "Bind-Mount" in grund
assert a.snapshot_moeglich({"rootfs": "local:105/vm-105-disk-0.raw"}, arten)[0] is False
def test_ausfuehrer_lehnt_fremdes_ab(monkeypatch):
a = _ausfuehrer_modul()
monkeypatch.setattr(a, "_pvesh", lambda pfad, *p: [
{"vmid": 104, "type": "lxc", "tags": "community-script;git"},
{"vmid": 106, "type": "qemu", "tags": ""}])
ausgefuehrt: list[list[str]] = []
monkeypatch.setattr(a, "_laufen", lambda befehl, zeitlimit=60: ausgefuehrt.append(befehl) or (0, "ok"))
assert a.ausfuehren({"aktion": "rm", "parameter": {}})["code"] == 2
assert "kein erlaubtes Etikett" in a.ausfuehren({"aktion": "update", "parameter": {"vmid": 106}})["text"]
assert a.ausfuehren({"aktion": "zurueck", "parameter": {"vmid": 104, "snapshot": "vorher"}})["code"] == 2
assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": "104; reboot"}})["code"] == 2
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]