homelab: Speicherpool, NAS und Sicherungen überwachen – mit Empfehlungen, was Platz bringt
User-Wunsch 25.09.: Speicherpool im Wächter (gelb ab 80 %, rot bei 10 % frei) samt Empfehlungen, dazu das NAS (Backup-Ziel, per NFS auf dem Proxmox-Host). - Ausführer: Thin-Pools mit echter Belegung je Volume (lvs), ungenutzte und verwaiste Platten, VM-Belegung über den Gast-Agenten, Discard je VM, Netzlaufwerke (df mit Zeitlimit, hängender harter NFS-Mount hält nichts auf), Sicherungsaufträge und jüngste Sicherung je Gast. - Homelab-Teil: services/homelab/speicher.py (Auswertung, Empfehlungen, Wächter pruefe_speicher, NAS-Erreichbarkeit per TCP), GET /api/homelab/speicher; Gast-Plattenwächter nennt jetzt auch VMs richtig. - Oberfläche: Karte „Speicher und Sicherungen“ im Homelab-Cockpit. - deploy.sh und ausfuehrer-einrichten.sh warten, solange im Homelab ein Update oder „Alle aktualisieren“ läuft (der Neustart bräche den Lauf ab). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9867affc5a
commit
903fbbfbb9
@@ -126,6 +126,13 @@ def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
|
||||
return {"eintraege": protokoll.eintraege(grenze, berichte)}
|
||||
|
||||
|
||||
@router.get("/speicher")
|
||||
def speicher_lage() -> dict:
|
||||
"""Speicherpool, NAS und Sicherungen samt Empfehlungen, was Platz bringt (Karte im Homelab-Cockpit)."""
|
||||
from services.homelab import speicher
|
||||
return speicher.lage()
|
||||
|
||||
|
||||
@router.get("/einstellungen")
|
||||
def einstellungen_stand() -> dict:
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Speicher und Sicherungen des Homelabs (seit 25.09.2026, User-Wunsch).
|
||||
|
||||
Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfung, ob das NAS antwortet:
|
||||
• Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab
|
||||
90 % — also bei 10 % frei (User-Vorgabe) —, für Daten wie Metadaten. Dazu Empfehlungen, was Platz bringt,
|
||||
der größte Gewinn zuerst: Platz, den ein Gast längst freigegeben hat (TRIM), ungenutzte und verwaiste Platten,
|
||||
Snapshots.
|
||||
• NAS: jedes Netzlaufwerk des Proxmox-Hosts (heute das QNAP per NFS, Ziel der nächtlichen Sicherungen): antwortet,
|
||||
ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben).
|
||||
• Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung
|
||||
(täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar.
|
||||
Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage).
|
||||
"""
|
||||
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.homelab import apps, kanal
|
||||
|
||||
GELB, ROT = 0.80, 0.90
|
||||
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert
|
||||
TRIM_AB = 250_000_000 # ein Container zählt beim gemeinsamen TRIM erst ab hier mit
|
||||
TAEGLICH_S, SONST_S = 26 * 3600, 8 * 86400
|
||||
PORTS = {"nfs": 2049, "nfs4": 2049, "cifs": 445, "smb3": 445}
|
||||
ANTWORT_CACHE_S = 30
|
||||
RANG = {"ok": 0, "gelb": 1, "rot": 2}
|
||||
|
||||
_antwort_cache: dict[tuple[str, int], tuple[float, bool]] = {}
|
||||
_antwort_lock = threading.Lock()
|
||||
|
||||
|
||||
def _stufe(anteil: float | None) -> str:
|
||||
if anteil is None:
|
||||
return "ok"
|
||||
return "rot" if anteil >= ROT else "gelb" if anteil >= GELB else "ok"
|
||||
|
||||
|
||||
def _schlimmer(*stufen: str) -> str:
|
||||
return max(stufen, key=lambda s: RANG.get(s, 0))
|
||||
|
||||
|
||||
def _gb(n: float | None) -> str:
|
||||
return "–" if n is None else f"{n / 1e9:.1f}".replace(".", ",")
|
||||
|
||||
|
||||
def _groesse(n: float | None) -> str:
|
||||
"""„125 GB“, „2,3 TB“ — für Texte."""
|
||||
if n is None:
|
||||
return "–"
|
||||
if n >= 1e12:
|
||||
return f"{n / 1e12:.1f}".replace(".", ",") + " TB"
|
||||
return f"{n / 1e9:.0f} GB" if n >= 10e9 else f"{_gb(n)} GB"
|
||||
|
||||
|
||||
def _name(g: dict) -> str:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
return app.name if app else str(g.get("name") or g["vmid"])
|
||||
|
||||
|
||||
def _wo(g: dict) -> str:
|
||||
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
|
||||
|
||||
|
||||
def _zid(g: dict) -> str:
|
||||
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
|
||||
|
||||
def _datum(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m. %H:%M")
|
||||
|
||||
|
||||
def _bericht() -> dict:
|
||||
return (kanal.bericht() or {}).get("bericht") or {}
|
||||
|
||||
|
||||
# --- Speicherpool ----------------------------------------------------------------------------------------------
|
||||
|
||||
def pools(bericht: dict) -> list[dict]:
|
||||
ergebnis = []
|
||||
for p in (bericht.get("host") or {}).get("pools") or []:
|
||||
gesamt, belegt = int(p.get("gesamt") or 0), int(p.get("belegt") or 0)
|
||||
if not gesamt:
|
||||
continue
|
||||
anteil, meta = belegt / gesamt, p.get("meta_anteil")
|
||||
ergebnis.append({"speicher": p.get("speicher"), "belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt,
|
||||
"anteil": anteil, "meta_anteil": meta, "aktiv": p.get("aktiv", True),
|
||||
"stufe": _schlimmer(_stufe(anteil), _stufe(meta))})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def empfehlungen(bericht: dict) -> list[dict]:
|
||||
"""Was im Speicherpool Platz bringt, der größte Gewinn zuerst; Snapshots (Größe unbekannt) danach.
|
||||
Je Eintrag: art, titel, text (was zu tun ist, mit Klickweg), kurz (für Meldungen), platz (Bytes oder None)."""
|
||||
gaeste = bericht.get("gaeste") or []
|
||||
liste: list[dict] = []
|
||||
container: list[tuple[dict, int]] = []
|
||||
for g in gaeste:
|
||||
platte, im_pool = g.get("platte") or {}, g.get("pool_belegt")
|
||||
if im_pool and platte.get("belegt") is not None:
|
||||
gewinn = int(im_pool) - int(platte["belegt"])
|
||||
if g.get("art") == "lxc":
|
||||
if gewinn >= TRIM_AB:
|
||||
container.append((g, gewinn))
|
||||
elif gewinn >= EMPFEHLUNG_AB:
|
||||
liste.append(_trim_vm(g, int(im_pool), int(platte["belegt"]), gewinn))
|
||||
for u in g.get("ungenutzt") or []:
|
||||
liste.append({
|
||||
"art": "ungenutzt", "platz": u.get("belegt"), "titel": f"{_name(g)}: ungenutzte Platte {u['volume']}",
|
||||
"kurz": f"ungenutzte Platte bei {_name(g)}",
|
||||
"text": (f"Hängt an keinem Laufwerk mehr. Wird sie nicht mehr gebraucht: in Proxmox {_wo(g)} → Hardware "
|
||||
"→ „Unbenutzte Festplatte“ → Entfernen (löscht sie endgültig).")})
|
||||
if (summe := sum(x for _, x in container)) >= EMPFEHLUNG_AB:
|
||||
container.sort(key=lambda t: -t[1])
|
||||
einzeln = ", ".join(f"{_name(g)} {_gb(x)} GB" for g, x in container)
|
||||
befehle = "; ".join(f"pct fstrim {g['vmid']} --ignore-mountpoints" for g, _ in container)
|
||||
liste.append({
|
||||
"art": "trim", "platz": summe, "titel": "Container: freigegebenen Platz an den Pool zurückgeben",
|
||||
"kurz": f"{_groesse(summe)} per TRIM in den Containern",
|
||||
"text": (f"Die Container belegen im Pool {_gb(summe)} GB mehr, als sie nutzen ({einzeln}). In Proxmox: pve → "
|
||||
f"Shell → {befehle} — im laufenden Betrieb und ohne Verlust.")})
|
||||
for p in (bericht.get("host") or {}).get("pools") or []:
|
||||
for v in p.get("verwaist") or []:
|
||||
liste.append({
|
||||
"art": "verwaist", "platz": v.get("belegt"), "titel": f"Verwaiste Platte {v['volume']}",
|
||||
"kurz": f"verwaiste Platte {v['volume']}",
|
||||
"text": (f"Gehört zu keinem Gast mehr. Wird sie nicht mehr gebraucht: in Proxmox pve → {p.get('speicher')} "
|
||||
"→ Datenträger → Entfernen (löscht sie endgültig).")})
|
||||
liste.sort(key=lambda e: -(e["platz"] or 0))
|
||||
for g in gaeste:
|
||||
for s in g.get("snapshots") or []:
|
||||
m = re.fullmatch(r"mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})\d{2}", s)
|
||||
wann = f"vom {m.group(3)}.{m.group(2)}. {m.group(4)}:{m.group(5)}" if m else "von Hand angelegt"
|
||||
liste.append({
|
||||
"art": "snapshot", "platz": None, "titel": f"{_name(g)}: Snapshot {s}", "kurz": f"Snapshot bei {_name(g)}",
|
||||
"text": ((f"Rückweg des Updates {wann}. Läuft {_name(g)} seitdem gut, kann er weg" if m else
|
||||
f"Snapshot {wann}. Wird er nicht mehr gebraucht, kann er weg")
|
||||
+ f": in Proxmox {_wo(g)} → Snapshots → {s} → Entfernen. Wie viel er belegt, sagt Proxmox "
|
||||
"nicht — bei Containern meist wenig, er wächst mit jeder Änderung seitdem.")})
|
||||
return liste
|
||||
|
||||
|
||||
def _trim_vm(g: dict, im_pool: int, genutzt: int, gewinn: int) -> dict:
|
||||
kopf = f"Im Pool belegt die VM {_gb(im_pool)} GB, genutzt sind darin {_gb(genutzt)} GB."
|
||||
if g.get("discard"):
|
||||
text = (f"{kopf} In der VM „sudo fstrim -av“ ausführen; Linux-Gäste tun das meist ohnehin jede Woche "
|
||||
"(fstrim.timer).")
|
||||
else:
|
||||
text = (f"{kopf} Discard ist aus, deshalb kommt Platz, den die VM freigibt, nie im Pool an. In Proxmox: "
|
||||
f"VM {g['vmid']} → Hardware → Festplatte → Bearbeiten → „Discard“ anhaken, dann die VM einmal neu "
|
||||
"starten (die Dienste darin sind kurz weg). Danach gibt sie freien Platz jede Woche von selbst zurück; "
|
||||
"sofort mit „sudo fstrim -av“ in der VM.")
|
||||
return {"art": "trim-vm", "platz": gewinn, "titel": f"{_name(g)}: {_groesse(gewinn)} an den Pool zurückgeben",
|
||||
"kurz": f"{_groesse(gewinn)} bei {_name(g)} per TRIM" + ("" if g.get("discard") else " (Discard einschalten)"),
|
||||
"text": text}
|
||||
|
||||
|
||||
# --- NAS --------------------------------------------------------------------------------------------------------
|
||||
|
||||
def antwortet(server: str, port: int) -> bool:
|
||||
"""Nimmt das NAS auf dem Port seines Dienstes Verbindungen an? (30 s gemerkt: Wächter und Oberfläche fragen oft.)"""
|
||||
jetzt = time.monotonic()
|
||||
with _antwort_lock:
|
||||
if (e := _antwort_cache.get((server, port))) and jetzt - e[0] < ANTWORT_CACHE_S:
|
||||
return e[1]
|
||||
try:
|
||||
with socket.create_connection((server, port), timeout=3):
|
||||
ok = True
|
||||
except OSError:
|
||||
ok = False
|
||||
with _antwort_lock:
|
||||
_antwort_cache[(server, port)] = (jetzt, ok)
|
||||
return ok
|
||||
|
||||
|
||||
def nas(bericht: dict, pruefen=None) -> list[dict]:
|
||||
pruefen = pruefen or antwortet
|
||||
ergebnis = []
|
||||
for m in (bericht.get("host") or {}).get("netzlaufwerke") or []:
|
||||
server, port = m.get("server"), PORTS.get(str(m.get("art")))
|
||||
erreichbar = pruefen(server, port) if server and port else None
|
||||
platz = m.get("platz") or {}
|
||||
gesamt, belegt = platz.get("gesamt"), platz.get("belegt")
|
||||
anteil = belegt / gesamt if gesamt and belegt is not None else None
|
||||
eintrag = {"server": server, "ziel": m.get("ziel"), "quelle": m.get("quelle"), "art": m.get("art"),
|
||||
"eingebunden": bool(m.get("eingebunden")), "zustand": m.get("zustand"), "erreichbar": erreichbar,
|
||||
"belegt": belegt, "gesamt": gesamt, "anteil": anteil, "stufe": "ok", "problem": None}
|
||||
if erreichbar is False:
|
||||
eintrag.update(stufe="rot", problem=f"Das NAS ({server}) antwortet nicht.")
|
||||
elif not m.get("eingebunden"):
|
||||
eintrag.update(stufe="rot", problem=f"{m.get('ziel')} ist auf dem Proxmox-Host nicht eingebunden.")
|
||||
elif m.get("zustand") == "haengt":
|
||||
eintrag.update(stufe="rot", problem=f"Die Einbindung {m.get('ziel')} hängt: Der Proxmox-Host bekommt keine "
|
||||
"Antwort vom NAS.")
|
||||
elif m.get("zustand") == "fehler":
|
||||
eintrag.update(stufe="gelb", problem=f"Die Belegung von {m.get('ziel')} ließ sich nicht lesen.")
|
||||
else:
|
||||
eintrag["stufe"] = _stufe(anteil)
|
||||
ergebnis.append(eintrag)
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Sicherungen ------------------------------------------------------------------------------------------------
|
||||
|
||||
def max_alter_s(zeitplan: object) -> int:
|
||||
"""Wie alt die jüngste Sicherung höchstens sein darf: täglich (nur Uhrzeit, etwa „02:30“) 26 Stunden, bei
|
||||
Wochentagen oder allem Ausgefalleneren 8 Tage."""
|
||||
return TAEGLICH_S if re.fullmatch(r"\s*(\d{1,2}|\*)(:\d{2})?\s*", str(zeitplan or "")) else SONST_S
|
||||
|
||||
|
||||
def _erfasst(job: dict, vmid: int) -> bool:
|
||||
"""Sichert dieser Auftrag den Gast? (alle außer den ausgenommenen, oder die aufgezählten)"""
|
||||
if vmid in (job.get("ausgenommen") or []):
|
||||
return False
|
||||
return bool(job.get("alle")) or vmid in (job.get("vmids") or [])
|
||||
|
||||
|
||||
def sicherungen(bericht: dict, jetzt: float) -> dict:
|
||||
host = bericht.get("host") or {}
|
||||
jobs = [j for j in host.get("sicherung_jobs") or [] if j.get("an")]
|
||||
ziele = host.get("sicherungsziele") or {}
|
||||
probleme: list[str] = []
|
||||
for name in sorted({j.get("speicher") for j in jobs if j.get("speicher")}):
|
||||
z = ziele.get(name) or {}
|
||||
if not z.get("aktiv"):
|
||||
probleme.append(f"Das Sicherungsziel {name} ist nicht erreichbar.")
|
||||
elif z.get("fehler"):
|
||||
probleme.append(f"Die Sicherungen auf {name} ließen sich nicht lesen.")
|
||||
erfasst, fehlend, zuletzt = 0, [], None
|
||||
for g in bericht.get("gaeste") or []:
|
||||
eigene = [j for j in jobs if _erfasst(j, g["vmid"])]
|
||||
if not eigene:
|
||||
continue
|
||||
erfasst += 1
|
||||
stand = [((ziele.get(j.get("speicher")) or {}).get("neueste") or {}).get(str(g["vmid"])) for j in eigene]
|
||||
juengste = max((t for t in stand if t), default=None)
|
||||
if juengste:
|
||||
zuletzt = max(zuletzt or 0, juengste)
|
||||
if not any(t and jetzt - t <= max_alter_s(j.get("zeitplan")) for t, j in zip(stand, eigene, strict=True)):
|
||||
fehlend.append({"ziel": _zid(g), "name": _name(g), "zuletzt": juengste})
|
||||
stufe = "keine" if not jobs else "ok"
|
||||
if fehlend:
|
||||
stufe = "rot" if len(fehlend) == erfasst else "gelb"
|
||||
if probleme:
|
||||
stufe = "rot"
|
||||
return {"jobs": [{"zeitplan": j.get("zeitplan"), "speicher": j.get("speicher"), "alle": j.get("alle")} for j in jobs],
|
||||
"ziele": [{"speicher": n, "art": z.get("art"), "aktiv": z.get("aktiv"), "belegt": z.get("belegt"),
|
||||
"gesamt": z.get("gesamt")} for n, z in sorted(ziele.items())],
|
||||
"erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe}
|
||||
|
||||
|
||||
# --- Für Oberfläche und Wächter ----------------------------------------------------------------------------------
|
||||
|
||||
def lage(jetzt: float | None = None) -> dict:
|
||||
b = _bericht()
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
return {"bericht": bool(b), "pools": pools(b), "nas": nas(b), "sicherungen": sicherungen(b, jetzt),
|
||||
"empfehlungen": empfehlungen(b)}
|
||||
|
||||
|
||||
def pruefe_speicher(jetzt: float | None = None) -> list:
|
||||
"""Befunde für den Wächter. Ohne Bericht keine — das Schweigen des Ausführers meldet pruefe_ausfuehrer()."""
|
||||
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||
b = _bericht()
|
||||
if not b:
|
||||
return []
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
befunde = []
|
||||
tipps = empfehlungen(b)
|
||||
platz_tipp = "; ".join(e["kurz"] for e in tipps[:3])
|
||||
for p in pools(b):
|
||||
if p["stufe"] == "ok":
|
||||
continue
|
||||
anteil = max(p["anteil"], p["meta_anteil"] or 0)
|
||||
was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool"
|
||||
befunde.append(Befund(
|
||||
id=f"pool:{p['speicher']}", stufe=p["stufe"],
|
||||
titel=f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll",
|
||||
text=(f"Noch {_groesse(p['frei'])} frei. Läuft er voll, stehen alle Gäste darauf still. "
|
||||
+ (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
|
||||
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“."),
|
||||
quelle="pve"))
|
||||
for n in nas(b):
|
||||
if n["stufe"] == "ok":
|
||||
continue
|
||||
if n["problem"]:
|
||||
titel, text = n["problem"], ("Die nächtlichen Sicherungen landen dort; bis das behoben ist, scheitern sie. "
|
||||
"Strom und Netz des NAS prüfen, dann in dessen Oberfläche nachsehen.")
|
||||
else:
|
||||
titel = f"NAS ({n['server']}) zu {min(100, round((n['anteil'] or 0) * 100))} % voll"
|
||||
text = (f"Noch {_groesse((n['gesamt'] or 0) - (n['belegt'] or 0))} frei. Die Sicherungen brauchen jede "
|
||||
"Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder alte Dateien auf dem NAS "
|
||||
"aufräumen.")
|
||||
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=n["stufe"], titel=titel, text=text, quelle="nas"))
|
||||
s = sicherungen(b, jetzt)
|
||||
if s["stufe"] in ("gelb", "rot"):
|
||||
teile = list(s["probleme"])
|
||||
if s["fehlend"]:
|
||||
namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})"
|
||||
for f in s["fehlend"][:6])
|
||||
teile.append(f"Keine frische Sicherung: {namen}.")
|
||||
befunde.append(Befund(
|
||||
id="sicherungen", stufe=s["stufe"],
|
||||
titel=("Sicherungen scheitern" if s["stufe"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät"
|
||||
+ ("en" if len(s["fehlend"]) != 1 else "")),
|
||||
text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.",
|
||||
quelle="pve"))
|
||||
return befunde
|
||||
@@ -580,12 +580,16 @@ def pruefe_gast_platten() -> list[Befund]:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
name = app.name if app else str(g.get("name") or g["vmid"])
|
||||
anteil = belegt / gesamt
|
||||
# Seit 25.09.2026 kennt der Bericht auch die Platte der VMs (über den Gast-Agenten).
|
||||
vm = g.get("art") != "lxc"
|
||||
vergroessern = ("VM → Hardware → Festplatte → Aktion → Größe ändern, danach in der VM das Dateisystem "
|
||||
"mitwachsen lassen" if vm else "Container → Ressourcen → Laufwerk → Größe ändern")
|
||||
befunde.append(Befund(
|
||||
id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb",
|
||||
titel=f"Platte von {name} zu {min(100, round(anteil * 100))} % voll",
|
||||
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt (Container {g['vmid']}). Platz schaffen "
|
||||
"oder in Proxmox die Platte vergrößern: Container → Ressourcen → Laufwerk → Größe ändern."),
|
||||
quelle=f"ct-{g['vmid']}"))
|
||||
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']}). "
|
||||
f"Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."),
|
||||
quelle=f"{'vm' if vm else 'ct'}-{g['vmid']}"))
|
||||
return befunde
|
||||
|
||||
|
||||
@@ -599,6 +603,8 @@ PRUEFUNGEN = {
|
||||
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
|
||||
# Messwerte des Proxmox-Hosts (seit 24.09.2026): zehn Minuten über 95 % RAM oder 90 °C = gelb.
|
||||
PRUEFUNGEN += (__import__("services.homelab.messwerte").homelab.messwerte.pruefe_host,) if ROLLE == "homelab" else ()
|
||||
# Speicherpool, NAS und Sicherungen (seit 25.09.2026): gelb ab 80 %, rot bei 10 % frei; Sicherung älter als erlaubt.
|
||||
PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_speicher,) if ROLLE == "homelab" else ()
|
||||
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
|
||||
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Speicherpool, NAS und Sicherungen (25.09.2026). Die Zahlen stammen aus dem echten Bericht vom 25.09.:
|
||||
local-lvm zu 49 % belegt, davon 161 GB die Platte der Arcane-VM, die innen nur 35 GB nutzt (Discard aus)."""
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from services.homelab import speicher
|
||||
|
||||
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
|
||||
JETZT = 1_790_330_000.0 # 25.09.2026 ~11:13
|
||||
NACHT = 1_790_296_203 # 25.09.2026 02:30 — die Sicherungen der Nacht
|
||||
|
||||
|
||||
def _ausfuehrer():
|
||||
spec = importlib.util.spec_from_file_location("ausfuehrer_speicher", AUSFUEHRER)
|
||||
modul = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(modul)
|
||||
return modul
|
||||
|
||||
|
||||
def _bericht(pool_anteil: float = 0.4916, meta: float = 0.0209, nas_anteil: float = 0.3945) -> dict:
|
||||
gesamt = 374_538_764_288
|
||||
return {
|
||||
"host": {
|
||||
"pools": [{"speicher": "local-lvm", "art": "lvmthin", "aktiv": True, "belegt": math.ceil(gesamt * pool_anteil),
|
||||
"gesamt": gesamt, "meta_anteil": meta, "verwaist": []}],
|
||||
"netzlaufwerke": [{"ziel": "/mnt/qnap-backup", "quelle": "192.168.178.62:/Backup", "art": "nfs",
|
||||
"eingebunden": True, "server": "192.168.178.62", "zustand": "ok",
|
||||
"platz": {"gesamt": 3_847_503_609_856, "belegt": int(3_847_503_609_856 * nas_anteil)}}],
|
||||
"sicherung_jobs": [{"id": "j1", "zeitplan": "02:30", "speicher": "pbs-qnap", "an": True, "alle": True,
|
||||
"vmids": [], "ausgenommen": []}],
|
||||
"sicherungsziele": {"pbs-qnap": {"art": "pbs", "aktiv": True, "belegt": 1, "gesamt": 2,
|
||||
"neueste": {"102": NACHT, "104": NACHT, "106": NACHT}}},
|
||||
},
|
||||
"gaeste": [
|
||||
{"vmid": 102, "art": "lxc", "name": "netbird", "app": {"kennung": "netbird"}, "pool_belegt": 2_076_616_687,
|
||||
"platte": {"belegt": 888_725_504, "gesamt": 4_143_677_440}, "snapshots": [], "ungenutzt": []},
|
||||
{"vmid": 104, "art": "lxc", "name": "gitea", "app": {"kennung": "gitea"}, "pool_belegt": 6_442_450_944,
|
||||
"platte": {"belegt": 4_867_121_152, "gesamt": 8_350_298_112}, "snapshots": ["mc2-20260924-192352"],
|
||||
"ungenutzt": []},
|
||||
{"vmid": 106, "art": "qemu", "name": "arcane", "pool_belegt": 160_948_530_708, "discard": False,
|
||||
"platte": {"belegt": 35_454_582_784, "gesamt": 151_205_154_816}, "snapshots": [], "ungenutzt": []},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# --- Ausführer: reine Auswertungen --------------------------------------------------------------------------------
|
||||
|
||||
def test_thin_pool_mit_echter_belegung_je_volume():
|
||||
a = _ausfuehrer()
|
||||
status = [{"storage": "local-lvm", "type": "lvmthin", "active": 1, "used": 184_123_256_523, "total": 374_538_764_288},
|
||||
{"storage": "local", "type": "dir", "active": 1, "used": 1, "total": 2}]
|
||||
konfig = [{"storage": "local-lvm", "type": "lvmthin", "vgname": "pve", "thinpool": "data"}]
|
||||
lvs = [{"vg_name": "pve", "lv_name": "data", "lv_size": "374538764288", "data_percent": "49.16",
|
||||
"metadata_percent": "2.09", "pool_lv": "", "origin": ""},
|
||||
{"vg_name": "pve", "lv_name": "vm-106-disk-0", "lv_size": "161061273600", "data_percent": "99.93",
|
||||
"metadata_percent": "", "pool_lv": "data", "origin": ""},
|
||||
{"vg_name": "pve", "lv_name": "snap_vm-104-disk-0_mc2-20260924-192352", "lv_size": "8589934592",
|
||||
"data_percent": "", "metadata_percent": "", "pool_lv": "data", "origin": "vm-104-disk-0"},
|
||||
{"vg_name": "pve", "lv_name": "root", "lv_size": "103079215104", "data_percent": "", "pool_lv": "", "origin": ""}]
|
||||
pools, zuteilung = a.thin_pools(status, konfig, lvs)
|
||||
assert pools == [{"speicher": "local-lvm", "art": "lvmthin", "aktiv": True, "belegt": 184_123_256_523,
|
||||
"gesamt": 374_538_764_288, "meta_anteil": pytest.approx(0.0209), "verwaist": []}]
|
||||
# Der Snapshot teilt seine Blöcke mit dem Original und zählt nicht mit.
|
||||
assert zuteilung == {"local-lvm:vm-106-disk-0": int(161_061_273_600 * 0.9993)}
|
||||
|
||||
|
||||
def test_volumes_discard_und_ungenutzte_platten():
|
||||
a = _ausfuehrer()
|
||||
assert a.gast_volumes({"rootfs": "local-lvm:vm-105-disk-0,size=10G",
|
||||
"mp0": "/mnt/qnap-backup/pbs-datastore,mp=/mnt/datastore"}) == (
|
||||
["local-lvm:vm-105-disk-0"], [], True)
|
||||
vm = {"scsi0": "local-lvm:vm-106-disk-0,iothread=1,size=150G", "ide2": "none,media=cdrom",
|
||||
"efidisk0": "local-lvm:vm-106-disk-1,efitype=4m,size=4M", "unused0": "local-lvm:vm-106-disk-2", "cores": 4}
|
||||
assert a.gast_volumes(vm) == (["local-lvm:vm-106-disk-0", "local-lvm:vm-106-disk-1"], ["local-lvm:vm-106-disk-2"],
|
||||
False)
|
||||
vm["scsi0"] += ",discard=on"
|
||||
assert a.gast_volumes(vm)[2] is True
|
||||
|
||||
|
||||
def test_vm_belegung_ueber_den_gast_agenten():
|
||||
a = _ausfuehrer()
|
||||
daten = {"result": [
|
||||
{"name": "sda2", "type": "ext4", "mountpoint": "/", "used-bytes": 35_000_000_000, "total-bytes": 150_000_000_000},
|
||||
{"name": "sda2", "type": "ext4", "mountpoint": "/var/snap", "used-bytes": 35_000_000_000,
|
||||
"total-bytes": 150_000_000_000},
|
||||
{"name": "sda1", "type": "vfat", "mountpoint": "/boot/efi", "used-bytes": 6_000_000, "total-bytes": 1_000_000_000},
|
||||
{"name": "overlay", "type": "overlay", "mountpoint": "/var/lib/docker/x", "used-bytes": 9, "total-bytes": 9},
|
||||
{"name": "loop3", "type": "squashfs", "mountpoint": "/snap/core", "used-bytes": 9, "total-bytes": 9}]}
|
||||
assert a.fs_belegung(daten) == {"belegt": 35_006_000_000, "gesamt": 151_000_000_000}
|
||||
assert a.fs_belegung({"result": []}) is None and a.fs_belegung(None) is None
|
||||
|
||||
|
||||
def test_netzlaufwerke_eingebunden_und_fehlend():
|
||||
a = _ausfuehrer()
|
||||
findmnt = {"filesystems": [{"target": "/mnt/qnap-backup", "source": "192.168.178.62:/Backup", "fstype": "nfs"}]}
|
||||
fstab = ("192.168.178.62:/Backup /mnt/qnap-backup nfs vers=3,hard,_netdev 0 0\n"
|
||||
"//192.168.178.62/Medien /mnt/medien cifs credentials=/root/.smb,_netdev 0 0\n"
|
||||
"# 192.168.178.9:/alt /mnt/alt nfs defaults 0 0\n"
|
||||
"192.168.178.62:/Selten /mnt/selten nfs noauto 0 0\n"
|
||||
"/dev/pve/root / ext4 errors=remount-ro 0 1\n")
|
||||
assert a.netzlaufwerke(findmnt, fstab) == [
|
||||
{"ziel": "/mnt/medien", "quelle": "//192.168.178.62/Medien", "art": "cifs", "eingebunden": False,
|
||||
"server": "192.168.178.62"},
|
||||
{"ziel": "/mnt/qnap-backup", "quelle": "192.168.178.62:/Backup", "art": "nfs", "eingebunden": True,
|
||||
"server": "192.168.178.62"}]
|
||||
|
||||
|
||||
def test_sicherungsauftraege_und_juengste_sicherung():
|
||||
a = _ausfuehrer()
|
||||
assert a.sicherungs_jobs([{"id": "j1", "schedule": "02:30", "storage": "pbs-qnap", "all": 1, "type": "vzdump"},
|
||||
{"id": "j2", "schedule": "sat 03:00", "storage": "local", "vmid": "100,101",
|
||||
"exclude": "", "enabled": 0}]) == [
|
||||
{"id": "j1", "zeitplan": "02:30", "speicher": "pbs-qnap", "an": True, "alle": True, "vmids": [], "ausgenommen": []},
|
||||
{"id": "j2", "zeitplan": "sat 03:00", "speicher": "local", "an": False, "alle": False, "vmids": [100, 101],
|
||||
"ausgenommen": []}]
|
||||
assert a.neueste_sicherungen([{"vmid": 100, "ctime": 5}, {"vmid": 100, "ctime": 9}, {"vmid": "101", "ctime": 7},
|
||||
{"volid": "kaputt"}]) == {"100": 9, "101": 7}
|
||||
|
||||
|
||||
def test_haengender_nfs_mount_haelt_den_bericht_nicht_auf(monkeypatch):
|
||||
a = _ausfuehrer()
|
||||
monkeypatch.setattr(a, "NETZ_ZEITLIMIT_S", 0.05)
|
||||
gestartet: list[list[str]] = []
|
||||
|
||||
class Haengt:
|
||||
returncode = None
|
||||
|
||||
def __init__(self, befehl, **_):
|
||||
gestartet.append(befehl)
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def kill(self):
|
||||
pass # im Zustand D wirkt das nicht — der Prozess bleibt
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", Haengt)
|
||||
anfang = time.monotonic()
|
||||
assert a._netz_platz("/mnt/qnap-backup") == ("haengt", None)
|
||||
assert a._netz_platz("/mnt/qnap-backup") == ("haengt", None)
|
||||
assert len(gestartet) == 1 and time.monotonic() - anfang < 2 # kein zweiter Prozess, kein Warten
|
||||
|
||||
|
||||
# --- Homelab-Teil: Auswertung, Empfehlungen, Hinweise ---------------------------------------------------------
|
||||
|
||||
def test_pool_stufen_fuer_daten_und_metadaten():
|
||||
assert speicher.pools(_bericht(0.79))[0]["stufe"] == "ok"
|
||||
assert speicher.pools(_bericht(0.80))[0]["stufe"] == "gelb"
|
||||
assert speicher.pools(_bericht(0.90))[0]["stufe"] == "rot" # 10 % frei (User-Vorgabe)
|
||||
assert speicher.pools(_bericht(0.40, meta=0.91))[0]["stufe"] == "rot"
|
||||
p = speicher.pools(_bericht())[0]
|
||||
assert (p["speicher"], p["frei"]) == ("local-lvm", p["gesamt"] - p["belegt"])
|
||||
|
||||
|
||||
def test_empfehlungen_groesster_gewinn_zuerst():
|
||||
tipps = speicher.empfehlungen(_bericht())
|
||||
assert [t["art"] for t in tipps] == ["trim-vm", "trim", "snapshot"]
|
||||
arcane = tipps[0]
|
||||
assert arcane["platz"] == 160_948_530_708 - 35_454_582_784 and arcane["titel"].startswith("Arcane (Docker): 125 GB")
|
||||
assert "„Discard“ anhaken" in arcane["text"] and arcane["kurz"].endswith("(Discard einschalten)")
|
||||
container = tipps[1]
|
||||
assert container["platz"] == (2_076_616_687 - 888_725_504) + (6_442_450_944 - 4_867_121_152)
|
||||
assert "pct fstrim 104 --ignore-mountpoints" in container["text"] and "Gitea 1,6 GB" in container["text"]
|
||||
assert tipps[2]["titel"] == "Gitea: Snapshot mc2-20260924-192352" and "vom 24.09. 19:23" in tipps[2]["text"]
|
||||
|
||||
|
||||
def test_kleine_gewinne_sind_keine_empfehlung():
|
||||
b = _bericht()
|
||||
b["gaeste"] = [b["gaeste"][0]] # NetBird allein: 1,2 GB — über der Schwelle, weil ≥ 1 GB
|
||||
assert [t["art"] for t in speicher.empfehlungen(b)] == ["trim"]
|
||||
b["gaeste"][0]["pool_belegt"] = b["gaeste"][0]["platte"]["belegt"] + 600_000_000
|
||||
assert speicher.empfehlungen(b) == []
|
||||
|
||||
|
||||
def test_nas_antwortet_nicht_haengt_oder_ist_voll():
|
||||
assert speicher.nas(_bericht(), pruefen=lambda s, p: True)[0]["stufe"] == "ok"
|
||||
weg = speicher.nas(_bericht(), pruefen=lambda s, p: False)[0]
|
||||
assert (weg["stufe"], weg["problem"]) == ("rot", "Das NAS (192.168.178.62) antwortet nicht.")
|
||||
b = _bericht()
|
||||
b["host"]["netzlaufwerke"][0].update(zustand="haengt", platz=None)
|
||||
assert "hängt" in speicher.nas(b, pruefen=lambda s, p: True)[0]["problem"]
|
||||
assert speicher.nas(_bericht(nas_anteil=0.85), pruefen=lambda s, p: True)[0]["stufe"] == "gelb"
|
||||
geprueft = []
|
||||
speicher.nas(_bericht(), pruefen=lambda s, p: geprueft.append((s, p)) or True)
|
||||
assert geprueft == [("192.168.178.62", 2049)]
|
||||
|
||||
|
||||
def test_sicherungen_frisch_fehlend_und_ziel_weg():
|
||||
s = speicher.sicherungen(_bericht(), JETZT)
|
||||
assert (s["stufe"], s["erfasst"], s["fehlend"], s["zuletzt"]) == ("ok", 3, [], NACHT)
|
||||
b = _bericht()
|
||||
b["host"]["sicherungsziele"]["pbs-qnap"]["neueste"]["104"] = NACHT - 3 * 86400
|
||||
s = speicher.sicherungen(b, JETZT)
|
||||
assert s["stufe"] == "gelb" and [f["name"] for f in s["fehlend"]] == ["Gitea"]
|
||||
# Wöchentlicher Auftrag: drei Tage alt ist in Ordnung.
|
||||
b["host"]["sicherung_jobs"][0]["zeitplan"] = "sat 02:30"
|
||||
assert speicher.sicherungen(b, JETZT)["stufe"] == "ok"
|
||||
b = _bericht()
|
||||
b["host"]["sicherungsziele"]["pbs-qnap"]["neueste"] = {}
|
||||
assert speicher.sicherungen(b, JETZT)["stufe"] == "rot" # keiner gesichert: Auftrag läuft nicht
|
||||
b = _bericht()
|
||||
b["host"]["sicherungsziele"]["pbs-qnap"]["aktiv"] = False
|
||||
s = speicher.sicherungen(b, JETZT)
|
||||
assert s["stufe"] == "rot" and s["probleme"] == ["Das Sicherungsziel pbs-qnap ist nicht erreichbar."]
|
||||
b["host"]["sicherung_jobs"][0]["ausgenommen"] = [102, 104, 106]
|
||||
assert speicher.sicherungen(b, JETZT)["erfasst"] == 0
|
||||
b["host"]["sicherung_jobs"] = []
|
||||
assert speicher.sicherungen(b, JETZT)["stufe"] == "keine"
|
||||
|
||||
|
||||
def test_waechter_befunde(monkeypatch):
|
||||
monkeypatch.setattr(speicher, "antwortet", lambda s, p: True)
|
||||
monkeypatch.setattr(speicher, "_bericht", lambda: _bericht())
|
||||
assert speicher.pruefe_speicher(JETZT) == []
|
||||
monkeypatch.setattr(speicher, "_bericht", lambda: _bericht(0.91, nas_anteil=0.93))
|
||||
befunde = {b.id: b for b in speicher.pruefe_speicher(JETZT)}
|
||||
pool = befunde["pool:local-lvm"]
|
||||
assert (pool.stufe, pool.titel) == ("rot", "Speicherpool local-lvm zu 91 % voll")
|
||||
assert "Platz bringt: 125 GB bei Arcane (Docker) per TRIM (Discard einschalten)" in pool.text
|
||||
assert befunde["nas:/mnt/qnap-backup"].titel == "NAS (192.168.178.62) zu 93 % voll"
|
||||
monkeypatch.setattr(speicher, "_bericht", dict)
|
||||
assert speicher.pruefe_speicher(JETZT) == []
|
||||
|
||||
|
||||
def test_schnittstelle(monkeypatch):
|
||||
from routers import homelab
|
||||
monkeypatch.setattr(speicher, "antwortet", lambda s, p: True)
|
||||
monkeypatch.setattr(speicher, "_bericht", lambda: _bericht())
|
||||
app = FastAPI()
|
||||
app.include_router(homelab.router)
|
||||
daten = TestClient(app).get("/api/homelab/speicher").json()
|
||||
assert daten["bericht"] is True and daten["pools"][0]["stufe"] == "ok"
|
||||
assert daten["nas"][0]["erreichbar"] is True and daten["sicherungen"]["stufe"] == "ok"
|
||||
assert daten["empfehlungen"][0]["art"] == "trim-vm"
|
||||
@@ -220,7 +220,7 @@ 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",
|
||||
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host"]
|
||||
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher"]
|
||||
|
||||
|
||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user