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
+24
View File
@@ -76,6 +76,18 @@ laufende_jobs() {
2>/dev/null || echo 0
}
# Läuft im Homelab ein Update oder „Alle aktualisieren“? Schritt 8 startet den Homelab-Teil neu, und das bräche den
# Lauf mittendrin ab (seit 25.09.2026 geprüft). Ist der Homelab-Teil nicht zu erreichen: 0 — dann gibt es nichts
# abzubrechen, und Schritt 8 meldet selbst, dass er nicht nachziehen konnte.
homelab_beschaeftigt() {
if curl -sf -m 5 "$API/api/partner/homelab/laeufe" "$API/api/partner/homelab/sammellauf" 2>/dev/null \
| grep -q '"status": *"laeuft"'; then
echo 1
else
echo 0
fi
}
stufe1() {
exec 9>"${XDG_RUNTIME_DIR}/mc2-deploy.lock"
flock -n 9 || { echo "Es läuft schon ein Deploy."; exit 1; }
@@ -84,6 +96,10 @@ stufe1() {
echo "Es läuft ein Update-Auftrag. Deploy verschoben — später erneut, oder MC_DEPLOY_TROTZDEM=1."
exit 1
fi
if [ "$(homelab_beschaeftigt)" = "1" ] && [ "${MC_DEPLOY_TROTZDEM:-0}" != "1" ]; then
echo "Im Homelab läuft gerade ein Update. Deploy verschoben — später erneut, oder MC_DEPLOY_TROTZDEM=1."
exit 1
fi
local vorher; vorher="$(git rev-parse HEAD)"
echo "Starte Deploy (vorher ${vorher:0:7})…"
git fetch -q origin main
@@ -233,6 +249,14 @@ homelab_mitziehen() {
[ "${MC_DEPLOY_OHNE_HOMELAB:-0}" = "1" ] && return 0
ziel="$(grep -o 'MC_PARTNER_URL=http://[0-9.]*' "$conf" 2>/dev/null | head -n 1 | cut -d/ -f3)"
[ -n "$ziel" ] || return 0
if [ "$(homelab_beschaeftigt)" = "1" ]; then
# Erst während der Prüfungen gestartet: nicht mittendrin neu starten. Die Box ist schon live; nachziehen,
# wenn der Lauf fertig ist (deploy.sh noch einmal).
echo "✗ Im Homelab läuft gerade ein Update — der Homelab-Teil ($ziel) wird nicht nachgezogen."
bash deploy/notify.sh -s "[Deploy]" \
"Die Box ist auf dem neuen Stand; den Homelab-Teil ($ziel) nicht nachgezogen, weil dort gerade ein Update lief. Nach dem Lauf deploy.sh noch einmal." || true
return 0
fi
echo "Ziehe den Homelab-Teil ($ziel) nach…"
if bash deploy/homelab/ausrollen.sh "$ziel" > /tmp/mc2-homelab-ausrollen.log 2>&1; then
echo "✅ Homelab-Teil ($ziel) hat denselben Stand."
+7
View File
@@ -15,6 +15,13 @@ NUR_LESEN=false
[ "${2:-}" = "--nur-lesen" ] && NUR_LESEN=true
PVE="${MC_PVE:-pve}"
# Nicht mitten in ein Update: Der Neustart des Ausführers bräche einen laufenden Auftrag ab (apt in einem Gast!).
if [ "${MC_TROTZDEM:-0}" != "1" ] && curl -sf -m 5 "http://$IP:9001/api/homelab/laeufe" \
"http://$IP:9001/api/homelab/sammellauf" 2>/dev/null | grep -q '"status": *"laeuft"'; then
echo "Im Homelab läuft gerade ein Update — Ausführer später einrichten (oder MC_TROTZDEM=1)." >&2
exit 1
fi
ssh -o BatchMode=yes "$PVE" "install -d -m 755 /usr/local/lib/mc2"
ssh -o BatchMode=yes "$PVE" "cat > /usr/local/lib/mc2/ausfuehrer.py && chmod 755 /usr/local/lib/mc2/ausfuehrer.py" \
< deploy/homelab/ausfuehrer.py
+243
View File
@@ -19,6 +19,9 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
• Messwerte jede Minute (seit 24.09.2026) in einem eigenen Faden, unabhängig vom Bericht und von
Aufträgen: Host aus /proc und /sys, Gäste aus einem einzigen pvesh-Aufruf. Nur lesend;
Fehler bleiben still.
• Speicher im Bericht (seit 25.09.2026): Thin-Pools mit der echten Belegung je Volume, Netzlaufwerke
(NAS; ein hängender NFS-Mount hält den Bericht nicht auf), Sicherungsaufträge und je Gast die
jüngste Sicherung auf deren Ziel; bei VMs die Belegung innen über den Gast-Agenten.
Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve", "nur_lesen": false}
@@ -349,6 +352,233 @@ def _eigene_sicherungen(speicher: str, vmid: int | None = None) -> list[dict]:
return eigene_sicherungen(_pvesh(f"/nodes/{NODE}/storage/{speicher}/content", *parameter), vmid)
# --- Speicher: Thin-Pool, NAS, Sicherungen (seit 25.09.2026; reine Auswertungen, ohne Host testbar) --------------
PLATTEN_SCHLUESSEL = re.compile(r"rootfs|mp\d+|scsi\d+|virtio\d+|sata\d+|ide\d+|efidisk\d+|tpmstate\d+")
VM_PLATTE = re.compile(r"(?:scsi|virtio|sata|ide)\d+")
ECHTE_DATEISYSTEME = {"ext2", "ext3", "ext4", "xfs", "btrfs", "f2fs", "vfat", "ntfs"}
NETZ_ARTEN = {"nfs", "nfs4", "cifs", "smb3"}
NETZ_ZEITLIMIT_S = 10
def _prozent(wert: object) -> float | None:
try:
return float(str(wert).strip()) / 100
except ValueError:
return None
def thin_pools(status: list[dict], konfig: list[dict], lvs: list[dict]) -> tuple[list[dict], dict[str, int]]:
"""Die Thin-Pools des Hosts (Füllstand von Daten und Metadaten) und je Volume, wie viel davon im Pool wirklich
belegt ist — Proxmox zeigt nur die Nenngröße. Rückgabe: (Pools, {volid: belegte Bytes}). Snapshot-Volumes
(origin gesetzt) zählen nicht mit: Ihre Blöcke teilen sie mit dem Original."""
ort = {k["storage"]: (k.get("vgname"), k.get("thinpool")) for k in konfig
if isinstance(k, dict) and k.get("type") == "lvmthin" and k.get("storage")}
pools: list[dict] = []
zuteilung: dict[str, int] = {}
for s in status:
if s.get("type") != "lvmthin" or s.get("storage") not in ort:
continue
vg, thin = ort[s["storage"]]
pool = {"speicher": s["storage"], "art": "lvmthin", "aktiv": bool(s.get("active")),
"belegt": int(s.get("used") or 0), "gesamt": int(s.get("total") or 0), "meta_anteil": None,
"verwaist": []}
for lv in lvs:
if lv.get("vg_name") != vg:
continue
if lv.get("lv_name") == thin:
pool["meta_anteil"] = _prozent(lv.get("metadata_percent"))
elif lv.get("pool_lv") == thin and not lv.get("origin"):
anteil = _prozent(lv.get("data_percent"))
if anteil is not None and str(lv.get("lv_size") or "").isdigit():
zuteilung[f"{s['storage']}:{lv['lv_name']}"] = int(int(lv["lv_size"]) * anteil)
pools.append(pool)
return pools, zuteilung
def gast_volumes(konfig: dict) -> tuple[list[str], list[str], bool]:
"""Die Volumes eines Gasts (ohne Bind-Mounts und CD-Laufwerke), die ungenutzten (unusedN) und ob an jeder
VM-Platte Discard an ist — nur dann kommt Platz, den die VM per TRIM freigibt, im Pool an."""
volumes: list[str] = []
ungenutzt: list[str] = []
discard = True
for schluessel, wert in konfig.items():
text = str(wert)
volid = text.split(",")[0]
if re.fullmatch(r"unused\d+", schluessel):
ungenutzt.append(volid)
continue
if (not PLATTEN_SCHLUESSEL.fullmatch(schluessel) or ":" not in volid or volid.startswith("/")
or "media=cdrom" in text):
continue
volumes.append(volid)
if VM_PLATTE.fullmatch(schluessel) and "discard=on" not in text:
discard = False
return volumes, ungenutzt, discard
def fs_belegung(daten: object) -> dict | None:
"""Belegung der Dateisysteme in einer VM laut Gast-Agent (get-fsinfo), jedes Gerät einmal."""
gesehen: set[str] = set()
belegt = gesamt = 0
for fs in (daten.get("result") or []) if isinstance(daten, dict) else []:
if not isinstance(fs, dict) or fs.get("type") not in ECHTE_DATEISYSTEME or fs.get("total-bytes") is None:
continue
if fs.get("name") in gesehen:
continue
gesehen.add(fs.get("name"))
belegt += int(fs.get("used-bytes") or 0)
gesamt += int(fs["total-bytes"])
return {"belegt": belegt, "gesamt": gesamt} if gesamt else None
def _server(quelle: object) -> str | None:
"""„192.168.178.62:/Backup“ (NFS) oder „//nas/freigabe“ (SMB) → der Rechner."""
q = str(quelle or "")
if q.startswith("//"):
return q[2:].split("/")[0] or None
if ":" not in q:
return None
return q.split(":")[0] or None
def netzlaufwerke(findmnt: object, fstab: str) -> list[dict]:
"""Netzlaufwerke des Hosts: eingebunden laut findmnt, dazu jedes aus /etc/fstab, das es sein sollte (ohne noauto)."""
da: dict[str, dict] = {}
for fs in (findmnt.get("filesystems") or []) if isinstance(findmnt, dict) else []:
if isinstance(fs, dict) and fs.get("fstype") in NETZ_ARTEN and fs.get("target"):
da[fs["target"]] = {"ziel": fs["target"], "quelle": fs.get("source"), "art": fs["fstype"],
"eingebunden": True}
for zeile in fstab.splitlines():
teile = zeile.split()
if len(teile) < 3 or teile[0].startswith("#") or teile[2] not in NETZ_ARTEN:
continue
if "noauto" in (teile[3] if len(teile) > 3 else "").split(","):
continue
da.setdefault(teile[1], {"ziel": teile[1], "quelle": teile[0], "art": teile[2], "eingebunden": False})
for m in da.values():
m["server"] = _server(m["quelle"])
return sorted(da.values(), key=lambda m: m["ziel"])
def _zahlen(wert: object) -> list[int]:
return [int(x) for x in re.findall(r"\d+", str(wert or ""))]
def sicherungs_jobs(jobs: object) -> list[dict]:
"""Die Sicherungsaufträge des Clusters (Rechenzentrum → Backup)."""
ergebnis = []
for j in jobs if isinstance(jobs, list) else []:
if not isinstance(j, dict) or j.get("type", "vzdump") != "vzdump":
continue
ergebnis.append({"id": j.get("id"), "zeitplan": j.get("schedule") or j.get("starttime"),
"speicher": j.get("storage"), "an": str(j.get("enabled", 1)) not in ("0", "false"),
"alle": str(j.get("all", 0)) in ("1", "true"), "vmids": _zahlen(j.get("vmid")),
"ausgenommen": _zahlen(j.get("exclude"))})
return ergebnis
def neueste_sicherungen(inhalt: object) -> dict[str, int]:
"""Je Gast die jüngste Sicherung auf einem Speicher (Unix-Zeit)."""
neu: dict[str, int] = {}
for e in inhalt if isinstance(inhalt, list) else []:
try:
vmid, zeit = str(int(e["vmid"])), int(e["ctime"])
except (KeyError, TypeError, ValueError):
continue
neu[vmid] = max(zeit, neu.get(vmid, 0))
return neu
_haengend: dict[str, subprocess.Popen] = {}
def _netz_platz(ziel: str) -> tuple[str, dict | None]:
"""df auf ein Netzlaufwerk, ohne mitzuhängen: Ein harter NFS-Mount blockiert jeden Zugriff, solange das NAS fehlt —
auch das Beenden des Prozesses. Der bleibt dann zurück (ein zweiter kommt nicht dazu), und es heißt „haengt“."""
alt = _haengend.get(ziel)
if alt is not None and alt.poll() is None:
return "haengt", None
_haengend.pop(ziel, None)
try:
prozess = subprocess.Popen(["df", "-B1", "--output=size,used", ziel], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, errors="replace")
except OSError:
return "fehler", None
ende = time.monotonic() + NETZ_ZEITLIMIT_S
while prozess.poll() is None:
if time.monotonic() >= ende:
try:
prozess.kill()
except OSError:
pass
_haengend[ziel] = prozess
return "haengt", None
time.sleep(0.2)
aus, _ = prozess.communicate()
zahlen = aus.split()[-2:] if prozess.returncode == 0 else []
if len(zahlen) == 2 and all(z.isdigit() for z in zahlen):
return "ok", {"gesamt": int(zahlen[0]), "belegt": int(zahlen[1])}
return "fehler", None
def _lvs() -> list[dict]:
code, text = _laufen(["lvs", "--reportformat", "json", "--units", "b", "--nosuffix", "-o",
"vg_name,lv_name,lv_size,data_percent,metadata_percent,pool_lv,origin"], 30)
if code != 0:
raise RuntimeError(f"lvs: {text[:200]}")
return json.loads(text)["report"][0]["lv"]
def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict:
"""Pools, Netzlaufwerke und Sicherungen für den Bericht. Jeder Teil für sich: Scheitert einer, fehlt nur er."""
lage: dict = {}
try:
pools, zuteilung = thin_pools(speicher, _pvesh("/storage") or [], _lvs())
vmids = {g["vmid"] for g in gaeste}
for g in gaeste:
eigene = [v for v in g.get("volumes") or [] if v in zuteilung]
if eigene:
g["pool_belegt"] = sum(zuteilung[v] for v in eigene)
for u in g.get("ungenutzt") or []:
u["belegt"] = zuteilung.get(u["volume"])
for p in pools:
for volid, belegt in sorted(zuteilung.items()):
m = re.match(rf"{re.escape(p['speicher'])}:(?:vm|base)-(\d+)-", volid)
if m and int(m.group(1)) not in vmids:
p["verwaist"].append({"volume": volid, "belegt": belegt})
lage["pools"] = pools
except Exception as exc:
lage["pools_fehler"] = str(exc)[:200]
try:
_, findmnt = _laufen(["findmnt", "-J", "-l", "-t", ",".join(sorted(NETZ_ARTEN)), "-o", "TARGET,SOURCE,FSTYPE"], 10)
laufwerke = netzlaufwerke(json.loads(findmnt) if findmnt.strip().startswith("{") else {}, _lesen("/etc/fstab"))
for m in laufwerke:
if m["eingebunden"]:
m["zustand"], m["platz"] = _netz_platz(m["ziel"])
lage["netzlaufwerke"] = laufwerke
except Exception as exc:
lage["netz_fehler"] = str(exc)[:200]
try:
jobs = sicherungs_jobs(_pvesh("/cluster/backup"))
lage["sicherung_jobs"] = jobs
ziele = {}
for name in sorted({j["speicher"] for j in jobs if j["an"] and j["speicher"]}):
s = next((x for x in speicher if x.get("storage") == name), {})
ziel = {"art": s.get("type"), "aktiv": bool(s.get("active")), "belegt": s.get("used"),
"gesamt": s.get("total"), "neueste": {}}
try:
ziel["neueste"] = neueste_sicherungen(
_pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup"))
except Exception as exc:
ziel["fehler"] = str(exc)[:200]
ziele[name] = ziel
lage["sicherungsziele"] = ziele
except Exception as exc:
lage["sicherung_fehler"] = str(exc)[:200]
return lage
def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[str]]) -> dict:
art, vmid = eintrag["type"], int(eintrag["vmid"])
etiketten = _etiketten(eintrag.get("tags"))
@@ -365,6 +595,18 @@ def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: di
gast["fehler"] = str(exc)[:200]
return gast
gast["onboot"] = bool(konfig.get("onboot"))
volumes, ungenutzt, discard = gast_volumes(konfig)
gast["volumes"] = volumes
gast["ungenutzt"] = [{"volume": v, "belegt": None} for v in ungenutzt]
if art != "lxc":
gast["discard"] = discard
agent = str(konfig.get("agent") or "0")
if gast["status"] == "running" and agent.split(",")[0] in ("1", "enabled=1"):
try:
# Wie voll die VM innen ist, weiß nur der Gast-Agent (Proxmox sieht nur die Nenngröße der Platte).
gast["platte"] = fs_belegung(_pvesh(f"/nodes/{NODE}/qemu/{vmid}/agent/get-fsinfo"))
except Exception:
pass
gast["snapshot_moeglich"], gast["snapshot_grund"] = snapshot_moeglich(konfig, arten)
if art == "lxc" and not gast["snapshot_moeglich"]:
# Kein Snapshot: Geht wenigstens eine Sicherung (Speicher da, genug Platz)?
@@ -415,6 +657,7 @@ def bericht() -> dict:
gaeste.append(_gast(eintrag, arten, sicherung, sicherungen))
host = _host()
host["sicherung"] = sicherung
host.update(_speicherlage(speicher, gaeste))
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": host,
"gaeste": sorted(gaeste, key=lambda g: g["vmid"])}