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:
co-authored by
Claude Opus 5.5
parent
363bc211e1
commit
9da666d04e
@@ -37,6 +37,7 @@ class BausteinStand:
|
||||
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
|
||||
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
|
||||
sicherheit: int | None = None # davon Sicherheitsupdates (seit 25.09.2026; None = unbekannt)
|
||||
uebersprungen: str | None = None # diese Version überspringt der User (seit 25.09.2026)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
|
||||
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
|
||||
GET /api/homelab/neu „Neu entdeckt“: Geräte und Docker-Stacks, die er noch nicht kennt
|
||||
GET /api/homelab/wartung Wartungsfenster, Regel je Gerät, übersprungene Versionen, letzter Lauf
|
||||
POST /api/homelab/wartung {"fenster"?, "standard"?, "regeln"?} ändern
|
||||
POST /api/homelab/ziele/{id}/ueberspringen {"baustein": "app", "version": …}; DELETE bietet sie wieder an
|
||||
GET /api/homelab/docker Docker-Dienste unter Arcane je Stack: Zustand, Gesundheit, Update
|
||||
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
|
||||
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
||||
@@ -183,6 +186,36 @@ def neuerungen(ziel_id: str, baustein: str = "app") -> dict:
|
||||
return neu.fuer(ziel_id, baustein)
|
||||
|
||||
|
||||
@router.get("/wartung")
|
||||
def wartung_lage() -> dict:
|
||||
"""Wartungsfenster und Update-Regel je Gerät (seit 25.09.2026)."""
|
||||
from services.homelab import wartung
|
||||
return wartung.lage()
|
||||
|
||||
|
||||
@router.post("/wartung")
|
||||
def wartung_setzen(daten: dict) -> dict:
|
||||
from services.homelab import wartung
|
||||
return _antwort(wartung.setzen(daten))
|
||||
|
||||
|
||||
class Ueberspringen(BaseModel):
|
||||
baustein: str = "app"
|
||||
version: str
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/ueberspringen")
|
||||
def version_ueberspringen(ziel_id: str, u: Ueberspringen) -> dict:
|
||||
from services.homelab import wartung
|
||||
return _antwort(wartung.ueberspringen(ziel_id, u.baustein, u.version))
|
||||
|
||||
|
||||
@router.delete("/ziele/{ziel_id}/ueberspringen")
|
||||
def version_wieder_anbieten(ziel_id: str, baustein: str = "app") -> dict:
|
||||
from services.homelab import wartung
|
||||
return _antwort(wartung.ueberspringen(ziel_id, baustein, None))
|
||||
|
||||
|
||||
@router.get("/neu")
|
||||
def neu_entdeckt() -> dict:
|
||||
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
|
||||
|
||||
@@ -23,7 +23,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, karenz, sicherheit
|
||||
from services.homelab import apps, arcane, kanal, karenz, sicherheit, wartung
|
||||
|
||||
BERICHT_ALT_S = 30 * 60
|
||||
LISTEN_ALT_S = 14 * 24 * 3600
|
||||
@@ -142,6 +142,9 @@ def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand:
|
||||
elif ist_neuer and app.weg != "skript":
|
||||
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
|
||||
stand.grund = f"{app.name} aktualisiert sich über die eigene Oberfläche; das Community-Script tut es nicht."
|
||||
elif ist_neuer and wartung.uebersprungen(zid, "app") == neu["version"]:
|
||||
# „Version überspringen“ (seit 25.09.2026): gilt als aktuell, bis eine neuere kommt.
|
||||
stand.uebersprungen, stand.grund = neu["version"], f"Version {neu['version']} wird übersprungen."
|
||||
elif ist_neuer:
|
||||
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
|
||||
# Frisch geänderte Update-Skripte erst nach der Wartezeit (karenz.py): „neu“, aber ohne Knopf.
|
||||
@@ -310,7 +313,9 @@ def ziele() -> dict:
|
||||
z.kennzahl = zahlen.get(z.id)
|
||||
return {"ziele": [z.als_dict() for z in liste],
|
||||
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
|
||||
"ausfuehrer": ausfuehrer}
|
||||
"ausfuehrer": ausfuehrer,
|
||||
# Seit 25.09.2026: Läuft das Wartungsfenster, zeigt das Cockpit „Wartung“ statt „Störung“.
|
||||
"wartung": {"laeuft": wartung.laeuft(), "fenster": wartung.fenster_text()}}
|
||||
|
||||
|
||||
def _kennzahlen(bericht: dict) -> dict:
|
||||
|
||||
@@ -75,6 +75,7 @@ _AKTION = {
|
||||
}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
|
||||
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||
"sicherheit": "Sicherheitsupdate",
|
||||
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
|
||||
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
|
||||
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
|
||||
@@ -355,6 +356,8 @@ def _lauf(x: dict, schw: Callable) -> dict | None:
|
||||
titel = f"Docker-Images aktualisiert · {name}"
|
||||
elif baustein == "pakete":
|
||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
|
||||
elif baustein == "sicherheit":
|
||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Sicherheitspaket')} · {name}"
|
||||
else:
|
||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
|
||||
elif ergebnis == "zurueckgerollt":
|
||||
|
||||
@@ -100,11 +100,17 @@ def secdb(zweig: str, jetzt: float | None = None) -> dict | None:
|
||||
|
||||
def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None:
|
||||
"""Wie viele der offenen Alpine-Updates eine Lücke schließen (None, solange die Datenbank fehlt)."""
|
||||
namen = alpine_pakete(os_stand, fixes)
|
||||
return None if namen is None else len(namen)
|
||||
|
||||
|
||||
def alpine_pakete(os_stand: dict, fixes: dict | None = None) -> list[str] | None:
|
||||
"""Die offenen Alpine-Updates, die eine Lücke schließen (Namen; None, solange die Datenbank fehlt)."""
|
||||
pakete = os_stand.get("pakete")
|
||||
if not isinstance(pakete, list):
|
||||
return None
|
||||
if not pakete:
|
||||
return 0
|
||||
return []
|
||||
if fixes is None:
|
||||
zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2])
|
||||
if not re.fullmatch(r"\d+\.\d+", zweig):
|
||||
@@ -112,9 +118,9 @@ def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None:
|
||||
fixes = secdb(zweig)
|
||||
if fixes is None:
|
||||
return None
|
||||
anzahl = 0
|
||||
namen = []
|
||||
for p in pakete:
|
||||
alt, neu = apk_teile(str(p.get("alt") or "")), apk_teile(str(p.get("neu") or ""))
|
||||
if any(alt < apk_teile(v) <= neu for v in fixes.get(str(p.get("name")), [])):
|
||||
anzahl += 1
|
||||
return anzahl
|
||||
namen.append(str(p.get("name")))
|
||||
return namen
|
||||
|
||||
@@ -42,7 +42,7 @@ 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, karenz
|
||||
from services.homelab import apps, arcane, inventar, kanal, karenz, sicherheit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -202,7 +202,9 @@ def _gast_lauf(lauf: dict, gast: dict) -> None:
|
||||
"Am Gerät wurde nichts geändert.")
|
||||
return
|
||||
try:
|
||||
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S)
|
||||
# Seit 25.09.2026 „sicherheit“: nur die Pakete, die eine Lücke schließen (Wartungsfenster, Lucy).
|
||||
mehr = {"pakete": lauf["sicherheit_pakete"]} if baustein == "sicherheit" else {}
|
||||
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid, **mehr}, ZEITLIMIT_UPDATE_S)
|
||||
time.sleep(WARTEN_NACH_UPDATE_S)
|
||||
nachher = _frischer_gast(lauf, vmid)
|
||||
if baustein == "app" and nachher:
|
||||
@@ -366,9 +368,11 @@ def _host_lauf(lauf: dict) -> None:
|
||||
_auftrag(lauf, "host_neustart", {}, 60)
|
||||
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
|
||||
return
|
||||
_auftrag(lauf, "host_update", {}, 60 * 60)
|
||||
sicherheit = lauf["baustein"] == "sicherheit"
|
||||
_auftrag(lauf, "host_update", {"pakete": lauf["sicherheit_pakete"]} if sicherheit else {}, 60 * 60)
|
||||
_auftrag(lauf, "bericht", {}, 5 * 60)
|
||||
_ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.")
|
||||
_ende(lauf, "eingespielt", f"Proxmox-Host: {'Sicherheitsupdates' if sicherheit else 'Pakete'} eingespielt. "
|
||||
"Ein Neustart ist ein eigener Knopf.")
|
||||
except RuntimeError as exc:
|
||||
was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update"
|
||||
_ende(lauf, "fehler", f"Proxmox-Host: {was} gescheitert — {exc}", dringend=True)
|
||||
@@ -385,7 +389,7 @@ def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
|
||||
try:
|
||||
# Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass).
|
||||
start = datetime.fromtimestamp(lauf.get("sammellauf_start") or lauf["start"], LOCAL_TZ)
|
||||
anlass = "Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand"
|
||||
anlass = lauf.get("anlass") or ("Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand")
|
||||
update_verlauf.eintragen(start.isoformat(timespec="seconds"), anlass, lauf["ziel"], ergebnis, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
|
||||
@@ -398,6 +402,8 @@ def _neuer_lauf(ziel_id: str, baustein: str, name: str, sammellauf: dict | None
|
||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": [], **mehr}
|
||||
if sammellauf:
|
||||
lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"])
|
||||
if sammellauf.get("anlass"): # „Wartungsfenster“ (seit 25.09.2026)
|
||||
lauf["anlass"] = sammellauf["anlass"]
|
||||
_merken(lauf)
|
||||
return lauf
|
||||
|
||||
@@ -407,7 +413,7 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict
|
||||
|
||||
sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt
|
||||
der Knopf ab, solange ein Sammellauf läuft."""
|
||||
if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel", "probe"):
|
||||
if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel", "probe", "sicherheit"):
|
||||
return {"ok": False, "detail": "Unbekannter Baustein."}
|
||||
with START_SPERRE:
|
||||
if sammellauf is None and _sammellauf_laeuft():
|
||||
@@ -421,6 +427,11 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
mehr = ({"pakete": len(host.get("updates") or [])} if baustein == "pakete"
|
||||
else {"kernel": len(host.get("kernel_alt") or [])} if baustein == "kernel" else {})
|
||||
if baustein == "sicherheit":
|
||||
namen = [str(n) for n in host.get("updates_sicherheit") or []]
|
||||
if not namen:
|
||||
return {"ok": False, "detail": "Auf dem Proxmox-Host steht kein Sicherheitsupdate an."}
|
||||
mehr = {"pakete": len(namen), "sicherheit_pakete": namen}
|
||||
else:
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast:
|
||||
@@ -453,6 +464,13 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict
|
||||
# Was vorher war, fürs Protokoll: die App-Version bzw. die Zahl der Pakete.
|
||||
mehr = ({"version_alt": (gast.get("app") or {}).get("version")} if baustein == "app"
|
||||
else {"pakete": (gast.get("os_updates") or {}).get("anzahl")})
|
||||
if baustein == "sicherheit":
|
||||
os_stand = gast.get("os_updates") or {}
|
||||
namen = os_stand.get("sicherheit_pakete") if "sicherheit_pakete" in os_stand \
|
||||
else sicherheit.alpine_pakete(os_stand)
|
||||
if not namen:
|
||||
return {"ok": False, "detail": f"Bei {name} steht kein Sicherheitsupdate an."}
|
||||
mehr = {"pakete": len(namen), "sicherheit_pakete": [str(n) for n in namen]}
|
||||
lauf = _neuer_lauf(ziel_id, baustein, name, sammellauf, **mehr)
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Wartungsfenster und Update-Regel je Gerät (Ausbauplan Welle 3, Punkt 17, seit 25.09.2026).
|
||||
|
||||
User-Entscheid 25.09.2026: Fenster sonntags 03:00–04:15; zu Beginn gilt für jedes Gerät „Sicherheitsupdates
|
||||
automatisch“, alles andere wird nur gemeldet. Je Gerät einstellbar (Einstellungen → Homelab):
|
||||
melden nichts von selbst — das Update steht an und wartet auf den Knopf
|
||||
sicherheit im Fenster nur die Pakete, die eine Sicherheitslücke schließen (Debian-Suite „-security“, Alpine über
|
||||
die Sicherheitsdatenbank, beim Proxmox-Host ebenso — neu gestartet wird er nie von selbst)
|
||||
alles im Fenster alles, was einen Knopf hat: App, Pakete, Docker-Images (nie der Neustart des Hosts)
|
||||
Dazu je App „Version überspringen“: Diese Version gilt als aktuell, bis eine neuere kommt.
|
||||
|
||||
Der Steward (Rolle homelab) ruft jede Minute takt(): Ist das Fenster offen und heute noch nicht gelaufen, arbeitet er
|
||||
den Plan Gerät für Gerät ab — mit den Update-Läufen aus updates.py (Rückweg vorher, Prüfung danach, bei Rot zurück).
|
||||
Nach dem Ende des Fensters beginnt kein Schritt mehr. Einzelne Erfolge bleiben still (wie bei „Alle aktualisieren“),
|
||||
Fehler und Rückwege gehen sofort raus; am Ende kommt eine Zusammenfassung. Solange er läuft, zeigt das Cockpit
|
||||
„Wartung“ statt „Störung“.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
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 import announce
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REGELN = ("melden", "sicherheit", "alles")
|
||||
REGEL_TEXT = {"melden": "nur melden", "sicherheit": "Sicherheitsupdates automatisch", "alles": "alles automatisch"}
|
||||
TAGE = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
|
||||
STANDARD = {"fenster": {"tag": 6, "von": "03:00", "bis": "04:15"}, "standard": "sicherheit", "regeln": {},
|
||||
"uebersprungen": {}}
|
||||
IM_FENSTER = {"app", "os", "pakete", "docker"} # nie: neustart, kernel, probe
|
||||
BETREFF = "[Homelab-Wartung]"
|
||||
TAKT_S = 60
|
||||
LAUF_ZEITLIMIT_S = 70 * 60
|
||||
_UHR = re.compile(r"([01]\d|2[0-3]):[0-5]\d")
|
||||
_ZIEL = re.compile(r"pve|(?:ct|vm)-\d+")
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad(name: str) -> Path:
|
||||
return einstellungen().daten_dir / name
|
||||
|
||||
|
||||
def _json_lesen(name: str, leer: dict) -> dict:
|
||||
try:
|
||||
daten = json.loads(_pfad(name).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return json.loads(json.dumps(leer))
|
||||
return daten if isinstance(daten, dict) else json.loads(json.dumps(leer))
|
||||
|
||||
|
||||
def _json_schreiben(name: str, daten: dict) -> None:
|
||||
pfad = _pfad(name)
|
||||
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 lesen() -> dict:
|
||||
daten = _json_lesen("homelab-wartung.json", STANDARD)
|
||||
return {**json.loads(json.dumps(STANDARD)), **daten}
|
||||
|
||||
|
||||
def stand_lesen() -> dict:
|
||||
return _json_lesen("homelab-wartung-stand.json", {})
|
||||
|
||||
|
||||
# --- Regeln, Fenster, Überspringen --------------------------------------------------------------------------------
|
||||
|
||||
def regel(zid: str) -> str:
|
||||
e = lesen()
|
||||
return e["regeln"].get(zid) or e["standard"]
|
||||
|
||||
|
||||
def uebersprungen(zid: str, baustein: str) -> str | None:
|
||||
return lesen()["uebersprungen"].get(f"{zid}:{baustein}")
|
||||
|
||||
|
||||
def ueberspringen(zid: str, baustein: str, version: str | None) -> dict:
|
||||
"""POST/DELETE /api/homelab/ziele/{zid}/ueberspringen: eine Version überspringen oder wieder anbieten."""
|
||||
if not _ZIEL.fullmatch(zid) or baustein != "app":
|
||||
return {"ok": False, "detail": "Überspringen geht nur für die App eines Geräts."}
|
||||
with _lock:
|
||||
e = lesen()
|
||||
schluessel = f"{zid}:{baustein}"
|
||||
if version:
|
||||
e["uebersprungen"][schluessel] = str(version)[:80]
|
||||
else:
|
||||
e["uebersprungen"].pop(schluessel, None)
|
||||
_json_schreiben("homelab-wartung.json", e)
|
||||
return {"ok": True, "text": (f"Version {version} wird übersprungen, bis eine neuere kommt." if version
|
||||
else "Die Version wird wieder angeboten.")}
|
||||
|
||||
|
||||
def _minuten(uhr: str) -> int:
|
||||
h, m = uhr.split(":")
|
||||
return int(h) * 60 + int(m)
|
||||
|
||||
|
||||
def im_fenster(jetzt: float | None = None, fenster: dict | None = None) -> bool:
|
||||
f = fenster or lesen()["fenster"]
|
||||
t = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ)
|
||||
minute = t.hour * 60 + t.minute
|
||||
return t.weekday() == int(f["tag"]) and _minuten(f["von"]) <= minute < _minuten(f["bis"])
|
||||
|
||||
|
||||
def naechstes(jetzt: float | None = None, fenster: dict | None = None) -> float:
|
||||
"""Beginn des nächsten Fensters (Unix-Sekunden); läuft eins gerade, dessen Beginn."""
|
||||
f = fenster or lesen()["fenster"]
|
||||
t = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ)
|
||||
von = _minuten(f["von"])
|
||||
for tage in range(8):
|
||||
tag = (t + timedelta(days=tage)).replace(hour=von // 60, minute=von % 60, second=0, microsecond=0)
|
||||
if tag.weekday() == int(f["tag"]) and (tag + timedelta(minutes=_minuten(f["bis"]) - von)) > t:
|
||||
return tag.timestamp()
|
||||
return t.timestamp()
|
||||
|
||||
|
||||
def fenster_text(fenster: dict | None = None) -> str:
|
||||
f = fenster or lesen()["fenster"]
|
||||
return f"{TAGE[int(f['tag'])]} {f['von']}–{f['bis']}"
|
||||
|
||||
|
||||
def setzen(neu: dict) -> dict:
|
||||
"""POST /api/homelab/wartung: Fenster, Standardregel und Regeln je Gerät (nur was mitkommt, wird geändert)."""
|
||||
with _lock:
|
||||
e = lesen()
|
||||
if "fenster" in neu:
|
||||
f = neu["fenster"] if isinstance(neu["fenster"], dict) else {}
|
||||
tag, von, bis = f.get("tag"), str(f.get("von") or ""), str(f.get("bis") or "")
|
||||
if not (isinstance(tag, int) and 0 <= tag <= 6 and _UHR.fullmatch(von) and _UHR.fullmatch(bis)
|
||||
and _minuten(von) < _minuten(bis)):
|
||||
return {"ok": False, "detail": "Das Fenster braucht einen Tag und eine Uhrzeit von–bis am selben Tag."}
|
||||
e["fenster"] = {"tag": tag, "von": von, "bis": bis}
|
||||
if "standard" in neu:
|
||||
if neu["standard"] not in REGELN:
|
||||
return {"ok": False, "detail": "Unbekannte Regel."}
|
||||
e["standard"] = neu["standard"]
|
||||
if "regeln" in neu:
|
||||
regeln = neu["regeln"] if isinstance(neu["regeln"], dict) else {}
|
||||
if not all(_ZIEL.fullmatch(str(k)) and (v in REGELN or v is None) for k, v in regeln.items()):
|
||||
return {"ok": False, "detail": "Unbekanntes Gerät oder unbekannte Regel."}
|
||||
for k, v in regeln.items():
|
||||
if v is None:
|
||||
e["regeln"].pop(k, None)
|
||||
else:
|
||||
e["regeln"][k] = v
|
||||
_json_schreiben("homelab-wartung.json", e)
|
||||
return {"ok": True, "text": f"Gespeichert. Wartungsfenster {fenster_text(e['fenster'])}."}
|
||||
|
||||
|
||||
# --- Plan und Lauf -------------------------------------------------------------------------------------------------
|
||||
|
||||
def plan(ziele: list[dict]) -> list[dict]:
|
||||
"""Was im Fenster laufen soll: je Gerät nach seiner Regel, in der Reihenfolge der Geräteliste (Host zuerst)."""
|
||||
schritte = []
|
||||
for z in ziele:
|
||||
r = regel(z["id"])
|
||||
if r == "melden":
|
||||
continue
|
||||
for b in z.get("bausteine") or []:
|
||||
if b.get("zustand") != "neu" or not b.get("aktion") or b.get("id") not in IM_FENSTER:
|
||||
continue
|
||||
if r == "alles":
|
||||
schritte.append({"ziel": z["id"], "name": z["name"], "baustein": b["id"]})
|
||||
elif b["id"] in ("os", "pakete") and b.get("sicherheit"):
|
||||
schritte.append({"ziel": z["id"], "name": z["name"], "baustein": "sicherheit",
|
||||
"anzahl": b["sicherheit"]})
|
||||
return schritte
|
||||
|
||||
|
||||
def _warten_auf(lauf_id: str, updates) -> dict | None:
|
||||
ende = time.monotonic() + LAUF_ZEITLIMIT_S
|
||||
while time.monotonic() < ende:
|
||||
lauf = updates.lauf_lesen(lauf_id)
|
||||
if lauf and lauf.get("status") != "laeuft":
|
||||
return lauf
|
||||
time.sleep(5)
|
||||
return None
|
||||
|
||||
|
||||
def _abarbeiten(stand: dict) -> None:
|
||||
from services.homelab import updates
|
||||
kennung = {"id": f"wartung-{stand['datum']}", "start": stand["start"], "anlass": "Wartungsfenster"}
|
||||
for schritt in stand["schritte"]:
|
||||
if not im_fenster():
|
||||
schritt.update(ergebnis="nicht-mehr", text="Das Fenster war zu Ende.")
|
||||
continue
|
||||
antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf=kennung)
|
||||
if not antwort.get("ok"):
|
||||
schritt.update(ergebnis="fehler", text=antwort.get("detail"))
|
||||
else:
|
||||
lauf = _warten_auf(antwort["lauf"], updates) or {}
|
||||
schritt.update(lauf=antwort["lauf"], ergebnis=lauf.get("ergebnis") or "offen", text=lauf.get("text"))
|
||||
_json_schreiben("homelab-wartung-stand.json", stand)
|
||||
stand.update(status="fertig", ende=time.time())
|
||||
_json_schreiben("homelab-wartung-stand.json", stand)
|
||||
_melden(zusammenfassung(stand))
|
||||
|
||||
|
||||
def zusammenfassung(stand: dict) -> str:
|
||||
"""Die eine Meldung am Ende des Fensters."""
|
||||
def namen(ergebnis: str) -> str:
|
||||
return ", ".join(f"{s['name']} ({'Sicherheitsupdates' if s['baustein'] == 'sicherheit' else s['baustein']})"
|
||||
for s in stand["schritte"] if s.get("ergebnis") == ergebnis)
|
||||
teile = []
|
||||
if eingespielt := namen("eingespielt"):
|
||||
teile.append(f"Eingespielt: {eingespielt}.")
|
||||
if zurueck := namen("zurueckgerollt"):
|
||||
teile.append(f"Zurückgerollt (läuft wie vorher): {zurueck}.")
|
||||
if gescheitert := namen("fehler"):
|
||||
teile.append(f"Gescheitert: {gescheitert}.")
|
||||
if offen := namen("nicht-mehr"):
|
||||
teile.append(f"Nicht mehr im Fenster: {offen} — nächste Woche.")
|
||||
kopf = f"Wartung {fenster_text()}: "
|
||||
return kopf + (" ".join(teile) if teile else "nichts zu tun.")
|
||||
|
||||
|
||||
def _melden(text: str) -> None:
|
||||
try:
|
||||
announce.notify_telegram(BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("wartung: Meldung ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
def takt(jetzt: float | None = None) -> bool:
|
||||
"""Jede Minute (Steward): im offenen Fenster einmal je Tag den Plan starten. Rückgabe: ob ein Lauf begann."""
|
||||
from services.homelab import inventar, sammellauf, updates
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
if not im_fenster(jetzt):
|
||||
return False
|
||||
heute = datetime.fromtimestamp(jetzt, LOCAL_TZ).date().isoformat()
|
||||
with _lock:
|
||||
if stand_lesen().get("datum") == heute:
|
||||
return False
|
||||
# Läuft gerade ein Update von Hand oder „Alle aktualisieren“, wartet das Fenster eine Minute.
|
||||
if sammellauf.laeuft() or updates.laufende_ziele():
|
||||
return False
|
||||
schritte = plan(inventar.ziele()["ziele"])
|
||||
stand = {"datum": heute, "status": "laeuft" if schritte else "fertig", "start": jetzt,
|
||||
"ende": None if schritte else jetzt, "schritte": schritte}
|
||||
_json_schreiben("homelab-wartung-stand.json", stand)
|
||||
if not schritte:
|
||||
log.info("wartung: Fenster offen, nichts zu tun")
|
||||
return False
|
||||
log.info("wartung: Fenster offen, %d Schritte", len(schritte))
|
||||
threading.Thread(target=_abarbeiten, args=(stand,), name="homelab-wartung", daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def laeuft() -> bool:
|
||||
s = stand_lesen()
|
||||
return s.get("status") == "laeuft" and time.time() - float(s.get("start") or 0) < 6 * 3600
|
||||
|
||||
|
||||
async def wartung_loop() -> None:
|
||||
"""Im Steward der Rolle homelab: jede Minute takt(). Ein Fehler kostet eine Minute, nie die Schleife."""
|
||||
log.info("wartung: aktiv (Fenster %s)", fenster_text())
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(takt)
|
||||
except Exception:
|
||||
log.warning("wartung: Takt gescheitert", exc_info=True)
|
||||
await asyncio.sleep(TAKT_S)
|
||||
|
||||
|
||||
def lage(jetzt: float | None = None) -> dict:
|
||||
"""Für GET /api/homelab/wartung."""
|
||||
e = lesen()
|
||||
return {"fenster": e["fenster"], "fenster_text": fenster_text(e["fenster"]), "standard": e["standard"],
|
||||
"regeln": e["regeln"], "uebersprungen": e["uebersprungen"], "naechstes": naechstes(jetzt, e["fenster"]),
|
||||
"laeuft": laeuft(), "stand": stand_lesen() or None}
|
||||
@@ -14,6 +14,8 @@ Mini-Dienst (mc2-steward.service, Restart=always):
|
||||
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
|
||||
• messwerte.messwerte_loop — Messwerte der KI-Box mit Verlauf (seit 24.09.2026): ein Punkt
|
||||
je Minute nach <Datenordner>/mc2-messwerte/ (nur Rolle box)
|
||||
• wartung.wartung_loop — Wartungsfenster des Homelabs (seit 25.09.2026, nur Rolle homelab): im Fenster
|
||||
die Updates nach der Regel je Gerät
|
||||
|
||||
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
|
||||
am 07.08.2026 entfallen — Hermes führt sein Gedächtnis selbst (docs/wissen/VERDIKTE.md).
|
||||
@@ -75,6 +77,9 @@ async def main() -> None:
|
||||
# Messwerte mit Verlauf (Logik in services/messwerte.py); das Homelab bekommt seine vom Ausführer.
|
||||
if einstellungen().rolle == "box" and messwerte.ENABLED:
|
||||
tasks.append(asyncio.create_task(messwerte.messwerte_loop()))
|
||||
if einstellungen().rolle == "homelab":
|
||||
from services.homelab import wartung
|
||||
tasks.append(asyncio.create_task(wartung.wartung_loop()))
|
||||
if not tasks:
|
||||
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
||||
return
|
||||
|
||||
@@ -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
|
||||
@@ -66,8 +66,12 @@ def test_datenbank_laedt_im_hintergrund_und_blockiert_nie(daten, monkeypatch):
|
||||
# --- Ausführer ---------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_ausfuehrer_zaehlt_sicherheitsupdates(a, monkeypatch):
|
||||
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\n3\n1790329178\n"))
|
||||
assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "listen_stand": 1790329178}
|
||||
# Seit dem Wartungsfenster (Welle 3) steht in Zeile 2 nicht die Zahl, sondern die Namen der Sicherheitsupdates.
|
||||
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\ncurl libssl3 vim \n1790329178\n"))
|
||||
assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "sicherheit_pakete": ["curl", "libssl3", "vim"],
|
||||
"listen_stand": 1790329178}
|
||||
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "4\n\n1790329178\n"))
|
||||
assert a._os_updates(104, "debian")["sicherheit"] == 0
|
||||
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (
|
||||
0, "3.24.2\n1790328932\nbusybox-1.37.0-r18 < 1.37.0-r19\n"))
|
||||
alpine = a._os_updates(101, "alpine")
|
||||
|
||||
Reference in New Issue
Block a user