Files
mission-control-v2/deploy/homelab/ausfuehrer.py
T
HitonabiandClaude Opus 5.5 f2733f44fe homelab: Wartezeit nach Skriptaenderung, PBS mit Sicherung statt Snapshot, woechentliches Suchen
- karenz.py: ct/<app>.sh juenger als MC_HOMELAB_KARENZ_H (Standard 48 h) -> Baustein "neu" ohne Knopf,
  updates.starten lehnt mit demselben Satz ab (Berliner Zeit). GitHub stumm -> nicht blockieren, die
  Rueckfrage sagt es.
- Ausfuehrer: neue Aktionen sichern, sicherung_zurueck, sicherung_loeschen. vzdump auf den ersten lokalen
  Speicher mit Inhalt backup (oder sicherung_speicher aus /etc/mc2-ausfuehrer.json), nie auf pbs; vorher
  Platz pruefen (frei > belegt x 1,2); Notiz mc2-sicherung, nur solche werden zurueckgespielt/geloescht;
  Rueckweg: stoppen, pct restore --force auf den bisherigen rootfs-Speicher, starten. Bericht mit
  host.sicherung, sicherung_moeglich/_grund, eigenen Sicherungen und nur_lesen. Unerwartete Fehler werden
  beantwortet statt verschluckt; vzdump/restore beim Zeitlimit erst SIGTERM.
- updates.py: Sicherung, wo kein Snapshot geht; bei Rot zurueckspielen, nach Gruen aeltere Sicherungen
  weg. Scheitert Snapshot oder Sicherung, beginnt das Update nicht (nicht dringend gemeldet).
- pflege.py: "suchen" fuer Gaeste mit Paketlisten aelter als 7 Tage, nachts 02-05 Uhr (sonst nachholen),
  einmal je Gast und Tag, nie neben einem Update oder offenen Auftrag; gelber Waechter-Hinweis, wenn es
  zweimal hintereinander scheitert. Eine Registrierungszeile in waechter.py.
- kanal.py: Auftragsliste unter flock, weil jetzt auch der Steward Auftraege anlegt.
- inventar.py: Rueckweg und Rueckfrage fuer die Sicherung; Gaeste mitten im Update-Lauf nicht in der
  Webpruefung des Waechters (sonst zweiter Alarm beim Zurueckspielen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:22:39 +02:00

668 lines
32 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.
• 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.
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)
"""
import argparse
import json
import logging
import os
import platform
import re
import subprocess
import sys
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)
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, [])}
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"))
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
return host
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
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:]}
# --- 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:
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)")
args = teile.parse_args()
if args.bericht:
json.dump(bericht(), 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())