From 778d4436c3db0b40e327506c25a345b1bf0bc263 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 3 Sep 2026 13:28:28 +0200 Subject: [PATCH] proxmox-update.py aktualisiert --- proxmox-update.py | 619 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 466 insertions(+), 153 deletions(-) diff --git a/proxmox-update.py b/proxmox-update.py index bcf93f7..2f997a5 100644 --- a/proxmox-update.py +++ b/proxmox-update.py @@ -19,19 +19,38 @@ ZFS_DATASETS = [ ] SNAPSHOT_TAG = "pve-update-via-cron" + +# Anzahl der ZFS-Snapshots, die je Dataset behalten werden. MAX_SNAPSHOTS = 5 +# Anzahl älterer Kernel, die zusätzlich zum aktuell +# laufenden Kernel als Fallback behalten werden. +# +# 0 = nur aktiven Kernel behalten +# 1 = aktiven + 1 Fallback +# 2 = aktiven + 2 Fallback-Kernel +KEEP_OLD_KERNELS = 1 + 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") -# 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", "") + +# Unterstützt sowohl historische pve-kernel-* Pakete +# als auch aktuelle proxmox-kernel-* Pakete. +KERNEL_PKG_PATTERN = re.compile( + r"^(?:pve-kernel|proxmox-kernel)-([0-9][^\s]*?)(?:-signed)?$" +) + + +# Cron-Jobs laufen mit einem minimalen PATH. +# Proxmox-Tools liegen teilweise in /usr/sbin bzw. /sbin. +os.environ["PATH"] = ( + "/usr/sbin:/usr/bin:/sbin:/bin:" + + os.environ.get("PATH", "") +) # ========================= @@ -46,7 +65,6 @@ logging.basicConfig( def log(msg, level="info"): - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"[{timestamp}] {msg}") @@ -67,13 +85,13 @@ def log(msg, level="info"): 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(). + Führt einen Befehl ohne Shell aus. - quiet=True unterdrückt das automatische Loggen der stdout-Ausgabe - (nützlich, wenn der Aufrufer die Ausgabe erst selbst filtert und - danach gezielt loggt). + cmd kann eine Argumentliste oder ein String ohne + Shell-Metazeichen sein. + + quiet=True unterdrückt das automatische Loggen + von stdout. """ if isinstance(cmd, str): @@ -84,13 +102,12 @@ def run(cmd, check=True, quiet=False): ) try: - result = subprocess.run( cmd, check=check, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True + text=True, ) if result.stdout.strip() and not quiet: @@ -104,8 +121,9 @@ def run(cmd, check=True, quiet=False): except FileNotFoundError as e: log( - f"FEHLER: Befehl nicht gefunden: {' '.join(cmd)} ({e})", - "error" + f"FEHLER: Befehl nicht gefunden: " + f"{' '.join(cmd)} ({e})", + "error", ) if check: @@ -117,51 +135,14 @@ def run(cmd, check=True, quiet=False): log( f"FEHLER: {' '.join(cmd)}", - "error" + "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") + log( + e.stderr.strip(), + "error", + ) if check: raise @@ -174,29 +155,54 @@ def run_pipeline(cmd_str, check=False): # ========================= def get_running_kernel(): - return run([ "uname", - "-r" + "-r", ]) -KERNEL_PKG_PATTERN = re.compile(r"^(?:pve-kernel|proxmox-kernel)-([0-9][^\s]*?)(?:-signed)?$") +def version_key(version): + """ + Sortierbarer Schlüssel für Kernel-Versionen. + + Beispiel: + 6.14.8 + 6.14.10 + 6.14.11 + """ + + return [ + int(part) if part.isdigit() else part + for part in re.split(r"[.\-]", version) + ] + 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. + Fragt dpkg nach beiden Proxmox-Kernel-Paketgruppen ab. + + Zurückgegeben werden Tupel: + + (status, paketname) + + Beispiel: + + ("ii", "proxmox-kernel-6.14.11-1-pve-signed") + ("rc", "proxmox-kernel-6.8.12-2-pve-signed") + + Nur "ii" bedeutet tatsächlich installiert. """ output = run( - ["dpkg-query", "-W", "-f=${db:Status-Abbrev} ${Package}\n", - "proxmox-kernel-*"], + [ + "dpkg-query", + "-W", + "-f=${db:Status-Abbrev} ${Package}\n", + "pve-kernel-*", + "proxmox-kernel-*", + ], check=False, - quiet=True + quiet=True, ) packages = [] @@ -210,67 +216,287 @@ def _query_kernel_packages(): status, pkg = parts - packages.append((status, pkg)) + packages.append( + (status, pkg) + ) return packages -def get_installed_kernel(): +def get_installed_kernel_versions(): """ - 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). + Liefert tatsächlich installierte Proxmox-Kernel. + + Nur dpkg-Status "ii" wird berücksichtigt. """ packages = _query_kernel_packages() - installed_pkgs = [pkg for status, pkg in packages if status == "ii"] + kernels = [] - if installed_pkgs: - log( - "Installierte Kernel-Pakete: " + ", ".join(sorted(installed_pkgs)) - ) + for status, pkg in packages: - versions = [] - - for pkg in installed_pkgs: - - m = KERNEL_PKG_PATTERN.match(pkg) - - if not m: + if status != "ii": continue - versions.append(m.group(1)) + match = KERNEL_PKG_PATTERN.match(pkg) - if not versions: + if not match: + continue + + kernels.append({ + "version": match.group(1), + "package": pkg, + }) + + return kernels + + +def get_installed_kernel(): + """ + Ermittelt die höchste installierte Proxmox-Kernel-Version. + """ + + kernels = get_installed_kernel_versions() + + if not kernels: 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 = sorted( + { + kernel["version"] + for kernel in kernels + }, + key=version_key, + ) - versions.sort(key=version_key) + log( + "Installierte Proxmox-Kernel: " + + ", ".join(versions) + ) return versions[-1] +def cleanup_old_kernels(): + """ + Entfernt alte installierte Proxmox-Kernel. + + Sicherheitsregeln: + + - Der aktuell laufende Kernel wird niemals gelöscht. + - Ist der laufende Kernel nicht eindeutig in der + installierten Kernel-Liste vorhanden, wird nichts gelöscht. + - KEEP_OLD_KERNELS ältere Kernel bleiben erhalten. + - Ein neuerer installierter Kernel als der laufende + wird ebenfalls nicht gelöscht, da er nach einem + Reboot benötigt werden kann. + - Nur Status "ii" wird hier berücksichtigt. + - Entfernen erfolgt über apt-get purge. + """ + + running = get_running_kernel() + + if not running: + log( + "⚠️ Aktiver Kernel konnte nicht ermittelt werden - " + "keine Kernel werden gelöscht", + "warn", + ) + return + + kernels = get_installed_kernel_versions() + + if not kernels: + log( + "⚠️ Keine installierten Proxmox-Kernel erkannt - " + "keine Kernel werden gelöscht", + "warn", + ) + return + + # Version -> Pakete + version_packages = {} + + for kernel in kernels: + + version = kernel["version"] + package = kernel["package"] + + version_packages.setdefault( + version, + [] + ).append(package) + + installed_versions = sorted( + version_packages.keys(), + key=version_key, + ) + + log( + f"🖥️ Aktiver Kernel: {running}" + ) + + log( + "📋 Installierte Proxmox-Kernel:" + ) + + for version in reversed(installed_versions): + + marker = "" + + if version == running: + marker = " ← AKTIV" + + log( + f" {version}{marker}" + ) + + # Der laufende Kernel muss exakt als installierte + # Kernel-Version vorhanden sein. + active_matches = [ + version + for version in installed_versions + if version == running + ] + + if len(active_matches) != 1: + + log( + "⚠️ Aktiver Kernel wurde nicht eindeutig in den " + "installierten Kernel-Paketen gefunden - " + "keine Kernel werden gelöscht", + "warn", + ) + + return + + active_index = installed_versions.index(running) + + # Alles unterhalb des laufenden Kernels ist älter. + older_versions = installed_versions[:active_index] + + # Die direkt älteren KEEP_OLD_KERNELS Versionen bleiben. + fallback_versions = older_versions[ + max(0, len(older_versions) - KEEP_OLD_KERNELS): + ] + + for version in reversed(fallback_versions): + + log( + f"🛡️ Fallback-Kernel bleibt erhalten: {version}" + ) + + # Sicherheitsregel: + # + # Versionen oberhalb des laufenden Kernels werden NICHT + # gelöscht. Das kann ein neuer Kernel sein, auf den nach + # dem Reboot gewechselt werden soll. + newer_versions = [ + version + for version in installed_versions + if version_key(version) > version_key(running) + ] + + for version in newer_versions: + + log( + f"🛡️ Neuerer installierter Kernel bleibt erhalten: " + f"{version} - Reboot kann erforderlich sein", + "warn", + ) + + protected_versions = { + running, + *fallback_versions, + *newer_versions, + } + + remove_versions = [ + version + for version in installed_versions + if version not in protected_versions + ] + + if not remove_versions: + + log( + "OK: Keine alten Kernel zur Entfernung vorhanden" + ) + + return + + for version in remove_versions: + + packages_to_remove = version_packages.get( + version, + [], + ) + + for package in packages_to_remove: + + log( + f"🗑️ Entferne alten Kernel: {package}" + ) + + result = subprocess.run( + [ + "apt-get", + "purge", + "-y", + package, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if result.returncode == 0: + + log( + f"✅ Kernel entfernt: {package}" + ) + + else: + + log( + f"❌ Fehler beim Entfernen von {package} " + f"(Exit-Code {result.returncode})", + "error", + ) + + if result.stderr.strip(): + + log( + result.stderr.strip(), + "error", + ) + + # Absichtlich kein raise: + # Fehler bei einem einzelnen Kernel sollen + # den restlichen Update-Lauf nicht abbrechen. + + 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. + Entfernt ausschließlich dpkg-Konfigurationsreste ("rc") + alter Proxmox-Kernel-Pakete. """ packages = _query_kernel_packages() - residual_pkgs = [pkg for status, pkg in packages if status == "rc"] + residual_pkgs = [ + pkg + for status, pkg in packages + if status == "rc" + ] if not residual_pkgs: + log( "OK: Keine Konfigurationsreste alter Kernel-Pakete gefunden" ) + return residual_pkgs = sorted(residual_pkgs) @@ -278,30 +504,64 @@ def cleanup_old_kernel_configs(): total = len(residual_pkgs) log( - f"Entferne Konfigurationsreste von {total} alten Kernel-Paketen" + f"🧼 Entferne Konfigurationsreste von " + f"{total} alten Kernel-Paketen" ) - for index, pkg in enumerate(residual_pkgs, start=1): + 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 + result = subprocess.run( + [ + "apt-get", + "purge", + "-y", + "-qq", + pkg, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, ) - log( - f"OK: ({index}/{total}) Erledigt: {pkg}" - ) + if result.returncode == 0: + + log( + f"✅ ({index}/{total}) Erledigt: {pkg}" + ) + + else: + + log( + f"❌ ({index}/{total}) Fehler beim Entfernen " + f"der Reste: {pkg}", + "error", + ) + + if result.stderr.strip(): + + log( + result.stderr.strip(), + "error", + ) def reboot_required(): + """ + Prüft getrennt von der Kernelbereinigung, ob ein Reboot + erforderlich ist. + + Reboot erforderlich, wenn der laufende Kernel nicht + der höchsten installierten Kernel-Version entspricht. + """ running = get_running_kernel() - installed = get_installed_kernel() log( @@ -313,17 +573,16 @@ def reboot_required(): ) if not running or not installed: + log( - "WARNUNG: Kernel-Vergleich nicht möglich (leerer Wert) - " + "⚠️ Kernel-Vergleich nicht möglich - " "gehe sicherheitshalber von 'kein Reboot nötig' aus", - "warn" + "warn", ) + return False - if running != installed: - return True - - return False + return running != installed # ========================= @@ -333,26 +592,27 @@ def reboot_required(): def proxmox_boot_ok(): try: + result = subprocess.run( [ "proxmox-boot-tool", - "status" + "status", ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True + text=True, ) except FileNotFoundError as e: log( - f"FEHLER: proxmox-boot-tool nicht gefunden (PATH-Problem?): {e}", - "error" + f"FEHLER: proxmox-boot-tool nicht gefunden: {e}", + "error", ) return False - if result.stdout: + if result.stdout.strip(): log( result.stdout.strip() @@ -362,16 +622,19 @@ def proxmox_boot_ok(): return True - if result.stderr: + if result.stderr.strip(): log( result.stderr.strip(), - "error" + "error", ) + else: + log( - f"FEHLER: proxmox-boot-tool status lieferte Exit-Code {result.returncode} ohne Fehlermeldung", - "error" + "FEHLER: proxmox-boot-tool status " + f"lieferte Exit-Code {result.returncode}", + "error", ) return False @@ -385,7 +648,7 @@ def refresh_boot(): run([ "proxmox-boot-tool", - "refresh" + "refresh", ]) @@ -397,16 +660,16 @@ def has_updates(): run([ "apt-get", - "update" + "update", ]) output = run( [ "apt-get", "-s", - "dist-upgrade" + "dist-upgrade", ], - check=False + check=False, ) updates = [ @@ -431,9 +694,28 @@ def has_updates(): # ========================= -# Snapshot Funktionen +# ZFS Snapshot Funktionen # ========================= +def zfs_dataset_exists(dataset): + + result = subprocess.run( + [ + "zfs", + "list", + "-H", + "-o", + "name", + dataset, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + return result.returncode == 0 + + def create_snapshot(dataset): snapshot = ( @@ -447,7 +729,7 @@ def create_snapshot(dataset): run([ "zfs", "snapshot", - snapshot + snapshot, ]) @@ -463,9 +745,10 @@ def cleanup_snapshots(dataset): "-o", "name", "-s", - "creation" + "creation", ], - check=False + check=False, + quiet=True, ) snapshots = [] @@ -494,10 +777,29 @@ def cleanup_snapshots(dataset): "zfs", "destroy", "-r", - snap + snap, ]) +def create_update_snapshots(): + + for dataset in ZFS_DATASETS: + + if not zfs_dataset_exists(dataset): + + log( + f"⚠️ ZFS-Dataset nicht vorhanden: {dataset} - " + "Snapshot wird übersprungen, Update läuft weiter", + "warn", + ) + + continue + + cleanup_snapshots(dataset) + + create_snapshot(dataset) + + # ========================= # Upgrade # ========================= @@ -505,26 +807,31 @@ def cleanup_snapshots(dataset): def upgrade_system(): log( - "Starte dist-upgrade" + "⚙️ Starte dist-upgrade" ) run([ "apt-get", "dist-upgrade", - "-y" + "-y", ]) log( - "Starte autoremove" + "🧼 Starte autoremove" ) run([ "apt-get", "autoremove", "--purge", - "-y" + "-y", ]) + # Eigene Kernelbereinigung nach dem normalen + # Debian/Proxmox-Upgrade. + cleanup_old_kernels() + + # Danach eventuell verbliebene rc-Reste bereinigen. cleanup_old_kernel_configs() @@ -542,11 +849,12 @@ def main(): f"REBOOT-Flag: {REBOOT_FLAG}" ) + # Exit-Code 2 für nicht-root beibehalten. if os.geteuid() != 0: log( "FEHLER: Muss als root laufen", - "error" + "error", ) sys.exit(2) @@ -559,31 +867,33 @@ def main(): if has_updates(): - for dataset in ZFS_DATASETS: - - cleanup_snapshots(dataset) - - create_snapshot(dataset) + # Snapshots VOR dem eigentlichen dist-upgrade. + create_update_snapshots() upgrade_system() + # Nach Kernel-/Paket-Updates Bootloader aktualisieren. refresh_boot() else: log( - "Keine Updates installiert" + "ℹ️ Keine Updates installiert" ) - # auch ohne Update-Lauf vorhandene Reste alter Kernel aufräumen + # Auch ohne verfügbare Updates regelmäßig alte + # installierte Kernel bereinigen. + cleanup_old_kernels() + cleanup_old_kernel_configs() - # immer prüfen + # Reboot-Erkennung bleibt vollständig getrennt + # von der Kernelbereinigung. if reboot_required(): log( - "WARNUNG: Neuer Kernel aktivieren erforderlich", - "warn" + "⚠️ Neuer Kernel vorhanden - Reboot erforderlich", + "warn", ) if REBOOT_FLAG == "YES": @@ -599,29 +909,33 @@ def main(): ) log( - "REBOOT=YES erkannt" + "REBOOT=YES erkannt - Neustart in 1 Minute" ) run([ "shutdown", "-r", "+1", - "Proxmox-Update abgeschlossen" + "Proxmox-Update abgeschlossen", ]) else: log( - "FEHLER: Bootloader Prüfung fehlgeschlagen - kein Neustart", - "error" + "❌ Bootloader Prüfung fehlgeschlagen - " + "kein Neustart", + "error", ) else: log( - "REBOOT-Flag ist nicht 'YES' - kein automatischer Neustart" + "REBOOT-Flag ist nicht 'YES' - " + "kein automatischer Neustart" ) + # Monitoring muss weiterhin erkennen, + # dass ein Reboot erforderlich ist. sys.exit(1) log( @@ -632,5 +946,4 @@ def main(): if __name__ == "__main__": - main()