proxmox-update.py aktualisiert

This commit is contained in:
2026-08-11 10:43:28 +02:00
parent 3109e06c47
commit 81e99754a2
+589 -67
View File
@@ -1,115 +1,637 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import subprocess import subprocess
import logging import logging
import datetime import datetime
import os import os
import sys
import re 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" SNAPSHOT_TAG = "pve-update-via-cron"
MAX_SNAPSHOTS = 5 MAX_SNAPSHOTS = 5
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")
# ========== 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( logging.basicConfig(
filename=LOGFILE, filename=LOGFILE,
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
) )
def run(cmd, check=True):
"""Kommando ausführen""" def log(msg, level="info"):
logging.debug(f"Running: {cmd}")
try: timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
result = subprocess.run(
cmd, shell=True, check=check, print(f"[{timestamp}] {msg}")
stdout=subprocess.PIPE, stderr=subprocess.PIPE
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)}"
) )
return result.stdout.decode().strip()
except subprocess.CalledProcessError as e: try:
logging.error(f"Fehler bei Befehl: {cmd}")
logging.error(e.stderr.decode().strip()) result = subprocess.run(
cmd,
check=check,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
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: if check:
raise raise
return "" return ""
def has_updates(): except subprocess.CalledProcessError as e:
"""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)
def is_kernel_update_available(): log(
"""Prüfen, ob ein Kernel-Update verfügbar ist""" f"❌ Fehler: {' '.join(cmd)}",
output = run("apt list --upgradable", check=False) "error"
return any("linux-image-" in line for line in output.splitlines()) )
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): def create_snapshot(dataset):
"""Snapshot mit Tag erstellen"""
snapshot_name = f"{dataset}@{SNAPSHOT_TAG}-{TIMESTAMP}" snapshot = (
logging.info(f"Erstelle Snapshot: {snapshot_name}") f"{dataset}@{SNAPSHOT_TAG}-{TIMESTAMP}"
run(f"zfs snapshot {snapshot_name}") )
log(
f"📸 Snapshot erstellen: {snapshot}"
)
run([
"zfs",
"snapshot",
snapshot
])
def cleanup_snapshots(dataset): def cleanup_snapshots(dataset):
"""Alte Snapshots mit dem definierten Tag löschen"""
logging.info(f"Bereinige alte Snapshots in {dataset}") output = run(
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: "zfs",
for snap in snap_list[:-MAX_SNAPSHOTS]: "list",
logging.info(f"Lösche Snapshot: {snap}") "-H",
run(f"zfs destroy {snap}") "-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(): def upgrade_system():
"""Upgrade durchführen"""
try: log(
logging.info("Starte apt dist-upgrade") "⚙️ Starte dist-upgrade"
run("apt dist-upgrade -y") )
logging.info("Führe apt autoremove aus …")
run("apt autoremove -y") run([
except Exception: "apt-get",
logging.error("Upgrade fehlgeschlagen.") "dist-upgrade",
sys.exit(2) "-y"
])
log(
"🧼 Starte autoremove"
)
run([
"apt-get",
"autoremove",
"--purge",
"-y"
])
cleanup_old_kernel_configs()
# =========================
# Hauptprogramm
# =========================
def main(): def main():
logging.info("===== Proxmox Update & Snapshot gestartet =====")
log(
"🚀 ===== Proxmox Update gestartet ====="
)
log(
f"️ REBOOT-Flag: {REBOOT_FLAG}"
)
if os.geteuid() != 0: if os.geteuid() != 0:
print("Dieses Skript muss als root ausgeführt werden.")
log(
"❌ Muss als root laufen",
"error"
)
sys.exit(2) sys.exit(2)
if not has_updates(): kernel_before = get_running_kernel()
logging.info("Beende keine Updates.")
sys.exit(0) log(
f"🖥️ Kernel vor Update: {kernel_before}"
)
if has_updates():
# Snapshots nur bei Update
for dataset in ZFS_DATASETS: for dataset in ZFS_DATASETS:
create_snapshot(dataset)
cleanup_snapshots(dataset) cleanup_snapshots(dataset)
# Upgrade create_snapshot(dataset)
upgrade_system() upgrade_system()
# Kernel-Update erkennen refresh_boot()
if is_kernel_update_available():
logging.warning("Kernel-Update erkannt Neustart empfohlen.") else:
if REBOOT_FLAG == "YES":
logging.info("Reboot wird ausgeführt …") log(
run("reboot") "️ Keine Updates installiert"
sys.exit(1) # Exit 1 = Neustart empfohlen )
# 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"
)
if REBOOT_FLAG == "YES":
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"
)
logging.info("===== Update-Vorgang abgeschlossen =====")
sys.exit(0) sys.exit(0)
if __name__ == "__main__": if __name__ == "__main__":
main() main()