Nach dem ersten echten „Alle aktualisieren“ (25.09., 8 Schritte grün): - 9 veraltete Images, die kein Container benutzt (Basis-Images für Builds/CI), blieben für immer „neu“ – Arcanes Updater tauscht nur Container. Sie zählen jetzt nicht mehr, der Grund nennt sie als Aufräum-Hinweis. - Proxmox legt bei Kernel-Updates kein /var/run/reboot-required an: 7.0.14-19 war installiert, 7.0.14-12 lief, kein Neustart-Knopf. Der Ausführer vergleicht jetzt den neuesten installierten (oder festgepinnten) Kernel mit dem laufenden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1155 lines
55 KiB
Python
1155 lines
55 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.
|
||
• 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}$")
|
||
# 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[str]:
|
||
try:
|
||
return [s["name"] for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") if s.get("name") != "current"]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
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[str]]) -> dict:
|
||
art, vmid = eintrag["type"], int(eintrag["vmid"])
|
||
etiketten = _etiketten(eintrag.get("tags"))
|
||
gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
|
||
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
|
||
"snapshots": _snapshots(art, vmid), "sicherungen": sicherungen.get(vmid, [])}
|
||
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
|
||
vorhanden = [os.path.basename(p)[len("vmlinuz-"):] for p in glob.glob("/boot/vmlinuz-*")]
|
||
host["kernel_neu"] = neuer_kernel(host["kernel"], vorhanden, _lesen("/etc/kernel/proxmox-boot-pin").strip() or None)
|
||
host["neustart_noetig"] = host["neustart_noetig"] or bool(host["kernel_neu"])
|
||
return host
|
||
|
||
|
||
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[str]] = {}
|
||
if sicherung.get("speicher"):
|
||
try:
|
||
for e in _eigene_sicherungen(sicherung["speicher"]):
|
||
sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e["volid"])
|
||
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"))
|
||
name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description",
|
||
"Vor einem Update durch den Homelab Orchestrator"], 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)
|
||
|
||
|
||
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}
|
||
|
||
|
||
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:
|
||
_anfrage("POST", "/api/homelab/ausfuehrer/bericht", bericht())
|
||
naechster_bericht = time.time() + BERICHT_ALLE_S
|
||
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)
|
||
|
||
|
||
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())
|