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]
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ def daten(tmp_path, monkeypatch):
|
||||
"tag": neueste.get(repo, "2026-07-24-r1"), "tag_roh": roh.get(repo, neueste.get(repo, "2026-07-24-r1")),
|
||||
"version": neueste.get(repo, "2026-07-24-r1"), "datum": "2026-07-24"})
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
|
||||
monkeypatch.setattr(inventar.karenz, "skript_geaendert", lambda kennung: 0.0) # Update-Skripte alt, kein Netz
|
||||
monkeypatch.setattr(inventar.arcane, "app_version",
|
||||
lambda url: {"installiert": "2.12.0", "neu": "2.13.1", "update": True})
|
||||
monkeypatch.delenv("MC_ARCANE_KEY", raising=False)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Wartezeit nach einer Änderung am Update-Skript (24.09.2026): ct/<kennung>.sh kommt ungepinnt von GitHub und
|
||||
läuft als root im Gast — frisch Geändertes erst nach MC_HOMELAB_KARENZ_H Stunden."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from kern.zeit import LOCAL_TZ
|
||||
from services.homelab import inventar, kanal, karenz, updates
|
||||
|
||||
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
|
||||
# 23.09.2026 14:05 in Berlin = 12:05 UTC, so wie GitHub es schreibt.
|
||||
GEAENDERT = "2026-09-23T12:05:11Z"
|
||||
GEAENDERT_TS = datetime(2026, 9, 23, 14, 5, 11, tzinfo=LOCAL_TZ).timestamp()
|
||||
|
||||
|
||||
def _github(datum: str = GEAENDERT, pfade: list | None = None):
|
||||
def antwort(pfad, timeout=8):
|
||||
if pfade is not None:
|
||||
pfade.append(pfad)
|
||||
return [{"sha": "abc", "commit": {"author": {"date": "2026-09-01T08:00:00Z"},
|
||||
"committer": {"date": datum}}}]
|
||||
return antwort
|
||||
|
||||
|
||||
def test_frische_aenderung_sperrt_mit_berliner_zeiten(monkeypatch):
|
||||
pfade: list[str] = []
|
||||
monkeypatch.setattr(karenz, "github_json", _github(pfade=pfade))
|
||||
monkeypatch.delenv("MC_HOMELAB_KARENZ_H", raising=False)
|
||||
stand = karenz.pruefen("gitea", jetzt=GEAENDERT_TS + 10 * 3600)
|
||||
assert stand == {"gesperrt": "Das Update-Skript wurde am 23.09. geändert; zur Sicherheit erst ab 25.09. 14:05.",
|
||||
"geprueft": True}
|
||||
# Das Datum, an dem die Änderung auf main landete (committer), nicht das des Autors.
|
||||
assert pfade == ["repos/community-scripts/ProxmoxVE/commits?path=ct/gitea.sh&sha=main&per_page=1"]
|
||||
assert karenz.pruefen("gitea", jetzt=GEAENDERT_TS + 48 * 3600) == {"gesperrt": None, "geprueft": True}
|
||||
|
||||
|
||||
def test_wartezeit_einstellbar(monkeypatch):
|
||||
monkeypatch.setattr(karenz, "github_json", _github())
|
||||
monkeypatch.setenv("MC_HOMELAB_KARENZ_H", "0")
|
||||
assert karenz.pruefen("gitea", jetzt=GEAENDERT_TS + 60)["gesperrt"] is None
|
||||
monkeypatch.setenv("MC_HOMELAB_KARENZ_H", "72")
|
||||
assert "erst ab 26.09. 14:05" in karenz.pruefen("gitea", jetzt=GEAENDERT_TS + 50 * 3600)["gesperrt"]
|
||||
monkeypatch.setenv("MC_HOMELAB_KARENZ_H", "viel")
|
||||
assert karenz.karenz_s() == 48 * 3600
|
||||
|
||||
|
||||
def test_ohne_antwort_von_github_nicht_blockieren(monkeypatch):
|
||||
def kaputt(pfad, timeout=8):
|
||||
raise RuntimeError("403 rate limit")
|
||||
monkeypatch.setattr(karenz, "github_json", kaputt)
|
||||
assert karenz.pruefen("gitea") == {"gesperrt": None, "geprueft": False}
|
||||
monkeypatch.setattr(karenz, "github_json", lambda pfad, timeout=8: []) # Skript gibt es nicht
|
||||
assert karenz.pruefen("gitea")["geprueft"] is False
|
||||
assert karenz.pruefen("../../etc")["geprueft"] is False # fremde Kennung: keine Abfrage
|
||||
|
||||
|
||||
# --- Übersicht und Knopf -------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def gitea_neu(tmp_path, monkeypatch):
|
||||
"""Gitea 1.27.2 installiert, 1.27.3 verfügbar (der Stand vor dem ersten echten Lauf am 24.09.)."""
|
||||
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
monkeypatch.setattr(inventar, "neueste_version", lambda repo: {
|
||||
"tag": "v1.27.3", "tag_roh": "v1.27.3", "version": "1.27.3" if repo == "go-gitea/gitea" else "0.0.1",
|
||||
"datum": "2026-09-20"})
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
|
||||
monkeypatch.setattr(inventar.arcane, "app_version", lambda url: None)
|
||||
monkeypatch.delenv("MC_HOMELAB_KARENZ_H", raising=False)
|
||||
kanal.bericht_speichern(BERICHT)
|
||||
yield
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def _gitea_app() -> dict:
|
||||
ziele = {z["id"]: z for z in inventar.ziele()["ziele"]}
|
||||
return {b["id"]: b for b in ziele["ct-104"]["bausteine"]}["app"]
|
||||
|
||||
|
||||
def test_frisch_geaendertes_skript_neu_aber_ohne_knopf(gitea_neu, monkeypatch):
|
||||
jetzt = datetime.now(LOCAL_TZ).timestamp()
|
||||
monkeypatch.setattr(karenz, "skript_geaendert", lambda kennung: jetzt - 3600)
|
||||
app = _gitea_app()
|
||||
assert (app["zustand"], app["kurz"], app["aktion"]) == ("neu", "1.27.2 → 1.27.3", None)
|
||||
assert app["grund"].startswith("Das Update-Skript wurde am ") and "zur Sicherheit erst ab" in app["grund"]
|
||||
# Der Knopf fehlt nicht nur: auch die Schnittstelle lehnt mit demselben Satz ab, und kein Lauf beginnt.
|
||||
antwort = updates.starten("ct-104", "app")
|
||||
assert antwort == {"ok": False, "detail": app["grund"]}
|
||||
assert updates.laufende_ziele() == set() and kanal.liste() == []
|
||||
|
||||
|
||||
def test_altes_skript_knopf_wie_bisher(gitea_neu, monkeypatch):
|
||||
monkeypatch.setattr(karenz, "skript_geaendert", lambda kennung: 0.0)
|
||||
app = _gitea_app()
|
||||
assert app["aktion"]["frage"] == ("Gitea jetzt aktualisieren? Vorher wird ein Snapshot angelegt; ist die Prüfung "
|
||||
"danach rot, geht es von selbst zurück.")
|
||||
|
||||
|
||||
def test_ungeprueft_sagt_es_die_rueckfrage(gitea_neu, monkeypatch):
|
||||
def kaputt(kennung):
|
||||
raise RuntimeError("GitHub antwortet nicht")
|
||||
monkeypatch.setattr(karenz, "skript_geaendert", kaputt)
|
||||
app = _gitea_app()
|
||||
assert app["aktion"] is not None and app["aktion"]["frage"].endswith(karenz.NICHT_GEPRUEFT)
|
||||
assert app["aktion"]["frage"] == ("Gitea jetzt aktualisieren? Vorher wird ein Snapshot angelegt; ist die Prüfung "
|
||||
"danach rot, geht es von selbst zurück. Ob das Skript kürzlich geändert wurde, "
|
||||
"ließ sich nicht prüfen.")
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Wöchentliches Suchen (services/homelab/pflege.py, 24.09.2026): Der Homelab-Teil erneuert alte Paketlisten in den
|
||||
Gästen selbst — nachts, einmal je Gast und Tag, nie neben einem Update. Mit dem echten Bericht vom 24.09.
|
||||
(tests/fixtures/pve-bericht.json): AdGuard, NPMplus, NetBird, PVE Scripts Local und Gitea haben alte Listen, die des
|
||||
PBS sind frisch."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from kern.zeit import LOCAL_TZ
|
||||
from services import waechter
|
||||
from services.homelab import kanal, pflege, updates
|
||||
|
||||
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
NACHT = datetime(2026, 9, 25, 3, 0, tzinfo=LOCAL_TZ).timestamp()
|
||||
TAG = datetime(2026, 9, 25, 14, 0, tzinfo=LOCAL_TZ).timestamp()
|
||||
NAECHSTE_NACHT = datetime(2026, 9, 26, 3, 0, tzinfo=LOCAL_TZ).timestamp()
|
||||
ALTE_LISTEN = {100, 101, 102, 103, 104}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uhr(tmp_path, monkeypatch):
|
||||
"""Daten in tmp; der Ausführer hat zuletzt um uhr["zuletzt"] berichtet."""
|
||||
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
stand = {"zuletzt": NACHT}
|
||||
monkeypatch.setattr(kanal, "zuletzt", lambda: stand["zuletzt"])
|
||||
monkeypatch.setattr(waechter, "TROCKEN", False)
|
||||
kanal.bericht_speichern(BERICHT)
|
||||
yield stand
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def _suchen() -> Counter:
|
||||
"""Wie viele Aufträge „suchen“ es je Gast gibt."""
|
||||
return Counter(a["parameter"]["vmid"] for a in kanal.liste() if a["aktion"] == "suchen")
|
||||
|
||||
|
||||
def _ausfuehrer_antwortet(scheitert: set[int] = frozenset()) -> None:
|
||||
while (a := kanal.naechster()):
|
||||
vmid = a["parameter"].get("vmid")
|
||||
if vmid in scheitert:
|
||||
kanal.ergebnis(a["id"], 100, "Hit:1 http://deb.debian.org/debian trixie InRelease\n"
|
||||
"E: Failed to fetch http://security.debian.org Temporary failure resolving")
|
||||
else:
|
||||
kanal.ergebnis(a["id"], 0, "Hit:1 http://deb.debian.org/debian trixie InRelease")
|
||||
|
||||
|
||||
def test_nachts_einmal_je_gast_und_tag(uhr):
|
||||
assert pflege.pruefe_paketlisten(jetzt=NACHT) == []
|
||||
assert set(_suchen()) == ALTE_LISTEN # PBS: frische Listen; die Arcane-VM ist kein Container
|
||||
pflege.pruefe_paketlisten(jetzt=NACHT + 60) # nächster Takt: offen, nichts doppelt
|
||||
_ausfuehrer_antwortet()
|
||||
pflege.pruefe_paketlisten(jetzt=NACHT + 120) # erledigt, Listen im Bericht noch alt: heute nicht mehr
|
||||
assert _suchen() == Counter({v: 1 for v in ALTE_LISTEN})
|
||||
zustand = pflege.zustand()
|
||||
assert zustand["nacht"] == "2026-09-25"
|
||||
assert zustand["gaeste"]["100"]["name"] == "AdGuard Home" and zustand["gaeste"]["100"]["fehlschlaege"] == 0
|
||||
uhr["zuletzt"] = NAECHSTE_NACHT # am nächsten Tag wieder, falls die Listen alt bleiben
|
||||
pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT)
|
||||
assert _suchen() == Counter({v: 2 for v in ALTE_LISTEN})
|
||||
|
||||
|
||||
def test_tagsueber_nur_wenn_die_nacht_verpasst_wurde(uhr):
|
||||
uhr["zuletzt"] = TAG
|
||||
pflege._schreiben({"nacht": "2026-09-25", "gaeste": {}}) # die Instanz war letzte Nacht da
|
||||
pflege.pruefe_paketlisten(jetzt=TAG)
|
||||
assert _suchen() == Counter() # also bis zur nächsten Nacht warten
|
||||
pflege._schreiben({"nacht": "2026-09-24", "gaeste": {}}) # letzte Nacht war sie aus
|
||||
pflege.pruefe_paketlisten(jetzt=TAG)
|
||||
assert set(_suchen()) == ALTE_LISTEN # dann eben jetzt
|
||||
assert pflege.zustand()["nacht"] == "2026-09-24" # tagsüber gilt keine Nacht als gesehen
|
||||
|
||||
|
||||
def test_vor_zwei_uhr_zaehlt_die_nacht_von_gestern(uhr):
|
||||
kurz_vor_zwei = datetime(2026, 9, 26, 1, 30, tzinfo=LOCAL_TZ).timestamp()
|
||||
uhr["zuletzt"] = kurz_vor_zwei
|
||||
pflege._schreiben({"nacht": "2026-09-25", "gaeste": {}})
|
||||
pflege.pruefe_paketlisten(jetzt=kurz_vor_zwei)
|
||||
assert _suchen() == Counter()
|
||||
|
||||
|
||||
def test_nie_neben_einem_update_oder_einem_offenen_auftrag(uhr):
|
||||
updates._merken({"id": "l1", "ziel": "ct-104", "baustein": "app", "name": "Gitea", "status": "laeuft",
|
||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []})
|
||||
kanal.anlegen("os_update", {"vmid": 100})
|
||||
pflege.pruefe_paketlisten(jetzt=NACHT)
|
||||
assert set(_suchen()) == ALTE_LISTEN - {100, 104}
|
||||
|
||||
|
||||
def test_ohne_ausfuehrer_keine_auftraege(uhr):
|
||||
uhr["zuletzt"] = NACHT - 3600 # letzter Bericht vor einer Stunde
|
||||
pflege.pruefe_paketlisten(jetzt=NACHT)
|
||||
assert _suchen() == Counter() and pflege.zustand().get("nacht") is None
|
||||
|
||||
|
||||
def test_ausfuehrer_im_nur_lesen_modus_bekommt_keine_auftraege(uhr):
|
||||
nur_lesen = json.loads(json.dumps(BERICHT))
|
||||
nur_lesen["host"]["nur_lesen"] = True
|
||||
kanal.bericht_speichern(nur_lesen)
|
||||
pflege.pruefe_paketlisten(jetzt=NACHT)
|
||||
assert _suchen() == Counter() and pflege.zustand().get("nacht") is None
|
||||
|
||||
|
||||
def test_trockenlauf_legt_nichts_an(uhr, monkeypatch):
|
||||
monkeypatch.setattr(waechter, "TROCKEN", True)
|
||||
pflege.pruefe_paketlisten(jetzt=NACHT)
|
||||
assert _suchen() == Counter()
|
||||
|
||||
|
||||
def test_wiederholtes_scheitern_wird_ein_gelber_hinweis(uhr):
|
||||
pflege.pruefe_paketlisten(jetzt=NACHT)
|
||||
_ausfuehrer_antwortet(scheitert={100})
|
||||
assert pflege.pruefe_paketlisten(jetzt=NACHT + 60) == [] # einmal ist noch kein Muster
|
||||
uhr["zuletzt"] = NAECHSTE_NACHT
|
||||
pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT) # neuer Tag, neuer Versuch
|
||||
_ausfuehrer_antwortet(scheitert={100})
|
||||
befunde = pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT + 60)
|
||||
assert [(b.id, b.stufe, b.titel, b.sofort) for b in befunde] == [
|
||||
("pflege:ct-100", "gelb", "AdGuard Home: Die Suche nach Updates scheitert", True)]
|
||||
assert "2-mal hintereinander" in befunde[0].text and "E: Failed to fetch" in befunde[0].text
|
||||
# Listen wieder frisch (etwa von Hand gesucht): Hinweis weg, Zähler vergessen.
|
||||
frisch = json.loads(json.dumps(BERICHT))
|
||||
next(g for g in frisch["gaeste"] if g["vmid"] == 100)["os_updates"]["listen_stand"] = int(NAECHSTE_NACHT)
|
||||
kanal.bericht_speichern(frisch)
|
||||
assert pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT + 120) == []
|
||||
assert pflege.zustand()["gaeste"]["100"]["fehlschlaege"] == 0
|
||||
|
||||
|
||||
def test_der_waechter_zeigt_den_hinweis_ohne_telegram(uhr, tmp_path, monkeypatch):
|
||||
pflege._schreiben({"nacht": "2026-09-25", "gaeste": {"100": {
|
||||
"name": "AdGuard Home", "tag": "2026-09-25", "auftrag": None, "fehlschlaege": 3, "fehler": "E: kaputt"}}})
|
||||
uhr["zuletzt"] = TAG
|
||||
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "mc2-waechter.json")
|
||||
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
|
||||
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
|
||||
meldungen: list[str] = []
|
||||
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(text))
|
||||
|
||||
def pruefe_paketlisten():
|
||||
return pflege.pruefe_paketlisten(jetzt=TAG)
|
||||
|
||||
monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefe_paketlisten,))
|
||||
waechter.takt()
|
||||
hinweis = waechter._stand["hinweise"]["pflege:ct-100"]
|
||||
assert hinweis["stufe"] == "gelb" and "3-mal" in hinweis["text"] and meldungen == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="Die Dateisperre gibt es nur unter Linux (am PC: nur Thread-Sperre).")
|
||||
def test_auftraege_aus_zwei_prozessen_gehen_nicht_verloren(tmp_path):
|
||||
"""Oberfläche und Steward schreiben beide in die Auftragsliste; ohne Dateisperre überschrieben sie sich."""
|
||||
code = ("from services.homelab import kanal\n"
|
||||
"for i in range(40):\n"
|
||||
" kanal.anlegen('suchen', {'vmid': 100 + i})\n")
|
||||
env = {**os.environ, "MC_DATEN_DIR": str(tmp_path), "MC_ROLLE": "homelab"}
|
||||
prozesse = [subprocess.Popen([sys.executable, "-c", code], cwd=BACKEND, env=env) for _ in range(3)]
|
||||
assert all(p.wait(timeout=120) == 0 for p in prozesse)
|
||||
auftraege = json.loads((tmp_path / "ausfuehrer-auftraege.json").read_text(encoding="utf-8"))
|
||||
assert len(auftraege) == 120
|
||||
@@ -0,0 +1,439 @@
|
||||
"""PBS: Sicherung statt Snapshot (24.09.2026). Der Ausführer über Fakes (die Speicher und CT 105 so, wie sie am
|
||||
24.09. auf dem Proxmox-Host standen: pvesm status, storage.cfg, pct config 105), der Ablauf im Homelab-Teil mit
|
||||
einem nachgespielten Ausführer."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from services.homelab import inventar, kanal, updates
|
||||
|
||||
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
|
||||
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
|
||||
GIB = 1024 ** 3
|
||||
|
||||
# /nodes/pve/storage am 24.09.2026 (Zahlen aus pvesm status, KiB → Bytes).
|
||||
SPEICHER = [
|
||||
{"storage": "local", "type": "dir", "content": "vztmpl,backup,iso", "active": 1, "enabled": 1, "shared": 0,
|
||||
"avail": 46835092 * 1024, "used": 46613140 * 1024, "total": 98497780 * 1024},
|
||||
{"storage": "local-lvm", "type": "lvmthin", "content": "rootdir,images", "active": 1, "enabled": 1, "shared": 0,
|
||||
"avail": 186793893 * 1024},
|
||||
{"storage": "pbs-qnap", "type": "pbs", "content": "backup", "active": 1, "enabled": 1, "shared": 1,
|
||||
"avail": 2282130304 * 1024},
|
||||
]
|
||||
KONFIG_105 = {"rootfs": "local-lvm:vm-105-disk-0,size=10G", "mp0": "/mnt/qnap-backup/pbs-datastore,mp=/mnt/datastore",
|
||||
"unprivileged": 1, "onboot": 1, "ostype": "debian", "tags": "backup;community-script"}
|
||||
ALT = "local:backup/vzdump-lxc-105-2026_09_20-03_00_00.tar.zst"
|
||||
NOTIZ = "mc2-sicherung: vor einem Update durch den Homelab Orchestrator (proxmox-backup-server)"
|
||||
|
||||
|
||||
def _modul():
|
||||
spec = importlib.util.spec_from_file_location("ausfuehrer", AUSFUEHRER)
|
||||
modul = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(modul)
|
||||
return modul
|
||||
|
||||
|
||||
# --- Reine Auswertungen im Ausführer ----------------------------------------------------------
|
||||
|
||||
def test_speicher_wahl_nimmt_local_und_nie_den_pbs():
|
||||
a = _modul()
|
||||
s, fehlt = a.sicherungsspeicher(SPEICHER)
|
||||
assert s["storage"] == "local" and fehlt is None
|
||||
# Ohne lokalen Speicher mit Sicherungen: klare Ablehnung, der PBS ist keine Ausweichlösung.
|
||||
s, fehlt = a.sicherungsspeicher([x for x in SPEICHER if x["storage"] != "local"])
|
||||
assert s is None and "Kein lokaler Speicher" in fehlt and "VZDump-Sicherung" in fehlt
|
||||
assert a.sicherungsspeicher([])[1].startswith("Die Speicher des Proxmox-Hosts")
|
||||
# Ein lokaler Speicher, der nur als „geteilt“ eingetragen ist (Netzfreigabe), zählt nicht als lokal.
|
||||
geteilt = [{**SPEICHER[0], "shared": 1}] + SPEICHER[1:]
|
||||
assert a.sicherungsspeicher(geteilt)[0] is None
|
||||
|
||||
|
||||
def test_speicher_wunsch_aus_der_konfiguration():
|
||||
a = _modul()
|
||||
assert a.sicherungsspeicher(SPEICHER, "local")[0]["storage"] == "local"
|
||||
s, fehlt = a.sicherungsspeicher(SPEICHER, "pbs-qnap")
|
||||
assert s is None and "nie" in fehlt and "selbst sichern" in fehlt
|
||||
assert "keine Sicherungen" in a.sicherungsspeicher(SPEICHER, "local-lvm")[1]
|
||||
assert "gibt es nicht" in a.sicherungsspeicher(SPEICHER, "usb")[1]
|
||||
aus = [{**SPEICHER[0], "active": 0}]
|
||||
assert "nicht verfügbar" in a.sicherungsspeicher(aus, "local")[1]
|
||||
|
||||
|
||||
def test_platzbedarf_ohne_bind_mount():
|
||||
a = _modul()
|
||||
# Läuft der Gast: die belegte rootfs. Der Bind-Mount (Datenspeicher des PBS) zählt nicht.
|
||||
assert a.sicherung_bedarf(KONFIG_105, 3 * GIB) == 3 * GIB
|
||||
# Läuft er nicht: die volle Größe der rootfs.
|
||||
assert a.sicherung_bedarf(KONFIG_105, None) == 10 * GIB
|
||||
# Weitere Volumes zählen nur mit backup=1.
|
||||
mit = {**KONFIG_105, "mp1": "local-lvm:vm-105-disk-1,mp=/daten,backup=1,size=8G",
|
||||
"mp2": "local-lvm:vm-105-disk-2,mp=/tmp2,size=4G"}
|
||||
assert a.sicherung_bedarf(mit, 3 * GIB) == 11 * GIB
|
||||
assert a.sicherung_bedarf({}, None) is None
|
||||
|
||||
|
||||
def test_platzpruefung_frei_groesser_als_belegt_mal_1_2():
|
||||
a = _modul()
|
||||
stand = a.sicherung_stand(SPEICHER)
|
||||
assert stand == {"speicher": "local", "art": "dir", "frei": 46835092 * 1024}
|
||||
assert a.sicherung_pruefen(stand, 3 * GIB) == (True, None)
|
||||
eng = {"speicher": "local", "frei": int(3.5 * GIB)}
|
||||
ok, grund = a.sicherung_pruefen(eng, 3 * GIB) # 3,5 GB frei < 3 GB × 1,2
|
||||
assert not ok and grund == "Zu wenig Platz auf „local“: frei 3,5 GB, nötig mehr als 3,6 GB (belegt 3,0 GB × 1,2)."
|
||||
assert a.sicherung_pruefen({"speicher": "local", "frei": int(3.7 * GIB)}, 3 * GIB)[0] is True
|
||||
assert a.sicherung_pruefen({"speicher": None, "fehler": "Kein lokaler Speicher …"}, 3 * GIB) == (
|
||||
False, "Kein lokaler Speicher …")
|
||||
|
||||
|
||||
def test_modus_snapshot_wo_die_rootfs_es_kann():
|
||||
a = _modul()
|
||||
arten = {s["storage"]: s["type"] for s in SPEICHER}
|
||||
assert a.sicherung_modus(KONFIG_105, arten) == "snapshot"
|
||||
assert a.sicherung_modus({"rootfs": "local:105/vm-105-disk-0.raw,size=8G"}, arten) == "stop"
|
||||
|
||||
|
||||
def test_nur_eigene_sicherungen_zaehlen():
|
||||
a = _modul()
|
||||
eintraege = [
|
||||
{"volid": "local:backup/vzdump-lxc-105-2026_09_24-22_00_00.tar.zst", "ctime": 20, "notes": NOTIZ},
|
||||
{"volid": ALT, "ctime": 10, "notes": NOTIZ},
|
||||
{"volid": "local:backup/vzdump-lxc-105-2026_09_01-03_00_00.tar.zst", "ctime": 5, "notes": "von Hand"},
|
||||
{"volid": "local:backup/vzdump-lxc-104-2026_09_24-22_00_00.tar.zst", "ctime": 30, "notes": NOTIZ},
|
||||
{"volid": "pbs-qnap:backup/ct/105/2026-09-24T20:00:00Z", "ctime": 40, "notes": NOTIZ},
|
||||
]
|
||||
assert [e["volid"] for e in a.eigene_sicherungen(eintraege, 105)] == [
|
||||
ALT, "local:backup/vzdump-lxc-105-2026_09_24-22_00_00.tar.zst"]
|
||||
assert len(a.eigene_sicherungen(eintraege)) == 3
|
||||
assert a.eigene_sicherungen(None) == []
|
||||
|
||||
|
||||
# --- Aktionen des Ausführers über Fakes -------------------------------------------------------
|
||||
|
||||
class Host:
|
||||
"""Der Proxmox-Host als Fake: beantwortet pvesh und führt „Befehle“ nur ins Protokoll."""
|
||||
|
||||
def __init__(self, a, speicher=SPEICHER, belegt=3 * GIB):
|
||||
self.befehle: list[list[str]] = []
|
||||
self.inhalt = [{"volid": ALT, "vmid": 105, "ctime": 1790000000, "notes": NOTIZ},
|
||||
{"volid": "local:backup/vzdump-lxc-105-2026_09_01-03_00_00.tar.zst", "vmid": 105,
|
||||
"ctime": 1788000000, "notes": "von Hand"}]
|
||||
self.status = "running"
|
||||
self.antworten = {
|
||||
"/cluster/resources": [{"vmid": 105, "type": "lxc", "tags": "backup;community-script"},
|
||||
{"vmid": 106, "type": "qemu", "tags": ""}],
|
||||
"/nodes/pve/storage": speicher,
|
||||
"/nodes/pve/lxc/105/config": KONFIG_105,
|
||||
"/nodes/pve/lxc/105/status/current": lambda: {"status": self.status, "disk": belegt},
|
||||
"/nodes/pve/storage/local/content": lambda: self.inhalt,
|
||||
}
|
||||
a.NODE = "pve"
|
||||
a._pvesh = self.pvesh
|
||||
a._laufen = self.laufen
|
||||
|
||||
def pvesh(self, pfad, *parameter):
|
||||
if pfad not in self.antworten:
|
||||
raise RuntimeError(f"pvesh {pfad}: gibt es im Fake nicht")
|
||||
wert = self.antworten[pfad]
|
||||
return wert() if callable(wert) else wert
|
||||
|
||||
def laufen(self, befehl, zeitlimit=60, sanft=False):
|
||||
self.befehle.append(list(befehl))
|
||||
if befehl[0] == "vzdump":
|
||||
self.sanft_vzdump = sanft
|
||||
self.inhalt.append({"volid": "local:backup/vzdump-lxc-105-2026_09_24-22_10_00.tar.zst", "vmid": 105,
|
||||
"ctime": int(time.time()), "notes": NOTIZ})
|
||||
return 0, "INFO: Backup job finished successfully"
|
||||
if befehl[:2] == ["pct", "shutdown"]:
|
||||
self.status = "stopped"
|
||||
return 0, "ok"
|
||||
|
||||
|
||||
def test_sichern_vzdump_auf_local_mit_notiz(monkeypatch):
|
||||
a = _modul()
|
||||
host = Host(a)
|
||||
antwort = a.ausfuehren({"aktion": "sichern", "parameter": {"vmid": 105}})
|
||||
assert antwort["code"] == 0
|
||||
assert antwort["text"].endswith("\nsicherung=local:backup/vzdump-lxc-105-2026_09_24-22_10_00.tar.zst")
|
||||
befehl = host.befehle[-1]
|
||||
assert befehl[:2] == ["vzdump", "105"] and host.sanft_vzdump is True
|
||||
optionen = dict(zip(befehl[2::2], befehl[3::2], strict=True))
|
||||
assert optionen["--storage"] == "local" and optionen["--mode"] == "snapshot"
|
||||
assert optionen["--remove"] == "0" and optionen["--compress"] == "zstd"
|
||||
assert optionen["--notes-template"].startswith("mc2-sicherung")
|
||||
|
||||
|
||||
def test_sichern_lehnt_ab_ohne_speicher_und_ohne_platz(monkeypatch):
|
||||
a = _modul()
|
||||
host = Host(a, speicher=[s for s in SPEICHER if s["storage"] != "local"])
|
||||
antwort = a.ausfuehren({"aktion": "sichern", "parameter": {"vmid": 105}})
|
||||
assert antwort["code"] == 2 and "Kein lokaler Speicher" in antwort["text"] and host.befehle == []
|
||||
|
||||
host = Host(a, speicher=[{**SPEICHER[0], "avail": 2 * GIB}] + SPEICHER[1:], belegt=3 * GIB)
|
||||
antwort = a.ausfuehren({"aktion": "sichern", "parameter": {"vmid": 105}})
|
||||
assert antwort["code"] == 2 and "Zu wenig Platz" in antwort["text"] and host.befehle == []
|
||||
|
||||
# Wunsch in der Konfiguration zeigt auf den PBS: nie.
|
||||
host = Host(a)
|
||||
monkeypatch.setattr(a, "SICHERUNG_SPEICHER", "pbs-qnap")
|
||||
antwort = a.ausfuehren({"aktion": "sichern", "parameter": {"vmid": 105}})
|
||||
assert antwort["code"] == 2 and "Proxmox-Backup-Server" in antwort["text"] and host.befehle == []
|
||||
|
||||
# Ein Gast ohne erlaubtes Etikett bleibt unangetastet.
|
||||
assert "kein erlaubtes Etikett" in a.ausfuehren({"aktion": "sichern", "parameter": {"vmid": 106}})["text"]
|
||||
|
||||
|
||||
def test_sichern_ohne_kennzeichnung_ist_rot():
|
||||
a = _modul()
|
||||
host = Host(a)
|
||||
|
||||
def ohne_notiz(befehl, zeitlimit=60, sanft=False):
|
||||
host.befehle.append(befehl)
|
||||
host.inhalt.append({"volid": "local:backup/vzdump-lxc-105-2026_09_24-22_10_00.tar.zst", "vmid": 105,
|
||||
"ctime": int(time.time()), "notes": ""})
|
||||
return 0, "INFO: fertig"
|
||||
|
||||
a._laufen = ohne_notiz
|
||||
antwort = a.ausfuehren({"aktion": "sichern", "parameter": {"vmid": 105}})
|
||||
assert antwort["code"] == 1 and "mc2-sicherung" in antwort["text"]
|
||||
|
||||
|
||||
def test_sicherung_zurueck_stoppt_spielt_zurueck_und_startet():
|
||||
a = _modul()
|
||||
host = Host(a)
|
||||
antwort = a.ausfuehren({"aktion": "sicherung_zurueck", "parameter": {"vmid": 105, "sicherung": ALT}})
|
||||
assert antwort["code"] == 0
|
||||
assert host.befehle == [
|
||||
["pct", "shutdown", "105", "--timeout", "120", "--forceStop", "1"],
|
||||
["pct", "restore", "105", ALT, "--force", "1", "--storage", "local-lvm"], # der bisherige rootfs-Speicher
|
||||
["pct", "start", "105"]]
|
||||
|
||||
|
||||
def test_zurueckgespielt_und_geloescht_wird_nur_eigenes():
|
||||
a = _modul()
|
||||
host = Host(a)
|
||||
fremd = "local:backup/vzdump-lxc-105-2026_09_01-03_00_00.tar.zst" # von Hand, ohne mc2-Notiz
|
||||
for aktion in ("sicherung_zurueck", "sicherung_loeschen"):
|
||||
for volid in (fremd, "local:backup/vzdump-lxc-104-2026_09_20-03_00_00.tar.zst",
|
||||
"pbs-qnap:backup/ct/105/2026-09-24T20:00:00Z", "/var/lib/vz/dump/x.tar.zst; reboot", None):
|
||||
antwort = a.ausfuehren({"aktion": aktion, "parameter": {"vmid": 105, "sicherung": volid}})
|
||||
assert antwort["code"] == 2, (aktion, volid)
|
||||
assert host.befehle == []
|
||||
assert a.ausfuehren({"aktion": "sicherung_loeschen", "parameter": {"vmid": 105, "sicherung": ALT}})["code"] == 0
|
||||
assert host.befehle == [["pvesm", "free", ALT]]
|
||||
|
||||
|
||||
def test_laufen_mit_zeitlimit_und_sanftem_abbruch():
|
||||
import sys
|
||||
a = _modul()
|
||||
assert a._laufen([sys.executable, "-c", "print('hallo')"]) == (0, "hallo")
|
||||
beginn = time.time()
|
||||
code, text = a._laufen([sys.executable, "-c", "import time; time.sleep(30)"], zeitlimit=1, sanft=True)
|
||||
assert (code, text) == (124, "Zeitlimit (1 s) überschritten") and time.time() - beginn < 20
|
||||
assert a._laufen(["gibt-es-nicht-mc2"])[0] == 127
|
||||
|
||||
|
||||
def test_unerwarteter_fehler_wird_beantwortet():
|
||||
"""Sonst bliebe der Auftrag offen, und der Homelab-Teil wartete bis zu seinem Zeitlimit."""
|
||||
a = _modul()
|
||||
Host(a)
|
||||
a.AKTIONEN["sichern"] = lambda p: {}["kaputt"]
|
||||
antwort = a.ausfuehren({"aktion": "sichern", "parameter": {"vmid": 105}})
|
||||
assert antwort["code"] == 1 and "KeyError" in antwort["text"]
|
||||
|
||||
|
||||
def test_bericht_sagt_ob_eine_sicherung_geht():
|
||||
a = _modul()
|
||||
host = Host(a)
|
||||
host.antworten.update({
|
||||
"/cluster/resources": [{"vmid": 105, "type": "lxc", "node": "pve", "name": "proxmox-backup-server",
|
||||
"status": "running", "tags": "backup;community-script", "disk": 3 * GIB}],
|
||||
"/nodes/pve/lxc/105/snapshot": [{"name": "current"}],
|
||||
"/version": {"version": "9.2.11"},
|
||||
"/nodes/pve/apt/update": [],
|
||||
})
|
||||
bericht = a.bericht()
|
||||
assert bericht["host"]["sicherung"] == {"speicher": "local", "art": "dir", "frei": 46835092 * 1024}
|
||||
assert bericht["host"]["nur_lesen"] is False # die Pflege legt sonst keine Aufträge an
|
||||
pbs = bericht["gaeste"][0]
|
||||
assert pbs["snapshot_moeglich"] is False and "Bind-Mount" in pbs["snapshot_grund"]
|
||||
assert (pbs["sicherung_moeglich"], pbs["sicherung_grund"], pbs["sicherung_speicher"]) == (True, None, "local")
|
||||
assert pbs["sicherungen"] == [ALT] # die fremde Sicherung von Hand zählt nicht
|
||||
|
||||
host.antworten["/nodes/pve/storage"] = SPEICHER[1:] # kein lokaler Speicher mit Sicherungen
|
||||
pbs = a.bericht()["gaeste"][0]
|
||||
assert pbs["sicherung_moeglich"] is False and "Kein lokaler Speicher" in pbs["sicherung_grund"]
|
||||
|
||||
|
||||
# --- Der Ablauf im Homelab-Teil ---------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def daten(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("MC_AUSFUEHRER_TOKEN", raising=False)
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
|
||||
monkeypatch.setattr(inventar, "neueste_version", lambda repo: None)
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
|
||||
monkeypatch.setattr(inventar.karenz, "skript_geaendert", lambda kennung: 0.0)
|
||||
yield tmp_path
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def _mit_sicherung(bericht: dict, **pbs) -> dict:
|
||||
neu = json.loads(json.dumps(bericht))
|
||||
gast = next(g for g in neu["gaeste"] if g["vmid"] == 105)
|
||||
gast.update(pbs)
|
||||
gast["os_updates"]["listen_stand"] = int(time.time()) # frische Listen: 58 Pakete bleiben „neu“
|
||||
return neu
|
||||
|
||||
|
||||
BERICHT_PBS = _mit_sicherung(BERICHT, sicherung_moeglich=True, sicherung_grund=None, sicherung_speicher="local",
|
||||
sicherungen=[ALT])
|
||||
NEU = "local:backup/vzdump-lxc-105-2026_09_24-22_10_00.tar.zst"
|
||||
|
||||
|
||||
def _ausfuehrer(stopp: threading.Event, protokoll: list, fehlschlag: dict) -> None:
|
||||
"""Nachgespielter Ausführer; fehlschlag: Aktion → (Code, Text)."""
|
||||
while not stopp.is_set():
|
||||
auftrag = kanal.naechster()
|
||||
if not auftrag:
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
protokoll.append((auftrag["aktion"], auftrag["parameter"]))
|
||||
code, text = fehlschlag.get(auftrag["aktion"], (0, ""))
|
||||
if auftrag["aktion"] == "sichern" and code == 0:
|
||||
text = f"INFO: fertig\nsicherung={NEU}"
|
||||
elif auftrag["aktion"] == "bericht":
|
||||
text = json.dumps(_mit_sicherung(BERICHT_PBS, sicherungen=[ALT, NEU]))
|
||||
kanal.ergebnis(auftrag["id"], code, text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lauf(daten, monkeypatch):
|
||||
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
|
||||
echt = kanal.warten
|
||||
monkeypatch.setattr(kanal, "warten", lambda aid, zeitlimit_s, takt_s=2.0: echt(aid, min(zeitlimit_s, 10), 0.02))
|
||||
meldungen: list[tuple[str, bool]] = []
|
||||
monkeypatch.setattr(updates, "_melden", lambda text, dringend=False: meldungen.append((text, dringend)))
|
||||
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: None)
|
||||
kanal.bericht_speichern(BERICHT_PBS)
|
||||
stopp, protokoll, fehlschlag = threading.Event(), [], {}
|
||||
|
||||
def starten(ziel: str, baustein: str) -> dict:
|
||||
threading.Thread(target=_ausfuehrer, args=(stopp, protokoll, fehlschlag), daemon=True).start()
|
||||
antwort = updates.starten(ziel, baustein)
|
||||
assert antwort["ok"], antwort
|
||||
ende = time.time() + 20
|
||||
while time.time() < ende:
|
||||
x = next((x for x in updates.laeufe() if x["id"] == antwort["lauf"]), {})
|
||||
if x.get("status") == "fertig":
|
||||
return x
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("Lauf wurde nicht fertig")
|
||||
|
||||
yield starten, protokoll, meldungen, fehlschlag
|
||||
stopp.set()
|
||||
|
||||
|
||||
def test_pbs_gruen_mit_sicherung_und_alte_sicherung_weg(lauf):
|
||||
starten, protokoll, meldungen, _ = lauf
|
||||
x = starten("ct-105", "os")
|
||||
assert x["ergebnis"] == "eingespielt"
|
||||
assert protokoll == [("sichern", {"vmid": 105}), ("os_update", {"vmid": 105}), ("bericht", {}),
|
||||
("sicherung_loeschen", {"vmid": 105, "sicherung": ALT})] # die neue bleibt
|
||||
assert meldungen == [("Proxmox Backup Server: eingespielt, Prüfung grün.", False)]
|
||||
|
||||
|
||||
def test_pbs_rot_spielt_die_sicherung_zurueck(lauf, monkeypatch):
|
||||
starten, protokoll, meldungen, _ = lauf
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
|
||||
x = starten("ct-105", "os")
|
||||
assert x["ergebnis"] == "zurueckgerollt"
|
||||
assert [p[0] for p in protokoll] == ["sichern", "os_update", "bericht", "sicherung_zurueck"]
|
||||
assert protokoll[-1][1] == {"vmid": 105, "sicherung": NEU}
|
||||
text, dringend = meldungen[0]
|
||||
assert dringend and "Sicherung von vorher zurückgespielt (vzdump-lxc-105-2026_09_24-22_10_00.tar.zst)" in text
|
||||
|
||||
|
||||
def test_pbs_rot_und_auch_das_zurueckspielen_scheitert(lauf, monkeypatch):
|
||||
starten, _, meldungen, fehlschlag = lauf
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
|
||||
fehlschlag["sicherung_zurueck"] = (1, "restore failed")
|
||||
x = starten("ct-105", "os")
|
||||
assert x["ergebnis"] == "fehler"
|
||||
text, dringend = meldungen[0]
|
||||
assert dringend and "Auch der Rückweg scheiterte" in text and f"Die Sicherung {NEU} liegt weiter" in text
|
||||
|
||||
|
||||
def test_ohne_sicherung_beginnt_das_update_nicht(lauf):
|
||||
starten, protokoll, meldungen, fehlschlag = lauf
|
||||
fehlschlag["sichern"] = (2, "Abgelehnt: Zu wenig Platz auf „local“: frei 2,0 GB, nötig mehr als 3,6 GB.")
|
||||
x = starten("ct-105", "os")
|
||||
assert x["ergebnis"] == "fehler" and [p[0] for p in protokoll] == ["sichern"]
|
||||
text, dringend = meldungen[0]
|
||||
assert not dringend and "Update nicht begonnen" in text and "Zu wenig Platz" in text
|
||||
assert "nichts geändert" in text and ".." not in text
|
||||
|
||||
|
||||
def test_gescheiterter_snapshot_heisst_update_nicht_begonnen(lauf, monkeypatch):
|
||||
"""Vorher hieß das „Update gescheitert … kein Rückweg“ (dringend), obwohl das Update nie lief."""
|
||||
starten, protokoll, meldungen, fehlschlag = lauf
|
||||
kanal.bericht_speichern(BERICHT)
|
||||
monkeypatch.setattr(inventar, "neueste_version", lambda repo: {
|
||||
"tag": "v1.27.3", "tag_roh": "v1.27.3", "version": "1.27.3", "datum": "2026-09-20"})
|
||||
fehlschlag["snapshot"] = (1, "snapshot feature is not available")
|
||||
x = starten("ct-104", "app")
|
||||
assert x["ergebnis"] == "fehler" and [p[0] for p in protokoll] == ["snapshot"]
|
||||
erwartet = ("Gitea: Update nicht begonnen — snapshot gescheitert: snapshot feature is not available. "
|
||||
"Am Gerät wurde nichts geändert.")
|
||||
assert meldungen == [(erwartet, False)]
|
||||
|
||||
|
||||
def test_weder_snapshot_noch_sicherung_wie_bisher(lauf, monkeypatch):
|
||||
starten, protokoll, meldungen, _ = lauf
|
||||
kanal.bericht_speichern(_mit_sicherung(BERICHT, sicherung_moeglich=False,
|
||||
sicherung_grund="Kein lokaler Speicher des Proxmox-Hosts …"))
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
|
||||
x = starten("ct-105", "os")
|
||||
assert [p[0] for p in protokoll] == ["os_update", "bericht"] and x["ergebnis"] == "fehler"
|
||||
assert "weder Snapshot noch Sicherung" in meldungen[0][0]
|
||||
|
||||
|
||||
# --- Was die Übersicht dazu sagt ----------------------------------------------------------------
|
||||
|
||||
def _pbs(bericht: dict) -> dict:
|
||||
kanal.bericht_speichern(bericht)
|
||||
return {z["id"]: z for z in inventar.ziele()["ziele"]}["ct-105"]
|
||||
|
||||
|
||||
def test_rueckweg_und_rueckfrage_mit_sicherung(daten):
|
||||
pbs = _pbs(BERICHT_PBS)
|
||||
assert pbs["rueckweg"].startswith("Sicherung auf „local“ vor jedem Update (Bind-Mount mp0")
|
||||
os_baustein = {b["id"]: b for b in pbs["bausteine"]}["os"]
|
||||
assert os_baustein["aktion"]["frage"] == ("58 Pakete im Gast jetzt einspielen? Vorher wird eine Sicherung "
|
||||
"angelegt; ist die Prüfung danach rot, wird sie zurückgespielt.")
|
||||
|
||||
|
||||
def test_ohne_sicherung_sagt_die_rueckfrage_warum(daten):
|
||||
pbs = _pbs(_mit_sicherung(BERICHT, sicherung_moeglich=False,
|
||||
sicherung_grund="Zu wenig Platz auf „local“: frei 2,0 GB."))
|
||||
assert "Auch keine Sicherung: Zu wenig Platz" in pbs["rueckweg"]
|
||||
frage = {b["id"]: b for b in pbs["bausteine"]}["os"]["aktion"]["frage"]
|
||||
assert "keinen automatischen Rückweg" in frage and "Eine Sicherung geht auch nicht: Zu wenig Platz" in frage
|
||||
# Der alte Bericht (Ausführer noch ohne Sicherungen) bleibt wie bisher.
|
||||
assert _pbs(BERICHT)["rueckweg"] == "Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich"
|
||||
|
||||
|
||||
def test_waechter_laesst_gaeste_im_update_in_ruhe(daten):
|
||||
from services import waechter
|
||||
kanal.bericht_speichern(BERICHT_PBS)
|
||||
updates._merken({"id": "x1", "ziel": "ct-105", "baustein": "os", "name": "PBS", "status": "laeuft",
|
||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []})
|
||||
ids = [e["id"] for e in inventar.erreichbarkeit()]
|
||||
assert "ct-105" not in ids and "ct-104" in ids
|
||||
assert all(b.id != "gast:ct-105" for b in waechter.pruefe_gaeste())
|
||||
@@ -217,7 +217,8 @@ import steward
|
||||
from services import waechter
|
||||
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
|
||||
""")
|
||||
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste"]
|
||||
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
|
||||
"pruefe_paketlisten"]
|
||||
|
||||
|
||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user