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:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
f2733f44fe
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user