Files
mission-control-v2/deploy/homelab/ausfuehrer.py
T
HitonabiandClaude Opus 5.5 528198de60 homelab: Update-Weg je App (Knopf nur, wo das Community-Script die App wirklich aktualisiert), Version von PVE Scripts Local aus der App selbst
Nachgelesen in ct/<app>.sh: AdGuard und PVE Scripts Local verweisen auf ihren eingebauten Updater,
Gitea/NetBird/NPMplus aktualisieren wirklich, PBS ueber die Pakete. /root/.proxmoxve-local stand noch
auf 0.5.8, die App selbst schon auf der neuesten Veroeffentlichung (v1.2.1, untagged).

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

409 lines
17 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. 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.
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.
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"}
# 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) -> tuple[int, str]:
try:
r = subprocess.run(befehl, capture_output=True, text=True, timeout=zeitlimit)
except subprocess.TimeoutExpired:
return 124, f"Zeitlimit ({zeitlimit} s) überschritten"
except OSError as exc:
return 127, str(exc)
return r.returncode, (r.stdout + r.stderr).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 _speicherart() -> dict[str, str]:
try:
return {s["storage"]: s["type"] for s in _pvesh(f"/nodes/{NODE}/storage")}
except Exception:
return {}
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
def _app_kennung(vmid: int) -> str | None:
"""Welches Community-Script steckt im Gast? /usr/bin/update lädt ct/<kennung>.sh."""
code, text = _im_gast(vmid, "cat /usr/bin/update 2>/dev/null", 10)
m = re.search(r"/ct/([a-z0-9-]+)\.sh", text) if code == 0 else None
return m.group(1) if m 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 _gast(eintrag: dict, arten: dict[str, 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)}
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":
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")}
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:
arten = _speicherart()
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))
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 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}
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}"}
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"))
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())