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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 20:22:39 +02:00
co-authored by Claude Opus 5.5
parent 40d2840b16
commit f2733f44fe
13 changed files with 1385 additions and 54 deletions
+1
View File
@@ -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)
+110
View File
@@ -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.")
+167
View File
@@ -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
+439
View File
@@ -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())
+2 -1
View File
@@ -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):