#!/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()
