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:
co-authored by
Claude Opus 5.5
parent
a47489fa85
commit
5e9227c4a3
@@ -72,6 +72,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
from services import jobengine
|
||||
await asyncio.to_thread(jobengine.wiederaufnehmen)
|
||||
tasks = _box_hintergrund() if ROLLE == "box" else []
|
||||
if ROLLE == "homelab":
|
||||
from services.homelab import updates
|
||||
updates.unterbrochene_abschliessen() # Läufe des vorigen Prozesses gelten als unterbrochen
|
||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||
app.state.gw_client = httpx.AsyncClient(
|
||||
@@ -160,6 +163,9 @@ app.include_router(health.router)
|
||||
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
|
||||
if ROLLE == "box":
|
||||
_box_router(app)
|
||||
else:
|
||||
from routers import homelab
|
||||
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
|
||||
|
||||
|
||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""GitHub-Abfragen mit Zwischenspeicher — für beide Rollen (Box: Motor und llama-swap, Homelab: die Apps).
|
||||
|
||||
GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte früher
|
||||
neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def github_json(pfad: str, timeout: float = 8) -> object:
|
||||
"""GET https://api.github.com/<pfad>, 15 Minuten gemerkt. Wirft bei HTTP-Fehlern."""
|
||||
jetzt = time.time()
|
||||
with _lock:
|
||||
if (eintrag := _cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
|
||||
return eintrag[1]
|
||||
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
|
||||
r.raise_for_status()
|
||||
daten = r.json()
|
||||
with _lock:
|
||||
_cache[pfad] = (jetzt, daten)
|
||||
return daten
|
||||
|
||||
|
||||
_VERSION = re.compile(r"^v?(\d+(?:\.\d+)+|\d{4}-\d{2}-\d{2}(?:-r\d+)?)", re.IGNORECASE)
|
||||
|
||||
|
||||
def neueste_version(repo: str) -> dict | None:
|
||||
"""Neueste Veröffentlichung eines Repos: {"tag", "version", "datum"} oder None. Manche Projekte
|
||||
veröffentlichen „untagged“ und tragen die Version nur im Namen (PVE Scripts Local, 09/2026)."""
|
||||
daten = github_json(f"repos/{repo}/releases/latest")
|
||||
if not isinstance(daten, dict) or not daten.get("tag_name"):
|
||||
return None
|
||||
tag, name = str(daten["tag_name"]), str(daten.get("name") or "")
|
||||
roh = tag if _VERSION.match(tag) or not _VERSION.match(name) else name
|
||||
return {"tag": roh, "version": roh.lstrip("vV"), "datum": str(daten.get("published_at") or "")[:10] or None}
|
||||
@@ -23,6 +23,7 @@ class Aktion:
|
||||
methode: str # "POST"
|
||||
pfad: str # "/api/maintenance/engine-update"
|
||||
frage: str # Rückfrage vor dem Klick
|
||||
label: str = "Jetzt updaten"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Schnittstellen des Homelab-Teils (Rolle homelab, Phase 3/4, 24.09.2026).
|
||||
|
||||
GET /api/homelab/ziele Proxmox-Host und Gäste im gemeinsamen Ziel-Modell
|
||||
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/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
|
||||
GET /api/homelab/ausfuehrer/auftrag ┐
|
||||
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
|
||||
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
|
||||
|
||||
Dünn: die Logik liegt in services/homelab/.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.homelab import inventar, kanal, updates
|
||||
|
||||
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
|
||||
|
||||
|
||||
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
|
||||
if not kanal.token_gueltig(x_mc2_ausfuehrer):
|
||||
raise HTTPException(status_code=403, detail="Nur für den Ausführer auf dem Proxmox-Host.")
|
||||
kanal.kontakt()
|
||||
|
||||
|
||||
@router.get("/ziele")
|
||||
def ziele() -> dict:
|
||||
return inventar.ziele()
|
||||
|
||||
|
||||
def _antwort(ergebnis: dict) -> dict:
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/update")
|
||||
def update(ziel_id: str) -> dict:
|
||||
return _antwort(updates.starten(ziel_id, "pakete" if ziel_id == "pve" else "app"))
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/os-update")
|
||||
def os_update(ziel_id: str) -> dict:
|
||||
return _antwort(updates.starten(ziel_id, "os"))
|
||||
|
||||
|
||||
@router.post("/ziele/pve/neustart")
|
||||
def host_neustart() -> dict:
|
||||
return _antwort(updates.starten("pve", "neustart"))
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/suchen")
|
||||
def suchen(ziel_id: str) -> dict:
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast or not gast.get("erlaubt"):
|
||||
raise HTTPException(status_code=404, detail="Dieses Gerät steht nicht (freigegeben) im Bericht.")
|
||||
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
|
||||
|
||||
|
||||
@router.get("/laeufe")
|
||||
def laeufe() -> dict:
|
||||
return {"laeufe": updates.laeufe()}
|
||||
|
||||
|
||||
@router.get("/ausfuehrer")
|
||||
def ausfuehrer() -> dict:
|
||||
zuletzt = kanal.zuletzt()
|
||||
return {"zuletzt": zuletzt, "verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
||||
"auftraege": kanal.liste()[:20]}
|
||||
|
||||
|
||||
@router.get("/ausfuehrer/auftrag", dependencies=[Depends(_nur_ausfuehrer)])
|
||||
def auftrag_abholen() -> dict:
|
||||
return kanal.naechster() or {}
|
||||
|
||||
|
||||
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
|
||||
def bericht(daten: dict) -> dict:
|
||||
kanal.bericht_speichern(daten)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class Ergebnis(BaseModel):
|
||||
code: int
|
||||
text: str = ""
|
||||
|
||||
|
||||
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
|
||||
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
|
||||
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
|
||||
@@ -20,13 +20,13 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from config import MODELS_DIR
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
|
||||
|
||||
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json")))
|
||||
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(einstellungen().daten_dir / "mc2-announce.json")))
|
||||
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
|
||||
|
||||
# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Der Homelab-Teil (Rolle homelab, Phase 3/4): Proxmox-Host, Container und Arcane.
|
||||
|
||||
apps.py was in welchem Container steckt, wo seine Oberfläche liegt, woher die neueste Version kommt
|
||||
kanal.py Aufträge und Berichte des Ausführers auf dem Proxmox-Host (gemeinsames Geheimnis)
|
||||
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py)
|
||||
updates.py „Jetzt updaten“: Snapshot → Update → Prüfung → bei Rot zurück, mit Meldung
|
||||
"""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Was in welchem Container steckt (Community-Scripts-Kennung aus /usr/bin/update im Gast).
|
||||
|
||||
Stand der Bestandsaufnahme vom 24.09.2026: sechs Container, alle mit dem Etikett community-script.
|
||||
Neue Container mit dem Etikett erscheinen von selbst; fehlt ihre Kennung hier, zeigt die Übersicht
|
||||
sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class App:
|
||||
kennung: str
|
||||
name: str
|
||||
quelle: str | None = None # GitHub owner/repo für die neueste Version; None = über die Pakete des Gasts
|
||||
vergleich: str = "version" # „version“ oder „datum“ (Docker-Images ohne Versionsnummer)
|
||||
port: int | None = None # Weboberfläche; None = keine (z. B. NetBird-Client)
|
||||
https: bool = False
|
||||
pfad: str = "/"
|
||||
|
||||
|
||||
KATALOG: dict[str, App] = {a.kennung: a for a in (
|
||||
App("adguard", "AdGuard Home", "AdguardTeam/AdGuardHome", port=8080),
|
||||
App("npmplus", "NPMplus", "ZoeyVid/NPMplus", vergleich="datum", port=81, https=True),
|
||||
App("netbird", "NetBird", "netbirdio/netbird"),
|
||||
App("pve-scripts-local", "PVE Scripts Local", "community-scripts/ProxmoxVE-Local", port=3000),
|
||||
App("gitea", "Gitea", "go-gitea/gitea", port=3000),
|
||||
App("proxmox-backup-server", "Proxmox Backup Server", port=8007, https=True),
|
||||
)}
|
||||
|
||||
# Gäste, die keine Community-Scripts sind, erkennt man am Namen.
|
||||
NACH_NAME: dict[str, App] = {
|
||||
"arcane": App("arcane", "Arcane (Docker)", port=3552),
|
||||
}
|
||||
|
||||
|
||||
def app_fuer(kennung: str | None, name: str | None) -> App | None:
|
||||
if kennung and kennung in KATALOG:
|
||||
return KATALOG[kennung]
|
||||
return NACH_NAME.get(str(name or "").lower())
|
||||
|
||||
|
||||
def adresse(app: App | None, ip: str | None) -> str | None:
|
||||
"""Adresse der Weboberfläche, falls es eine gibt und die IP bekannt ist."""
|
||||
if not app or not app.port or not ip:
|
||||
return None
|
||||
return f"{'https' if app.https else 'http'}://{ip}:{app.port}{app.pfad}"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Vom Bericht des Ausführers zu Zielen im gemeinsamen Modell (kern/ziele.py) — Phase 3, 24.09.2026.
|
||||
|
||||
Je Gerät ein Ziel: der Proxmox-Host (Pakete, Neustart) und jeder freigegebene Gast (Etikett
|
||||
community-script oder watcher, nicht watcher-aus) mit seinen Bausteinen „App“ (Community-Script,
|
||||
Vergleich mit der neuesten Veröffentlichung auf GitHub) und „Pakete“ (Betriebssystem im Gast).
|
||||
Dazu prüft dieser Teil selbst, ob die Weboberfläche jedes Gasts antwortet.
|
||||
|
||||
Wie ehrlich der Stand ist, steht dabei: Sind die Paketlisten im Gast älter als zwei Wochen, heißt es
|
||||
„unbekannt“ (mit Knopf zum Suchen) statt „aktuell“; ist der Bericht älter als eine halbe Stunde, sagt
|
||||
die Übersicht, dass der Ausführer schweigt.
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
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
|
||||
|
||||
BERICHT_ALT_S = 30 * 60
|
||||
LISTEN_ALT_S = 14 * 24 * 3600
|
||||
ERREICHBAR_CACHE_S = 60
|
||||
|
||||
_erreichbar_cache: dict[str, tuple[float, bool]] = {}
|
||||
_erreichbar_lock = threading.Lock()
|
||||
|
||||
|
||||
def erreichbar(url: str) -> bool:
|
||||
"""Antwortet die Weboberfläche? Alles unter 500 zählt (eine Anmeldeseite ist eine Antwort)."""
|
||||
jetzt = time.time()
|
||||
with _erreichbar_lock:
|
||||
if (eintrag := _erreichbar_cache.get(url)) and jetzt - eintrag[0] < ERREICHBAR_CACHE_S:
|
||||
return eintrag[1]
|
||||
try:
|
||||
# Selbstsignierte Zertifikate sind im Heimnetz üblich (PBS, NPMplus); geprüft wird nur, ob jemand antwortet.
|
||||
ok = httpx.get(url, timeout=5.0, verify=False, follow_redirects=False).status_code < 500
|
||||
except httpx.HTTPError:
|
||||
ok = False
|
||||
with _erreichbar_lock:
|
||||
_erreichbar_cache[url] = (jetzt, ok)
|
||||
return ok
|
||||
|
||||
|
||||
def erreichbar_frisch(url: str) -> bool:
|
||||
"""Wie erreichbar(), aber ohne den Zwischenspeicher — für die Prüfung direkt nach einem Update."""
|
||||
with _erreichbar_lock:
|
||||
_erreichbar_cache.pop(url, None)
|
||||
return erreichbar(url)
|
||||
|
||||
|
||||
def _teile(version: str) -> tuple[int, ...]:
|
||||
return tuple(int(x) for x in re.findall(r"\d+", version.split("-")[0])[:4])
|
||||
|
||||
|
||||
def neuer(verfuegbar: str, installiert: str) -> bool | None:
|
||||
"""Ist verfuegbar neuer als installiert? None, wenn sich das nicht sagen lässt."""
|
||||
a, b = _teile(verfuegbar), _teile(installiert)
|
||||
if not a or not b:
|
||||
return None
|
||||
return a > b
|
||||
|
||||
|
||||
def _datum(ts: float | None) -> str:
|
||||
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.%Y") if ts else "?"
|
||||
|
||||
|
||||
def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand:
|
||||
installiert = (gast.get("app") or {}).get("version")
|
||||
stand = BausteinStand(id="app", name=app.name, zustand="aktuell", installiert=installiert)
|
||||
if not app.quelle:
|
||||
return stand
|
||||
try:
|
||||
neu = neueste_version(app.quelle)
|
||||
except Exception:
|
||||
neu = None
|
||||
if not neu:
|
||||
stand.zustand, stand.grund = "unbekannt", "Die neueste Version ist gerade nicht abrufbar (GitHub)."
|
||||
return stand
|
||||
stand.verfuegbar = neu["version"]
|
||||
if app.vergleich == "datum":
|
||||
# Docker-Image ohne Versionsnummer: neuer, wenn die Veröffentlichung jünger ist als das Image.
|
||||
bild = re.search(r"\d{4}-\d{2}-\d{2}", installiert or "")
|
||||
ist_neuer = (neu["datum"] or "") > bild.group(0) if bild else None
|
||||
else:
|
||||
ist_neuer = neuer(neu["version"], installiert) if installiert else None
|
||||
if ist_neuer is None:
|
||||
stand.zustand, stand.grund = "unbekannt", "Die installierte Version ließ sich nicht lesen."
|
||||
elif ist_neuer:
|
||||
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
|
||||
rueckweg = ("Vorher wird ein Snapshot angelegt; ist die Prüfung danach rot, geht es von selbst zurück."
|
||||
if gast.get("snapshot_moeglich") else
|
||||
"Ein Snapshot ist hier nicht möglich — scheitert das Update, gibt es keinen automatischen Rückweg.")
|
||||
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/update", f"{app.name} jetzt aktualisieren? {rueckweg}")
|
||||
return stand
|
||||
|
||||
|
||||
def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
|
||||
os_stand = gast.get("os_updates") or {}
|
||||
stand = BausteinStand(id="os", name="Pakete", zustand="aktuell")
|
||||
anzahl, listen = os_stand.get("anzahl"), os_stand.get("listen_stand")
|
||||
suchen = Aktion("POST", f"/api/homelab/ziele/{zid}/suchen", "Die Paketlisten im Gast jetzt erneuern?",
|
||||
label="Nach Updates suchen")
|
||||
if anzahl is None:
|
||||
stand.zustand, stand.grund = "unbekannt", "Die Pakete ließen sich nicht lesen."
|
||||
elif listen and jetzt - listen > LISTEN_ALT_S:
|
||||
stand.zustand, stand.aktion = "unbekannt", suchen
|
||||
stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche."
|
||||
elif anzahl > 0:
|
||||
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
|
||||
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
|
||||
f"{anzahl} Pakete im Gast jetzt einspielen? Vorher wird, wo möglich, ein Snapshot angelegt.")
|
||||
return stand
|
||||
|
||||
|
||||
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']}"
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
url = apps.adresse(app, gast.get("ip"))
|
||||
laeuft = gast.get("status") == "running"
|
||||
ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab",
|
||||
erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None),
|
||||
rueckweg=("Snapshot vor jedem Update" if gast.get("snapshot_moeglich")
|
||||
else gast.get("snapshot_grund") or "kein Snapshot möglich"))
|
||||
if not laeuft:
|
||||
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
|
||||
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
|
||||
return ziel
|
||||
if not gast.get("erlaubt"):
|
||||
ziel.bausteine.append(BausteinStand(
|
||||
id="freigabe", name="Freigabe", zustand="unbekannt",
|
||||
grund="Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt)."))
|
||||
return ziel
|
||||
if app and app.kennung in apps.KATALOG:
|
||||
ziel.bausteine.append(_app_baustein(gast, app, zid))
|
||||
if gast["art"] == "lxc":
|
||||
ziel.bausteine.append(_os_baustein(gast, zid, jetzt))
|
||||
return ziel
|
||||
|
||||
|
||||
def _host_ziel(host: dict) -> ZielStand:
|
||||
ziel = ZielStand(id="pve", name="Proxmox-Host", art="proxmox-host", instanz="homelab", erreichbar=True,
|
||||
rueckweg="Kein Snapshot möglich; der Host bleibt beim Update in Betrieb, neu gestartet wird getrennt.")
|
||||
updates = host.get("updates")
|
||||
pakete = BausteinStand(id="pakete", name="Proxmox VE und Debian", zustand="aktuell",
|
||||
installiert=host.get("version"))
|
||||
if updates is None:
|
||||
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
|
||||
elif updates:
|
||||
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
|
||||
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
|
||||
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
|
||||
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
|
||||
ziel.bausteine.append(pakete)
|
||||
if host.get("neustart_noetig"):
|
||||
ziel.bausteine.append(BausteinStand(
|
||||
id="neustart", name="Neustart", zustand="neu", kurz="steht an",
|
||||
aktion=Aktion("POST", "/api/homelab/ziele/pve/neustart",
|
||||
"Den Proxmox-Host jetzt neu starten? ALLE Container und die Arcane-VM sind dann ein paar "
|
||||
"Minuten weg. Die KI-Box prüft danach von außen, ob alles wiederkommt.",
|
||||
label="Jetzt neu starten")))
|
||||
return ziel
|
||||
|
||||
|
||||
def ziele() -> dict:
|
||||
"""Alle Ziele des Homelabs aus dem letzten Bericht, dazu wie frisch er ist."""
|
||||
eingang = kanal.bericht()
|
||||
jetzt = time.time()
|
||||
zuletzt = kanal.zuletzt()
|
||||
ausfuehrer = {"verbunden": bool(zuletzt and jetzt - zuletzt < BERICHT_ALT_S), "zuletzt": zuletzt}
|
||||
if not eingang:
|
||||
return {"ziele": [], "bericht": None, "ausfuehrer": ausfuehrer}
|
||||
b = eingang["bericht"]
|
||||
liste = [_host_ziel(b.get("host") or {})]
|
||||
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or []]
|
||||
for z in liste:
|
||||
z.geprueft = eingang["empfangen"]
|
||||
return {"ziele": [z.als_dict() for z in liste],
|
||||
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
|
||||
"ausfuehrer": ausfuehrer}
|
||||
|
||||
|
||||
def erreichbarkeit() -> list[dict]:
|
||||
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen."""
|
||||
eingang = kanal.bericht()
|
||||
ergebnis = []
|
||||
for g in (eingang or {}).get("bericht", {}).get("gaeste") or []:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
url = apps.adresse(app, g.get("ip"))
|
||||
if url and g.get("erlaubt"):
|
||||
zid = f"{'ct' if g['art'] == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
ergebnis.append({"id": zid, "name": app.name if app else g.get("name"), "url": url,
|
||||
"laeuft": g.get("status") == "running",
|
||||
"ok": g.get("status") == "running" and erreichbar(url)})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def gast(zid: str) -> dict | None:
|
||||
"""Der Gast zu einer Ziel-ID aus dem letzten Bericht (ct-104 → vmid 104)."""
|
||||
m = re.fullmatch(r"(ct|vm)-(\d+)", zid)
|
||||
eingang = kanal.bericht()
|
||||
if not m or not eingang:
|
||||
return None
|
||||
return next((g for g in eingang["bericht"].get("gaeste") or [] if g.get("vmid") == int(m.group(2))), None)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Kanal zum Ausführer auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
|
||||
|
||||
Der Ausführer (deploy/homelab/ausfuehrer.py) holt sich Aufträge hier ab, liefert die Ergebnisse und
|
||||
alle zehn Minuten einen Bericht. Er weist sich mit einem gemeinsamen Geheimnis aus (Kopfzeile
|
||||
X-MC2-Ausfuehrer). Das erzeugt dieser Teil beim ersten Bedarf in <Datenordner>/ausfuehrer.token (nur
|
||||
für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc/mc2-ausfuehrer.json.
|
||||
|
||||
Aufträge liegen in <Datenordner>/ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
|
||||
"host_update", "host_neustart"}
|
||||
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
|
||||
BEHALTEN = 60 # so viele erledigte Aufträge bleiben sichtbar
|
||||
|
||||
_lock = threading.Lock()
|
||||
_kontakt: dict = {"zuletzt": None}
|
||||
|
||||
|
||||
def _dir() -> Path:
|
||||
return einstellungen().daten_dir
|
||||
|
||||
|
||||
def _json_schreiben(pfad: Path, daten: object) -> None:
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
|
||||
|
||||
def _json_lesen(pfad: Path, leer: object) -> object:
|
||||
try:
|
||||
return json.loads(pfad.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return leer
|
||||
|
||||
|
||||
# --- Gemeinsames Geheimnis ---------------------------------------------------------------
|
||||
|
||||
def token() -> str:
|
||||
"""Das Geheimnis des Kanals; beim ersten Aufruf erzeugt (0600). MC_AUSFUEHRER_TOKEN geht vor."""
|
||||
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
|
||||
return wert
|
||||
pfad = _dir() / "ausfuehrer.token"
|
||||
try:
|
||||
return pfad.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
pass
|
||||
wert = secrets.token_urlsafe(32)
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError: # gleichzeitig erzeugt: den anderen nehmen
|
||||
return pfad.read_text(encoding="utf-8").strip()
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(wert + "\n")
|
||||
return wert
|
||||
|
||||
|
||||
def token_gueltig(wert: str | None) -> bool:
|
||||
return bool(wert) and hmac.compare_digest(str(wert), token())
|
||||
|
||||
|
||||
def kontakt() -> None:
|
||||
_kontakt["zuletzt"] = time.time()
|
||||
|
||||
|
||||
def zuletzt() -> float | None:
|
||||
"""Letztes Lebenszeichen des Ausführers (nach einem Neustart: Zeit des letzten Berichts)."""
|
||||
return _kontakt["zuletzt"] or (bericht() or {}).get("empfangen")
|
||||
|
||||
|
||||
# --- Bericht ------------------------------------------------------------------------------
|
||||
|
||||
def bericht_speichern(daten: dict) -> None:
|
||||
_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)
|
||||
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
|
||||
|
||||
|
||||
# --- Aufträge -------------------------------------------------------------------------------
|
||||
|
||||
def _pfad() -> Path:
|
||||
return _dir() / "ausfuehrer-auftraege.json"
|
||||
|
||||
|
||||
def _alle() -> list[dict]:
|
||||
daten = _json_lesen(_pfad(), [])
|
||||
return daten if isinstance(daten, list) else []
|
||||
|
||||
|
||||
def _merken(auftraege: list[dict]) -> None:
|
||||
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
|
||||
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
|
||||
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
|
||||
|
||||
|
||||
def anlegen(aktion: str, parameter: dict | None = None) -> str:
|
||||
if aktion not in AKTIONEN:
|
||||
raise ValueError(f"Unbekannte Aktion: {aktion}")
|
||||
auftrag = {"id": uuid.uuid4().hex[:12], "aktion": aktion, "parameter": parameter or {},
|
||||
"status": "wartet", "erstellt": time.time(), "abgeholt": None, "fertig": None,
|
||||
"code": None, "text": None}
|
||||
with _lock:
|
||||
auftraege = _alle()
|
||||
auftraege.append(auftrag)
|
||||
_merken(auftraege)
|
||||
return auftrag["id"]
|
||||
|
||||
|
||||
def naechster() -> dict | None:
|
||||
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
|
||||
vorher als verloren abgeschlossen."""
|
||||
jetzt = time.time()
|
||||
with _lock:
|
||||
auftraege = _alle()
|
||||
for a in auftraege:
|
||||
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
|
||||
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
|
||||
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
|
||||
if auftrag:
|
||||
auftrag.update(status="laeuft", abgeholt=jetzt)
|
||||
_merken(auftraege)
|
||||
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
|
||||
|
||||
|
||||
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
|
||||
with _lock:
|
||||
auftraege = _alle()
|
||||
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
|
||||
if auftrag is None or auftrag["status"] != "laeuft":
|
||||
return False
|
||||
auftrag.update(status="fertig" if code == 0 else "fehler", fertig=time.time(), code=code,
|
||||
text=str(text)[-20000:])
|
||||
_merken(auftraege)
|
||||
return True
|
||||
|
||||
|
||||
def auftrag(auftrag_id: str) -> dict | None:
|
||||
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:
|
||||
"""Bis der Auftrag fertig ist (oder das Zeitlimit abläuft). Rückgabe: der Auftrag, None bei Zeitablauf."""
|
||||
ende = time.time() + zeitlimit_s
|
||||
while time.time() < ende:
|
||||
a = auftrag(auftrag_id)
|
||||
if a and a["status"] not in ("wartet", "laeuft"):
|
||||
return a
|
||||
time.sleep(takt_s)
|
||||
return None
|
||||
|
||||
|
||||
def liste() -> list[dict]:
|
||||
return list(reversed(_alle()))
|
||||
@@ -0,0 +1,212 @@
|
||||
"""„Jetzt updaten“ im Homelab (Phase 4, User-Entscheide 24.09.2026).
|
||||
|
||||
Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es automatisch zurück
|
||||
und es wird gemeldet. Host-Updates gibt es mit Warnung, der Neustart ist ein eigener Knopf.
|
||||
|
||||
Ablauf für einen Gast:
|
||||
1. Snapshot (wo möglich; beim PBS verhindert der Bind-Mount ihn — dann ohne Rückweg, die Rückfrage sagt es)
|
||||
2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts
|
||||
3. 20 s warten, frischen Bericht holen
|
||||
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer?
|
||||
5. Rot und Snapshot da → zurück auf den Snapshot → dringende Meldung.
|
||||
Grün → ältere Snapshots des Orchestrators für diesen Gast weg → Meldung „eingespielt“.
|
||||
Jeder Lauf steht in <Datenordner>/homelab-laeufe.json und im strukturierten Update-Verlauf. Pro Ziel
|
||||
läuft höchstens ein Lauf; startet dieser Teil neu, gilt ein offener Lauf als unterbrochen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce, update_verlauf
|
||||
from services.homelab import apps, inventar, kanal
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WARTEN_NACH_UPDATE_S = 20
|
||||
ZEITLIMIT_UPDATE_S = 45 * 60
|
||||
ZEITLIMIT_KURZ_S = 15 * 60
|
||||
BETREFF = "[Homelab-Update]"
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "homelab-laeufe.json"
|
||||
|
||||
|
||||
def _laeufe() -> list[dict]:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
return daten if isinstance(daten, list) else []
|
||||
|
||||
|
||||
def _merken(lauf: dict) -> None:
|
||||
with _lock:
|
||||
laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf]
|
||||
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad().with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(laeufe[-100:], ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(_pfad())
|
||||
|
||||
|
||||
def laeufe(anzahl: int = 20) -> list[dict]:
|
||||
return list(reversed(_laeufe()))[:anzahl]
|
||||
|
||||
|
||||
def unterbrochene_abschliessen() -> None:
|
||||
"""Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess."""
|
||||
for lauf in _laeufe():
|
||||
if lauf.get("status") == "laeuft":
|
||||
lauf.update(status="unterbrochen", ende=time.time())
|
||||
lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.")
|
||||
_merken(lauf)
|
||||
|
||||
|
||||
def _melden(text: str, dringend: bool = False) -> None:
|
||||
try:
|
||||
announce.notify_telegram("[Alarm] Homelab-Update" if dringend else BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
def _auftrag(lauf: dict, aktion: str, parameter: dict, zeitlimit_s: float) -> dict:
|
||||
"""Auftrag an den Ausführer und auf das Ergebnis warten. Wirft RuntimeError bei Fehler/Zeitablauf."""
|
||||
aid = kanal.anlegen(aktion, parameter)
|
||||
lauf["schritte"].append(f"{aktion} …")
|
||||
_merken(lauf)
|
||||
a = kanal.warten(aid, zeitlimit_s)
|
||||
if a is None:
|
||||
raise RuntimeError(f"{aktion}: keine Antwort des Ausführers in {int(zeitlimit_s // 60)} Minuten")
|
||||
if a["status"] != "fertig":
|
||||
raise RuntimeError(f"{aktion} gescheitert: {str(a.get('text') or '')[-300:]}")
|
||||
lauf["schritte"][-1] = f"{aktion}: ok"
|
||||
return a
|
||||
|
||||
|
||||
def _frischer_gast(lauf: dict, vmid: int) -> dict | None:
|
||||
try:
|
||||
a = _auftrag(lauf, "bericht", {}, 5 * 60)
|
||||
bericht = json.loads(a.get("text") or "{}")
|
||||
kanal.bericht_speichern(bericht)
|
||||
except (RuntimeError, ValueError):
|
||||
return None
|
||||
return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None)
|
||||
|
||||
|
||||
def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]:
|
||||
"""Was nach dem Update nicht stimmt (leer = grün)."""
|
||||
if nachher is None:
|
||||
return ["Der Gast fehlt im neuen Bericht."]
|
||||
fehler = []
|
||||
if nachher.get("status") != "running":
|
||||
fehler.append(f"Der Gast läuft nicht ({nachher.get('status')}).")
|
||||
app = apps.app_fuer((nachher.get("app") or {}).get("kennung"), nachher.get("name"))
|
||||
url = apps.adresse(app, nachher.get("ip"))
|
||||
if url and not inventar.erreichbar_frisch(url):
|
||||
fehler.append(f"Die Weboberfläche ({url}) antwortet nicht.")
|
||||
if baustein == "app":
|
||||
alt, neu = (vorher.get("app") or {}).get("version"), (nachher.get("app") or {}).get("version")
|
||||
if alt and neu and alt == neu:
|
||||
fehler.append(f"Die App-Version ist unverändert ({alt}).")
|
||||
return fehler
|
||||
|
||||
|
||||
def _gast_lauf(lauf: dict, gast: dict) -> None:
|
||||
vmid, baustein = gast["vmid"], lauf["baustein"]
|
||||
snapshot = None
|
||||
try:
|
||||
if gast.get("snapshot_moeglich"):
|
||||
a = _auftrag(lauf, "snapshot", {"vmid": vmid}, ZEITLIMIT_KURZ_S)
|
||||
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
|
||||
snapshot = m.group(1) if m else None
|
||||
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S)
|
||||
time.sleep(WARTEN_NACH_UPDATE_S)
|
||||
fehler = pruefen(gast, _frischer_gast(lauf, vmid), baustein)
|
||||
except RuntimeError as exc:
|
||||
fehler = [str(exc)]
|
||||
if not fehler:
|
||||
_aufraeumen(lauf, vmid, behalten=snapshot)
|
||||
_ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.")
|
||||
return
|
||||
if snapshot:
|
||||
try:
|
||||
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, ZEITLIMIT_KURZ_S)
|
||||
_ende(lauf, "zurueckgerollt",
|
||||
f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — automatisch zurück auf den Snapshot "
|
||||
f"{snapshot}. Läuft wieder wie vorher.", dringend=True)
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}"
|
||||
+ ("" if snapshot else " Es gab keinen Snapshot, also keinen automatischen Rückweg."),
|
||||
dringend=True)
|
||||
|
||||
|
||||
def _aufraeumen(lauf: dict, vmid: int, behalten: str | None) -> None:
|
||||
"""Ältere Snapshots des Orchestrators für diesen Gast löschen; den neuesten behalten."""
|
||||
gast = inventar.gast(lauf["ziel"]) or {}
|
||||
for name in gast.get("snapshots") or []:
|
||||
if name.startswith("mc2-") and name != behalten:
|
||||
try:
|
||||
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError:
|
||||
lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.")
|
||||
|
||||
|
||||
def _host_lauf(lauf: dict) -> None:
|
||||
try:
|
||||
if lauf["baustein"] == "neustart":
|
||||
_auftrag(lauf, "host_neustart", {}, 60)
|
||||
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
|
||||
return
|
||||
_auftrag(lauf, "host_update", {}, 60 * 60)
|
||||
_auftrag(lauf, "bericht", {}, 5 * 60)
|
||||
_ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.")
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"Proxmox-Host: Update gescheitert — {exc}", dringend=True)
|
||||
|
||||
|
||||
def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
|
||||
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time())
|
||||
_merken(lauf)
|
||||
try:
|
||||
start = datetime.fromtimestamp(lauf["start"], LOCAL_TZ).isoformat(timespec="seconds")
|
||||
update_verlauf.eintragen(start, "Update von Hand", lauf["ziel"], ergebnis, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
|
||||
_melden(text, dringend=dringend)
|
||||
|
||||
|
||||
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"):
|
||||
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."}
|
||||
if ziel_id == "pve":
|
||||
name, gast = "Proxmox-Host", None
|
||||
else:
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast:
|
||||
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
|
||||
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)
|
||||
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"]}
|
||||
@@ -14,6 +14,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.github import github_json
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import jobengine, system, update_verlauf
|
||||
@@ -51,21 +52,8 @@ _engine_cache = {"ts": 0.0, "avail": False}
|
||||
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
|
||||
|
||||
|
||||
# GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte
|
||||
# bisher neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt.
|
||||
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
|
||||
_github_cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
|
||||
def _github_json(pfad: str, timeout: float = 8) -> object:
|
||||
jetzt = time.time()
|
||||
if (eintrag := _github_cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
|
||||
return eintrag[1]
|
||||
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
|
||||
r.raise_for_status()
|
||||
daten = r.json()
|
||||
_github_cache[pfad] = (jetzt, daten)
|
||||
return daten
|
||||
# GitHub-Abfragen mit 15 Minuten Zwischenspeicher (kern/github.py, seit 24.09.2026 für beide Rollen).
|
||||
_github_json = github_json
|
||||
|
||||
|
||||
def _latest_engine_asset_release() -> dict | None:
|
||||
|
||||
@@ -457,12 +457,35 @@ def pruefe_partner() -> list[Befund]:
|
||||
quelle="partner", nach_takten=PARTNER_TAKTE)]
|
||||
|
||||
|
||||
def pruefe_ausfuehrer() -> list[Befund]:
|
||||
"""Homelab: Der Ausführer auf dem Proxmox-Host meldet sich alle zehn Minuten. Schweigt er, zeigt die
|
||||
Übersicht nichts Neues mehr, und „Jetzt updaten“ bliebe liegen. Vor seinem ersten Bericht: kein Alarm."""
|
||||
from services.homelab import inventar, kanal
|
||||
if kanal.bericht() is None:
|
||||
return []
|
||||
zuletzt = kanal.zuletzt()
|
||||
if zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S:
|
||||
return []
|
||||
return [Befund(id="ausfuehrer", stufe="rot", titel="Der Ausführer auf dem Proxmox-Host schweigt",
|
||||
text="Seit über 30 Minuten kein Bericht. Läuft mc2-ausfuehrer.service auf dem Proxmox-Host?",
|
||||
quelle="ausfuehrer")]
|
||||
|
||||
|
||||
def pruefe_gaeste() -> list[Befund]:
|
||||
"""Homelab: Läuft jeder freigegebene Gast, und antwortet seine Weboberfläche?"""
|
||||
from services.homelab import inventar
|
||||
return [Befund(id=f"gast:{e['id']}", stufe="rot", titel=f"{e['name']} antwortet nicht",
|
||||
text=(f"Die Weboberfläche ({e['url']}) antwortet nicht." if e["laeuft"]
|
||||
else "Der Gast läuft nicht."), quelle=e["id"])
|
||||
for e in inventar.erreichbarkeit() if not e["ok"]]
|
||||
|
||||
|
||||
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
|
||||
# bekommt seine Prüfungen mit den Homelab-Bausteinen.
|
||||
# den Ausführer auf dem Proxmox-Host und seine Gäste.
|
||||
PRUEFUNGEN = {
|
||||
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
||||
pruefe_festgehalten, pruefe_partner),
|
||||
"homelab": (pruefe_platte, pruefe_partner),
|
||||
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste),
|
||||
}[ROLLE]
|
||||
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
|
||||
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"zeit": "2026-09-24T16:26:04+00:00",
|
||||
"host": {
|
||||
"node": "pve",
|
||||
"neustart_noetig": false,
|
||||
"version": "9.2.11",
|
||||
"updates": [
|
||||
{
|
||||
"paket": "libvirt-common",
|
||||
"alt": "11.3.0-3+deb13u2",
|
||||
"neu": "11.3.0-3+deb13u3",
|
||||
"herkunft": "Debian"
|
||||
},
|
||||
{
|
||||
"paket": "librados2",
|
||||
"alt": "19.2.3-pve1",
|
||||
"neu": "19.2.6-pve4",
|
||||
"herkunft": "Proxmox"
|
||||
},
|
||||
{
|
||||
"paket": "libperl5.40",
|
||||
"alt": "5.40.1-6",
|
||||
"neu": "5.40.1-6+deb13u1",
|
||||
"herkunft": "Debian"
|
||||
},
|
||||
{
|
||||
"paket": "pve-docs",
|
||||
"alt": "9.2.4",
|
||||
"neu": "9.2.12",
|
||||
"herkunft": "Proxmox"
|
||||
},
|
||||
{
|
||||
"paket": "pve-edk2-firmware-ovmf",
|
||||
"alt": "4.2025.05-3",
|
||||
"neu": "4.2026.08-1",
|
||||
"herkunft": "Proxmox"
|
||||
}
|
||||
],
|
||||
"kernel": "7.0.14-12-pve"
|
||||
},
|
||||
"gaeste": [
|
||||
{
|
||||
"vmid": 100,
|
||||
"art": "lxc",
|
||||
"name": "adguard",
|
||||
"status": "running",
|
||||
"etiketten": [
|
||||
"adblock",
|
||||
"community-script"
|
||||
],
|
||||
"erlaubt": true,
|
||||
"uptime": 3366322,
|
||||
"snapshots": [],
|
||||
"onboot": true,
|
||||
"snapshot_moeglich": true,
|
||||
"snapshot_grund": null,
|
||||
"ostype": "debian",
|
||||
"ip": "192.168.178.100",
|
||||
"app": {
|
||||
"kennung": "adguard",
|
||||
"version": "0.107.79"
|
||||
},
|
||||
"os_updates": {
|
||||
"anzahl": 0,
|
||||
"listen_stand": 1761157469
|
||||
}
|
||||
},
|
||||
{
|
||||
"vmid": 101,
|
||||
"art": "lxc",
|
||||
"name": "npmplus",
|
||||
"status": "running",
|
||||
"etiketten": [
|
||||
"community-script",
|
||||
"nginx",
|
||||
"proxy"
|
||||
],
|
||||
"erlaubt": true,
|
||||
"uptime": 2868368,
|
||||
"snapshots": [],
|
||||
"onboot": true,
|
||||
"snapshot_moeglich": true,
|
||||
"snapshot_grund": null,
|
||||
"ostype": "alpine",
|
||||
"ip": "192.168.178.110",
|
||||
"app": {
|
||||
"kennung": "npmplus",
|
||||
"version": "Image vom 2026-08-27"
|
||||
},
|
||||
"os_updates": {
|
||||
"anzahl": 0,
|
||||
"listen_stand": 1787398997
|
||||
}
|
||||
},
|
||||
{
|
||||
"vmid": 102,
|
||||
"art": "lxc",
|
||||
"name": "netbird",
|
||||
"status": "running",
|
||||
"etiketten": [
|
||||
"community-script",
|
||||
"network",
|
||||
"vpn"
|
||||
],
|
||||
"erlaubt": true,
|
||||
"uptime": 3366322,
|
||||
"snapshots": [],
|
||||
"onboot": true,
|
||||
"snapshot_moeglich": true,
|
||||
"snapshot_grund": null,
|
||||
"ostype": "debian",
|
||||
"ip": "192.168.178.148",
|
||||
"app": {
|
||||
"kennung": "netbird",
|
||||
"version": "0.77.1"
|
||||
},
|
||||
"os_updates": {
|
||||
"anzahl": 0,
|
||||
"listen_stand": 1787399037
|
||||
}
|
||||
},
|
||||
{
|
||||
"vmid": 103,
|
||||
"art": "lxc",
|
||||
"name": "pve-scripts-local",
|
||||
"status": "running",
|
||||
"etiketten": [
|
||||
"community-script",
|
||||
"pve-scripts-local"
|
||||
],
|
||||
"erlaubt": true,
|
||||
"uptime": 1713361,
|
||||
"snapshots": [],
|
||||
"onboot": true,
|
||||
"snapshot_moeglich": true,
|
||||
"snapshot_grund": null,
|
||||
"ostype": "debian",
|
||||
"ip": "192.168.178.149",
|
||||
"app": {
|
||||
"kennung": "pve-scripts-local",
|
||||
"version": "0.5.8"
|
||||
},
|
||||
"os_updates": {
|
||||
"anzahl": 0,
|
||||
"listen_stand": 1777589263
|
||||
}
|
||||
},
|
||||
{
|
||||
"vmid": 104,
|
||||
"art": "lxc",
|
||||
"name": "gitea",
|
||||
"status": "running",
|
||||
"etiketten": [
|
||||
"community-script",
|
||||
"git"
|
||||
],
|
||||
"erlaubt": true,
|
||||
"uptime": 3366321,
|
||||
"snapshots": [],
|
||||
"onboot": true,
|
||||
"snapshot_moeglich": true,
|
||||
"snapshot_grund": null,
|
||||
"ostype": "debian",
|
||||
"ip": "192.168.178.153",
|
||||
"app": {
|
||||
"kennung": "gitea",
|
||||
"version": "1.27.2"
|
||||
},
|
||||
"os_updates": {
|
||||
"anzahl": 0,
|
||||
"listen_stand": 1781978337
|
||||
}
|
||||
},
|
||||
{
|
||||
"vmid": 105,
|
||||
"art": "lxc",
|
||||
"name": "proxmox-backup-server",
|
||||
"status": "running",
|
||||
"etiketten": [
|
||||
"backup",
|
||||
"community-script"
|
||||
],
|
||||
"erlaubt": true,
|
||||
"uptime": 3366321,
|
||||
"snapshots": [],
|
||||
"onboot": true,
|
||||
"snapshot_moeglich": false,
|
||||
"snapshot_grund": "Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich",
|
||||
"ostype": "debian",
|
||||
"ip": "192.168.178.156",
|
||||
"app": {
|
||||
"kennung": "proxmox-backup-server",
|
||||
"version": "4.2.5-1"
|
||||
},
|
||||
"os_updates": {
|
||||
"anzahl": 58,
|
||||
"listen_stand": 1790207866
|
||||
}
|
||||
},
|
||||
{
|
||||
"vmid": 106,
|
||||
"art": "qemu",
|
||||
"name": "arcane",
|
||||
"status": "running",
|
||||
"etiketten": [],
|
||||
"erlaubt": false,
|
||||
"uptime": 1713735,
|
||||
"snapshots": [],
|
||||
"onboot": true,
|
||||
"snapshot_moeglich": true,
|
||||
"snapshot_grund": null,
|
||||
"ip": "192.168.178.28"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
@@ -208,3 +208,18 @@ def test_unbekannte_rolle_bricht_ab(monkeypatch):
|
||||
finally:
|
||||
monkeypatch.delenv("MC_ROLLE")
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def test_homelab_waechter_prueft_ausfuehrer_und_gaeste(tmp_path):
|
||||
ergebnis = _starte("homelab", tmp_path, """
|
||||
import json
|
||||
import steward
|
||||
from services import waechter
|
||||
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
|
||||
""")
|
||||
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste"]
|
||||
|
||||
|
||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||
ergebnis = _starte("homelab", tmp_path, _PROBE)
|
||||
assert "/api/homelab/ziele" in ergebnis["pfade"] and "/api/ziele" not in ergebnis["pfade"]
|
||||
|
||||
@@ -37,15 +37,19 @@ class _Antwort:
|
||||
|
||||
|
||||
def test_github_antworten_werden_gemerkt_fehler_nicht(monkeypatch):
|
||||
monkeypatch.setattr(maintenance, "_github_cache", {})
|
||||
antworten = [_Antwort(403, {"message": "API rate limit exceeded"}), _Antwort(200, {"tag_name": "v258"})]
|
||||
from kern import github
|
||||
|
||||
monkeypatch.setattr(github, "_cache", {})
|
||||
antworten = [_Antwort(403, {"message": "API rate limit exceeded"}),
|
||||
_Antwort(200, {"tag_name": "v258", "published_at": "2026-09-20T10:00:00Z"})]
|
||||
aufrufe = []
|
||||
monkeypatch.setattr(maintenance.httpx, "get", lambda url, **kw: aufrufe.append(url) or antworten.pop(0))
|
||||
monkeypatch.setattr(github.httpx, "get", lambda url, **kw: aufrufe.append(url) or antworten.pop(0))
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
maintenance._github_json("repos/a/b/releases/latest")
|
||||
assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"}
|
||||
assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"}
|
||||
github.github_json("repos/a/b/releases/latest")
|
||||
assert github.neueste_version("a/b") == {"tag": "v258", "version": "258", "datum": "2026-09-20"}
|
||||
assert github.github_json("repos/a/b/releases/latest")["tag_name"] == "v258"
|
||||
assert len(aufrufe) == 2
|
||||
assert maintenance._github_json is github.github_json
|
||||
|
||||
|
||||
def test_hermes_version_auch_wenn_der_abruf_haengt(monkeypatch, tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user