proxmox-update.py aktualisiert

This commit is contained in:
2026-09-03 13:28:28 +02:00
parent b9cb390519
commit 778d4436c3
+466 -153
View File
@@ -19,19 +19,38 @@ ZFS_DATASETS = [
] ]
SNAPSHOT_TAG = "pve-update-via-cron" SNAPSHOT_TAG = "pve-update-via-cron"
# Anzahl der ZFS-Snapshots, die je Dataset behalten werden.
MAX_SNAPSHOTS = 5 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" LOGFILE = "/var/log/proxmox_update.log"
REBOOT_FLAG = os.environ.get("REBOOT", "NO").upper() REBOOT_FLAG = os.environ.get("REBOOT", "NO").upper()
TIMESTAMP = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 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. # Unterstützt sowohl historische pve-kernel-* Pakete
# Deshalb PATH hier explizit erweitern, damit es unter cron genauso # als auch aktuelle proxmox-kernel-* Pakete.
# funktioniert wie in einer interaktiven root-Shell. KERNEL_PKG_PATTERN = re.compile(
os.environ["PATH"] = "/usr/sbin:/usr/bin:/sbin:/bin:" + os.environ.get("PATH", "") 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"): def log(msg, level="info"):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] {msg}") print(f"[{timestamp}] {msg}")
@@ -67,13 +85,13 @@ def log(msg, level="info"):
def run(cmd, check=True, quiet=False): def run(cmd, check=True, quiet=False):
""" """
cmd darf entweder eine Liste von Argumenten sein (kein Shell-Aufruf, Führt einen Befehl ohne Shell aus.
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 cmd kann eine Argumentliste oder ein String ohne
(nützlich, wenn der Aufrufer die Ausgabe erst selbst filtert und Shell-Metazeichen sein.
danach gezielt loggt).
quiet=True unterdrückt das automatische Loggen
von stdout.
""" """
if isinstance(cmd, str): if isinstance(cmd, str):
@@ -84,13 +102,12 @@ def run(cmd, check=True, quiet=False):
) )
try: try:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
check=check, check=check,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True text=True,
) )
if result.stdout.strip() and not quiet: if result.stdout.strip() and not quiet:
@@ -104,8 +121,9 @@ def run(cmd, check=True, quiet=False):
except FileNotFoundError as e: except FileNotFoundError as e:
log( log(
f"FEHLER: Befehl nicht gefunden: {' '.join(cmd)} ({e})", f"FEHLER: Befehl nicht gefunden: "
"error" f"{' '.join(cmd)} ({e})",
"error",
) )
if check: if check:
@@ -117,51 +135,14 @@ def run(cmd, check=True, quiet=False):
log( log(
f"FEHLER: {' '.join(cmd)}", f"FEHLER: {' '.join(cmd)}",
"error" "error",
) )
if e.stderr: if e.stderr:
log(e.stderr.strip(), "error") log(
e.stderr.strip(),
if check: "error",
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: if check:
raise raise
@@ -174,29 +155,54 @@ def run_pipeline(cmd_str, check=False):
# ========================= # =========================
def get_running_kernel(): def get_running_kernel():
return run([ return run([
"uname", "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(): def _query_kernel_packages():
""" """
Fragt dpkg nach allen proxmox-kernel-* Paketen ab und gibt eine Liste Fragt dpkg nach beiden Proxmox-Kernel-Paketgruppen ab.
von (status, paketname) Tupeln zurück. Die rohe dpkg-Ausgabe wird NICHT
geloggt (quiet=True), da sie auch nicht-installierte ("un") und nur Zurückgegeben werden Tupel:
noch als Konfigurationsreste vorhandene ("rc") Pakete enthält, die wir
im Log nicht als "installiert" anzeigen wollen. (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( 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, check=False,
quiet=True quiet=True,
) )
packages = [] packages = []
@@ -210,67 +216,287 @@ def _query_kernel_packages():
status, pkg = parts status, pkg = parts
packages.append((status, pkg)) packages.append(
(status, pkg)
)
return packages return packages
def get_installed_kernel(): def get_installed_kernel_versions():
""" """
Ermittelt die höchste installierte Kernel-Version rein in Python Liefert tatsächlich installierte Proxmox-Kernel.
(kein fragiles Shell-Pipeline-Kommando mehr, kein sort -V nötig).
Loggt dabei NUR die tatsächlich installierten ("ii") Kernel-Pakete, Nur dpkg-Status "ii" wird berücksichtigt.
keine "un" (nicht installiert) oder "rc" (entfernt, Reste vorhanden).
""" """
packages = _query_kernel_packages() packages = _query_kernel_packages()
installed_pkgs = [pkg for status, pkg in packages if status == "ii"] kernels = []
if installed_pkgs: for status, pkg in packages:
log(
"Installierte Kernel-Pakete: " + ", ".join(sorted(installed_pkgs))
)
versions = [] if status != "ii":
for pkg in installed_pkgs:
m = KERNEL_PKG_PATTERN.match(pkg)
if not m:
continue 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 "" return ""
# Versionssortierung ohne externes sort -V: versions = sorted(
def version_key(v): {
return [int(p) if p.isdigit() else p for p in re.split(r"[.\-]", v)] kernel["version"]
for kernel in kernels
},
key=version_key,
)
versions.sort(key=version_key) log(
"Installierte Proxmox-Kernel: "
+ ", ".join(versions)
)
return versions[-1] 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(): def cleanup_old_kernel_configs():
""" """
Entfernt Konfigurationsreste von bereits deinstallierten alten Entfernt ausschließlich dpkg-Konfigurationsreste ("rc")
Kernel-Paketen (dpkg-Status "rc" = removed, config-files still present). alter Proxmox-Kernel-Pakete.
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() 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: if not residual_pkgs:
log( log(
"OK: Keine Konfigurationsreste alter Kernel-Pakete gefunden" "OK: Keine Konfigurationsreste alter Kernel-Pakete gefunden"
) )
return return
residual_pkgs = sorted(residual_pkgs) residual_pkgs = sorted(residual_pkgs)
@@ -278,30 +504,64 @@ def cleanup_old_kernel_configs():
total = len(residual_pkgs) total = len(residual_pkgs)
log( 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( log(
f"({index}/{total}) Lösche Reste von: {pkg}" f"({index}/{total}) Lösche Reste von: {pkg}"
) )
run( result = subprocess.run(
["apt-get", "purge", "-y", "-qq", pkg], [
check=False, "apt-get",
quiet=True "purge",
"-y",
"-qq",
pkg,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
) )
log( if result.returncode == 0:
f"OK: ({index}/{total}) Erledigt: {pkg}"
) 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(): 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() running = get_running_kernel()
installed = get_installed_kernel() installed = get_installed_kernel()
log( log(
@@ -313,17 +573,16 @@ def reboot_required():
) )
if not running or not installed: if not running or not installed:
log( log(
"WARNUNG: Kernel-Vergleich nicht möglich (leerer Wert) - " "⚠️ Kernel-Vergleich nicht möglich - "
"gehe sicherheitshalber von 'kein Reboot nötig' aus", "gehe sicherheitshalber von 'kein Reboot nötig' aus",
"warn" "warn",
) )
return False return False
if running != installed: return running != installed
return True
return False
# ========================= # =========================
@@ -333,26 +592,27 @@ def reboot_required():
def proxmox_boot_ok(): def proxmox_boot_ok():
try: try:
result = subprocess.run( result = subprocess.run(
[ [
"proxmox-boot-tool", "proxmox-boot-tool",
"status" "status",
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True text=True,
) )
except FileNotFoundError as e: except FileNotFoundError as e:
log( log(
f"FEHLER: proxmox-boot-tool nicht gefunden (PATH-Problem?): {e}", f"FEHLER: proxmox-boot-tool nicht gefunden: {e}",
"error" "error",
) )
return False return False
if result.stdout: if result.stdout.strip():
log( log(
result.stdout.strip() result.stdout.strip()
@@ -362,16 +622,19 @@ def proxmox_boot_ok():
return True return True
if result.stderr: if result.stderr.strip():
log( log(
result.stderr.strip(), result.stderr.strip(),
"error" "error",
) )
else: else:
log( log(
f"FEHLER: proxmox-boot-tool status lieferte Exit-Code {result.returncode} ohne Fehlermeldung", "FEHLER: proxmox-boot-tool status "
"error" f"lieferte Exit-Code {result.returncode}",
"error",
) )
return False return False
@@ -385,7 +648,7 @@ def refresh_boot():
run([ run([
"proxmox-boot-tool", "proxmox-boot-tool",
"refresh" "refresh",
]) ])
@@ -397,16 +660,16 @@ def has_updates():
run([ run([
"apt-get", "apt-get",
"update" "update",
]) ])
output = run( output = run(
[ [
"apt-get", "apt-get",
"-s", "-s",
"dist-upgrade" "dist-upgrade",
], ],
check=False check=False,
) )
updates = [ 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): def create_snapshot(dataset):
snapshot = ( snapshot = (
@@ -447,7 +729,7 @@ def create_snapshot(dataset):
run([ run([
"zfs", "zfs",
"snapshot", "snapshot",
snapshot snapshot,
]) ])
@@ -463,9 +745,10 @@ def cleanup_snapshots(dataset):
"-o", "-o",
"name", "name",
"-s", "-s",
"creation" "creation",
], ],
check=False check=False,
quiet=True,
) )
snapshots = [] snapshots = []
@@ -494,10 +777,29 @@ def cleanup_snapshots(dataset):
"zfs", "zfs",
"destroy", "destroy",
"-r", "-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 # Upgrade
# ========================= # =========================
@@ -505,26 +807,31 @@ def cleanup_snapshots(dataset):
def upgrade_system(): def upgrade_system():
log( log(
"Starte dist-upgrade" "⚙️ Starte dist-upgrade"
) )
run([ run([
"apt-get", "apt-get",
"dist-upgrade", "dist-upgrade",
"-y" "-y",
]) ])
log( log(
"Starte autoremove" "🧼 Starte autoremove"
) )
run([ run([
"apt-get", "apt-get",
"autoremove", "autoremove",
"--purge", "--purge",
"-y" "-y",
]) ])
# Eigene Kernelbereinigung nach dem normalen
# Debian/Proxmox-Upgrade.
cleanup_old_kernels()
# Danach eventuell verbliebene rc-Reste bereinigen.
cleanup_old_kernel_configs() cleanup_old_kernel_configs()
@@ -542,11 +849,12 @@ def main():
f"REBOOT-Flag: {REBOOT_FLAG}" f"REBOOT-Flag: {REBOOT_FLAG}"
) )
# Exit-Code 2 für nicht-root beibehalten.
if os.geteuid() != 0: if os.geteuid() != 0:
log( log(
"FEHLER: Muss als root laufen", "FEHLER: Muss als root laufen",
"error" "error",
) )
sys.exit(2) sys.exit(2)
@@ -559,31 +867,33 @@ def main():
if has_updates(): if has_updates():
for dataset in ZFS_DATASETS: # Snapshots VOR dem eigentlichen dist-upgrade.
create_update_snapshots()
cleanup_snapshots(dataset)
create_snapshot(dataset)
upgrade_system() upgrade_system()
# Nach Kernel-/Paket-Updates Bootloader aktualisieren.
refresh_boot() refresh_boot()
else: else:
log( 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() cleanup_old_kernel_configs()
# immer prüfen # Reboot-Erkennung bleibt vollständig getrennt
# von der Kernelbereinigung.
if reboot_required(): if reboot_required():
log( log(
"WARNUNG: Neuer Kernel aktivieren erforderlich", "⚠️ Neuer Kernel vorhanden - Reboot erforderlich",
"warn" "warn",
) )
if REBOOT_FLAG == "YES": if REBOOT_FLAG == "YES":
@@ -599,29 +909,33 @@ def main():
) )
log( log(
"REBOOT=YES erkannt" "REBOOT=YES erkannt - Neustart in 1 Minute"
) )
run([ run([
"shutdown", "shutdown",
"-r", "-r",
"+1", "+1",
"Proxmox-Update abgeschlossen" "Proxmox-Update abgeschlossen",
]) ])
else: else:
log( log(
"FEHLER: Bootloader Prüfung fehlgeschlagen - kein Neustart", " Bootloader Prüfung fehlgeschlagen - "
"error" "kein Neustart",
"error",
) )
else: else:
log( 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) sys.exit(1)
log( log(
@@ -632,5 +946,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()