Files
pve-update/proxmox-update.py
T

638 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import subprocess
import logging
import datetime
import os
import re
import sys
import shlex
# =========================
# Einstellungen
# =========================
ZFS_DATASETS = [
"rpool/ROOT/pve-1",
"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")
# 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 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,
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:
raise
return ""
except subprocess.CalledProcessError as e:
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 = (
f"{dataset}@{SNAPSHOT_TAG}-{TIMESTAMP}"
)
log(
f"📸 Snapshot erstellen: {snapshot}"
)
run([
"zfs",
"snapshot",
snapshot
])
def cleanup_snapshots(dataset):
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():
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():
log(
"🚀 ===== Proxmox Update gestartet ====="
)
log(
f"️ REBOOT-Flag: {REBOOT_FLAG}"
)
if os.geteuid() != 0:
log(
"❌ Muss als root laufen",
"error"
)
sys.exit(2)
kernel_before = get_running_kernel()
log(
f"🖥️ Kernel vor Update: {kernel_before}"
)
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"
)
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"
)
sys.exit(0)
if __name__ == "__main__":
main()