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>
313 lines
16 KiB
Python
313 lines
16 KiB
Python
"""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
|