Files
mission-control-v2/deploy/homelab/ausfuehrer.py
T
HitonabiandClaude Opus 5.5 afa3a476b5 Welle 1 · Sicherungs-Abdeckung: Gäste ohne Auftrag, Prüfung durch den PBS, Probe-Wiederherstellung je Gerät
Der Ausführer meldet jetzt, welche Gäste kein Sicherungsauftrag erfasst (wie Proxmox unter Rechenzentrum → Backup),
je Gast die jüngste Prüfung durch den PBS samt beschädigter Sicherungen, die Schätzung des PBS, wann sein Datastore
voll ist, und öffnet einmal die Woche die jüngste Sicherung jedes Gasts probeweise (nur lesend: Konfiguration, bei
Containern der Dateibaum). Der Wächter meldet: ohne Auftrag gelb (sofort), beschädigt rot, seit 15 Tagen nicht
geprüft gelb, Probe gescheitert gelb, Sicherungsziel laut PBS bald voll. Die Speicherkarte zeigt „Sicherungen je
Gerät“ mit Knopf „Jetzt prüfen“. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:37:21 +02:00

1452 lines
71 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.
• Abdeckung im Bericht (seit 25.09.2026): Gäste, die kein Sicherungsauftrag erfasst (wie Proxmox sie unter
Rechenzentrum → Backup zeigt), je Gast die jüngste Prüfung durch den PBS, die Schätzung des PBS,
wann sein Datastore voll ist, und die Probe-Wiederherstellung: einmal die Woche (erstmals eine
Viertelstunde nach dem Start) wird die jüngste Sicherung jedes Gasts probeweise geöffnet — die
Konfiguration daraus gelesen, bei Containern auf dem PBS dazu der Dateibaum. Nur lesend; es entsteht
kein Gast und keine Datei.
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 base64
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
def pruefzeit(upid: object) -> int | None:
"""Beginn einer Prüfung aus ihrer UPID. Beim PBS „UPID:<node>:<pid>:<pstart>:<task>:<start>:verificationjob:…“,
bei Proxmox VE fehlt <task> — also das erste der Felder 4 und 5, das als Zeit taugt."""
for feld in str(upid or "").split(":")[4:6]:
try:
zeit = int(feld, 16)
except ValueError:
continue
if 1_500_000_000 < zeit < 4_000_000_000:
return zeit
return None
def sicherungs_details(inhalt: object) -> dict[str, dict]:
"""Je Gast (seit 25.09.2026): die jüngste Sicherung („zeit“, „volid“, „format“, „groesse“), die älteste
(„aelteste“), die jüngste Prüfung durch den PBS („geprueft“: {"zustand": ok|failed, "zeit": Beginn der Prüfung,
"sicherung": Zeit der geprüften Sicherung}) und die Zeiten der Sicherungen, deren Prüfung scheiterte („defekt“)."""
details: dict[str, dict] = {}
for e in inhalt if isinstance(inhalt, list) else []:
try:
vmid, zeit = str(int(e["vmid"])), int(e["ctime"])
except (KeyError, TypeError, ValueError):
continue
d = details.setdefault(vmid, {"zeit": 0, "aelteste": zeit, "volid": None, "format": None, "groesse": None,
"geprueft": None, "defekt": []})
d["aelteste"] = min(d["aelteste"], zeit)
if zeit > d["zeit"]:
d.update(zeit=zeit, volid=e.get("volid"), format=e.get("format"), groesse=_ganzzahl(e.get("size")))
pruefung = e.get("verification") if isinstance(e.get("verification"), dict) else {}
if not pruefung.get("state"):
continue
beginn = pruefzeit(pruefung.get("upid")) or zeit
alt = d["geprueft"]
if not alt or (beginn, zeit) > (alt["zeit"], alt["sicherung"]):
d["geprueft"] = {"zustand": str(pruefung["state"]), "zeit": beginn, "sicherung": zeit}
if pruefung["state"] == "failed":
d["defekt"].append(zeit)
for d in details.values():
d["defekt"].sort()
return details
def pbs_schaetzung(nutzung: object, datastore: str | None) -> dict | None:
"""Aus /status/datastore-usage des PBS: {"voll_am": Unix-Zeit oder None (wächst nicht), "belegt", "gesamt"}."""
for d in nutzung if isinstance(nutzung, list) else []:
if isinstance(d, dict) and d.get("store") == datastore and not d.get("error"):
voll_am = _ganzzahl(d.get("estimated-full-date"))
return {"voll_am": voll_am if voll_am and voll_am > 0 else None,
"belegt": _ganzzahl(d.get("used")), "gesamt": _ganzzahl(d.get("total"))}
return None
# Eine Zeile, wie sie in jeder Gast-Konfiguration steht (Container oder VM).
KONFIG_ZEILE = re.compile(r"^(?:arch|cores|memory|ostype|rootfs|boot|(?:scsi|virtio|sata|ide)\d+)\s*:",
re.MULTILINE)
PROBE_ALLE_S = 7 * 86400
PROBE_NOCHMAL_S = 3600 # nach einer gescheiterten Probe
PROBE_ERSTE_S = 15 * 60 # nach dem Start: erst den PBS und die Gäste hochkommen lassen
_probe: dict = {"naechste": time.time() + PROBE_ERSTE_S, "stand": None}
def _letzte_zeile(text: str) -> str:
zeilen = [z for z in text.strip().splitlines() if z.strip()]
return f": {zeilen[-1][:200]}" if zeilen else ""
def probe_oeffnen(volid: str, art: str | None) -> str | None:
"""Eine Sicherung probeweise öffnen, nur lesend: die Konfiguration daraus lesen, bei Containern auf einem PBS dazu
den Dateibaum (/etc muss darin sein). None = gelungen, sonst was scheiterte."""
code, text = _laufen(["pvesm", "extractconfig", volid], 120)
if code != 0 or not KONFIG_ZEILE.search(text):
return "Die Konfiguration ließ sich nicht aus der Sicherung lesen" + _letzte_zeile(text)
if art == "pbs-ct":
speicher = volid.split(":", 1)[0]
code, text = _laufen(["pvesh", "get", f"/nodes/{NODE}/storage/{speicher}/file-restore/list", "--volume", volid,
"--filepath", base64.b64encode(b"/root.pxar.didx").decode(), "--output-format", "json"],
180)
try:
namen = {str(x.get("text")) for x in json.loads(text)} if code == 0 else set()
except (ValueError, AttributeError):
namen = set()
if "etc" not in namen:
return "Die Dateien der Sicherung ließen sich nicht auflisten" + _letzte_zeile(text)
return None
def sicherungs_probe(ziele: dict, oeffnen=None) -> dict:
"""Probe-Wiederherstellung: die jüngste Sicherung jedes Gasts (über alle erreichbaren Ziele) probeweise öffnen.
{"zeit", "gaeste": {vmid: {"speicher", "sicherung", "ok", "fehler"}}}"""
oeffnen = oeffnen or probe_oeffnen
neueste: dict[str, tuple[str, dict]] = {}
for name, ziel in ziele.items():
if not ziel.get("aktiv"):
continue
for vmid, d in (ziel.get("details") or {}).items():
if d.get("volid") and d["zeit"] > neueste.get(vmid, ("", {"zeit": 0}))[1]["zeit"]:
neueste[vmid] = (name, d)
gaeste = {}
for vmid, (name, d) in sorted(neueste.items(), key=lambda t: int(t[0])):
fehler = oeffnen(d["volid"], d.get("format"))
gaeste[vmid] = {"speicher": name, "sicherung": d["zeit"], "ok": fehler is None, "fehler": fehler}
return {"zeit": int(time.time()), "gaeste": gaeste}
def _probe_stand(ziele: dict) -> dict | None:
"""Die Probe, wenn sie fällig ist (einmal die Woche; nach einem Fehlschlag nach einer Stunde wieder), sonst der
letzte Stand. Er lebt nur im Speicher: Nach einem Neustart des Ausführers kommt die nächste Probe bald."""
if time.time() >= _probe["naechste"]:
stand = sicherungs_probe(ziele)
gelungen = all(g["ok"] for g in stand["gaeste"].values())
_probe.update(stand=stand, naechste=time.time() + (PROBE_ALLE_S if gelungen else PROBE_NOCHMAL_S))
return _probe["stand"]
_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:
konfig = _pvesh("/storage") or []
except Exception:
konfig = []
try:
pools, zuteilung = thin_pools(speicher, konfig, _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:
inhalt = _pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup")
ziel["neueste"] = neueste_sicherungen(inhalt)
ziel["details"] = sicherungs_details(inhalt)
except Exception as exc:
ziel["fehler"] = str(exc)[:200]
if s.get("type") == "pbs":
ziel["schaetzung"] = _pbs_schaetzung(next((k for k in konfig if k.get("storage") == name), {}), gaeste)
ziele[name] = ziel
lage["sicherungsziele"] = ziele
except Exception as exc:
lage["sicherung_fehler"] = str(exc)[:200]
try:
lage["sicherung_probe"] = _probe_stand(lage.get("sicherungsziele") or {})
except Exception as exc:
lage["sicherung_probe_fehler"] = str(exc)[:200]
try:
lage["nicht_gesichert"] = sorted(int(e["vmid"]) for e in _pvesh("/cluster/backup-info/not-backed-up") or [])
except Exception as exc:
lage["nicht_gesichert_fehler"] = str(exc)[:200]
return lage
def _pbs_schaetzung(konfig: dict, gaeste: list[dict]) -> dict | None:
"""Die Schätzung des PBS, wann sein Datastore voll ist — wenn der PBS ein laufender Container dieses Hosts ist
(seine Adresse die des Speichers). Gefragt wird er von innen, ohne Schlüssel."""
server = konfig.get("server")
pbs = next((g for g in gaeste if g.get("art") == "lxc" and g.get("status") == "running" and server
and g.get("ip") == server), None)
if not pbs:
return None
code, text = _laufen(["pct", "exec", str(pbs["vmid"]), "--", "proxmox-backup-debug", "api", "get",
"/status/datastore-usage", "--output-format", "json"], 60)
try:
return pbs_schaetzung(json.loads(text), konfig.get("datastore")) if code == 0 else None
except ValueError:
return None
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_sicherung_probe(p: dict) -> tuple[int, str]:
"""Probe-Wiederherstellung sofort (Knopf „Jetzt prüfen“, seit 25.09.2026), nur lesend. Das Ergebnis steht als JSON
in der Antwort und im nächsten Bericht."""
_probe["naechste"] = 0.0
stand = bericht()["host"].get("sicherung_probe") or {"gaeste": {}}
return (0 if all(g["ok"] for g in stand["gaeste"].values()) else 1), json.dumps(stand, 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, "sicherung_probe": a_sicherung_probe}
# Was auch im Nur-Lesen-Modus geht: Es liest nur.
NUR_LESEND = {a_bericht, a_sicherung_probe}
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 not in NUR_LESEND:
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())