Welle 3 · Wartungsfenster und Update-Regel je Gerät (So 03:00–04:15, Start: Sicherheitsupdates automatisch)

Der Steward des Homelab-Teils prüft jede Minute, ob das Fenster offen ist, und arbeitet dann einmal je Tag den Plan ab:
je Gerät nach seiner Regel — nur melden, Sicherheitsupdates automatisch (Standard, User-Entscheid) oder alles im
Fenster —, mit den bekannten Update-Läufen (Rückweg vorher, Prüfung danach, bei Rot zurück). Nach dem Fenster beginnt
kein Schritt mehr, Erfolge kommen als eine Meldung am Ende, Fehler sofort; der Proxmox-Host startet nie von selbst neu.
Der Ausführer meldet dafür die Namen der Sicherheitspakete und spielt auf Wunsch nur diese ein (Gäste und Host);
neuer Baustein „sicherheit“. Dazu „Version überspringen“ je App, die Einstellungen unter Einstellungen → Homelab und
„Wartung“ statt „Störung“ im Cockpit, solange das Fenster läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 18:33:38 +02:00
co-authored by Claude Opus 5.5
parent 363bc211e1
commit 9da666d04e
18 changed files with 717 additions and 26 deletions
+118
View File
@@ -0,0 +1,118 @@
"""Wartungsfenster und Update-Regel je Gerät (Ausbauplan Welle 3, Punkt 17, 25.09.2026). User-Entscheid: So 03:00–04:15,
zu Beginn für alle Geräte „Sicherheitsupdates automatisch“, alles andere nur melden; der Host startet nie von selbst."""
from datetime import datetime
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from kern.zeit import LOCAL_TZ
from services.homelab import wartung
SONNTAG_0310 = datetime(2026, 9, 27, 3, 10, tzinfo=LOCAL_TZ).timestamp()
SONNTAG_0420 = datetime(2026, 9, 27, 4, 20, tzinfo=LOCAL_TZ).timestamp()
SAMSTAG_0310 = datetime(2026, 9, 26, 3, 10, tzinfo=LOCAL_TZ).timestamp()
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
def _b(bid: str, zustand: str = "neu", sicherheit: int | None = None, aktion: bool = True) -> dict:
return {"id": bid, "zustand": zustand, "sicherheit": sicherheit, "aktion": {"pfad": "/x"} if aktion else None}
ZIELE = [
{"id": "pve", "name": "Proxmox-Host", "bausteine": [_b("pakete", sicherheit=2), _b("neustart")]},
{"id": "ct-100", "name": "AdGuard Home", "bausteine": [_b("os", sicherheit=0)]},
{"id": "ct-104", "name": "Gitea", "bausteine": [_b("app"), _b("os", sicherheit=3)]},
{"id": "vm-106", "name": "Arcane (Docker)", "bausteine": [_b("docker")]},
]
def test_fenster(daten):
assert wartung.im_fenster(SONNTAG_0310) and not wartung.im_fenster(SONNTAG_0420)
assert not wartung.im_fenster(SAMSTAG_0310)
assert wartung.fenster_text() == "So 03:00–04:15"
assert wartung.naechstes(SAMSTAG_0310) == datetime(2026, 9, 27, 3, 0, tzinfo=LOCAL_TZ).timestamp()
assert wartung.naechstes(SONNTAG_0310) == datetime(2026, 9, 27, 3, 0, tzinfo=LOCAL_TZ).timestamp() # läuft gerade
assert wartung.naechstes(SONNTAG_0420) == datetime(2026, 10, 4, 3, 0, tzinfo=LOCAL_TZ).timestamp()
def test_plan_nach_regel(daten):
# Standard „sicherheit“: nur Sicherheitsupdates, der Host ohne Neustart.
assert [(s["ziel"], s["baustein"]) for s in wartung.plan(ZIELE)] == [("pve", "sicherheit"), ("ct-104", "sicherheit")]
wartung.setzen({"regeln": {"ct-104": "alles", "pve": "melden", "vm-106": "alles"}})
assert [(s["ziel"], s["baustein"]) for s in wartung.plan(ZIELE)] == [
("ct-104", "app"), ("ct-104", "os"), ("vm-106", "docker")]
wartung.setzen({"regeln": {"ct-104": None}, "standard": "melden"})
assert [(s["ziel"], s["baustein"]) for s in wartung.plan(ZIELE)] == [("vm-106", "docker")]
def test_einstellungen_pruefen(daten):
assert wartung.setzen({"fenster": {"tag": 6, "von": "05:00", "bis": "04:00"}})["ok"] is False
assert wartung.setzen({"fenster": {"tag": 9, "von": "03:00", "bis": "04:00"}})["ok"] is False
assert wartung.setzen({"regeln": {"ct-104": "immer"}})["ok"] is False
assert wartung.setzen({"regeln": {"rm -rf": "alles"}})["ok"] is False
gut = wartung.setzen({"fenster": {"tag": 5, "von": "02:30", "bis": "04:00"}})
assert gut == {"ok": True, "text": "Gespeichert. Wartungsfenster Sa 02:30–04:00."}
def test_takt_einmal_je_sonntag_und_nie_neben_einem_update(daten, monkeypatch):
from services.homelab import inventar, sammellauf, updates
monkeypatch.setattr(inventar, "ziele", lambda: {"ziele": ZIELE})
monkeypatch.setattr(sammellauf, "laeuft", lambda: False)
laufend = [{"ct-100"}]
monkeypatch.setattr(updates, "laufende_ziele", lambda: laufend[0])
gestartet: list[dict] = []
monkeypatch.setattr(wartung.threading, "Thread", lambda target, args, name, daemon: type(
"T", (), {"start": lambda self: gestartet.append(args[0])})())
assert wartung.takt(SAMSTAG_0310) is False
assert wartung.takt(SONNTAG_0310) is False and gestartet == [] # ein Update läuft: nächste Minute
laufend[0] = set()
assert wartung.takt(SONNTAG_0310) is True and len(gestartet) == 1
assert wartung.takt(SONNTAG_0310 + 60) is False # heute schon gelaufen
assert wartung.lage(SONNTAG_0310)["stand"]["schritte"][0]["baustein"] == "sicherheit"
def test_abarbeiten_und_zusammenfassung(daten, monkeypatch):
from services.homelab import updates
monkeypatch.setattr(wartung, "im_fenster", lambda jetzt=None, fenster=None: True)
gestartet: list[tuple] = []
ergebnisse = {"pve": "eingespielt", "ct-104": "zurueckgerollt"}
def starten(zid, baustein, sammellauf=None):
gestartet.append((zid, baustein, sammellauf["anlass"]))
return {"ok": True, "lauf": zid}
monkeypatch.setattr(updates, "starten", starten)
monkeypatch.setattr(updates, "lauf_lesen", lambda lid: {"status": "fertig", "ergebnis": ergebnisse[lid], "text": "…"})
meldungen: list[str] = []
monkeypatch.setattr(wartung, "_melden", meldungen.append)
stand = {"datum": "2026-09-27", "status": "laeuft", "start": SONNTAG_0310, "schritte": wartung.plan(ZIELE)}
wartung._abarbeiten(stand)
assert gestartet == [("pve", "sicherheit", "Wartungsfenster"), ("ct-104", "sicherheit", "Wartungsfenster")]
assert wartung.stand_lesen()["status"] == "fertig"
assert meldungen == [("Wartung So 03:00–04:15: Eingespielt: Proxmox-Host (Sicherheitsupdates). Zurückgerollt (läuft "
"wie vorher): Gitea (Sicherheitsupdates).")]
def test_version_ueberspringen_und_schnittstelle(daten):
from routers import homelab
app = FastAPI()
app.include_router(homelab.router)
client = TestClient(app)
assert client.post("/api/homelab/ziele/ct-104/ueberspringen", json={"version": "1.27.4"}).json()["ok"] is True
assert wartung.uebersprungen("ct-104", "app") == "1.27.4"
lage = client.get("/api/homelab/wartung").json()
assert (lage["fenster_text"], lage["standard"], lage["uebersprungen"]) == (
"So 03:00–04:15", "sicherheit", {"ct-104:app": "1.27.4"})
assert client.delete("/api/homelab/ziele/ct-104/ueberspringen").json()["text"] == "Die Version wird wieder angeboten."
assert wartung.uebersprungen("ct-104", "app") is None
assert client.post("/api/homelab/wartung", json={"regeln": {"ct-104": "alles"}}).json()["ok"] is True
assert client.post("/api/homelab/wartung", json={"standard": "nie"}).status_code == 409