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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user