Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten. Entscheide: - Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen. - Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer, SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran. - Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt eingespielt“. - Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen). - speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot. - Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf); die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers); Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand). Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren, Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1290 lines
62 KiB
Python
1290 lines
62 KiB
Python
#!/usr/bin/env python3
|
||
"""Ausführer des Homelab Orchestrators auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
|
||
|
||
Läuft als root auf dem Proxmox-PC (mc2-ausfuehrer.service) und holt sich seine Arbeit selbst
|
||
beim Homelab-Teil ab („Pull“, User-Entscheid 24.09.): Der Proxmox-Host öffnet keinen Port, und der
|
||
Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
|
||
|
||
• Bericht alle 10 Minuten (oder auf Anfrage): Host-Updates, Gäste mit Status, App-Version,
|
||
OS-Updates, Snapshots und ob ein Snapshot überhaupt geht; wo nicht, ob eine Sicherung
|
||
geht, und die Sicherungen des Orchestrators. Nur lesend. Seit 25.09.2026 je Gast auch
|
||
snapshot_details (Zeitpunkt, Beschreibung, ob vom Orchestrator) und sicherung_details
|
||
(Zeitpunkt, Größe) für die Seite Homelab → Snapshots; die Namenslisten snapshots und
|
||
sicherungen bleiben, wie sie waren.
|
||
• Aufträge eine feste Liste von Aktionen (AKTIONEN); alles andere wird abgelehnt. Welche Gäste
|
||
angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten:
|
||
community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht
|
||
vertraut — er kann nur aus der Liste wählen.
|
||
• Sicherung wo kein Snapshot geht (PBS: Bind-Mount), vzdump auf einen lokalen Speicher des Hosts —
|
||
nie auf einen Speicher der Art pbs, sonst sicherte sich der PBS selbst. Nur eigene
|
||
Sicherungen (Notiz „mc2-sicherung“) werden zurückgespielt oder gelöscht. An der
|
||
Speicher-Konfiguration ändert der Ausführer nichts.
|
||
• 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}
|
||
„nur_lesen": true liefert nur den Bericht und lehnt jeden anderen Auftrag ab. Optional
|
||
„sicherung_speicher": "<Speicher>" legt fest, wohin die Sicherungen gehen (sonst der erste lokale Speicher,
|
||
der Sicherungen annimmt). ausfuehrer-einrichten.sh schreibt die Datei neu; den Schlüssel danach wieder eintragen.
|
||
|
||
Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
|
||
ausfuehrer.py --bericht Bericht einmal auf die Konsole (nur lesend, zum Prüfen)
|
||
ausfuehrer.py --messwerte einen Messpunkt auf die Konsole (nur lesend, CPU über eine Sekunde)
|
||
"""
|
||
|
||
import argparse
|
||
import glob
|
||
import json
|
||
import logging
|
||
import os
|
||
import platform
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from datetime import datetime, timezone
|
||
|
||
KONFIG = os.environ.get("MC2_AUSFUEHRER_KONFIG", "/etc/mc2-ausfuehrer.json")
|
||
BERICHT_ALLE_S = 600
|
||
ABFRAGE_ALLE_S = 5
|
||
ERLAUBT = {"community-script", "watcher"}
|
||
AUSGENOMMEN = "watcher-aus"
|
||
SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$")
|
||
# Beschreibung neuer Snapshots in Proxmox (seit 25.09.2026). Der Server wählt nur den Anlass, den Text legt dieser
|
||
# Host fest. „knopf“ = „Snapshot anlegen“ auf der Seite Homelab → Snapshots; solche räumt kein Update weg.
|
||
SNAPSHOT_ANLASS = {"update": "Vor einem Update durch den Homelab Orchestrator",
|
||
"knopf": "Auf Knopfdruck im Homelab Orchestrator angelegt"}
|
||
# Speicherarten, auf denen Proxmox Snapshots kann.
|
||
SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"}
|
||
|
||
# Sicherungen vor einem Update, wo kein Snapshot geht (24.09.2026). Jede trägt diese Notiz; nur solche fasst der
|
||
# Ausführer später wieder an (zurückspielen, löschen).
|
||
SICHERUNG_KENNUNG = "mc2-sicherung"
|
||
SICHERUNG_NOTIZ = SICHERUNG_KENNUNG + ": vor einem Update durch den Homelab Orchestrator ({{guestname}})"
|
||
SICHERUNG_ARCHIV = re.compile(
|
||
r"^([A-Za-z][\w.-]*):backup/vzdump-lxc-(\d+)-\d{4}_\d{2}_\d{2}-\d{2}_\d{2}_\d{2}\.tar(?:\.(?:zst|gz|lzo))?$")
|
||
# Dateispeicher auf dem Host selbst. Netzspeicher nur, wenn „sicherung_speicher“ es so will — pbs nie.
|
||
LOKALE_SICHERUNGSARTEN = {"dir", "btrfs"}
|
||
PLATZ_FAKTOR = 1.2
|
||
ZEITLIMIT_SICHERUNG_S = 30 * 60
|
||
|
||
# App-Version je Community-Script (Kennung aus /usr/bin/update im Gast). Neuere Skripte legen die Version
|
||
# in /root/.<app> ab; für die älteren steht hier, wie man sie erfährt. Festes Wissen dieses Hosts.
|
||
VERSION_PROBEN = {
|
||
"adguard": "/opt/AdGuardHome/AdGuardHome --version",
|
||
"netbird": "netbird version",
|
||
"proxmox-backup-server": "dpkg-query -W proxmox-backup-server",
|
||
"npmplus": "docker image inspect zoeyvid/npmplus:latest --format '{{.Created}}'",
|
||
}
|
||
# PVE Scripts Local aktualisiert sich über den eingebauten Updater; /root/.proxmoxve-local bleibt dabei auf dem
|
||
# Stand der Erstinstallation stehen (24.09.2026: „0.5.8“, tatsächlich die neueste Veröffentlichung).
|
||
VERSIONSDATEIEN = {"pve-scripts-local": "/opt/ProxmoxVE-Local/VERSION"}
|
||
|
||
log = logging.getLogger("ausfuehrer")
|
||
|
||
|
||
# --- Hilfen -----------------------------------------------------------------------------
|
||
|
||
def _laufen(befehl: list[str], zeitlimit: int = 60, sanft: bool = False) -> tuple[int, str]:
|
||
"""Befehl mit Zeitlimit. sanft (vzdump, pct restore): beim Zeitlimit zuerst SIGTERM — dann räumen sie Sperre
|
||
und Snapshot selbst weg; hart abgebrochen wird erst zwei Minuten später."""
|
||
try:
|
||
prozess = subprocess.Popen(befehl, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||
errors="replace")
|
||
except OSError as exc:
|
||
return 127, str(exc)
|
||
try:
|
||
aus, fehler = prozess.communicate(timeout=zeitlimit)
|
||
except subprocess.TimeoutExpired:
|
||
if sanft:
|
||
prozess.terminate()
|
||
try:
|
||
prozess.communicate(timeout=120)
|
||
except subprocess.TimeoutExpired:
|
||
pass
|
||
prozess.kill()
|
||
prozess.communicate()
|
||
return 124, f"Zeitlimit ({zeitlimit} s) überschritten"
|
||
return prozess.returncode, (aus + fehler).strip()
|
||
|
||
|
||
def _pvesh(pfad: str, *parameter: str) -> object:
|
||
code, text = _laufen(["pvesh", "get", pfad, *parameter, "--output-format", "json"], 30)
|
||
if code != 0:
|
||
raise RuntimeError(f"pvesh {pfad}: {text[:200]}")
|
||
return json.loads(text or "null")
|
||
|
||
|
||
def _im_gast(vmid: int, befehl: str, zeitlimit: int = 30) -> tuple[int, str]:
|
||
return _laufen(["pct", "exec", str(vmid), "--", "sh", "-c", befehl], zeitlimit)
|
||
|
||
|
||
def _etiketten(roh: str | None) -> list[str]:
|
||
return [t for t in (roh or "").replace(",", ";").split(";") if t]
|
||
|
||
|
||
def erlaubt(etiketten: list[str]) -> bool:
|
||
menge = set(etiketten)
|
||
return bool(menge & ERLAUBT) and AUSGENOMMEN not in menge
|
||
|
||
|
||
# --- Bericht (nur lesend) -------------------------------------------------------------------
|
||
|
||
def _speicher_liste() -> list[dict]:
|
||
"""Die Speicher des Hosts (Name, Art, Inhalt, frei …) — leer, wenn sie sich nicht lesen lassen."""
|
||
try:
|
||
return [s for s in _pvesh(f"/nodes/{NODE}/storage") or [] if isinstance(s, dict) and s.get("storage")]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _speicherart() -> dict[str, str]:
|
||
return {s["storage"]: s.get("type") for s in _speicher_liste()}
|
||
|
||
|
||
def snapshot_moeglich(konfig: dict, arten: dict[str, str]) -> tuple[bool, str | None]:
|
||
"""Geht ein Snapshot? Bind-Mounts (mpX mit Host-Pfad) und Speicher ohne Snapshots verhindern ihn."""
|
||
for schluessel, wert in konfig.items():
|
||
if re.fullmatch(r"mp\d+", schluessel) and str(wert).split(",")[0].startswith("/"):
|
||
return False, f"Bind-Mount {schluessel} ({str(wert).split(',')[0]}) — nur Backup möglich"
|
||
for schluessel in ("rootfs", "scsi0", "virtio0", "sata0", "ide0"):
|
||
if schluessel in konfig:
|
||
speicher = str(konfig[schluessel]).split(":")[0]
|
||
art = arten.get(speicher)
|
||
if art and art not in SNAPSHOT_SPEICHER:
|
||
return False, f"Speicher {speicher} ({art}) kann keine Snapshots"
|
||
return True, None
|
||
|
||
|
||
# --- Sicherung statt Snapshot (reine Auswertungen, ohne Host testbar) ---------------------------
|
||
|
||
def _inhalt(speicher: dict) -> set[str]:
|
||
return {x.strip() for x in str(speicher.get("content") or "").split(",") if x.strip()}
|
||
|
||
|
||
def sicherungsspeicher(speicher: list[dict], wunsch: str | None = None) -> tuple[dict | None, str | None]:
|
||
"""Wohin die Sicherung vor einem Update geht: (Speicher, None) oder (None, was fehlt). Nie auf einen Speicher
|
||
der Art pbs (der PBS würde sich selbst sichern). Ohne Wunsch der erste lokale Speicher, der Sicherungen annimmt.
|
||
Die Speicher-Konfiguration selbst ist Sache des Users; hier wird nur gewählt oder abgelehnt."""
|
||
if not speicher:
|
||
return None, "Die Speicher des Proxmox-Hosts ließen sich nicht lesen."
|
||
if wunsch:
|
||
s = next((x for x in speicher if x.get("storage") == wunsch), None)
|
||
if s is None:
|
||
return None, (f"Den eingestellten Sicherungsspeicher „{wunsch}“ (sicherung_speicher in {KONFIG}) "
|
||
"gibt es nicht.")
|
||
if s.get("type") == "pbs":
|
||
return None, (f"„{wunsch}“ ist ein Proxmox-Backup-Server-Speicher. Dorthin sichert der Orchestrator nie — "
|
||
"der PBS würde sich selbst sichern.")
|
||
if "backup" not in _inhalt(s):
|
||
return None, f"„{wunsch}“ nimmt keine Sicherungen an (Inhalt „VZDump-Sicherung“ fehlt)."
|
||
if not s.get("active"):
|
||
return None, f"Der Sicherungsspeicher „{wunsch}“ ist gerade nicht verfügbar."
|
||
return s, None
|
||
for s in speicher:
|
||
if (s.get("type") in LOKALE_SICHERUNGSARTEN and not s.get("shared") and s.get("active")
|
||
and "backup" in _inhalt(s)):
|
||
return s, None
|
||
return None, ("Kein lokaler Speicher des Proxmox-Hosts nimmt Sicherungen an. In Proxmox unter Rechenzentrum → "
|
||
"Speicher bei einem lokalen Verzeichnis (z. B. „local“) den Inhalt „VZDump-Sicherung“ zulassen oder "
|
||
f"„sicherung_speicher“ in {KONFIG} eintragen.")
|
||
|
||
|
||
def sicherung_stand(speicher: list[dict], wunsch: str | None = None) -> dict:
|
||
"""Für den Bericht: {"speicher", "art", "frei"} oder {"speicher": None, "fehler": was fehlt}."""
|
||
s, fehlt = sicherungsspeicher(speicher, wunsch)
|
||
if s is None:
|
||
return {"speicher": None, "fehler": fehlt}
|
||
return {"speicher": s["storage"], "art": s.get("type"), "frei": s.get("avail")}
|
||
|
||
|
||
_EINHEIT = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4}
|
||
|
||
|
||
def _groesse(text: str) -> int | None:
|
||
"""„10G“ → Bytes (Größenangaben der Proxmox-Konfiguration)."""
|
||
m = re.fullmatch(r"(\d+(?:\.\d+)?)([KMGT]?)", str(text or "").strip().upper())
|
||
return int(float(m.group(1)) * _EINHEIT[m.group(2)]) if m else None
|
||
|
||
|
||
def _volume(wert: object) -> tuple[str, dict[str, str]]:
|
||
teile = str(wert or "").split(",")
|
||
return teile[0], dict(t.split("=", 1) for t in teile[1:] if "=" in t)
|
||
|
||
|
||
def sicherung_bedarf(konfig: dict, belegt_rootfs: int | None) -> int | None:
|
||
"""Wie viel die Sicherung höchstens umfasst: die belegte rootfs (läuft der Gast nicht: ihre volle Größe) plus
|
||
weitere Volumes mit backup=1. Bind-Mounts sichert vzdump nie mit — der Datenspeicher des PBS bleibt draußen."""
|
||
_, optionen = _volume(konfig.get("rootfs"))
|
||
bedarf = belegt_rootfs or _groesse(optionen.get("size", ""))
|
||
if not bedarf:
|
||
return None
|
||
for schluessel, wert in konfig.items():
|
||
if re.fullmatch(r"mp\d+", schluessel):
|
||
quelle, optionen = _volume(wert)
|
||
if not quelle.startswith("/") and optionen.get("backup") in ("1", "yes", "on", "true"):
|
||
bedarf += _groesse(optionen.get("size", "")) or 0
|
||
return bedarf
|
||
|
||
|
||
def platz_reicht(frei: int | None, bedarf: int | None) -> bool:
|
||
return frei is not None and bedarf is not None and frei > bedarf * PLATZ_FAKTOR
|
||
|
||
|
||
def _gb(anzahl: float | None) -> str:
|
||
return f"{(anzahl or 0) / 1024 ** 3:.1f} GB".replace(".", ",")
|
||
|
||
|
||
def sicherung_pruefen(stand: dict, bedarf: int | None) -> tuple[bool, str | None]:
|
||
"""Geht vor dem Update eine Sicherung? stand wie sicherung_stand(); vorher Platz prüfen: frei > belegt × 1,2."""
|
||
if not stand.get("speicher"):
|
||
return False, stand.get("fehler") or "Es gibt keinen Speicher für Sicherungen."
|
||
if not bedarf:
|
||
return False, "Wie viel Platz der Gast belegt, ließ sich nicht lesen."
|
||
if not platz_reicht(stand.get("frei"), bedarf):
|
||
return False, (f"Zu wenig Platz auf „{stand['speicher']}“: frei {_gb(stand.get('frei'))}, nötig mehr als "
|
||
f"{_gb(bedarf * PLATZ_FAKTOR)} (belegt {_gb(bedarf)} × 1,2).")
|
||
return True, None
|
||
|
||
|
||
def sicherung_modus(konfig: dict, arten: dict[str, str]) -> str:
|
||
"""snapshot, wenn die rootfs Snapshots kann: Der Gast läuft durch, Bind-Mounts lässt vzdump aus. Sonst stop —
|
||
kurz aus, braucht aber keinen Zwischenspeicher wie „suspend“, auf den vzdump sonst ausweicht."""
|
||
speicher = str(konfig.get("rootfs") or "").split(":")[0]
|
||
return "snapshot" if arten.get(speicher) in SNAPSHOT_SPEICHER else "stop"
|
||
|
||
|
||
def eigene_sicherungen(eintraege: object, vmid: int | None = None) -> list[dict]:
|
||
"""Nur Sicherungen des Orchestrators (Notiz beginnt mit mc2-sicherung, Archiv vzdump-lxc-<vmid>-…), älteste
|
||
zuerst; mit vmid nur die dieses Gasts."""
|
||
eigene = []
|
||
for e in eintraege if isinstance(eintraege, list) else []:
|
||
m = SICHERUNG_ARCHIV.match(str(e.get("volid") or "")) if isinstance(e, dict) else None
|
||
if not m or not str(e.get("notes") or "").startswith(SICHERUNG_KENNUNG):
|
||
continue
|
||
if vmid is None or int(m.group(2)) == vmid:
|
||
eigene.append(e)
|
||
return sorted(eigene, key=lambda e: (e.get("ctime") or 0, e["volid"]))
|
||
|
||
|
||
def app_kennung_aus(update_skript: str) -> str | None:
|
||
"""Kennung des Community-Scripts aus /usr/bin/update. Zwei Formate: das alte lädt ct/<kennung>.sh direkt,
|
||
das neue (schreibt jedes erfolgreiche Update seit 09/2026) setzt `export SCRIPT_SLUG="<kennung>"`."""
|
||
m = (re.search(r'^export (?:SCRIPT_SLUG|UPDATE_SCRIPT_NAME)="([a-z0-9-]+)"', update_skript, re.MULTILINE)
|
||
or re.search(r"/ct/([a-z0-9-]+)\.sh", update_skript))
|
||
return m.group(1) if m else None
|
||
|
||
|
||
def _app_kennung(vmid: int) -> str | None:
|
||
"""Welches Community-Script steckt im Gast?"""
|
||
code, text = _im_gast(vmid, "cat /usr/bin/update 2>/dev/null", 10)
|
||
return app_kennung_aus(text) if code == 0 else None
|
||
|
||
|
||
def _app_version(vmid: int, kennung: str) -> str | None:
|
||
befehl = VERSION_PROBEN.get(kennung)
|
||
if befehl is None:
|
||
datei = VERSIONSDATEIEN.get(kennung, f"/root/.{kennung}")
|
||
befehl = f"cat {datei} 2>/dev/null"
|
||
code, text = _im_gast(vmid, befehl, 20)
|
||
if code != 0 or not text:
|
||
return None
|
||
zeile = text.splitlines()[-1].strip()
|
||
# Docker-Apps (NPMplus) haben keine Versionsnummer, nur das Datum ihres Images.
|
||
if m := re.match(r"(\d{4}-\d{2}-\d{2})T", zeile):
|
||
return f"Image vom {m.group(1)}"
|
||
m = re.search(r"v?(\d+(?:\.\d+){1,3}(?:[-+][\w.]+)?)", zeile)
|
||
return m.group(1) if m else zeile[:40]
|
||
|
||
|
||
def _os_updates(vmid: int, ostype: str) -> dict:
|
||
"""Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur) und wie alt die Listen sind."""
|
||
if ostype == "alpine":
|
||
code, text = _im_gast(vmid, "apk version -l '<' 2>/dev/null | tail -n +2 | wc -l; "
|
||
"stat -c %Y /var/cache/apk 2>/dev/null || echo 0", 30)
|
||
else:
|
||
code, text = _im_gast(vmid, "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable; "
|
||
"stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0", 30)
|
||
zeilen = text.split() if code in (0, 1) else []
|
||
try:
|
||
return {"anzahl": int(zeilen[0]), "listen_stand": int(zeilen[1]) or None}
|
||
except (IndexError, ValueError):
|
||
return {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
|
||
|
||
|
||
def _ip_lxc(vmid: int) -> str | None:
|
||
try:
|
||
for schnitt in _pvesh(f"/nodes/{NODE}/lxc/{vmid}/interfaces"):
|
||
if schnitt.get("name") in ("eth0", "ens18"):
|
||
for adresse in schnitt.get("ip-addresses", []):
|
||
if adresse.get("ip-address-type") == "inet":
|
||
return adresse["ip-address"].split("/")[0]
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _ip_vm(vmid: int) -> str | None:
|
||
try:
|
||
daten = _pvesh(f"/nodes/{NODE}/qemu/{vmid}/agent/network-get-interfaces")
|
||
for schnitt in (daten or {}).get("result", []):
|
||
if schnitt.get("name") == "lo":
|
||
continue
|
||
for adresse in schnitt.get("ip-addresses", []):
|
||
ip = adresse.get("ip-address", "")
|
||
if adresse.get("ip-address-type") == "ipv4" and not ip.startswith(("127.", "172.")):
|
||
return ip
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _snapshots(art: str, vmid: int) -> list[dict]:
|
||
"""Die Snapshots eines Gasts, wie pvesh sie liefert (samt „current“, dem laufenden Stand); leer bei Fehlern."""
|
||
try:
|
||
return [s for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") or [] if isinstance(s, dict)]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _ganzzahl(wert: object) -> int | None:
|
||
try:
|
||
return int(wert)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def snapshot_details(eintraege: object) -> list[dict]:
|
||
"""Die Snapshots für die Seite Homelab → Snapshots, ältester zuerst, ohne „current“: Name, Zeitpunkt (snaptime,
|
||
Unix-Sekunden; None, wo Proxmox keinen nennt), Beschreibung und ob er vom Orchestrator ist (nur solche setzt dieser
|
||
Host zurück oder löscht er). Eine Belegung steht bewusst nicht dabei: Wie viel ein Snapshot auf LVM-Thin allein
|
||
belegt, sagt Proxmox nicht — lvs zählt auch die Blöcke, die er mit dem Original teilt."""
|
||
details = []
|
||
for s in eintraege if isinstance(eintraege, list) else []:
|
||
if not isinstance(s, dict) or not s.get("name") or s["name"] == "current":
|
||
continue
|
||
name = str(s["name"])
|
||
details.append({"name": name, "zeit": _ganzzahl(s.get("snaptime")),
|
||
"beschreibung": str(s.get("description") or "").strip() or None,
|
||
"eigen": bool(SNAPSHOT_NAME.match(name))})
|
||
return sorted(details, key=lambda d: (d["zeit"] or 0, d["name"]))
|
||
|
||
|
||
def sicherung_details(eintraege: list[dict]) -> list[dict]:
|
||
"""Die eigenen Sicherungen eines Gasts für die Seite Homelab → Snapshots: Archiv, Zeitpunkt (ctime), Größe in Bytes."""
|
||
return [{"volid": e["volid"], "zeit": _ganzzahl(e.get("ctime")), "groesse": _ganzzahl(e.get("size"))}
|
||
for e in eintraege]
|
||
|
||
|
||
def _eigene_sicherungen(speicher: str, vmid: int | None = None) -> list[dict]:
|
||
parameter = ["--content", "backup"] + (["--vmid", str(vmid)] if vmid is not None else [])
|
||
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[dict]]) -> dict:
|
||
art, vmid = eintrag["type"], int(eintrag["vmid"])
|
||
etiketten = _etiketten(eintrag.get("tags"))
|
||
snapshots = _snapshots(art, vmid)
|
||
eigene = sicherungen.get(vmid, [])
|
||
gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
|
||
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
|
||
"snapshots": [str(s["name"]) for s in snapshots if s.get("name") and s["name"] != "current"],
|
||
"snapshot_details": snapshot_details(snapshots),
|
||
"sicherungen": [e["volid"] for e in eigene], "sicherung_details": sicherung_details(eigene)}
|
||
if art == "lxc" and gast["status"] == "running" and eintrag.get("maxdisk"):
|
||
# Belegung der rootfs (Proxmox liest sie bei laufenden Containern mit). Am 24.09.2026 stand AdGuard bei
|
||
# 100 % — das Abfrageprotokoll schrieb nicht mehr, die Paketsuche scheiterte, und niemand sah es.
|
||
gast["platte"] = {"belegt": int(eintrag.get("disk") or 0), "gesamt": int(eintrag["maxdisk"])}
|
||
try:
|
||
konfig = _pvesh(f"/nodes/{NODE}/{art}/{vmid}/config")
|
||
except Exception as exc:
|
||
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)?
|
||
belegt = eintrag.get("disk") if gast["status"] == "running" else None
|
||
gast["sicherung_moeglich"], gast["sicherung_grund"] = sicherung_pruefen(
|
||
sicherung, sicherung_bedarf(konfig, belegt))
|
||
if gast["sicherung_moeglich"]:
|
||
gast["sicherung_speicher"] = sicherung["speicher"]
|
||
if art == "lxc":
|
||
gast["ostype"] = konfig.get("ostype")
|
||
gast["ip"] = _ip_lxc(vmid) if gast["status"] == "running" else None
|
||
if gast["status"] == "running" and gast["erlaubt"]:
|
||
kennung = _app_kennung(vmid)
|
||
gast["app"] = {"kennung": kennung, "version": _app_version(vmid, kennung) if kennung else None}
|
||
gast["os_updates"] = _os_updates(vmid, str(konfig.get("ostype") or "debian"))
|
||
else:
|
||
gast["ip"] = _ip_vm(vmid) if gast["status"] == "running" else None
|
||
return gast
|
||
|
||
|
||
def _host() -> dict:
|
||
host: dict = {"node": NODE, "neustart_noetig": os.path.exists("/var/run/reboot-required"), "nur_lesen": NUR_LESEN}
|
||
try:
|
||
host["version"] = _pvesh("/version").get("version")
|
||
host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"),
|
||
"herkunft": p.get("Origin")} for p in _pvesh(f"/nodes/{NODE}/apt/update")]
|
||
except Exception as exc:
|
||
host["fehler"] = str(exc)[:200]
|
||
code, kernel = _laufen(["uname", "-r"], 5)
|
||
host["kernel"] = kernel if code == 0 else None
|
||
pin = _lesen("/etc/kernel/proxmox-boot-pin").strip() or None
|
||
host["kernel_neu"] = neuer_kernel(host["kernel"], _kernel_vorhanden(), pin)
|
||
host["neustart_noetig"] = host["neustart_noetig"] or bool(host["kernel_neu"])
|
||
host["systemplatte"] = _platte("/")
|
||
try:
|
||
host["kernel_alt"], host["kernel_bleiben"] = _kernel_lage(host["kernel"], host["kernel_neu"] or host["kernel"], pin)
|
||
except Exception:
|
||
host["kernel_alt"], host["kernel_bleiben"] = [], []
|
||
return host
|
||
|
||
|
||
def _platte(pfad: str) -> dict | None:
|
||
"""Belegung wie df sie zeigt: belegt + was Nutzer noch schreiben dürfen (ohne die Reserve für root)."""
|
||
try:
|
||
s = os.statvfs(pfad)
|
||
except (OSError, AttributeError): # AttributeError: Windows kennt statvfs nicht (Tests am PC)
|
||
return None
|
||
belegt = (s.f_blocks - s.f_bfree) * s.f_frsize
|
||
return {"belegt": belegt, "gesamt": belegt + s.f_bavail * s.f_frsize}
|
||
|
||
|
||
def _kernel_vorhanden() -> list[str]:
|
||
return [os.path.basename(p)[len("vmlinuz-"):] for p in glob.glob("/boot/vmlinuz-*")]
|
||
|
||
|
||
KERNEL_PAKET = re.compile(r"^(?:proxmox|pve)-kernel-(\d+\.\d+\.\d+-\d+-pve)(?:-signed)?$")
|
||
|
||
|
||
def alte_kernel(simulation: str, behalten: set[str]) -> list[str]:
|
||
"""Kernel-Pakete, die `apt-get -s autoremove` entfernen würde — nur Kernel und nie einer aus `behalten` (der
|
||
laufende, der beim nächsten Start kommt, ein festgepinnter). Was Proxmox selbst hält (je Serie den jüngsten, den
|
||
bei der Installation), steht gar nicht erst in der Simulation."""
|
||
pakete = []
|
||
for zeile in simulation.splitlines():
|
||
m = re.match(r"^Remv (\S+)", zeile)
|
||
k = KERNEL_PAKET.match(m.group(1)) if m else None
|
||
if k and k.group(1) not in behalten:
|
||
pakete.append(m.group(1))
|
||
return pakete
|
||
|
||
|
||
def _kernel_lage(laufend: str | None, naechster: str | None, pin: str | None) -> tuple[list[dict], list[str]]:
|
||
"""(entbehrliche Kernel mit Paket, Version und belegtem Platz samt initrd; die bleibenden Versionen)."""
|
||
code, simulation = _laufen(["apt-get", "-s", "autoremove"], 120)
|
||
if code != 0 or not laufend:
|
||
return [], []
|
||
pakete = alte_kernel(simulation, {k for k in (laufend, naechster, pin) if k})
|
||
groessen: dict[str, int] = {}
|
||
if pakete:
|
||
code, aus = _laufen(["dpkg-query", "-W", "-f=${Package}\t${Installed-Size}\n", *pakete], 30)
|
||
for zeile in aus.splitlines() if code == 0 else []:
|
||
name, _, kib = zeile.partition("\t")
|
||
if kib.strip().isdigit():
|
||
groessen[name] = int(kib) * 1024
|
||
weg = []
|
||
for paket in pakete:
|
||
version = KERNEL_PAKET.match(paket).group(1)
|
||
initrd = f"/boot/initrd.img-{version}"
|
||
weg.append({"paket": paket, "version": version,
|
||
"groesse": groessen.get(paket, 0) + (os.path.getsize(initrd) if os.path.exists(initrd) else 0)})
|
||
entbehrlich = {w["version"] for w in weg}
|
||
return weg, sorted((v for v in _kernel_vorhanden() if v not in entbehrlich), key=_versionsschluessel)
|
||
|
||
|
||
def _versionsschluessel(version: str) -> tuple[int, ...]:
|
||
return tuple(int(x) for x in re.findall(r"\d+", version))
|
||
|
||
|
||
def neuer_kernel(laufend: str | None, vorhanden: list[str], pin: str | None = None) -> str | None:
|
||
"""Der Kernel, mit dem der Host beim nächsten Start hochkäme, wenn es nicht der laufende ist (ein festgepinnter
|
||
geht vor). Proxmox legt bei Kernel-Updates keine /var/run/reboot-required an — am 25.09.2026 war 7.0.14-19
|
||
installiert, 7.0.14-12 lief, und nichts zeigte einen Neustart an."""
|
||
if not laufend or not vorhanden:
|
||
return None
|
||
naechster = pin if pin in vorhanden else max(vorhanden, key=_versionsschluessel)
|
||
return None if naechster == laufend else naechster
|
||
|
||
|
||
def bericht() -> dict:
|
||
speicher = _speicher_liste()
|
||
arten = {s["storage"]: s.get("type") for s in speicher}
|
||
sicherung = sicherung_stand(speicher, SICHERUNG_SPEICHER)
|
||
sicherungen: dict[int, list[dict]] = {}
|
||
if sicherung.get("speicher"):
|
||
try:
|
||
for e in _eigene_sicherungen(sicherung["speicher"]):
|
||
sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e)
|
||
except Exception:
|
||
pass
|
||
gaeste = []
|
||
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
|
||
if eintrag.get("node") == NODE and not eintrag.get("template"):
|
||
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"])}
|
||
|
||
|
||
# --- Aufträge (feste Liste) ---------------------------------------------------------------
|
||
|
||
def _gast_pruefen(vmid: object) -> tuple[str, int]:
|
||
"""Gibt (art, vmid) zurück, wenn dieser Host den Gast anfassen darf — sonst ValueError."""
|
||
if not isinstance(vmid, int) or not 100 <= vmid <= 999_999_999:
|
||
raise ValueError("ungültige Gast-Nummer")
|
||
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
|
||
if int(eintrag.get("vmid", -1)) == vmid:
|
||
if not erlaubt(_etiketten(eintrag.get("tags"))):
|
||
raise ValueError(f"Gast {vmid} hat kein erlaubtes Etikett (community-script/watcher) "
|
||
f"oder ist mit {AUSGENOMMEN} ausgenommen")
|
||
return eintrag["type"], vmid
|
||
raise ValueError(f"Gast {vmid} gibt es nicht")
|
||
|
||
|
||
def _werkzeug(art: str) -> str:
|
||
return "pct" if art == "lxc" else "qm"
|
||
|
||
|
||
def a_snapshot(p: dict) -> tuple[int, str]:
|
||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||
beschreibung = SNAPSHOT_ANLASS.get(str(p.get("anlass") or "update"))
|
||
if beschreibung is None:
|
||
raise ValueError("unbekannter Anlass für einen Snapshot (update oder knopf)")
|
||
name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description", beschreibung], 600)
|
||
return code, f"snapshot={name}\n{text}" if code == 0 else text
|
||
|
||
|
||
def a_update(p: dict) -> tuple[int, str]:
|
||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||
if art != "lxc":
|
||
raise ValueError("Update per Community-Script gibt es nur für Container")
|
||
# PHS_SILENT=1: das Community-Script fragt nichts. Es lädt ct/<app>.sh live von GitHub (main).
|
||
return _im_gast(vmid, "PHS_SILENT=1 bash /usr/bin/update", int(p.get("zeitlimit") or 1800))
|
||
|
||
|
||
def a_suchen(p: dict) -> tuple[int, str]:
|
||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||
if art != "lxc":
|
||
raise ValueError("nur für Container")
|
||
return _im_gast(vmid, "if command -v apk >/dev/null; then apk update; else apt-get update -q; fi", 300)
|
||
|
||
|
||
def a_os_update(p: dict) -> tuple[int, str]:
|
||
"""Die Pakete des Gasts (unabhängig vom App-Update des Community-Scripts)."""
|
||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||
if art != "lxc":
|
||
raise ValueError("nur für Container")
|
||
befehl = ("if command -v apk >/dev/null; then apk update && apk upgrade; else apt-get update -q && "
|
||
"DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade; fi")
|
||
return _im_gast(vmid, befehl, int(p.get("zeitlimit") or 1800))
|
||
|
||
|
||
def a_zurueck(p: dict) -> tuple[int, str]:
|
||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||
name = str(p.get("snapshot") or "")
|
||
if not SNAPSHOT_NAME.match(name):
|
||
raise ValueError("nur Snapshots des Orchestrators (mc2-…) lassen sich zurückspielen")
|
||
code, text = _laufen([_werkzeug(art), "rollback", str(vmid), name], 900)
|
||
if code == 0:
|
||
code2, text2 = _laufen([_werkzeug(art), "start", str(vmid)], 120)
|
||
text += "\n" + text2
|
||
code = 0 if code2 == 0 or "already running" in text2 else code2
|
||
return code, text
|
||
|
||
|
||
def a_snapshot_loeschen(p: dict) -> tuple[int, str]:
|
||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||
name = str(p.get("snapshot") or "")
|
||
if not SNAPSHOT_NAME.match(name):
|
||
raise ValueError("nur Snapshots des Orchestrators (mc2-…) werden gelöscht")
|
||
return _laufen([_werkzeug(art), "delsnapshot", str(vmid), name], 600)
|
||
|
||
|
||
def _eigene_sicherung(vmid: int, volid: object) -> str:
|
||
"""Die Sicherung, wenn sie eine des Orchestrators für genau diesen Gast ist — sonst ValueError. Nur solche
|
||
werden zurückgespielt oder gelöscht: Archiv vzdump-lxc-<vmid>-…, Notiz mc2-sicherung, kein PBS-Speicher."""
|
||
volid = str(volid or "")
|
||
m = SICHERUNG_ARCHIV.match(volid)
|
||
if not m or int(m.group(2)) != vmid:
|
||
raise ValueError("nur Sicherungen des Orchestrators für diesen Gast (vzdump-lxc-<Gast>-…)")
|
||
if _speicherart().get(m.group(1)) == "pbs":
|
||
raise ValueError("Sicherungen auf einem PBS-Speicher fasst der Orchestrator nicht an")
|
||
if not any(e["volid"] == volid for e in _eigene_sicherungen(m.group(1), vmid)):
|
||
raise ValueError(f"{volid} fehlt oder ist keine Sicherung des Orchestrators (Notiz „{SICHERUNG_KENNUNG}“)")
|
||
return volid
|
||
|
||
|
||
def a_sichern(p: dict) -> tuple[int, str]:
|
||
"""vzdump vor einem Update, wo kein Snapshot geht. Nur auf einen Speicher, den sicherungsspeicher() wählt, und
|
||
nur mit genug Platz. Die neue Sicherung steht am Ende des Texts: sicherung=<volid>."""
|
||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||
if art != "lxc":
|
||
raise ValueError("Sicherungen vor einem Update gibt es nur für Container")
|
||
speicher = _speicher_liste()
|
||
ziel, fehlt = sicherungsspeicher(speicher, SICHERUNG_SPEICHER)
|
||
if ziel is None:
|
||
raise ValueError(fehlt)
|
||
konfig = _pvesh(f"/nodes/{NODE}/lxc/{vmid}/config")
|
||
status = _pvesh(f"/nodes/{NODE}/lxc/{vmid}/status/current") or {}
|
||
bedarf = sicherung_bedarf(konfig, status.get("disk") if status.get("status") == "running" else None)
|
||
ok, grund = sicherung_pruefen({"speicher": ziel["storage"], "frei": ziel.get("avail")}, bedarf)
|
||
if not ok:
|
||
raise ValueError(grund)
|
||
modus = sicherung_modus(konfig, {s["storage"]: s.get("type") for s in speicher})
|
||
beginn = time.time()
|
||
# --remove 0: keine Aufräumregeln des Speichers anwenden (die träfen auch fremde Sicherungen dieses Gasts).
|
||
# --lockwait 10: läuft gerade eine andere Sicherung, nach zehn Minuten sauber aufgeben statt drei Stunden warten.
|
||
code, text = _laufen(["vzdump", str(vmid), "--storage", ziel["storage"], "--mode", modus, "--compress", "zstd",
|
||
"--remove", "0", "--lockwait", "10", "--notes-template", SICHERUNG_NOTIZ],
|
||
ZEITLIMIT_SICHERUNG_S, sanft=True)
|
||
if code != 0:
|
||
return code, text
|
||
neue = [e for e in _eigene_sicherungen(ziel["storage"], vmid) if (e.get("ctime") or 0) >= beginn - 120]
|
||
if not neue:
|
||
return 1, text + "\nDie neue Sicherung ist nicht zu finden oder trägt die Notiz mc2-sicherung nicht."
|
||
return 0, f"{text}\nsicherung={neue[-1]['volid']}"
|
||
|
||
|
||
def a_sicherung_zurueck(p: dict) -> tuple[int, str]:
|
||
"""Rückweg bei Rot, wo kein Snapshot ging: Gast stoppen, die Sicherung auf den bisherigen Speicher der rootfs
|
||
zurückspielen (pct restore --force), starten. Bind-Mounts (der Datenspeicher des PBS) bleiben unberührt."""
|
||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||
if art != "lxc":
|
||
raise ValueError("nur für Container")
|
||
volid = _eigene_sicherung(vmid, p.get("sicherung"))
|
||
ziel = str(_pvesh(f"/nodes/{NODE}/lxc/{vmid}/config").get("rootfs") or "").split(":")[0]
|
||
if not ziel or ziel.startswith("/"):
|
||
raise ValueError("Der Speicher der rootfs ließ sich nicht lesen")
|
||
protokoll = []
|
||
if (_pvesh(f"/nodes/{NODE}/lxc/{vmid}/status/current") or {}).get("status") == "running":
|
||
code, text = _laufen(["pct", "shutdown", str(vmid), "--timeout", "120", "--forceStop", "1"], 300)
|
||
protokoll.append(text)
|
||
if code != 0:
|
||
return code, "\n".join(protokoll + ["Der Gast ließ sich nicht stoppen; zurückgespielt wurde nichts."])
|
||
code, text = _laufen(["pct", "restore", str(vmid), volid, "--force", "1", "--storage", ziel],
|
||
ZEITLIMIT_SICHERUNG_S, sanft=True)
|
||
protokoll.append(text)
|
||
if code != 0:
|
||
return code, "\n".join(protokoll + [f"Zurückspielen gescheitert; die Sicherung {volid} liegt weiter da."])
|
||
code, text = _laufen(["pct", "start", str(vmid)], 120)
|
||
protokoll.append(text)
|
||
return code, "\n".join(t for t in protokoll if t)
|
||
|
||
|
||
def a_sicherung_loeschen(p: dict) -> tuple[int, str]:
|
||
_, vmid = _gast_pruefen(p.get("vmid"))
|
||
return _laufen(["pvesm", "free", _eigene_sicherung(vmid, p.get("sicherung"))], 300)
|
||
|
||
|
||
def a_host_update(p: dict) -> tuple[int, str]:
|
||
# Kein Neustart: der ist ein eigener Knopf (User-Entscheid 24.09.2026).
|
||
befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y "
|
||
"-o Dpkg::Options::=--force-confold dist-upgrade")
|
||
return _laufen(["bash", "-c", befehl], 3600)
|
||
|
||
|
||
def a_host_neustart(p: dict) -> tuple[int, str]:
|
||
return _laufen(["systemctl", "reboot"], 30)
|
||
|
||
|
||
def a_bericht(p: dict) -> tuple[int, str]:
|
||
return 0, json.dumps(bericht(), ensure_ascii=False)
|
||
|
||
|
||
def a_kernel_aufraeumen(p: dict) -> tuple[int, str]:
|
||
"""Alte Kernel entfernen (seit 25.09.2026, nur auf Knopfdruck). Welche, bestimmt dieser Host selbst, jetzt und wie
|
||
im Bericht — der Server kann nur den Anstoß geben. Am Ende: entfernt=<Anzahl>, frei=<Bytes>."""
|
||
code, laufend = _laufen(["uname", "-r"], 5)
|
||
if code != 0 or not laufend.strip():
|
||
raise ValueError("der laufende Kernel ließ sich nicht lesen")
|
||
laufend = laufend.strip()
|
||
pin = _lesen("/etc/kernel/proxmox-boot-pin").strip() or None
|
||
naechster = neuer_kernel(laufend, _kernel_vorhanden(), pin) or laufend
|
||
weg, _ = _kernel_lage(laufend, naechster, pin)
|
||
pakete = [w["paket"] for w in weg if w["version"] not in {laufend, naechster, pin}]
|
||
if not pakete:
|
||
return 0, "entfernt=0\nfrei=0\nKein alter Kernel zu entfernen."
|
||
vorher = _platte("/") or {}
|
||
code, text = _laufen(["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "-y", "purge", *pakete], 30 * 60,
|
||
sanft=True)
|
||
nachher = _platte("/") or {}
|
||
frei = max(0, int(vorher.get("belegt") or 0) - int(nachher.get("belegt") or 0))
|
||
return code, f"entfernt={len(pakete) if code == 0 else 0}\nfrei={frei}\n{text[-1500:]}"
|
||
|
||
|
||
AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update, "suchen": a_suchen,
|
||
"zurueck": a_zurueck,
|
||
"snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update,
|
||
"host_neustart": a_host_neustart, "bericht": a_bericht,
|
||
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen,
|
||
"kernel_aufraeumen": a_kernel_aufraeumen}
|
||
|
||
|
||
def ausfuehren(auftrag: dict) -> dict:
|
||
aktion = AKTIONEN.get(str(auftrag.get("aktion")))
|
||
if aktion is None:
|
||
return {"code": 2, "text": f"Unbekannte Aktion: {auftrag.get('aktion')!r}"}
|
||
# Nur-Lesen-Modus ("nur_lesen": true in der Konfiguration): Bericht ja, Änderungen nein.
|
||
if NUR_LESEN and aktion is not a_bericht:
|
||
return {"code": 2, "text": "Abgelehnt: Der Ausführer läuft im Nur-Lesen-Modus."}
|
||
try:
|
||
code, text = aktion(auftrag.get("parameter") or {})
|
||
except (ValueError, RuntimeError) as exc:
|
||
return {"code": 2, "text": f"Abgelehnt: {exc}"}
|
||
except Exception as exc:
|
||
# Sonst bliebe der Auftrag ohne Antwort, und der Homelab-Teil wartete bis zu seinem Zeitlimit.
|
||
log.exception("Auftrag %s gescheitert", auftrag.get("aktion"))
|
||
return {"code": 1, "text": f"Fehler im Ausführer: {exc.__class__.__name__}: {exc}"[:500]}
|
||
return {"code": code, "text": text[-20000:]}
|
||
|
||
|
||
# --- Messwerte: jede Minute, eigener Faden (reine Auswertungen ohne Host testbar) ----------------
|
||
|
||
MESSWERTE_ALLE_S = 60
|
||
MESS_PHASE_S = 30 # gemessen wird zur halben Minute (wie auf der KI-Box): genau ein Punkt je Minutenschritt
|
||
# CPU-Fühler in hwmon, in dieser Reihenfolge. Auf dem Proxmox-PC (Ryzen 5 5500U) gibt es nur k10temp mit „Tctl“;
|
||
# /sys/class/thermal und lm-sensors fehlen dort (nachgesehen am 24.09.2026).
|
||
CPU_FUEHLER = ("k10temp", "zenpower", "coretemp", "cpu_thermal")
|
||
CPU_BESCHRIFTUNG = ("Tdie", "Tctl", "Package id 0")
|
||
_GAST_SCHNITTSTELLE = re.compile(r"(?:veth|tap)(\d+)i\d+")
|
||
|
||
|
||
def _erste_zahl(text: str | None) -> float | None:
|
||
try:
|
||
return float(str(text or "").split()[0])
|
||
except (IndexError, ValueError):
|
||
return None
|
||
|
||
|
||
def cpu_zeiten(stat: str) -> tuple[int, int] | None:
|
||
"""(beschäftigt, gesamt) in Ticks aus der Zeile „cpu“ von /proc/stat: user nice system idle iowait irq softirq
|
||
steal (guest steckt schon in user); beschäftigt ist alles außer idle und iowait."""
|
||
for zeile in stat.splitlines():
|
||
if zeile.startswith("cpu "):
|
||
try:
|
||
werte = [int(x) for x in zeile.split()[1:9]]
|
||
except ValueError:
|
||
return None
|
||
werte += [0] * (8 - len(werte))
|
||
gesamt = sum(werte)
|
||
return gesamt - werte[3] - werte[4], gesamt
|
||
return None
|
||
|
||
|
||
def cpu_anteil(vorher: tuple[int, int] | None, jetzt: tuple[int, int] | None) -> float | None:
|
||
"""Wie viel die CPU zwischen zwei Ständen von /proc/stat arbeitete (0–1) — das Mittel der Minute. pvesh kann das
|
||
nicht: /nodes/<node>/status zeigt in einem frischen pvesh-Prozess immer cpu 0 (24.09.2026 nachgesehen)."""
|
||
if not vorher or not jetzt or jetzt[1] <= vorher[1]:
|
||
return None
|
||
return round(min(1.0, max(0.0, (jetzt[0] - vorher[0]) / (jetzt[1] - vorher[1]))), 4)
|
||
|
||
|
||
def speicher_aus(meminfo: str) -> dict | None:
|
||
"""Arbeitsspeicher in Bytes; belegt = MemTotal − MemAvailable, wie Proxmox rechnet."""
|
||
werte = {}
|
||
for zeile in meminfo.splitlines():
|
||
name, _, rest = zeile.partition(":")
|
||
teile = rest.split()
|
||
if teile and teile[0].isdigit():
|
||
werte[name.strip()] = int(teile[0]) * 1024
|
||
gesamt, verfuegbar = werte.get("MemTotal"), werte.get("MemAvailable")
|
||
if not gesamt or verfuegbar is None:
|
||
return None
|
||
return {"belegt": gesamt - verfuegbar, "gesamt": gesamt}
|
||
|
||
|
||
def netz_zaehler(netdev: str) -> dict[str, tuple[int, int]]:
|
||
"""Schnittstelle → (empfangen, gesendet) in Bytes aus /proc/net/dev."""
|
||
zaehler = {}
|
||
for zeile in netdev.splitlines():
|
||
name, trenner, rest = zeile.partition(":")
|
||
teile = rest.split()
|
||
if trenner and len(teile) >= 9:
|
||
try:
|
||
zaehler[name.strip()] = (int(teile[0]), int(teile[8]))
|
||
except ValueError:
|
||
continue
|
||
return zaehler
|
||
|
||
|
||
def host_netz(zaehler: dict[str, tuple[int, int]], physisch: set[str]) -> dict | None:
|
||
"""Verkehr des Hosts: die Summe seiner physischen Schnittstellen, so zeigt ihn auch Proxmox für den Knoten. Die
|
||
Brücke vmbr0 zählt nur, was der Host selbst empfängt und sendet, nicht den Verkehr der Gäste nach draußen. Ohne
|
||
erkennbare physische Schnittstelle doch vmbr0."""
|
||
namen = sorted(n for n in zaehler if n in physisch) or [n for n in ("vmbr0",) if n in zaehler]
|
||
if not namen:
|
||
return None
|
||
return {"rx": sum(zaehler[n][0] for n in namen), "tx": sum(zaehler[n][1] for n in namen), "schnittstellen": namen}
|
||
|
||
|
||
def gast_netz(zaehler: dict[str, tuple[int, int]]) -> dict[int, tuple[int, int]]:
|
||
"""vmid → (netin, netout) aus den Schnittstellen der Gäste (veth<vmid>iN, tap<vmid>iN). Auf der Host-Seite ist die
|
||
Richtung vertauscht: Was der Host dorthin sendet, empfängt der Gast (so rechnet auch pvestatd). Zeitgleich mit
|
||
dem Host gelesen — /cluster/resources hat Stände, die bis zu 10 s alt sind, das verzerrte die Minutenrate."""
|
||
gaeste: dict[int, tuple[int, int]] = {}
|
||
for name, (rx, tx) in zaehler.items():
|
||
if treffer := _GAST_SCHNITTSTELLE.fullmatch(name):
|
||
netin, netout = gaeste.get(int(treffer.group(1)), (0, 0))
|
||
gaeste[int(treffer.group(1))] = (netin + tx, netout + rx)
|
||
return gaeste
|
||
|
||
|
||
def cpu_temperatur(fuehler: list[tuple[str, str, float]]) -> float | None:
|
||
"""CPU-Temperatur in °C aus den hwmon-Fühlern (Chip, Beschriftung, °C): AMD k10temp/zenpower (Tdie vor Tctl), Intel
|
||
coretemp (Package id 0), sonst der erste Wert des Chips. Ohne CPU-Fühler None."""
|
||
for chip in CPU_FUEHLER:
|
||
werte = [(beschriftung, grad) for name, beschriftung, grad in fuehler if name == chip]
|
||
if werte:
|
||
bevorzugt = [grad for gesucht in CPU_BESCHRIFTUNG for beschriftung, grad in werte if beschriftung == gesucht]
|
||
return round((bevorzugt or [werte[0][1]])[0], 1)
|
||
return None
|
||
|
||
|
||
def _gast_messwerte(eintrag: dict, netz: dict[int, tuple[int, int]]) -> dict:
|
||
vmid = int(eintrag["vmid"])
|
||
netin, netout = netz.get(vmid, (eintrag.get("netin"), eintrag.get("netout")))
|
||
werte = {"vmid": vmid, "art": eintrag.get("type"), "name": eintrag.get("name"),
|
||
"etiketten": _etiketten(eintrag.get("tags")), "status": eintrag.get("status")}
|
||
werte.update({k: eintrag.get(k) for k in ("cpu", "maxcpu", "mem", "maxmem", "disk", "maxdisk", "uptime")})
|
||
werte.update(netin=netin, netout=netout)
|
||
return werte
|
||
|
||
|
||
def messpunkt(roh: dict, cpu_vorher: tuple[int, int] | None, node: str | None = None) -> dict:
|
||
"""Der Minutenpunkt für den Homelab-Teil aus Rohdaten (reine Auswertung):
|
||
roh = {"zeit", "stat", "meminfo", "loadavg", "uptime", "netdev" (Dateiinhalte), "rootfs": (gesamt, frei) oder None,
|
||
"physisch": {Schnittstellen}, "fuehler": [(Chip, Beschriftung, °C)], "ressourcen": /cluster/resources oder None}
|
||
Netz-Zähler sind kumulativ (Bytes); die Raten rechnet der Homelab-Teil."""
|
||
node = node or NODE
|
||
zaehler = netz_zaehler(roh.get("netdev") or "")
|
||
rootfs = roh.get("rootfs")
|
||
uptime = _erste_zahl(roh.get("uptime"))
|
||
host = {"cpu": cpu_anteil(cpu_vorher, cpu_zeiten(roh.get("stat") or "")),
|
||
"speicher": speicher_aus(roh.get("meminfo") or ""),
|
||
# belegt wie bei Proxmox (/nodes/<node>/status): gesamt minus frei, samt der für root reservierten Blöcke
|
||
"rootfs": {"belegt": rootfs[0] - rootfs[1], "gesamt": rootfs[0]} if rootfs else None,
|
||
"load1": _erste_zahl(roh.get("loadavg")),
|
||
"uptime": None if uptime is None else int(uptime),
|
||
"temp_cpu": cpu_temperatur(roh.get("fuehler") or []),
|
||
"netz": host_netz(zaehler, roh.get("physisch") or set())}
|
||
gnetz = gast_netz(zaehler)
|
||
gaeste = [_gast_messwerte(e, gnetz) for e in roh.get("ressourcen") or []
|
||
if isinstance(e, dict) and e.get("type") in ("lxc", "qemu") and e.get("node") == node
|
||
and not e.get("template") and e.get("vmid") is not None]
|
||
return {"zeit": roh.get("zeit"), "host": host, "gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
|
||
|
||
|
||
def _lesen(pfad: str) -> str:
|
||
try:
|
||
with open(pfad, encoding="utf-8", errors="replace") as f:
|
||
return f.read()
|
||
except OSError:
|
||
return ""
|
||
|
||
|
||
def _physische_schnittstellen() -> set[str]:
|
||
"""Netzschnittstellen mit einem Gerät dahinter (/sys/class/net/<name>/device): keine Brücken, veth oder tap."""
|
||
try:
|
||
return {n for n in os.listdir("/sys/class/net") if os.path.exists(f"/sys/class/net/{n}/device")}
|
||
except OSError:
|
||
return set()
|
||
|
||
|
||
def _hwmon_fuehler() -> list[tuple[str, str, float]]:
|
||
fuehler = []
|
||
for ordner in sorted(glob.glob("/sys/class/hwmon/hwmon*")):
|
||
chip = _lesen(f"{ordner}/name").strip()
|
||
for eingang in sorted(glob.glob(f"{ordner}/temp*_input")):
|
||
try:
|
||
grad = int(_lesen(eingang).strip()) / 1000
|
||
except ValueError:
|
||
continue
|
||
fuehler.append((chip, _lesen(eingang[: -len("_input")] + "_label").strip(), grad))
|
||
return fuehler
|
||
|
||
|
||
def _messwerte_roh() -> dict:
|
||
"""Alles für einen Minutenpunkt: Dateien aus /proc und /sys (billig) und ein einziger pvesh-Aufruf (Gäste)."""
|
||
roh = {"zeit": time.time(), "stat": _lesen("/proc/stat"), "netdev": _lesen("/proc/net/dev"),
|
||
"meminfo": _lesen("/proc/meminfo"), "loadavg": _lesen("/proc/loadavg"), "uptime": _lesen("/proc/uptime"),
|
||
"physisch": _physische_schnittstellen(), "fuehler": _hwmon_fuehler(), "rootfs": None, "ressourcen": None}
|
||
try:
|
||
stand = os.statvfs("/")
|
||
roh["rootfs"] = (stand.f_blocks * stand.f_frsize, stand.f_bfree * stand.f_frsize)
|
||
except (AttributeError, OSError):
|
||
pass
|
||
try:
|
||
roh["ressourcen"] = _pvesh("/cluster/resources", "--type", "vm")
|
||
except Exception:
|
||
pass # dann fehlen in diesem Punkt die Gäste, der Host nicht
|
||
return roh
|
||
|
||
|
||
def _bis_zur_messung(jetzt: float) -> float:
|
||
"""Sekunden bis zur nächsten halben Minute, mindestens eine."""
|
||
warten = (MESS_PHASE_S - jetzt) % MESSWERTE_ALLE_S
|
||
return warten if warten >= 1 else warten + MESSWERTE_ALLE_S
|
||
|
||
|
||
def messwerte_schleife() -> None:
|
||
"""Jede Minute ein Messpunkt an den Homelab-Teil — im eigenen Faden, damit weder der Bericht noch ein langer Auftrag
|
||
(Sicherung, Host-Update: bis zu einer Stunde) ihn aufhält. Fehler bleiben still: derselbe steht nur einmal im
|
||
Journal, bis wieder ein Punkt durchgeht."""
|
||
vorher = cpu_zeiten(_lesen("/proc/stat"))
|
||
zuletzt_fehler = None
|
||
while True:
|
||
time.sleep(_bis_zur_messung(time.time()))
|
||
try:
|
||
roh = _messwerte_roh()
|
||
punkt = messpunkt(roh, vorher)
|
||
vorher = cpu_zeiten(roh["stat"]) or vorher
|
||
_anfrage("POST", "/api/homelab/ausfuehrer/messwerte", punkt)
|
||
zuletzt_fehler = None
|
||
except Exception as exc:
|
||
if str(exc) != zuletzt_fehler:
|
||
log.warning("Messwerte nicht gesendet: %s", exc)
|
||
zuletzt_fehler = str(exc)
|
||
|
||
|
||
# --- Verbindung zum Homelab-Teil -------------------------------------------------------------
|
||
|
||
def _anfrage(methode: str, pfad: str, daten: object = None) -> object:
|
||
roh = None if daten is None else json.dumps(daten).encode()
|
||
anfrage = urllib.request.Request(SERVER + pfad, data=roh, method=methode,
|
||
headers={"Content-Type": "application/json", "X-MC2-Ausfuehrer": TOKEN})
|
||
with urllib.request.urlopen(anfrage, timeout=30) as antwort:
|
||
text = antwort.read().decode() or "null"
|
||
return json.loads(text)
|
||
|
||
|
||
def dauerbetrieb() -> None:
|
||
# Messwerte im eigenen Faden: Ein Auftrag darf hier eine Stunde laufen, die Messung jede Minute nicht warten.
|
||
threading.Thread(target=messwerte_schleife, name="messwerte", daemon=True).start()
|
||
naechster_bericht = 0.0
|
||
while True:
|
||
try:
|
||
if time.time() >= naechster_bericht:
|
||
daten = bericht()
|
||
_anfrage("POST", "/api/homelab/ausfuehrer/bericht", daten)
|
||
naechster_bericht = time.time() + naechster_bericht_in(daten)
|
||
auftrag = _anfrage("GET", "/api/homelab/ausfuehrer/auftrag")
|
||
if isinstance(auftrag, dict) and auftrag.get("id"):
|
||
log.info("Auftrag %s: %s %s", auftrag["id"], auftrag.get("aktion"), auftrag.get("parameter"))
|
||
ergebnis = ausfuehren(auftrag)
|
||
_anfrage("POST", f"/api/homelab/ausfuehrer/ergebnis/{auftrag['id']}", ergebnis)
|
||
if auftrag.get("aktion") != "bericht":
|
||
naechster_bericht = 0.0 # nach jeder Änderung einen frischen Bericht
|
||
continue
|
||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||
log.warning("Homelab-Teil nicht erreichbar: %s", exc)
|
||
except Exception:
|
||
log.exception("Unerwarteter Fehler")
|
||
time.sleep(ABFRAGE_ALLE_S)
|
||
|
||
|
||
BERICHT_BALD_S = 60
|
||
|
||
|
||
def naechster_bericht_in(daten: dict) -> int:
|
||
"""Wann der nächste Bericht fällig ist. Kurz nach dem Hochfahren kennt Proxmox den Zustand der Gäste noch nicht
|
||
(„unknown“): Am 25.09.2026 kam der erste Bericht 34 s nach dem Neustart, und zehn Minuten lang zeigte die
|
||
Oberfläche jeden Gast als unbekannt. Dann schon nach einer Minute wieder."""
|
||
unklar = any(g.get("status") not in ("running", "stopped", "paused") for g in daten.get("gaeste") or [])
|
||
return BERICHT_BALD_S if unklar else BERICHT_ALLE_S
|
||
|
||
|
||
def _konfig_laden() -> dict:
|
||
try:
|
||
with open(KONFIG, encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except (OSError, ValueError):
|
||
return {}
|
||
|
||
|
||
_konfig = _konfig_laden()
|
||
SERVER = str(_konfig.get("server") or "").rstrip("/")
|
||
TOKEN = str(_konfig.get("token") or "")
|
||
NODE = str(_konfig.get("node") or platform.node())
|
||
NUR_LESEN = bool(_konfig.get("nur_lesen"))
|
||
SICHERUNG_SPEICHER = str(_konfig.get("sicherung_speicher") or "").strip() or None
|
||
|
||
|
||
def main() -> int:
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
teile = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||
teile.add_argument("--bericht", action="store_true", help="Bericht einmal ausgeben (nur lesend)")
|
||
teile.add_argument("--messwerte", action="store_true", help="einen Messpunkt ausgeben (nur lesend)")
|
||
args = teile.parse_args()
|
||
if args.bericht:
|
||
json.dump(bericht(), sys.stdout, ensure_ascii=False, indent=2)
|
||
print()
|
||
return 0
|
||
if args.messwerte:
|
||
vorher = cpu_zeiten(_lesen("/proc/stat"))
|
||
time.sleep(1) # die CPU über eine Sekunde statt über die Minute
|
||
json.dump(messpunkt(_messwerte_roh(), vorher), sys.stdout, ensure_ascii=False, indent=2)
|
||
print()
|
||
return 0
|
||
if not SERVER or not TOKEN:
|
||
log.error("Konfiguration %s fehlt oder ist unvollständig (server, token).", KONFIG)
|
||
return 1
|
||
dauerbetrieb()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|