homelab: Wartezeit nach Skriptaenderung, PBS mit Sicherung statt Snapshot, woechentliches Suchen

- karenz.py: ct/<app>.sh juenger als MC_HOMELAB_KARENZ_H (Standard 48 h) -> Baustein "neu" ohne Knopf,
  updates.starten lehnt mit demselben Satz ab (Berliner Zeit). GitHub stumm -> nicht blockieren, die
  Rueckfrage sagt es.
- Ausfuehrer: neue Aktionen sichern, sicherung_zurueck, sicherung_loeschen. vzdump auf den ersten lokalen
  Speicher mit Inhalt backup (oder sicherung_speicher aus /etc/mc2-ausfuehrer.json), nie auf pbs; vorher
  Platz pruefen (frei > belegt x 1,2); Notiz mc2-sicherung, nur solche werden zurueckgespielt/geloescht;
  Rueckweg: stoppen, pct restore --force auf den bisherigen rootfs-Speicher, starten. Bericht mit
  host.sicherung, sicherung_moeglich/_grund, eigenen Sicherungen und nur_lesen. Unerwartete Fehler werden
  beantwortet statt verschluckt; vzdump/restore beim Zeitlimit erst SIGTERM.
- updates.py: Sicherung, wo kein Snapshot geht; bei Rot zurueckspielen, nach Gruen aeltere Sicherungen
  weg. Scheitert Snapshot oder Sicherung, beginnt das Update nicht (nicht dringend gemeldet).
- pflege.py: "suchen" fuer Gaeste mit Paketlisten aelter als 7 Tage, nachts 02-05 Uhr (sonst nachholen),
  einmal je Gast und Tag, nie neben einem Update oder offenen Auftrag; gelber Waechter-Hinweis, wenn es
  zweimal hintereinander scheitert. Eine Registrierungszeile in waechter.py.
