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:
Hitonabi
2026-09-25 11:15:06 +02:00
co-authored by Claude Opus 5.5
parent 9867affc5a
commit 903fbbfbb9
43 changed files with 1249 additions and 40 deletions
+7
View File
@@ -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
+312
View File
@@ -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
+9 -3
View File
@@ -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]
+241
View File
@@ -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"
+1 -1
View File
@@ -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):