From 81e99754a2cf49c8087c6d17fb55c879ba744edf Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 10:43:28 +0200 Subject: [PATCH] proxmox-update.py aktualisiert --- proxmox-update.py | 656 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 589 insertions(+), 67 deletions(-) diff --git a/proxmox-update.py b/proxmox-update.py index 6bcdf16..3ab3511 100644 --- a/proxmox-update.py +++ b/proxmox-update.py @@ -1,115 +1,637 @@ #!/usr/bin/env python3 + import subprocess import logging import datetime import os -import sys import re +import sys +import shlex + + +# ========================= +# Einstellungen +# ========================= + +ZFS_DATASETS = [ + "rpool/ROOT/pve-1", + "rpool/pveconf" +] -# ========== Einstellungen ========== -ZFS_DATASETS = ["rpool/ROOT", "rpool/pveconf"] SNAPSHOT_TAG = "pve-update-via-cron" MAX_SNAPSHOTS = 5 + LOGFILE = "/var/log/proxmox_update.log" + REBOOT_FLAG = os.environ.get("REBOOT", "NO").upper() + TIMESTAMP = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") -# ========== Logging Setup ========== +# Cron-Jobs laufen mit einem minimalen PATH (oft nur /usr/bin:/bin). +# proxmox-boot-tool & Co. liegen aber in /usr/sbin bzw. /sbin. +# Deshalb PATH hier explizit erweitern, damit es unter cron genauso +# funktioniert wie in einer interaktiven root-Shell. +os.environ["PATH"] = "/usr/sbin:/usr/bin:/sbin:/bin:" + os.environ.get("PATH", "") + + +# ========================= +# Logging +# ========================= + logging.basicConfig( filename=LOGFILE, level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) -def run(cmd, check=True): - """Kommando ausführen""" - logging.debug(f"Running: {cmd}") + +def log(msg, level="info"): + + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + print(f"[{timestamp}] {msg}") + + if level == "info": + logging.info(msg) + + elif level == "warn": + logging.warning(msg) + + elif level == "error": + logging.error(msg) + + +# ========================= +# Kommando Helfer +# ========================= + +def run(cmd, check=True, quiet=False): + """ + cmd darf entweder eine Liste von Argumenten sein (kein Shell-Aufruf, + sicherer) oder ein String OHNE Shell-Metazeichen (Pipes, Redirects). + Für Pipelines gibt es run_pipeline(). + + quiet=True unterdrückt das automatische Loggen der stdout-Ausgabe + (nützlich, wenn der Aufrufer die Ausgabe erst selbst filtert und + danach gezielt loggt). + """ + + if isinstance(cmd, str): + cmd = shlex.split(cmd) + + log( + f"→ Ausführen: {' '.join(cmd)}" + ) + try: + result = subprocess.run( - cmd, shell=True, check=check, - stdout=subprocess.PIPE, stderr=subprocess.PIPE + cmd, + check=check, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True ) - return result.stdout.decode().strip() - except subprocess.CalledProcessError as e: - logging.error(f"Fehler bei Befehl: {cmd}") - logging.error(e.stderr.decode().strip()) + + if result.stdout.strip() and not quiet: + log(result.stdout.strip()) + + if result.stderr.strip() and not check: + log(result.stderr.strip(), "warn") + + return result.stdout.strip() + + except FileNotFoundError as e: + + log( + f"❌ Befehl nicht gefunden: {' '.join(cmd)} ({e})", + "error" + ) + if check: raise + return "" -def has_updates(): - """apt update ausführen & prüfen, ob Updates vorhanden""" - try: - output = run("apt update") - if "upgradable" in output.lower(): - logging.info("Updates verfügbar.") - return True - logging.info("Keine Updates verfügbar.") - return False - except Exception: - logging.error("apt update fehlgeschlagen.") - sys.exit(2) + except subprocess.CalledProcessError as e: -def is_kernel_update_available(): - """Prüfen, ob ein Kernel-Update verfügbar ist""" - output = run("apt list --upgradable", check=False) - return any("linux-image-" in line for line in output.splitlines()) + log( + f"❌ Fehler: {' '.join(cmd)}", + "error" + ) + + if e.stderr: + log(e.stderr.strip(), "error") + + if check: + raise + + return "" + + +def run_pipeline(cmd_str, check=False): + """ + Führt einen Shell-Befehl MIT Pipes/Redirects tatsächlich über eine Shell + aus (shell=True). Nur für fest im Skript definierte, ungefährliche + Kommandos verwenden - niemals mit Benutzereingaben füttern. + """ + + log(f"→ Ausführen (shell): {cmd_str}") + + try: + result = subprocess.run( + cmd_str, + shell=True, + check=check, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + if result.stdout.strip(): + log(result.stdout.strip()) + + if result.stderr.strip(): + log(result.stderr.strip(), "warn") + + return result.stdout.strip() + + except subprocess.CalledProcessError as e: + + log(f"❌ Fehler: {cmd_str}", "error") + + if e.stderr: + log(e.stderr.strip(), "error") + + if check: + raise + + return "" + + +# ========================= +# Kernel Prüfung +# ========================= + +def get_running_kernel(): + + return run([ + "uname", + "-r" + ]) + + +KERNEL_PKG_PATTERN = re.compile(r"^proxmox-kernel-([0-9][^\s]*?)(-signed)?$") + + +def _query_kernel_packages(): + """ + Fragt dpkg nach allen proxmox-kernel-* Paketen ab und gibt eine Liste + von (status, paketname) Tupeln zurück. Die rohe dpkg-Ausgabe wird NICHT + geloggt (quiet=True), da sie auch nicht-installierte ("un") und nur + noch als Konfigurationsreste vorhandene ("rc") Pakete enthält, die wir + im Log nicht als "installiert" anzeigen wollen. + """ + + output = run( + ["dpkg-query", "-W", "-f=${db:Status-Abbrev} ${Package}\n", + "proxmox-kernel-*"], + check=False, + quiet=True + ) + + packages = [] + + for line in output.splitlines(): + + parts = line.split(None, 1) + + if len(parts) != 2: + continue + + status, pkg = parts + + packages.append((status, pkg)) + + return packages + + +def get_installed_kernel(): + """ + Ermittelt die höchste installierte Kernel-Version rein in Python + (kein fragiles Shell-Pipeline-Kommando mehr, kein sort -V nötig). + Loggt dabei NUR die tatsächlich installierten ("ii") Kernel-Pakete, + keine "un" (nicht installiert) oder "rc" (entfernt, Reste vorhanden). + """ + + packages = _query_kernel_packages() + + installed_pkgs = [pkg for status, pkg in packages if status == "ii"] + + if installed_pkgs: + log( + "📋 Installierte Kernel-Pakete: " + ", ".join(sorted(installed_pkgs)) + ) + + versions = [] + + for pkg in installed_pkgs: + + m = KERNEL_PKG_PATTERN.match(pkg) + + if not m: + continue + + versions.append(m.group(1)) + + if not versions: + return "" + + # Versionssortierung ohne externes sort -V: + def version_key(v): + return [int(p) if p.isdigit() else p for p in re.split(r"[.\-]", v)] + + versions.sort(key=version_key) + + return versions[-1] + + +def cleanup_old_kernel_configs(): + """ + Entfernt Konfigurationsreste von bereits deinstallierten alten + Kernel-Paketen (dpkg-Status "rc" = removed, config-files still present). + Diese Reste belegen unnötig Platz und tauchen sonst dauerhaft in + 'dpkg -l' auf, obwohl das Paket längst deinstalliert ist. + """ + + packages = _query_kernel_packages() + + residual_pkgs = [pkg for status, pkg in packages if status == "rc"] + + if not residual_pkgs: + log( + "✅ Keine Konfigurationsreste alter Kernel-Pakete gefunden" + ) + return + + residual_pkgs = sorted(residual_pkgs) + + total = len(residual_pkgs) + + log( + f"🧹 Entferne Konfigurationsreste von {total} alten Kernel-Paketen" + ) + + for index, pkg in enumerate(residual_pkgs, start=1): + + log( + f"🗑️ ({index}/{total}) Lösche Reste von: {pkg}" + ) + + run( + ["apt-get", "purge", "-y", "-qq", pkg], + check=False, + quiet=True + ) + + log( + f"✅ ({index}/{total}) Erledigt: {pkg}" + ) + + +def reboot_required(): + + running = get_running_kernel() + + installed = get_installed_kernel() + + log( + f"🖥️ Kernel aktiv: {running}" + ) + + log( + f"📦 Kernel installiert: {installed}" + ) + + if not running or not installed: + log( + "⚠️ Kernel-Vergleich nicht möglich (leerer Wert) - " + "gehe sicherheitshalber von 'kein Reboot nötig' aus", + "warn" + ) + return False + + if running != installed: + return True + + return False + + +# ========================= +# Boot Prüfung +# ========================= + +def proxmox_boot_ok(): + + try: + result = subprocess.run( + [ + "proxmox-boot-tool", + "status" + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + except FileNotFoundError as e: + + log( + f"❌ proxmox-boot-tool nicht gefunden (PATH-Problem?): {e}", + "error" + ) + + return False + + if result.stdout: + + log( + result.stdout.strip() + ) + + if result.returncode == 0: + + return True + + if result.stderr: + + log( + result.stderr.strip(), + "error" + ) + else: + log( + f"❌ proxmox-boot-tool status lieferte Exit-Code {result.returncode} ohne Fehlermeldung", + "error" + ) + + return False + + +def refresh_boot(): + + log( + "🔄 Aktualisiere Proxmox Bootloader" + ) + + run([ + "proxmox-boot-tool", + "refresh" + ]) + + +# ========================= +# Update Prüfung +# ========================= + +def has_updates(): + + run([ + "apt-get", + "update" + ]) + + output = run( + [ + "apt-get", + "-s", + "dist-upgrade" + ], + check=False + ) + + updates = [ + line + for line in output.splitlines() + if line.startswith("Inst ") + ] + + if updates: + + log( + f"📦 {len(updates)} Updates verfügbar" + ) + + return True + + log( + "✅ Keine Updates verfügbar" + ) + + return False + + +# ========================= +# Snapshot Funktionen +# ========================= def create_snapshot(dataset): - """Snapshot mit Tag erstellen""" - snapshot_name = f"{dataset}@{SNAPSHOT_TAG}-{TIMESTAMP}" - logging.info(f"Erstelle Snapshot: {snapshot_name}") - run(f"zfs snapshot {snapshot_name}") + + snapshot = ( + f"{dataset}@{SNAPSHOT_TAG}-{TIMESTAMP}" + ) + + log( + f"📸 Snapshot erstellen: {snapshot}" + ) + + run([ + "zfs", + "snapshot", + snapshot + ]) + def cleanup_snapshots(dataset): - """Alte Snapshots mit dem definierten Tag löschen""" - logging.info(f"Bereinige alte Snapshots in {dataset}") - snap_list = run(f"zfs list -t snapshot -o name -s creation | grep '^{dataset}@{SNAPSHOT_TAG}-'", check=False).splitlines() - if len(snap_list) > MAX_SNAPSHOTS: - for snap in snap_list[:-MAX_SNAPSHOTS]: - logging.info(f"Lösche Snapshot: {snap}") - run(f"zfs destroy {snap}") + + output = run( + [ + "zfs", + "list", + "-H", + "-t", + "snapshot", + "-o", + "name", + "-s", + "creation" + ], + check=False + ) + + snapshots = [] + + prefix = ( + f"{dataset}@{SNAPSHOT_TAG}-" + ) + + for line in output.splitlines(): + + if line.startswith(prefix): + + snapshots.append(line) + + if len(snapshots) <= MAX_SNAPSHOTS: + + return + + for snap in snapshots[:-MAX_SNAPSHOTS]: + + log( + f"🗑️ Lösche Snapshot: {snap}" + ) + + run([ + "zfs", + "destroy", + "-r", + snap + ]) + + +# ========================= +# Upgrade +# ========================= def upgrade_system(): - """Upgrade durchführen""" - try: - logging.info("Starte apt dist-upgrade …") - run("apt dist-upgrade -y") - logging.info("Führe apt autoremove aus …") - run("apt autoremove -y") - except Exception: - logging.error("Upgrade fehlgeschlagen.") - sys.exit(2) + + log( + "⚙️ Starte dist-upgrade" + ) + + run([ + "apt-get", + "dist-upgrade", + "-y" + ]) + + log( + "🧼 Starte autoremove" + ) + + run([ + "apt-get", + "autoremove", + "--purge", + "-y" + ]) + + cleanup_old_kernel_configs() + + +# ========================= +# Hauptprogramm +# ========================= def main(): - logging.info("===== Proxmox Update & Snapshot gestartet =====") + + log( + "🚀 ===== Proxmox Update gestartet =====" + ) + + log( + f"ℹ️ REBOOT-Flag: {REBOOT_FLAG}" + ) if os.geteuid() != 0: - print("Dieses Skript muss als root ausgeführt werden.") + + log( + "❌ Muss als root laufen", + "error" + ) + sys.exit(2) - if not has_updates(): - logging.info("Beende – keine Updates.") - sys.exit(0) + kernel_before = get_running_kernel() - # Snapshots nur bei Update - for dataset in ZFS_DATASETS: - create_snapshot(dataset) - cleanup_snapshots(dataset) + log( + f"🖥️ Kernel vor Update: {kernel_before}" + ) - # Upgrade - upgrade_system() + if has_updates(): + + for dataset in ZFS_DATASETS: + + cleanup_snapshots(dataset) + + create_snapshot(dataset) + + upgrade_system() + + refresh_boot() + + else: + + log( + "ℹ️ Keine Updates installiert" + ) + + # auch ohne Update-Lauf vorhandene Reste alter Kernel aufräumen + cleanup_old_kernel_configs() + + # immer prüfen + if reboot_required(): + + log( + "⚠️ Neuer Kernel aktivieren erforderlich", + "warn" + ) - # Kernel-Update erkennen - if is_kernel_update_available(): - logging.warning("Kernel-Update erkannt – Neustart empfohlen.") if REBOOT_FLAG == "YES": - logging.info("Reboot wird ausgeführt …") - run("reboot") - sys.exit(1) # Exit 1 = Neustart empfohlen - logging.info("===== Update-Vorgang abgeschlossen =====") + log( + "🔍 Prüfe proxmox-boot-tool" + ) + + if proxmox_boot_ok(): + + log( + "✅ Bootloader OK" + ) + + log( + "🔄 REBOOT=YES erkannt" + ) + + run([ + "shutdown", + "-r", + "+1", + "Proxmox-Update abgeschlossen" + ]) + + else: + + log( + "❌ Bootloader Prüfung fehlgeschlagen - kein Neustart", + "error" + ) + + else: + + log( + "ℹ️ REBOOT-Flag ist nicht 'YES' - kein automatischer Neustart" + ) + + sys.exit(1) + + log( + "✅ Update erfolgreich abgeschlossen" + ) + sys.exit(0) + if __name__ == "__main__": + main()