Files
pve-update/proxmox-update.py
T

950 lines
18 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"
# 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")
# 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", "")
)
# =========================
# 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):
"""
Führt einen Befehl ohne Shell aus.
cmd kann eine Argumentliste oder ein String ohne
Shell-Metazeichen sein.
quiet=True unterdrückt das automatische Loggen
von stdout.
"""
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"FEHLER: Befehl nicht gefunden: "
f"{' '.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 ""
# =========================
# Kernel Prüfung
# =========================
def get_running_kernel():
return run([
"uname",
"-r",
])
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 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",
"pve-kernel-*",
"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_versions():
"""
Liefert tatsächlich installierte Proxmox-Kernel.
Nur dpkg-Status "ii" wird berücksichtigt.
"""
packages = _query_kernel_packages()
kernels = []
for status, pkg in packages:
if status != "ii":
continue
match = KERNEL_PKG_PATTERN.match(pkg)
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 ""
versions = sorted(
{
kernel["version"]
for kernel in kernels
},
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 ausschließlich dpkg-Konfigurationsreste ("rc")
alter Proxmox-Kernel-Pakete.
"""
packages = _query_kernel_packages()
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)
total = len(residual_pkgs)
log(
f"🧼 Entferne Konfigurationsreste von "
f"{total} alten Kernel-Paketen"
)
for index, pkg in enumerate(
residual_pkgs,
start=1,
):
log(
f"({index}/{total}) Lösche Reste von: {pkg}"
)
result = subprocess.run(
[
"apt-get",
"purge",
"-y",
"-qq",
pkg,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
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(
f"Kernel aktiv: {running}"
)
log(
f"Kernel installiert: {installed}"
)
if not running or not installed:
log(
"⚠️ Kernel-Vergleich nicht möglich - "
"gehe sicherheitshalber von 'kein Reboot nötig' aus",
"warn",
)
return False
return running != installed
# =========================
# 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"FEHLER: proxmox-boot-tool nicht gefunden: {e}",
"error",
)
return False
if result.stdout.strip():
log(
result.stdout.strip()
)
if result.returncode == 0:
return True
if result.stderr.strip():
log(
result.stderr.strip(),
"error",
)
else:
log(
"FEHLER: proxmox-boot-tool status "
f"lieferte Exit-Code {result.returncode}",
"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(
"OK: Keine Updates verfügbar"
)
return False
# =========================
# 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 = (
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,
quiet=True,
)
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,
])
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
# =========================
def upgrade_system():
log(
"⚙️ Starte dist-upgrade"
)
run([
"apt-get",
"dist-upgrade",
"-y",
])
log(
"🧼 Starte autoremove"
)
run([
"apt-get",
"autoremove",
"--purge",
"-y",
])
# Eigene Kernelbereinigung nach dem normalen
# Debian/Proxmox-Upgrade.
cleanup_old_kernels()
# Danach eventuell verbliebene rc-Reste bereinigen.
cleanup_old_kernel_configs()
# =========================
# Hauptprogramm
# =========================
def main():
log(
"===== Proxmox Update gestartet ====="
)
log(
f"REBOOT-Flag: {REBOOT_FLAG}"
)
# Exit-Code 2 für nicht-root beibehalten.
if os.geteuid() != 0:
log(
"FEHLER: Muss als root laufen",
"error",
)
sys.exit(2)
kernel_before = get_running_kernel()
log(
f"Kernel vor Update: {kernel_before}"
)
if has_updates():
# Snapshots VOR dem eigentlichen dist-upgrade.
create_update_snapshots()
upgrade_system()
# Nach Kernel-/Paket-Updates Bootloader aktualisieren.
refresh_boot()
else:
log(
"️ Keine Updates installiert"
)
# Auch ohne verfügbare Updates regelmäßig alte
# installierte Kernel bereinigen.
cleanup_old_kernels()
cleanup_old_kernel_configs()
# Reboot-Erkennung bleibt vollständig getrennt
# von der Kernelbereinigung.
if reboot_required():
log(
"⚠️ Neuer Kernel vorhanden - Reboot erforderlich",
"warn",
)
if REBOOT_FLAG == "YES":
log(
"Prüfe proxmox-boot-tool"
)
if proxmox_boot_ok():
log(
"OK: Bootloader OK"
)
log(
"REBOOT=YES erkannt - Neustart in 1 Minute"
)
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"
)
# Monitoring muss weiterhin erkennen,
# dass ein Reboot erforderlich ist.
sys.exit(1)
log(
"OK: Update erfolgreich abgeschlossen"
)
sys.exit(0)
if __name__ == "__main__":
main()