proxmox-update.py aktualisiert
This commit is contained in:
+589
-67
@@ -1,115 +1,637 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import logging
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
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"
|
||||
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")
|
||||
|
||||
# ========== 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(
|
||||
filename=LOGFILE,
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
|
||||
def run(cmd, check=True):
|
||||
"""Kommando ausführen"""
|
||||
logging.debug(f"Running: {cmd}")
|
||||
|
||||
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, shell=True, check=check,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
cmd,
|
||||
check=check,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
return result.stdout.decode().strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Fehler bei Befehl: {cmd}")
|
||||
logging.error(e.stderr.decode().strip())
|
||||
|
||||
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 ""
|
||||
|
||||
def has_updates():
|
||||
"""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)
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
def is_kernel_update_available():
|
||||
"""Prüfen, ob ein Kernel-Update verfügbar ist"""
|
||||
output = run("apt list --upgradable", check=False)
|
||||
return any("linux-image-" in line for line in output.splitlines())
|
||||
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 mit Tag erstellen"""
|
||||
snapshot_name = f"{dataset}@{SNAPSHOT_TAG}-{TIMESTAMP}"
|
||||
logging.info(f"Erstelle Snapshot: {snapshot_name}")
|
||||
run(f"zfs snapshot {snapshot_name}")
|
||||
|
||||
snapshot = (
|
||||
f"{dataset}@{SNAPSHOT_TAG}-{TIMESTAMP}"
|
||||
)
|
||||
|
||||
log(
|
||||
f"📸 Snapshot erstellen: {snapshot}"
|
||||
)
|
||||
|
||||
run([
|
||||
"zfs",
|
||||
"snapshot",
|
||||
snapshot
|
||||
])
|
||||
|
||||
|
||||
def cleanup_snapshots(dataset):
|
||||
"""Alte Snapshots mit dem definierten Tag löschen"""
|
||||
logging.info(f"Bereinige alte Snapshots in {dataset}")
|
||||
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:
|
||||
for snap in snap_list[:-MAX_SNAPSHOTS]:
|
||||
logging.info(f"Lösche Snapshot: {snap}")
|
||||
run(f"zfs destroy {snap}")
|
||||
|
||||
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():
|
||||
"""Upgrade durchführen"""
|
||||
try:
|
||||
logging.info("Starte apt dist-upgrade …")
|
||||
run("apt dist-upgrade -y")
|
||||
logging.info("Führe apt autoremove aus …")
|
||||
run("apt autoremove -y")
|
||||
except Exception:
|
||||
logging.error("Upgrade fehlgeschlagen.")
|
||||
sys.exit(2)
|
||||
|
||||
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():
|
||||
logging.info("===== Proxmox Update & Snapshot gestartet =====")
|
||||
|
||||
log(
|
||||
"🚀 ===== Proxmox Update gestartet ====="
|
||||
)
|
||||
|
||||
log(
|
||||
f"ℹ️ REBOOT-Flag: {REBOOT_FLAG}"
|
||||
)
|
||||
|
||||
if os.geteuid() != 0:
|
||||
print("Dieses Skript muss als root ausgeführt werden.")
|
||||
|
||||
log(
|
||||
"❌ Muss als root laufen",
|
||||
"error"
|
||||
)
|
||||
|
||||
sys.exit(2)
|
||||
|
||||
if not has_updates():
|
||||
logging.info("Beende – keine Updates.")
|
||||
sys.exit(0)
|
||||
kernel_before = get_running_kernel()
|
||||
|
||||
# Snapshots nur bei Update
|
||||
for dataset in ZFS_DATASETS:
|
||||
create_snapshot(dataset)
|
||||
cleanup_snapshots(dataset)
|
||||
log(
|
||||
f"🖥️ Kernel vor Update: {kernel_before}"
|
||||
)
|
||||
|
||||
# Upgrade
|
||||
upgrade_system()
|
||||
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"
|
||||
)
|
||||
|
||||
# Kernel-Update erkennen
|
||||
if is_kernel_update_available():
|
||||
logging.warning("Kernel-Update erkannt – Neustart empfohlen.")
|
||||
if REBOOT_FLAG == "YES":
|
||||
logging.info("Reboot wird ausgeführt …")
|
||||
run("reboot")
|
||||
sys.exit(1) # Exit 1 = Neustart empfohlen
|
||||
|
||||
logging.info("===== Update-Vorgang abgeschlossen =====")
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user