diff --git a/AppImage/ProxMenux-1.2.2.2-beta.AppImage b/AppImage/ProxMenux-1.2.2.2-beta.AppImage index f3aeae8e..dec3d8b3 100755 Binary files a/AppImage/ProxMenux-1.2.2.2-beta.AppImage and b/AppImage/ProxMenux-1.2.2.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 14527d24..aadda427 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -aa99bbf26b42528f72807a8c9d116fd79d4dc20f8df64a993c7efc0c0d805143 +900287e54a05641290f221245a504013e106abc955cdf009a03361e274c55770 diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index f1856b09..af88ea7b 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -5,7 +5,7 @@ import useSWR from "swr" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Button } from "./ui/button" import { Badge } from "./ui/badge" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Input } from "./ui/input" import { Label } from "./ui/label" @@ -999,6 +999,27 @@ function InspectModal({ paths: string[] rollbackExecute?: boolean } | null>(null) + // Cross-kernel modal state: + // - refuse: backup kernel is neither installed nor available in + // Proxmox repos. Restore cannot proceed cleanly; the modal + // mirrors the CLI msgbox and tells the operator what to do. + // - notice: backup kernel is installed (or installable). Restore + // will proceed but the operator sees a plain heads-up so they + // know why the flow will pin a different kernel + reboot. + const [crossKernelRefuse, setCrossKernelRefuse] = useState<{ + backupKernel: string + targetKernel: string + reason: string + } | null>(null) + const [crossKernelNotice, setCrossKernelNotice] = useState<{ + backupKernel: string + targetKernel: string + action: "installed" | "installable" + pkg: string + stagingPath: string + pathsAvailable: string[] + rollbackPlan?: RollbackPlan + } | null>(null) const beginRestore = async () => { if (!archive) return @@ -1023,6 +1044,41 @@ function InspectModal({ body: JSON.stringify(body), }) if (!r?.staging_path) throw new Error("backend did not return a staging path") + const ck = r.cross_kernel || {} + // Refuse path: kernel unreachable, do not open the restore + // options dialog. The operator sees the modal explaining why. + if (ck?.detected && ck?.action === "unavailable") { + setCrossKernelRefuse({ + backupKernel: ck.backup_kernel || "", + targetKernel: ck.target_kernel || "", + reason: ck.reason || "unknown", + }) + // Clean up the staging tree the backend just created — the + // restore is not going to run against it. + try { + await fetchApi("/api/host-backups/restore/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ staging_path: r.staging_path }), + }) + } catch { /* best effort */ } + return + } + // Notice path: heads-up modal, then continue to the standard + // restore options once the operator acknowledges. + if (ck?.detected && (ck?.action === "installed" || ck?.action === "installable")) { + setCrossKernelNotice({ + backupKernel: ck.backup_kernel || "", + targetKernel: ck.target_kernel || "", + action: ck.action, + pkg: ck.pkg || "", + stagingPath: r.staging_path, + pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [], + rollbackPlan: r.rollback_plan, + }) + return + } + // Same-kernel: straight to the options dialog. setRestoreOptions({ stagingPath: r.staging_path, pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [], @@ -1484,6 +1540,131 @@ function InspectModal({ )} + {/* ── Cross-kernel refuse modal ─────────────────────────── + Backend detected the backup was taken on a kernel major.minor + different from the target's, and that kernel is neither + installed nor pullable from Proxmox repos. Restoring anyway + would leave drivers/DKMS pinned to a kernel that isn't on + the box; we refuse and tell the operator what to do. Copy + mirrors the CLI msgbox in backup_host.sh so a user reading + both surfaces gets the exact same instructions. */} + {crossKernelRefuse && ( + !o && setCrossKernelRefuse(null)}> + + + + + Restore refused — incompatible kernel + + +
+

+ This backup was taken on kernel{" "} + {crossKernelRefuse.backupKernel}. +

+

+ That kernel is {crossKernelRefuse.reason}{" "} + (current host runs{" "} + {crossKernelRefuse.targetKernel}). +

+

+ Restoring without a matching kernel would leave drivers and modules built against a + kernel that is not present on this host — the system may fail to boot or run degraded. +

+
+

How to proceed

+
    +
  • Install Proxmox VE from an ISO whose kernel matches the backup.
  • +
  • + Or fetch the kernel manually:{" "} + + apt install proxmox-kernel-{crossKernelRefuse.backupKernel} + +
  • +
  • Then relaunch the restore from this dialog.
  • +
+
+
+ + + +
+
+ )} + + {/* ── Cross-kernel notice modal ────────────────────────── + Backend detected cross-kernel but the correct kernel is + available (already installed or pullable). The restore + flow will handle the pin + reboot automatically; this + just tells the operator what to expect before they see + the standard restore options dialog. */} + {crossKernelNotice && ( + !o && setCrossKernelNotice(null)}> + + + + + Cross-kernel restore + + +
+

+ This backup was taken on kernel{" "} + {crossKernelNotice.backupKernel}. The host + currently runs {crossKernelNotice.targetKernel}. +

+ {crossKernelNotice.action === "installed" ? ( +

+ {crossKernelNotice.pkg} is already installed on + this host. The restore will pin it as the default boot kernel and reboot into it + once so DKMS drivers are rebuilt against the correct ABI. +

+ ) : ( +

+ {crossKernelNotice.pkg} is available from + Proxmox repos. The restore will install it, pin it as the default boot kernel and + reboot into it once so DKMS drivers are rebuilt against the correct ABI. +

+ )} +

+ An extra reboot will happen automatically as part of the restore. You do not need to + do anything. +

+
+ + + + +
+
+ )} + {/* ── Restore options modal: opens only AFTER the prepare step, so the Custom checklist already knows what's inside. ──── */} /dev/null 2>&1 || true systemctl enable proxmenux-apply-cluster-postboot.service >/dev/null 2>&1 || true + # ── Cross-kernel: install (if needed) + pin backup's kernel ── + # When the backup was taken on a different kernel major.minor + # than the target, the restore flow pre-computed a plan telling + # us whether the backup's kernel is already on the target or + # can be pulled from Proxmox repos. Acting here — during the + # onboot restore, before the cluster-postboot fires — gives us + # a controlled reboot into the pinned kernel, so the DKMS + # auto-reinstall in postboot builds every module against the + # right ABI (the kernel the operator's backup actually expects). + _kernel_pin_reboot_needed=0 + if [[ "$HB_KERNEL_PIN_ACTION" != "none" && -n "$HB_KERNEL_PIN_KVER" ]]; then + echo "" + echo "── Cross-kernel: preparing kernel $HB_KERNEL_PIN_KVER ──" + if [[ "$HB_KERNEL_PIN_ACTION" == "installable" ]]; then + echo "Installing $HB_KERNEL_PIN_PKG from Proxmox repositories..." + apt-get update -qq 2>&1 | tail -5 + if DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$HB_KERNEL_PIN_PKG" 2>&1 | tail -10; then + echo " ✓ $HB_KERNEL_PIN_PKG installed" + else + echo " ✗ apt install failed — pin will fall back to whatever kernels are present" + fi + fi + if command -v proxmox-boot-tool >/dev/null 2>&1; then + proxmox-boot-tool kernel pin "$HB_KERNEL_PIN_KVER" 2>&1 | sed 's/^/ pin: /' + proxmox-boot-tool refresh 2>&1 | sed 's/^/ refresh: /' + _kernel_pin_reboot_needed=1 + else + echo " ! proxmox-boot-tool not available — kernel pin skipped" + fi + fi + + if (( _kernel_pin_reboot_needed == 1 )); then + # Do NOT start the cluster-postboot service in this boot. + # We must reboot first so the kernel pin takes effect; + # once the machine is back up on the pinned kernel, the + # postboot service (enabled with a ConditionPathExists on + # cluster-apply-pending) will fire automatically and its + # DKMS reinstalls will target the correct kernel. + echo "" + echo "Rebooting into pinned kernel $HB_KERNEL_PIN_KVER..." + echo "completed" >"$STATE_FILE" 2>/dev/null || true + # Mark this restore as completed so the on-boot service + # doesn't re-fire after we reboot. + _rid="$(basename "$PENDING_DIR")" + mv "$PENDING_DIR" "${PENDING_BASE}/completed/${_rid}" >/dev/null 2>&1 || true + rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true + systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true + systemctl daemon-reload >/dev/null 2>&1 || true + sync + systemctl --no-block reboot + exit 0 + fi + + # Same-kernel path: fire cluster-postboot inline in this boot. # `systemctl enable` only adds the unit to multi-user.target.wants/. # It does NOT pull the unit into the currently-running boot # transaction — by the time we run, multi-user.target may have diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh index 7170517a..f3fa06e2 100755 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -2061,6 +2061,9 @@ CREATED_AT=${created_at} HB_RESTORE_INCLUDE_ZFS=${HB_RESTORE_INCLUDE_ZFS:-0} HB_ROLLBACK_EXECUTE=${HB_ROLLBACK_EXECUTE:-0} HB_COMPAT_CROSS_VERSION=${HB_COMPAT_CROSS_VERSION:-0} +HB_KERNEL_PIN_ACTION=${HB_KERNEL_PIN_ACTION:-none} +HB_KERNEL_PIN_KVER=${HB_KERNEL_PIN_KVER:-} +HB_KERNEL_PIN_PKG=${HB_KERNEL_PIN_PKG:-} EOF # Persist hardware-drift skips so apply_pending_restore.sh can filter # them at boot. The RS_SKIP_PATHS env var only lives in the restore @@ -2226,6 +2229,15 @@ _rs_run_complete_guided() { if [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]]; then local -a cv_skipped=() local cv_line cv_path cv_reason cv_rel + # The drift block above trims the trailing newline off + # RS_SKIP_PATHS ("${skip_paths%$'\n'}"), so the first cv_path + # appended below would be pasted onto the previous drift path + # (bench-reproduced: `/etc/kernel/proxmox-boot-uuids/etc/default/grub` + # in rs-skip-paths.txt broke the match on /etc/default/grub, + # apply_pending_restore restored it from the backup, update-grub + # generated a boot config against the backup kernel and the + # host paniced on next reboot). Restore the missing separator. + [[ -n "$RS_SKIP_PATHS" && "${RS_SKIP_PATHS: -1}" != $'\n' ]] && RS_SKIP_PATHS+=$'\n' while IFS=$'\t' read -r cv_path cv_reason; do [[ -z "$cv_path" ]] && continue cv_rel="${cv_path#/}" @@ -2711,18 +2723,174 @@ _rs_run_complete_extras() { rm -f "$cur_pkgs_file" if [[ ${#missing[@]} -gt 0 ]]; then echo - # Split into apt-known vs unknown so the install count - # announced below matches what apt-get will actually attempt. - local -a installable=() unknown=() + # ── Intelligent package filter (universal, not cross-version-only) ─ + # + # `packages.manual.list` lists everything apt-marked as manual on + # the SOURCE host. A blind `apt install` of that list on the + # target can wreck it in two ways: + # + # 1. The backup pinned a package to an older SO-major (e.g. + # libzfs6linux from Proxmox 9.1) but the target already + # ships the newer sibling (libzfs7linux from 9.2). APT + # would resolve the older version by cascade-removing the + # newer one plus everything that depends on it + # (zfsutils-linux, zfs-initramfs, zfs-zed). The `zpool` + # binary vanishes → next reboot panics on rpool mount. + # Bench-reproduced on nvidia3 (9.2.3 / kernel 7.0.12) with + # an nvidia2 backup (9.1.9 / kernel 6.17.2). + # + # 2. Some packages are already present on the target because + # the fresh install included them — apt doesn't need us + # to re-request them, and re-requesting can pull config + # prompts we don't want during unattended restore. + # + # The filter runs three passes: + # (a) already_installed → dpkg -s says the pkg is on target + # (b) provided_by_newer → target already has libFOO* where + # the backup's pkg is libFOO* with + # M < N (SO-major bump between + # Proxmox releases) + # (c) cascade_risk → apt-get simulate proves the install + # would REMOVE something critical + # + # Each pass populates a report array. Nothing is silent — the + # operator sees the final tally with reasons. + local -a rep_skipped_installed=() + local -a rep_skipped_provided=() + local -a rep_skipped_cascade=() + local -a rep_unknown=() + local -a candidates=() + local pkg for pkg in "${missing[@]}"; do + # Pass (a): already installed? + if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \ + | grep -q '^install ok installed$'; then + rep_skipped_installed+=("$pkg") + continue + fi + # Pass (b): provided by newer sibling? Only for + # `` shaped names — the SO-major + # convention used by libzfslinux, libzpoollinux, + # libnvpairlinux, libuutillinux, libzstd, etc. + if [[ "$pkg" =~ ^(lib[a-z]+)([0-9]+)([a-z]*[0-9]*)$ ]]; then + local _base="${BASH_REMATCH[1]}" + local _num="${BASH_REMATCH[2]}" + local _suffix="${BASH_REMATCH[3]}" + local _newer_found="" + # Only look up to +5 majors ahead — enough to cover any + # realistic Proxmox jump without probing forever. + local _n + for _n in $(seq $((_num + 1)) $((_num + 5))); do + local _sibling="${_base}${_n}${_suffix}" + if dpkg-query -W -f='${Status}' "$_sibling" 2>/dev/null \ + | grep -q '^install ok installed$'; then + _newer_found="$_sibling" + break + fi + done + if [[ -n "$_newer_found" ]]; then + rep_skipped_provided+=("${pkg}|${_newer_found}") + continue + fi + fi + # Not skipped yet — needs the apt cache to know if it's + # installable at all. Unknown-to-apt goes to its own list. if apt-cache show "$pkg" >/dev/null 2>&1; then - installable+=("$pkg") + candidates+=("$pkg") else - unknown+=("$pkg") + rep_unknown+=("$pkg") fi done + # Pass (c): apt-simulate to catch cascade-remove risk. We + # simulate the full candidate set first; if apt would remove + # anything, iteratively drop the "first offender" (the pkg + # that appears earliest in the simulated remove path) and + # retry until the simulation is clean or the list is empty. + local -a installable=() + if (( ${#candidates[@]} > 0 )) && command -v apt-get >/dev/null 2>&1; then + local _work=("${candidates[@]}") + local _guard=0 + while (( ${#_work[@]} > 0 && _guard < 20 )); do + _guard=$((_guard + 1)) + local _sim_out + _sim_out=$(DEBIAN_FRONTEND=noninteractive \ + apt-get install -y --simulate --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" \ + -o Dpkg::Options::="--force-confold" \ + "${_work[@]}" 2>&1) + # Look for removals: apt marks them as `Remv ...` + # in the plan output. + local -a _would_remove=() + mapfile -t _would_remove < <(printf '%s\n' "$_sim_out" \ + | awk '/^Remv / {print $2}') + if (( ${#_would_remove[@]} == 0 )); then + installable=("${_work[@]}") + break + fi + # Pick the offender: a package in _work whose install + # is directly linked to the removal. Heuristic — if + # any _work package shares the base+suffix pattern + # with a would-remove package but a different major, + # that's the offender. Otherwise, drop the last one + # (least "essential" by convention of the list). + local _offender="" + local _rm + for _rm in "${_would_remove[@]}"; do + if [[ "$_rm" =~ ^(lib[a-z]+)([0-9]+)([a-z]*[0-9]*)$ ]]; then + local _rmbase="${BASH_REMATCH[1]}" + local _rmsuffix="${BASH_REMATCH[3]}" + local _cand + for _cand in "${_work[@]}"; do + if [[ "$_cand" =~ ^${_rmbase}[0-9]+${_rmsuffix}$ ]]; then + _offender="$_cand"; break + fi + done + fi + [[ -n "$_offender" ]] && break + done + if [[ -z "$_offender" ]]; then + # No obvious mapping — bail out safely: report + # everything as cascade risk and install nothing. + rep_skipped_cascade+=("${_work[@]/#/${_would_remove[0]}<-}") + _work=() + break + fi + rep_skipped_cascade+=("${_offender}|would remove ${_would_remove[*]:0:3}") + # Drop the offender and retry. + local -a _new_work=() + local _c + for _c in "${_work[@]}"; do + [[ "$_c" == "$_offender" ]] || _new_work+=("$_c") + done + _work=("${_new_work[@]}") + done + fi + + local -a unknown=("${rep_unknown[@]}") + + # ── Summarize what the filter decided ── + if (( ${#rep_skipped_installed[@]} > 0 )); then + local _prev="${rep_skipped_installed[*]:0:5}" + (( ${#rep_skipped_installed[@]} > 5 )) && _prev+=" … (+ $((${#rep_skipped_installed[@]} - 5)) more)" + echo -e "${TAB}${BGN}$(translate "Already installed — skipping"):${CL} ${BL}${_prev}${CL}" + fi + if (( ${#rep_skipped_provided[@]} > 0 )); then + echo -e "${TAB}${BGN}$(translate "Provided by newer version — skipping"):${CL}" + local _rec + for _rec in "${rep_skipped_provided[@]}"; do + echo -e "${TAB} ${DGN}${_rec%%|*}${CL} → ${BL}${_rec##*|}${CL}" + done + fi + if (( ${#rep_skipped_cascade[@]} > 0 )); then + echo -e "${TAB}${YWB}$(translate "Skipped to protect target system (would cascade-remove packages)"):${CL}" + local _rec + for _rec in "${rep_skipped_cascade[@]}"; do + echo -e "${TAB} ${YWB}${_rec%%|*}${CL} — ${_rec##*|}" + done + fi + if (( ${#installable[@]} > 0 )); then local _preview="${installable[*]:0:6}" (( ${#installable[@]} > 6 )) && _preview+=" … (+ $((${#installable[@]} - 6)) more)" @@ -2902,6 +3070,40 @@ restore_menu() { # (_rs_prompt_zfs_opt_in) read to choose between the # silent same-host path and the loud cross-host path. hb_compat_check "$staging_root" + + # Cross-kernel restore requires the backup's kernel to be + # bootable on the target — either already installed or + # obtainable from the Proxmox repositories. When it's + # neither, refuse cleanly: continuing would leave the + # operator with drivers built against a kernel that isn't + # present, and DKMS would silently pin the wrong ABI. + if [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]]; then + local _bk_kernel="" + if [[ -f "$staging_root/metadata/run_info.env" ]]; then + _bk_kernel=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null | cut -d= -f2-) + fi + if [[ -n "$_bk_kernel" ]]; then + hb_kernel_pin_plan "$_bk_kernel" + if [[ "$HB_KERNEL_PIN_ACTION" == "unavailable" ]]; then + # Log so the operator can inspect after the fact. + local _refuse_log="/var/log/proxmenux/restore-refused-$(date +%Y%m%d_%H%M%S).log" + mkdir -p "$(dirname "$_refuse_log")" 2>/dev/null + { + echo "Refused at: $(date -Iseconds)" + echo "Backup kernel: $_bk_kernel" + echo "Target running: $(uname -r)" + echo "Reason: $HB_KERNEL_PIN_REASON" + } > "$_refuse_log" + dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Restore refused — incompatible kernel")" \ + --msgbox "\Zb\Z1$(translate "This backup was taken on kernel"):\Zn \Zb${_bk_kernel}\ZB"$'\n\n'"$(translate "That kernel is") ${HB_KERNEL_PIN_REASON}."$'\n\n'"$(translate "Restoring without a matching kernel would leave drivers and modules built against a kernel that is not present on this host — the system may fail to boot or run degraded.")"$'\n\n'"\Zb$(translate "How to proceed"):\ZB"$'\n'" • $(translate "Install Proxmox VE from an ISO whose kernel matches")"$'\n'" • $(translate "Or fetch the kernel manually:") apt install proxmox-kernel-${_bk_kernel}"$'\n'" • $(translate "Then relaunch the restore.")"$'\n\n'"$(translate "Details logged at"): $_refuse_log" \ + 22 84 + rm -rf "$staging_root" + continue + fi + fi + fi + if hb_show_compat_report; then if _rs_apply_menu "$staging_root"; then rm -rf "$staging_root" diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh index 071aa882..617dc823 100755 --- a/scripts/backup_restore/lib_host_backup_common.sh +++ b/scripts/backup_restore/lib_host_backup_common.sh @@ -2711,6 +2711,85 @@ hb_apply_nic_remaps() { # Output: one line per path, tab-separated: \t # The reason is a short label used verbatim in the confirm dialog. # ========================================================== +# ========================================================== +# KERNEL PIN PLAN — cross-kernel restore only +# ========================================================== +# When the backup was taken on a kernel major.minor different +# from the target's running kernel, the safe and universally +# correct action is to boot the target with the backup's kernel +# after the restore. That way every driver / DKMS module that +# the backup expects (nvidia, coral, intel_gpu, plus every +# out-of-tree ZFS build) targets a kernel that actually exists +# and matches, and the auto-reinstall on post-boot builds them +# against the right ABI. Universal: works for ZFS root, LVM/ext4 +# root, BIOS or UEFI hosts alike. +# +# This function decides how to obtain that kernel on the target +# and publishes the plan for the restore flow to act on. +# +# Input: +# $1 bk_kernel — kernel release string from the backup +# (e.g. "6.17.2-1-pve") +# Output (env vars, all exported): +# HB_KERNEL_PIN_ACTION installed | installable | unavailable +# HB_KERNEL_PIN_PKG candidate package name to install (empty +# when installed / unavailable) +# HB_KERNEL_PIN_KVER the kernel release to pin (same as $1) +# Also fills HB_KERNEL_PIN_REASON with a short human message +# when action is "unavailable" (for the refuse dialog). +# ========================================================== +hb_kernel_pin_plan() { + local bk_kernel="$1" + export HB_KERNEL_PIN_ACTION="unavailable" + export HB_KERNEL_PIN_PKG="" + export HB_KERNEL_PIN_KVER="$bk_kernel" + export HB_KERNEL_PIN_REASON="" + + [[ -z "$bk_kernel" ]] && { + HB_KERNEL_PIN_REASON="backup did not record its kernel release" + return 0 + } + + # Signed package name convention on modern Proxmox: the actual + # binary kernel image ships as `proxmox-kernel--signed`, the + # unsigned build is `proxmox-kernel-` (only in special cases). + # `pve-kernel-` was the legacy name pre-Proxmox 8; try both + # so this works whether the backup came from an older PVE. + local -a candidates=( + "proxmox-kernel-${bk_kernel}-signed" + "proxmox-kernel-${bk_kernel}" + "pve-kernel-${bk_kernel}" + ) + local pkg + for pkg in "${candidates[@]}"; do + # Already installed → nothing to fetch, just pin later. + if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \ + | grep -q '^install ok installed$'; then + HB_KERNEL_PIN_ACTION="installed" + HB_KERNEL_PIN_PKG="$pkg" + return 0 + fi + done + # Not installed. Refresh apt metadata quietly, then look each + # candidate up. `apt-cache policy` shows a non-empty "Candidate" + # only when there IS an installable version in the enabled repos. + apt-get update -qq >/dev/null 2>&1 || true + for pkg in "${candidates[@]}"; do + local cand + cand=$(apt-cache policy "$pkg" 2>/dev/null \ + | awk '/Candidate:/ {print $2; exit}') + if [[ -n "$cand" && "$cand" != "(none)" ]]; then + HB_KERNEL_PIN_ACTION="installable" + HB_KERNEL_PIN_PKG="$pkg" + return 0 + fi + done + + HB_KERNEL_PIN_ACTION="unavailable" + HB_KERNEL_PIN_REASON="not installed on this host and not available in the configured Proxmox repositories" + return 0 +} + hb_unsafe_paths_cross_version() { cat <<'EOF' /etc/default/grub GRUB defaults tied to prior kernel order diff --git a/scripts/backup_restore/restore/kernel_pin_plan.sh b/scripts/backup_restore/restore/kernel_pin_plan.sh new file mode 100644 index 00000000..75c281a1 --- /dev/null +++ b/scripts/backup_restore/restore/kernel_pin_plan.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# ========================================================== +# kernel_pin_plan.sh — JSON reporter for cross-kernel restore +# ========================================================== +# Usage: bash kernel_pin_plan.sh +# Output: JSON on stdout — one of: +# +# {"detected":false} +# Backup + target run the same kernel major.minor. +# No action needed. +# +# {"detected":true, +# "backup_kernel":"6.17.2-1-pve", +# "target_kernel":"7.0.12-1-pve", +# "action":"installed", +# "pkg":"proxmox-kernel-6.17.2-1-pve-signed"} +# Cross-kernel. The backup's kernel is already installed on the +# target; the restore flow will pin it. +# +# {"detected":true, ..., +# "action":"installable", +# "pkg":"proxmox-kernel-6.17.2-1-pve-signed"} +# Cross-kernel. The kernel is available in Proxmox repos and +# will be installed before the pin. +# +# {"detected":true, ..., +# "action":"unavailable", +# "reason":"not installed on this host and not available in the +# configured Proxmox repositories"} +# Cross-kernel. The kernel is neither installed nor obtainable. +# The restore flow must refuse. +# +# The heavy lifting is delegated to hb_kernel_pin_plan in +# lib_host_backup_common.sh, so the CLI menu and the Web Monitor +# agree byte-for-byte on which restores are safe to start. +# ========================================================== +set -euo pipefail + +staging_root="${1:-}" +[[ -z "$staging_root" || ! -d "$staging_root" ]] && { + printf '{"detected":false,"error":"staging_root missing"}\n' + exit 0 +} + +LIB="/usr/local/share/proxmenux/scripts/backup_restore/lib_host_backup_common.sh" +if [[ ! -f "$LIB" ]]; then + printf '{"detected":false,"error":"lib_host_backup_common.sh not found"}\n' + exit 0 +fi +# shellcheck source=/dev/null +source "$LIB" + +# Read the backup's kernel release. +bk_kernel="" +if [[ -f "$staging_root/metadata/run_info.env" ]]; then + bk_kernel=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null \ + | cut -d= -f2-) +fi +cur_kernel=$(uname -r 2>/dev/null || echo "") + +if [[ -z "$bk_kernel" || -z "$cur_kernel" ]]; then + printf '{"detected":false}\n' + exit 0 +fi + +# Compare major.minor — the same rule hb_compat_check uses. +bk_mm=$(echo "$bk_kernel" | cut -d. -f1-2) +cur_mm=$(echo "$cur_kernel" | cut -d. -f1-2) +if [[ "$bk_mm" == "$cur_mm" ]]; then + printf '{"detected":false}\n' + exit 0 +fi + +# Cross-kernel: compute the pin plan. +hb_kernel_pin_plan "$bk_kernel" + +# Emit JSON. jq if we have it (safer with special chars), else printf. +if command -v jq >/dev/null 2>&1; then + jq -cn \ + --arg bkk "$bk_kernel" \ + --arg trk "$cur_kernel" \ + --arg act "${HB_KERNEL_PIN_ACTION:-unavailable}" \ + --arg pkg "${HB_KERNEL_PIN_PKG:-}" \ + --arg rsn "${HB_KERNEL_PIN_REASON:-}" \ + '{detected:true, backup_kernel:$bkk, target_kernel:$trk, + action:$act, pkg:$pkg, reason:$rsn}' +else + printf '{"detected":true,"backup_kernel":"%s","target_kernel":"%s","action":"%s","pkg":"%s","reason":"%s"}\n' \ + "$bk_kernel" "$cur_kernel" \ + "${HB_KERNEL_PIN_ACTION:-unavailable}" \ + "${HB_KERNEL_PIN_PKG:-}" \ + "${HB_KERNEL_PIN_REASON:-}" +fi