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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user