- kanal.py: Auftragsliste unter flock, weil jetzt auch der Steward Auftraege anlegt.
- inventar.py: Rueckweg und Rueckfrage fuer die Sicherung; Gaeste mitten im Update-Lauf nicht in der
  Webpruefung des Waechters (sonst zweiter Alarm beim Zurueckspielen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 20:22:39 +02:00
co-authored by Claude Opus 5.5
parent 40d2840b16
commit f2733f44fe
13 changed files with 1385 additions and 54 deletions
+3 -1
View File
@@ -3,5 +3,7 @@
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
karenz.py Wartezeit nach einer Änderung am Update-Skript (community-scripts, ungepinnt von GitHub)
updates.py „Jetzt updaten“: Snapshot bzw. Sicherung → Update → Prüfung → bei Rot zurück, mit Meldung
pflege.py wöchentliches Suchen (Paketlisten der Gäste), im Wächter-Takt des Stewards
"""
+46 -12
View File
@@ -7,7 +7,9 @@ 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.
die Übersicht, dass der Ausführer schweigt. Ist das Update-Skript einer App frisch geändert, bleibt sie
„neu“, aber ohne Knopf (karenz.py). Rückweg und Rückfrage sagen, was vor dem Update geschieht: Snapshot,
wo keiner geht eine Sicherung, sonst keiner.
"""
import re
@@ -20,7 +22,7 @@ from kern.github import neueste_version
from kern.zeit import LOCAL_TZ
from kern.ziele import Aktion, BausteinStand, ZielStand
from services.homelab import apps, arcane, kanal
from services.homelab import apps, arcane, kanal, karenz
BERICHT_ALT_S = 30 * 60
LISTEN_ALT_S = 14 * 24 * 3600
@@ -70,6 +72,30 @@ def _datum(ts: float | None) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.%Y") if ts else "?"
def _rueckweg(gast: dict) -> str:
"""Die Zeile „Rückweg“ der Karte: was vor jedem Update geschieht."""
if gast.get("snapshot_moeglich"):
return "Snapshot vor jedem Update"
grund = gast.get("snapshot_grund") or "kein Snapshot möglich"
if gast.get("sicherung_moeglich"):
return f"Sicherung auf „{gast.get('sicherung_speicher') or 'dem Host'}“ vor jedem Update ({grund})"
if gast.get("sicherung_grund"):
return f"Kein automatischer Rückweg: {grund}. Auch keine Sicherung: {gast['sicherung_grund']}"
return grund
def _rueckweg_frage(gast: dict) -> str:
"""Der Satz zum Rückweg in der Rückfrage vor „Jetzt updaten“."""
if gast.get("snapshot_moeglich"):
return "Vorher wird ein Snapshot angelegt; ist die Prüfung danach rot, geht es von selbst zurück."
if gast.get("sicherung_moeglich"):
return "Vorher wird eine Sicherung angelegt; ist die Prüfung danach rot, wird sie zurückgespielt."
satz = "Ein Snapshot ist hier nicht möglich — scheitert das Update, gibt es keinen automatischen Rückweg."
if gast.get("sicherung_grund"):
satz += f" Eine Sicherung geht auch nicht: {gast['sicherung_grund']}"
return satz
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)
@@ -103,10 +129,15 @@ def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand:
stand.grund = f"{app.name} aktualisiert sich über die eigene Oberfläche; das Community-Script tut es nicht."
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}")
# Frisch geänderte Update-Skripte erst nach der Wartezeit (karenz.py): „neu“, aber ohne Knopf.
wartezeit = karenz.pruefen(app.kennung)
if wartezeit["gesperrt"]:
stand.grund = wartezeit["gesperrt"]
return stand
frage = f"{app.name} jetzt aktualisieren? {_rueckweg_frage(gast)}"
if not wartezeit["geprueft"]:
frage += f" {karenz.NICHT_GEPRUEFT}"
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/update", frage)
return stand
@@ -124,7 +155,7 @@ def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
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.")
f"{anzahl} Pakete im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
return stand
@@ -172,8 +203,7 @@ def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
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"))
rueckweg=_rueckweg(gast))
if not laeuft:
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
@@ -238,14 +268,18 @@ def ziele() -> dict:
def erreichbarkeit() -> list[dict]:
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen."""
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen. Gäste mitten in
einem Update-Lauf bleiben außen vor: Sicherung, Update und Zurückspielen legen sie kurz still, und was dabei
herauskommt, meldet der Lauf selbst (sonst käme ein zweiter Alarm samt „wieder ok“)."""
from services.homelab import updates # updates importiert dieses Modul
eingang = kanal.bericht()
im_update = updates.laufende_ziele()
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']}"
zid = f"{'ct' if g['art'] == 'lxc' else 'vm'}-{g['vmid']}"
if url and g.get("erlaubt") and zid not in im_update:
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)})
+31 -4
View File
@@ -6,6 +6,8 @@ X-MC2-Ausfuehrer). Das erzeugt dieser Teil beim ersten Bedarf in <Datenordner>/a
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.
Zwei Prozesse schreiben hinein: die Oberfläche (Knöpfe, Abholen durch den Ausführer) und seit 24.09.2026 der
Steward (wöchentliches Suchen, pflege.py). Lesen, ändern, schreiben geschieht deshalb unter einer Dateisperre.
"""
import hmac
@@ -15,12 +17,19 @@ import secrets
import threading
import time
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from kern.einstellungen import einstellungen
try:
import fcntl # Linux: Sperre über Prozessgrenzen
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
fcntl = None
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart"}
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 60 # so viele erledigte Aufträge bleiben sichtbar
@@ -32,6 +41,24 @@ def _dir() -> Path:
return einstellungen().daten_dir
@contextmanager
def _sperre() -> Iterator[None]:
"""Für Lesen-ändern-schreiben der Auftragsliste: Thread-Sperre, unter Linux zusätzlich flock. Ohne sie
überschrieben sich Oberfläche und Steward gegenseitig (ein abgeholter Auftrag stünde wieder auf „wartet“)."""
with _lock:
if fcntl is None:
yield
return
pfad = _dir() / "ausfuehrer-auftraege.lock"
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "a", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def _json_schreiben(pfad: Path, daten: object) -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
@@ -118,7 +145,7 @@ def anlegen(aktion: str, parameter: dict | None = None) -> str:
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:
with _sperre():
auftraege = _alle()
auftraege.append(auftrag)
_merken(auftraege)
@@ -129,7 +156,7 @@ 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:
with _sperre():
auftraege = _alle()
for a in auftraege:
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
@@ -142,7 +169,7 @@ def naechster() -> dict | None:
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
with _lock:
with _sperre():
auftraege = _alle()
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
if auftrag is None or auftrag["status"] != "laeuft":
+61
View File
@@ -0,0 +1,61 @@
"""Wartezeit nach einer Änderung am Update-Skript (24.09.2026).
„Jetzt updaten“ lässt im Gast `update` laufen. Das lädt ct/<kennung>.sh ungepinnt von GitHub
(community-scripts/ProxmoxVE, Zweig main) und führt es als root aus. Eine Änderung, die erst ein paar Stunden
alt ist, hat noch kaum jemand im Betrieb gesehen. Deshalb gilt für Apps mit dem Weg „skript“: Ist die letzte
Änderung an ct/<kennung>.sh jünger als MC_HOMELAB_KARENZ_H Stunden (Standard 48), bleibt der Baustein „neu“,
aber ohne Knopf, und updates.starten() lehnt ab — beide mit demselben Satz.
Wann das Skript zuletzt geändert wurde, sagt die GitHub-API (kern/github.py, 15 Minuten gemerkt). Lässt sich das
nicht abfragen, wird nicht blockiert; die Rückfrage vor dem Update sagt es dann (NICHT_GEPRUEFT).
"""
import os
import re
import time
from datetime import datetime
from kern.github import github_json
from kern.zeit import LOCAL_TZ
REPO = "community-scripts/ProxmoxVE"
STANDARD_H = 48.0
NICHT_GEPRUEFT = "Ob das Skript kürzlich geändert wurde, ließ sich nicht prüfen."
_KENNUNG = re.compile(r"^[a-z0-9-]+$")
def karenz_s() -> float:
"""Die Wartezeit in Sekunden (MC_HOMELAB_KARENZ_H; 0 schaltet sie ab)."""
try:
stunden = float(os.environ.get("MC_HOMELAB_KARENZ_H", "") or STANDARD_H)
except ValueError:
stunden = STANDARD_H
return max(0.0, stunden) * 3600
def skript_geaendert(kennung: str) -> float:
"""Wann ct/<kennung>.sh auf main zuletzt geändert wurde (Unix-Sekunden). Wirft, wenn es sich nicht sagen lässt."""
if not _KENNUNG.match(kennung or ""):
raise ValueError(f"ungültige Kennung {kennung!r}")
daten = github_json(f"repos/{REPO}/commits?path=ct/{kennung}.sh&sha=main&per_page=1")
# Das Datum, an dem die Änderung auf main landete (committer, nicht author: ein Pull-Request kann älter sein).
datum = str(daten[0]["commit"]["committer"]["date"])
return datetime.fromisoformat(datum.replace("Z", "+00:00")).timestamp()
def sperrtext(geaendert: float, frei_ab: float) -> str:
a, b = datetime.fromtimestamp(geaendert, LOCAL_TZ), datetime.fromtimestamp(frei_ab, LOCAL_TZ)
return f"Das Update-Skript wurde am {a:%d.%m.} geändert; zur Sicherheit erst ab {b:%d.%m. %H:%M}."
def pruefen(kennung: str, jetzt: float | None = None) -> dict:
"""{"gesperrt": Satz oder None, "geprueft": bool}. Gesperrt, solange die letzte Änderung am Skript jünger ist
als die Wartezeit. Scheitert die Abfrage: nicht gesperrt, aber geprueft=False."""
try:
geaendert = skript_geaendert(kennung)
except Exception:
return {"gesperrt": None, "geprueft": False}
frei_ab = geaendert + karenz_s()
if (time.time() if jetzt is None else jetzt) >= frei_ab:
return {"gesperrt": None, "geprueft": True}
return {"gesperrt": sperrtext(geaendert, frei_ab), "geprueft": True}
+180
View File
@@ -0,0 +1,180 @@
"""Pflege im Hintergrund: wöchentlich nach Updates suchen (24.09.2026).
Die Übersicht ist nur so ehrlich wie die Paketlisten in den Gästen, und die erneuert dort niemand von selbst
(AdGuard stand im September 2026 auf Listen vom Oktober 2025). Deshalb stößt der Homelab-Teil den Auftrag
„suchen“ (apt-get update bzw. apk update im Gast, ändert keine Pakete) selbst an, sobald die Listen eines Gasts
älter als sieben Tage sind:
• nur freigegebene, laufende Container — der Ausführer prüft das Etikett ohnehin selbst noch einmal
• höchstens ein Auftrag je Gast und Tag; nie während eines Update-Laufs für diesen Gast und nie, solange für
ihn ein anderer Auftrag offen ist; nur, wenn der Ausführer gerade berichtet
• bevorzugt nachts 02:00–05:00 (Berlin); war die Instanz oder der Ausführer in der letzten Nacht nicht da,
eben gleich
• keine Meldungen. Scheitert die Suche für einen Gast FEHLSCHLAEGE_HINWEIS-mal hintereinander, wird daraus
ein gelber Hinweis des Wächters (gelb geht nicht an Telegram).
Der Steward der Rolle homelab ruft pruefe_paketlisten() in jedem Wächter-Takt auf (services/waechter.py,
PRUEFUNGEN). Zustand in <Datenordner>/homelab-pflege.json; den schreibt nur der Steward. Die Aufträge selbst
landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt).
"""
import json
import logging
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services.homelab import apps, inventar, kanal, updates
log = logging.getLogger(__name__)
LISTEN_ALT_S = 7 * 24 * 3600
NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin
FEHLSCHLAEGE_HINWEIS = 2
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-pflege.json"
def zustand() -> dict:
"""{"nacht": Datum der letzten Nacht, in der gesucht werden konnte, "gaeste": {vmid: {…}}}"""
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
daten = {}
if not isinstance(daten, dict):
daten = {}
if not isinstance(daten.get("gaeste"), dict):
daten["gaeste"] = {}
return daten
def _schreiben(daten: dict) -> None:
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(_pfad())
def faellig(gaeste: list[dict], jetzt: float) -> list[dict]:
"""Die Container, deren Paketlisten eine neue Suche brauchen: freigegeben, laufend, Listen älter als 7 Tage."""
ergebnis = []
for g in gaeste:
listen = (g.get("os_updates") or {}).get("listen_stand")
if (g.get("art") == "lxc" and g.get("status") == "running" and g.get("erlaubt")
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])
and listen and jetzt - listen > LISTEN_ALT_S):
ergebnis.append(g)
return ergebnis
def _fehlerzeile(text: str | None) -> str:
"""Die aussagekräftigste Zeile einer gescheiterten Suche (apt meldet Fehler mit „E:“)."""
zeilen = [z.strip() for z in str(text or "").splitlines() if z.strip()]
wichtig = [z for z in zeilen if z.startswith(("E:", "ERROR", "Abgelehnt", "Zeitlimit", "Fehler"))]
return (wichtig or zeilen or ["ohne Angabe"])[-1][:200]
def _einsammeln(daten: dict) -> bool:
"""Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr."""
geaendert = False
for eintrag in daten["gaeste"].values():
aid = eintrag.get("auftrag")
if not aid:
continue
a = kanal.auftrag(aid)
if a is not None and a.get("status") in ("wartet", "laeuft"):
continue
if a is not None and a.get("status") == "fertig":
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
elif a is not None: # fehler, verloren
eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1
eintrag["fehler"] = _fehlerzeile(a.get("text"))
eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen
geaendert = True
return geaendert
def _offene_gaeste() -> set:
"""Gäste mit einem offenen Auftrag gleich welcher Art (auch von Hand oder aus einem Update-Lauf)."""
return {(a.get("parameter") or {}).get("vmid") for a in kanal.liste() if a.get("status") in ("wartet", "laeuft")}
def _befunde(daten: dict, faellige: list[dict]) -> list:
"""Gelbe Hinweise für Gäste, deren Listen weiter alt sind und deren Suche wiederholt scheiterte."""
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
befunde = []
for g in faellige:
eintrag = daten["gaeste"].get(str(g["vmid"])) or {}
anzahl = int(eintrag.get("fehlschlaege") or 0)
if anzahl < FEHLSCHLAEGE_HINWEIS:
continue
name = eintrag.get("name") or str(g.get("name") or g["vmid"])
befunde.append(Befund(
id=f"pflege:ct-{g['vmid']}", stufe="gelb", titel=f"{name}: Die Suche nach Updates scheitert",
text=(f"Die Paketlisten ließen sich {anzahl}-mal hintereinander nicht erneuern (wöchentliche Suche). "
f"Zuletzt: {eintrag.get('fehler') or 'ohne Angabe'}"),
quelle=f"ct-{g['vmid']}", sofort=True))
return befunde
def pruefe_paketlisten(jetzt: float | None = None) -> list:
"""Im Wächter-Takt: Ergebnisse einsammeln, fällige Suchen anstoßen. Rückgabe: Befunde für den Wächter —
nur, wenn die Suche für einen Gast wiederholt scheitert (gelb)."""
from services import waechter
jetzt = time.time() if jetzt is None else jetzt
lokal = datetime.fromtimestamp(jetzt, LOCAL_TZ)
heute = lokal.date().isoformat()
nachts = NACHT_VON <= lokal.hour < NACHT_BIS
# Die zuletzt begonnene Nacht: vor 02:00 die von gestern.
letzte_nacht = (lokal.date() - timedelta(days=0 if lokal.hour >= NACHT_VON else 1)).isoformat()
eingang = kanal.bericht()
bericht = (eingang or {}).get("bericht") or {}
gaeste = bericht.get("gaeste") or []
zuletzt = kanal.zuletzt()
# Aufträge nur, wenn der Ausführer gerade berichtet und nicht im Nur-Lesen-Modus läuft (dann lehnte er ab).
verbunden = bool(eingang and zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S
and not (bericht.get("host") or {}).get("nur_lesen"))
faellige = faellig(gaeste, jetzt)
with _lock:
daten = zustand()
geaendert = _einsammeln(daten)
faellig_ids = {str(g["vmid"]) for g in faellige}
for vmid, eintrag in daten["gaeste"].items():
# Listen wieder frisch (auch von Hand gesucht) oder Gast nicht mehr dabei: Zähler vergessen.
if gaeste and eintrag.get("fehlschlaege") and vmid not in faellig_ids:
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
geaendert = True
if verbunden and nachts and daten.get("nacht") != heute:
daten["nacht"] = heute
geaendert = True
# Nachts sowieso; tagsüber nur, wenn die letzte Nacht verpasst wurde (Instanz oder Ausführer war weg).
if verbunden and (nachts or daten.get("nacht") != letzte_nacht):
offen = _offene_gaeste()
im_update = updates.laufende_ziele()
for g in faellige:
vmid = g["vmid"]
eintrag = daten["gaeste"].setdefault(str(vmid), {})
if eintrag.get("tag") == heute or eintrag.get("auftrag") or vmid in offen \
or f"ct-{vmid}" in im_update:
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
eintrag["name"] = app.name if app else str(g.get("name") or vmid)
if waechter.TROCKEN:
log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"])
continue
eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt)
geaendert = True
log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"])
if geaendert:
_schreiben(daten)
return _befunde(daten, faellige)
+75 -19
View File
@@ -4,12 +4,15 @@ Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es
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
1. Rückweg anlegen: ein Snapshot; wo keiner geht (PBS: Bind-Mount), eine Sicherung (vzdump auf einen lokalen
Speicher des Hosts, nie auf den PBS); geht beides nicht, ohne Rückweg — die Rückfrage sagt es. Scheitert
dieser Schritt, beginnt das Update gar nicht.
2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts. Ein frisch geändertes
Update-Skript wartet erst MC_HOMELAB_KARENZ_H Stunden (karenz.py).
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“.
5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung.
Grün → ältere Snapshots bzw. Sicherungen 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.
"""
@@ -27,13 +30,14 @@ from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, update_verlauf
from services.homelab import apps, arcane, inventar, kanal
from services.homelab import apps, arcane, inventar, kanal, karenz
log = logging.getLogger(__name__)
WARTEN_NACH_UPDATE_S = 20
ZEITLIMIT_UPDATE_S = 45 * 60
ZEITLIMIT_KURZ_S = 15 * 60
ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf)
BETREFF = "[Homelab-Update]"
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
@@ -65,6 +69,15 @@ def laeufe(anzahl: int = 20) -> list[dict]:
return list(reversed(_laeufe()))[:anzahl]
def laufende_ziele() -> set[str]:
"""Ziele mit einem laufenden Update. Liest nur die Datei — geht also auch im Steward (Wächter, Pflege)."""
return {str(x.get("ziel")) for x in _laeufe() if x.get("status") == "laeuft"}
def laeuft(ziel_id: str) -> bool:
return ziel_id in laufende_ziele()
def unterbrochene_abschliessen() -> None:
"""Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess."""
for lauf in _laeufe():
@@ -123,39 +136,72 @@ def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]:
return fehler
def _rueckweg_anlegen(lauf: dict, gast: dict) -> tuple[str, str] | None:
"""Vor dem Update: ("snapshot", Name), wo keiner geht ("sicherung", Archiv), sonst None. Wirft RuntimeError,
wenn der Schritt scheitert (etwa zu wenig Platz) — dann beginnt das Update gar nicht."""
vmid = gast["vmid"]
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 "")
return ("snapshot", m.group(1)) if m else None
if gast.get("sicherung_moeglich"):
a = _auftrag(lauf, "sichern", {"vmid": vmid}, ZEITLIMIT_SICHERUNG_S)
m = re.search(r"^sicherung=(\S+)$", a.get("text") or "", re.MULTILINE)
if not m:
raise RuntimeError("sichern: Der Ausführer nannte keine Sicherung")
return ("sicherung", m.group(1))
return None
def _zurueck(lauf: dict, vmid: int, rueckweg: tuple[str, str]) -> str:
"""Den Rückweg gehen; Rückgabe: was geschah (für die Meldung). Wirft RuntimeError, wenn er scheitert."""
art, name = rueckweg
if art == "snapshot":
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
return f"automatisch zurück auf den Snapshot {name}"
_auftrag(lauf, "sicherung_zurueck", {"vmid": vmid, "sicherung": name}, ZEITLIMIT_SICHERUNG_S)
return f"automatisch die Sicherung von vorher zurückgespielt ({name.rsplit('/', 1)[-1]})"
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
rueckweg = _rueckweg_anlegen(lauf, gast)
except RuntimeError as exc:
_ende(lauf, "fehler", f"{lauf['name']}: Update nicht begonnen — {str(exc).rstrip('.')}. "
"Am Gerät wurde nichts geändert.")
return
try:
_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)
_aufraeumen(lauf, vmid, rueckweg)
_ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.")
return
if snapshot:
if rueckweg:
try:
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, ZEITLIMIT_KURZ_S)
wie = _zurueck(lauf, vmid, rueckweg)
_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)
f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — {wie}. Läuft wieder wie vorher.",
dringend=True)
return
except RuntimeError as exc:
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
if rueckweg[0] == "sicherung":
fehler.append(f"Die Sicherung {rueckweg[1]} liegt weiter auf dem Proxmox-Host.")
_ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}"
+ ("" if snapshot else " Es gab keinen Snapshot, also keinen automatischen Rückweg."),
+ ("" if rueckweg else " Es gab weder Snapshot noch Sicherung, 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."""
def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None:
"""Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen;
der Rückweg dieses Laufs (der neueste) bleibt."""
art, behalten = rueckweg or (None, None)
gast = inventar.gast(lauf["ziel"]) or {}
for name in gast.get("snapshots") or []:
if name.startswith("mc2-") and name != behalten:
@@ -163,6 +209,14 @@ def _aufraeumen(lauf: dict, vmid: int, behalten: str | None) -> None:
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.")
if art != "sicherung":
return
for volid in gast.get("sicherungen") or []:
if volid != behalten:
try:
_auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Alte Sicherung {volid} blieb stehen.")
def _docker_lauf(lauf: dict, basis: str) -> None:
@@ -224,7 +278,7 @@ 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", "docker"):
return {"ok": False, "detail": "Unbekannter Baustein."}
if any(x.get("ziel") == ziel_id and x.get("status") == "laeuft" for x in _laeufe()):
if laeuft(ziel_id):
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
if ziel_id == "pve":
name, gast = "Proxmox-Host", None
@@ -247,6 +301,8 @@ def starten(ziel_id: str, baustein: str) -> dict:
if baustein == "app" and (not app or app.weg != "skript"):
return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script "
"(eigene Oberfläche oder Pakete)."}
if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]):
return {"ok": False, "detail": sperre}
lauf = _neuer_lauf(ziel_id, baustein, name)
ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast))
threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
+1
View File
@@ -487,6 +487,7 @@ PRUEFUNGEN = {
pruefe_festgehalten, pruefe_partner),
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste),
}[ROLLE]
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]