diff --git a/AppImage/ProxMenux-1.2.2.2-beta.AppImage b/AppImage/ProxMenux-1.2.2.2-beta.AppImage index dec3d8b3..30dfe6e2 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 aadda427..6f3db139 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -900287e54a05641290f221245a504013e106abc955cdf009a03361e274c55770 +1221dec8316a1292bbeaec68dd06fe0b385a01bdc649fa24f52ef99fec0d4078 diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index af88ea7b..a09810bf 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -992,6 +992,24 @@ function InspectModal({ stagingPath: string pathsAvailable: string[] rollbackPlan?: RollbackPlan + // Cross-kernel awareness passed through to RestoreOptionsModal: + // when direction === "bk_older" the modal shows the safe-subset + // banner and grays out any path prefixed by anything in + // blockedPaths. "bk_newer" and "same" pass through as no-ops. + crossKernel?: { + direction: "same" | "bk_older" | "bk_newer" + backupKernel: string + targetKernel: string + blockedPaths: string[] + } + // Kernel-agnostic hydration preview (bk_older only): what the + // restore will re-apply to the target via merge instead of + // whole-file copy — IOMMU cmdline tokens, VFIO modules, + // operator's GRUB keys, whitelisted vfio/nvidia files. + hydration?: { + applies: boolean + actions: string[] + } } | null>(null) const [restoreTerminal, setRestoreTerminal] = useState<{ stagingPath: string @@ -999,27 +1017,6 @@ 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 @@ -1045,44 +1042,25 @@ function InspectModal({ }) 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. + const hyd = r.hydration || {} setRestoreOptions({ stagingPath: r.staging_path, pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [], rollbackPlan: r.rollback_plan, + crossKernel: ck.direction + ? { + direction: ck.direction, + backupKernel: ck.backup_kernel || "", + targetKernel: ck.target_kernel || "", + blockedPaths: Array.isArray(ck.blocked_paths) ? ck.blocked_paths : [], + } + : undefined, + hydration: hyd.applies + ? { + applies: true, + actions: Array.isArray(hyd.actions) ? hyd.actions : [], + } + : undefined, }) } catch (e) { setRestoreError(e instanceof Error ? e.message : String(e)) @@ -1540,131 +1518,6 @@ 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. ──── */} { const sp = restoreOptions?.stagingPath @@ -7485,6 +7340,8 @@ function RestoreOptionsModal({ stagingPath, pathsAvailable, rollbackPlan, + crossKernel, + hydration, display_id, onLaunch, }: { @@ -7493,9 +7350,27 @@ function RestoreOptionsModal({ stagingPath: string pathsAvailable: string[] rollbackPlan?: RollbackPlan + crossKernel?: { + direction: "same" | "bk_older" | "bk_newer" + backupKernel: string + targetKernel: string + blockedPaths: string[] + } + hydration?: { + applies: boolean + actions: string[] + } display_id?: string onLaunch: (mode: "full" | "custom", paths: string[], rollbackExecute?: boolean) => void }) { + // When direction === "bk_older" the CLI-side filter (RS_SKIP_PATHS) + // will drop these prefixes from any restore. We mirror that + // behavior in the picker so the operator can see what will be + // skipped rather than being surprised at run time. + const isBkOlder = crossKernel?.direction === "bk_older" + const blockedPrefixes = isBkOlder ? (crossKernel?.blockedPaths ?? []) : [] + const isPathBlocked = (p: string) => + blockedPrefixes.some((b) => p === b || p.startsWith(b.endsWith("/") ? b : `${b}/`)) const [step, setStep] = useState<"choose" | "custom">("choose") const [selected, setSelected] = useState>(new Set()) const [error, setError] = useState(null) @@ -7524,6 +7399,7 @@ function RestoreOptionsModal({ }, [open]) const togglePath = (p: string) => { + if (isPathBlocked(p)) return setSelected((prev) => { const next = new Set(prev) if (next.has(p)) next.delete(p) @@ -7533,16 +7409,18 @@ function RestoreOptionsModal({ } const toggleAll = () => { - if (selected.size === filteredPaths.length) { + const selectable = filteredPaths.filter((p) => !isPathBlocked(p)) + if (selected.size === selectable.length) { setSelected(new Set()) } else { - setSelected(new Set(filteredPaths)) + setSelected(new Set(selectable)) } } const filteredPaths = filter ? pathsAvailable.filter((p) => p.toLowerCase().includes(filter.toLowerCase())) : pathsAvailable + const selectableCount = filteredPaths.filter((p) => !isPathBlocked(p)).length const launch = (mode: "full" | "custom") => { if (mode === "custom" && selected.size === 0) { @@ -7568,6 +7446,56 @@ function RestoreOptionsModal({ {step === "choose" && (
+ {isBkOlder && ( +
+
+ + Cross-kernel restore — safe subset only +
+
+
+ Backup kernel {crossKernel?.backupKernel} is older than target kernel {crossKernel?.targetKernel}. +
+
+ Both Complete and Custom restore will automatically skip kernel/boot-tied paths to avoid a kernel panic on next boot. Everything else (VMs, LXCs, network, components, custom paths) restores normally. +
+ {blockedPrefixes.length > 0 && ( +
+ + Paths that will be skipped ({blockedPrefixes.length}) + +
    + {blockedPrefixes.map((p) => ( +
  • {p}
  • + ))} +
+
+ )} +
+
+ )} + + {isBkOlder && hydration?.applies && hydration.actions.length > 0 && ( +
+
+ + Operator config re-applied via kernel-agnostic merge +
+
+
+ The following operator-authored settings will be merged into the target automatically, without touching kernel-tied defaults. Runs on both Complete and Custom restore. +
+
    + {hydration.actions.map((a, i) => ( +
  • + {a} +
  • + ))} +
+
+
+ )} + {/* Surface destructive deltas BEFORE the operator picks Complete. Custom-by-paths can't trigger these (paths-only restore doesn't touch the guest list or @@ -7666,29 +7594,44 @@ function RestoreOptionsModal({ onClick={toggleAll} className="h-8 text-xs whitespace-nowrap" > - {selected.size === filteredPaths.length && filteredPaths.length > 0 ? "Clear" : "All"} + {selected.size === selectableCount && selectableCount > 0 ? "Clear" : "All"}
+ {isBkOlder && ( +
+ Kernel/boot-tied paths are grayed out and cannot be selected — target runs a newer kernel. +
+ )}
    - {filteredPaths.map((p) => ( -
  • - -
  • - ))} + {filteredPaths.map((p) => { + const blocked = isPathBlocked(p) + return ( +
  • + +
  • + ) + })} {filteredPaths.length === 0 && (
  • No paths match the filter.
  • )}
- Selected: {selected.size} / {pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""} + Selected: {selected.size} / {isBkOlder ? selectableCount : pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""}
{error && (
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 28d741dc..5f70e847 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -16905,27 +16905,113 @@ def api_host_backups_restore_prepare(): except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError): pass - # Cross-kernel plan: shared with the CLI menu via - # kernel_pin_plan.sh so both surfaces refuse the same restores. - # `detected=true` + `action=unavailable` is what the frontend - # treats as a hard refuse: it shows the "install a kernel first" - # modal instead of proceeding to the safety checkboxes. - cross_kernel: dict = {'detected': False} - kernel_plan_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/kernel_pin_plan.sh' - if os.path.isfile(kernel_plan_script): + # Direction-aware cross-kernel signal for the Web restore UI. + # We source the same logic the CLI uses: read the backup's + # kernel from the staging's run_info.env, compare major.minor + # to the target's current kernel, and classify the direction. + # "bk_older" is the only mode that trims the picker; the + # blocked list mirrors hb_unsafe_paths_cross_version so both + # surfaces skip the exact same set. "bk_newer" and "same" send + # empty lists and the Web treats them as a normal restore. + cross_kernel: dict = { + 'direction': 'same', + 'backup_kernel': '', + 'target_kernel': '', + 'blocked_paths': [], + } + try: + bk_kernel = '' + run_info = os.path.join(staging, 'metadata', 'run_info.env') + if os.path.isfile(run_info): + with open(run_info, 'r') as f: + for line in f: + if line.startswith('kernel='): + bk_kernel = line.strip().split('=', 1)[1] + break try: - r = subprocess.run(['bash', kernel_plan_script, staging], - capture_output=True, text=True, timeout=90) - if r.returncode == 0 and r.stdout.strip(): - cross_kernel = json.loads(r.stdout.strip()) - except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError): - pass + tg_kernel = subprocess.check_output(['uname', '-r'], text=True).strip() + except (OSError, subprocess.CalledProcessError): + tg_kernel = '' + cross_kernel['backup_kernel'] = bk_kernel + cross_kernel['target_kernel'] = tg_kernel + def _mm(k: str) -> tuple: + parts = (k or '').split('.') + try: + return (int(parts[0]), int(parts[1])) + except (ValueError, IndexError): + return (0, 0) + if bk_kernel and tg_kernel: + bk_mm, tg_mm = _mm(bk_kernel), _mm(tg_kernel) + if bk_mm != tg_mm and bk_mm != (0, 0) and tg_mm != (0, 0): + if bk_mm < tg_mm: + cross_kernel['direction'] = 'bk_older' + # Pull the exact list the CLI uses. + lib_sh = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/lib_host_backup_common.sh' + if os.path.isfile(lib_sh): + try: + r = subprocess.run( + ['bash', '-c', + f'source "{lib_sh}"; hb_unsafe_paths_cross_version'], + capture_output=True, text=True, timeout=10) + if r.returncode == 0: + blocked = [] + for line in r.stdout.splitlines(): + parts = line.split('\t', 1) + if parts and parts[0].strip(): + blocked.append(parts[0].strip()) + cross_kernel['blocked_paths'] = blocked + except (subprocess.TimeoutExpired, OSError): + pass + else: + cross_kernel['direction'] = 'bk_newer' + except (OSError, ValueError): + pass + + # Hydration preview — only meaningful in bk_older direction. + # Runs the same phases the CLI runs in "plan" mode: no + # writes to the live target, just the list of would-be + # actions. Same code path as the shell dialog, so the Web + # banner and the CLI msgbox always list the same items. + hydration = {'applies': False, 'actions': []} + if cross_kernel['direction'] == 'bk_older': + backup_sh = ( + f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/backup_host.sh' + ) + if os.path.isfile(backup_sh): + try: + # Source lib+backup_host in a subshell, set the + # direction so any inner guard passes, run the + # orchestrator in plan mode, print RS_HYDRATION_ACTIONS + # one per line. HB_LIB_ONLY/HB_NO_MAIN etc. are not + # needed: sourcing backup_host.sh only defines + # functions; nothing runs until we call one. + script = ( + f'set +e; ' + f'source "{_PROXMENUX_SCRIPTS_DIR}/backup_restore/lib_host_backup_common.sh"; ' + f'source "{backup_sh}" >/dev/null 2>&1; ' + f'HB_COMPAT_KERNEL_DIRECTION=bk_older ' + f'_rs_apply_bk_older_hydration "{staging}" plan >/dev/null 2>&1; ' + f'printf "%s\\n" "${{RS_HYDRATION_ACTIONS[@]}}"' + ) + r = subprocess.run( + ['bash', '-c', script], + capture_output=True, text=True, timeout=15) + if r.returncode == 0: + actions = [ + ln.strip() for ln in r.stdout.splitlines() + if ln.strip() + ] + hydration['actions'] = actions + hydration['applies'] = bool(actions) + except (subprocess.TimeoutExpired, OSError): + pass return jsonify({ 'staging_path': staging, 'paths_available': paths_available, 'rollback_plan': rollback_plan, 'cross_kernel': cross_kernel, + 'hydration': hydration, }) except Exception as e: shutil.rmtree(staging, ignore_errors=True) diff --git a/scripts/backup_restore/apply_pending_restore.sh b/scripts/backup_restore/apply_pending_restore.sh index 63b2ede8..594eb166 100755 --- a/scripts/backup_restore/apply_pending_restore.sh +++ b/scripts/backup_restore/apply_pending_restore.sh @@ -40,9 +40,8 @@ fi : "${HB_RESTORE_INCLUDE_ZFS:=0}" : "${HB_COMPAT_CROSS_VERSION:=0}" -: "${HB_KERNEL_PIN_ACTION:=none}" -: "${HB_KERNEL_PIN_KVER:=}" -: "${HB_KERNEL_PIN_PKG:=}" +: "${HB_COMPAT_KERNEL_DIRECTION:=same}" +: "${HB_HYDRATION_APPLIED:=0}" if [[ ! -f "$APPLY_LIST" ]]; then echo "Apply list missing: $APPLY_LIST" @@ -50,13 +49,11 @@ if [[ ! -f "$APPLY_LIST" ]]; then exit 1 fi -echo "Pending dir: $PENDING_DIR" -echo "Apply list: $APPLY_LIST" -echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS" -echo "Cross-version: $HB_COMPAT_CROSS_VERSION" -echo "Kernel pin act: $HB_KERNEL_PIN_ACTION" -echo "Kernel pin ver: $HB_KERNEL_PIN_KVER" -echo "Kernel pin pkg: $HB_KERNEL_PIN_PKG" +echo "Pending dir: $PENDING_DIR" +echo "Apply list: $APPLY_LIST" +echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS" +echo "Cross-version: $HB_COMPAT_CROSS_VERSION" +echo "Kernel direction: $HB_COMPAT_KERNEL_DIRECTION" # Hardware-drift skips persisted by _rs_prepare_pending_restore. # Each line is an absolute path; we drop any rel path that matches @@ -268,6 +265,16 @@ EOF NEEDS_GRUB=1 ;; esac done < "$APPLY_LIST" + # bk_older hydration writes tokens/modules/files DIRECTLY to + # the live target (bypassing the apply_list because the whole + # paths are in RS_SKIP_PATHS). Those changes still need + # update-initramfs + update-grub / proxmox-boot-tool refresh + # to take effect on the next boot — force the flags so the + # post-boot script picks them up. + if [[ "${HB_HYDRATION_APPLIED:-0}" == "1" ]]; then + NEEDS_INITRAMFS=1 + NEEDS_GRUB=1 + fi echo "Post-boot maintenance flags: initramfs=$NEEDS_INITRAMFS grub=$NEEDS_GRUB" # Marker as env-style key=value so the post-boot script @@ -310,60 +317,6 @@ UNITEOF systemctl daemon-reload >/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 f3fa06e2..6e709737 100755 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -2061,9 +2061,8 @@ 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:-} +HB_COMPAT_KERNEL_DIRECTION=${HB_COMPAT_KERNEL_DIRECTION:-same} +HB_HYDRATION_APPLIED=${HB_HYDRATION_APPLIED:-0} 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 @@ -2124,6 +2123,328 @@ _rs_handle_ssh_network_risk() { return 0 } +# ========================================================== +# Kernel-agnostic hydration for bk_older restores +# ========================================================== +# In bk_older direction we block whole-file restores of the +# kernel/boot-tied paths (see hb_unsafe_paths_cross_version) to +# stop the target's boot from being poisoned by systemd unit +# overrides, APT sources, GRUB defaults, etc. from an older +# base. But the operator's OWN customisations inside those +# paths (IOMMU cmdline, VFIO modules, usb-storage.quirks, custom +# GRUB_TIMEOUT, ...) are kernel-agnostic and should still land +# on the target — otherwise a fresh install after a restore +# forgets everything the operator tuned. +# +# The hydration functions below implement a merge-not-copy +# strategy: they read the operator-authored bits from the +# manifest (cmdline_extra, modules_loaded_at_boot) or from +# specific whitelisted files in the staging rootfs, and merge +# them into the target's live config without touching keys or +# tokens the target already carries. All four phases are +# idempotent and additive; running them twice is a no-op. +# ========================================================== + +_rs_hyd_manifest() { echo "$1/metadata/manifest.json"; } + +# List operator-authored kernel cmdline tokens from the manifest, +# one per line. Empty output = nothing to merge. +_rs_hyd_cmdline_tokens() { + local manifest="$1" + [[ -f "$manifest" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -r '.kernel_params.cmdline_extra // [] | .[]' "$manifest" 2>/dev/null || true +} + +# List modules the backup loaded at boot (from /etc/modules on +# the source host), one per line. +_rs_hyd_modules_at_boot() { + local manifest="$1" + [[ -f "$manifest" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -r '.kernel_params.modules_loaded_at_boot // [] | .[]' "$manifest" 2>/dev/null || true +} + +# Read a KEY=VALUE line from a `/etc/default/grub`-style file. +# Prints the raw VALUE part (including surrounding quotes) if +# present, empty otherwise. Handles both `KEY=value` and +# `KEY="value with spaces"` forms. +_rs_hyd_grub_read_key() { + local file="$1" key="$2" + [[ -f "$file" ]] || return 0 + # Grab the LAST assignment (later wins in shell-style config), + # skip comment lines, tolerate leading whitespace. + grep -E "^[[:space:]]*${key}=" "$file" 2>/dev/null | tail -1 | sed -E "s/^[[:space:]]*${key}=//" +} + +# Write or replace a KEY=VALUE line in a `/etc/default/grub`-style +# file. If the key already exists (uncommented), rewrites its +# line in place; otherwise appends the new line at the end. Leaves +# comment lines untouched. +_rs_hyd_grub_write_key() { + local file="$1" key="$2" value="$3" + [[ -f "$file" ]] || return 1 + if grep -qE "^[[:space:]]*${key}=" "$file"; then + # In-place rewrite of the last matching line. Use sed with + # a temp file so partial failure doesn't corrupt the target. + local tmp + tmp="$(mktemp)" + awk -v k="$key" -v v="$value" ' + BEGIN { pat = "^[[:space:]]*" k "=" } + $0 ~ pat { print k "=" v; next } + { print } + ' "$file" > "$tmp" && mv "$tmp" "$file" + else + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} + +# Strip surrounding double quotes and split a GRUB_CMDLINE_-style +# value into whitespace-delimited tokens (one per line). Handles +# escaped inner quotes conservatively. +_rs_hyd_cmdline_split() { + local raw="$1" + raw="${raw%\"}"; raw="${raw#\"}" + raw="${raw%\'}"; raw="${raw#\'}" + # tr will collapse any run of whitespace; xargs normalises. + tr -s '[:space:]' '\n' <<<"$raw" | sed '/^$/d' +} + +# Merge two cmdline strings: target wins on ties (same key), any +# backup token whose KEY (or bare flag) is not in target is +# appended. Prints the merged cmdline as a single line without +# surrounding quotes. Also returns 0 if any token was added, 1 +# if nothing changed — useful for the caller to decide whether +# an update-grub / proxmox-boot-tool refresh is needed. +_rs_hyd_cmdline_merge() { + local target_line="$1" backup_line="$2" + local -A target_keys=() + local -a merged=() + local t bkey + while IFS= read -r t; do + [[ -z "$t" ]] && continue + merged+=("$t") + bkey="${t%%=*}" + target_keys["$bkey"]=1 + done < <(_rs_hyd_cmdline_split "$target_line") + local added=0 + while IFS= read -r t; do + [[ -z "$t" ]] && continue + bkey="${t%%=*}" + if [[ -z "${target_keys[$bkey]:-}" ]]; then + merged+=("$t") + target_keys["$bkey"]=1 + added=1 + fi + done < <(_rs_hyd_cmdline_split "$backup_line") + printf '%s' "${merged[*]}" + (( added )) && return 0 || return 1 +} + +# Phase 1a — GRUB path +# ──────────────────── +# Merge cmdline tokens from the backup's cmdline_extra into the +# target's live GRUB_CMDLINE_LINUX_DEFAULT (never overwrite existing +# tokens or keys). Also merge whitelisted GRUB_* keys from the +# backup's /etc/default/grub whose values differ. Reports one +# summary line per action into the global RS_HYDRATION_ACTIONS[]. +# $3=mode: "plan" reports would-be actions only, "commit" writes. +_rs_hyd_grub() { + local backup_rootfs="$1" manifest="$2" mode="${3:-commit}" + local target="/etc/default/grub" + local bk_file="$backup_rootfs/etc/default/grub" + [[ -f "$target" ]] || return 0 + + local changed=0 + + # ── cmdline merge ── + local bk_tokens + bk_tokens="$(_rs_hyd_cmdline_tokens "$manifest" | tr '\n' ' ')" + if [[ -n "${bk_tokens// /}" ]]; then + local live_default merged_default added_tokens + live_default="$(_rs_hyd_grub_read_key "$target" GRUB_CMDLINE_LINUX_DEFAULT)" + local stripped="${live_default%\"}"; stripped="${stripped#\"}" + if merged_default="$(_rs_hyd_cmdline_merge "$stripped" "$bk_tokens")"; then + added_tokens="$(comm -13 \ + <(printf '%s\n' $stripped | sort -u) \ + <(printf '%s\n' $merged_default | sort -u) 2>/dev/null | tr '\n' ' ')" + [[ "$mode" == "commit" ]] && _rs_hyd_grub_write_key "$target" GRUB_CMDLINE_LINUX_DEFAULT "\"${merged_default}\"" + RS_HYDRATION_ACTIONS+=("GRUB_CMDLINE_LINUX_DEFAULT — merge tokens: ${added_tokens}") + changed=1 + fi + fi + + # ── whitelisted GRUB_* keys from the backup file ── + if [[ -f "$bk_file" ]]; then + local key bk_val live_val + while IFS= read -r key; do + [[ -z "$key" ]] && continue + [[ "$key" == "GRUB_CMDLINE_LINUX_DEFAULT" || "$key" == "GRUB_CMDLINE_LINUX" ]] && continue + bk_val="$(_rs_hyd_grub_read_key "$bk_file" "$key")" + [[ -z "$bk_val" ]] && continue + live_val="$(_rs_hyd_grub_read_key "$target" "$key")" + if [[ "$bk_val" != "$live_val" ]]; then + [[ "$mode" == "commit" ]] && _rs_hyd_grub_write_key "$target" "$key" "$bk_val" + RS_HYDRATION_ACTIONS+=("${key} — restore operator value from backup") + changed=1 + fi + done < <(hb_grub_keys_whitelist) + fi + + (( changed )) && return 0 || return 1 +} + +# Phase 1b — systemd-boot / ZFS path +# ────────────────────────────────── +# /etc/kernel/cmdline is a single-line file holding the ENTIRE +# kernel cmdline (root=, boot=, rootflags=, plus operator tokens). +# We MUST NOT copy it verbatim across kernels — the target's fresh +# install references its own ZFS pool. Instead: keep the target's +# boot boilerplate, append the operator tokens from cmdline_extra +# that aren't already there. +_rs_hyd_kernel_cmdline() { + local manifest="$1" mode="${2:-commit}" + local target="/etc/kernel/cmdline" + [[ -f "$target" ]] || return 0 + + local bk_tokens + bk_tokens="$(_rs_hyd_cmdline_tokens "$manifest" | tr '\n' ' ')" + [[ -z "${bk_tokens// /}" ]] && return 1 + + local live merged added_tokens + live="$(cat "$target" 2>/dev/null | tr -d '\n')" + if merged="$(_rs_hyd_cmdline_merge "$live" "$bk_tokens")"; then + added_tokens="$(comm -13 \ + <(printf '%s\n' $live | sort -u) \ + <(printf '%s\n' $merged | sort -u) 2>/dev/null | tr '\n' ' ')" + [[ "$mode" == "commit" ]] && printf '%s\n' "$merged" > "$target" + RS_HYDRATION_ACTIONS+=("/etc/kernel/cmdline (systemd-boot/ZFS) — merge tokens: ${added_tokens}") + return 0 + fi + return 1 +} + +# Phase 2 — /etc/modules merge +# ──────────────────────────── +# Append modules from modules_loaded_at_boot that are whitelisted +# AND not already present in /etc/modules. Preserves comments and +# existing entries. +_rs_hyd_modules() { + local manifest="$1" mode="${2:-commit}" + local target="/etc/modules" + [[ -f "$target" ]] || return 0 + + local -A live_modules=() whitelist=() + local m line + while IFS= read -r line; do + line="${line%%#*}" + m="$(printf '%s' "$line" | xargs)" + [[ -n "$m" ]] && live_modules["$m"]=1 + done < "$target" + while IFS= read -r m; do + m="$(printf '%s' "$m" | xargs)" + [[ -n "$m" ]] && whitelist["$m"]=1 + done < <(hb_hydration_module_whitelist) + + local added=0 + while IFS= read -r m; do + m="$(printf '%s' "$m" | xargs)" + [[ -z "$m" ]] && continue + [[ -z "${whitelist[$m]:-}" ]] && continue + [[ -n "${live_modules[$m]:-}" ]] && continue + [[ "$mode" == "commit" ]] && printf '%s\n' "$m" >> "$target" + live_modules["$m"]=1 + RS_HYDRATION_ACTIONS+=("/etc/modules — add: $m") + added=1 + done < <(_rs_hyd_modules_at_boot "$manifest") + + (( added )) && return 0 || return 1 +} + +# Phase 3 — whitelisted files +# ─────────────────────────── +# For each pattern in hb_hydration_files_patterns, copy the +# matching file from the staging rootfs to the live target IF the +# backup carries it and its content differs from the live copy. +_rs_hyd_files() { + local backup_rootfs="$1" mode="${2:-commit}" + local pattern src dst + local copied=0 + while IFS= read -r pattern; do + [[ -z "$pattern" ]] && continue + while IFS= read -r src; do + [[ -z "$src" || ! -f "$src" ]] && continue + dst="${src#${backup_rootfs}}" + [[ "$dst" == /* ]] || continue + if ! cmp -s "$src" "$dst" 2>/dev/null; then + if [[ "$mode" == "commit" ]]; then + mkdir -p "$(dirname "$dst")" 2>/dev/null || true + cp -a "$src" "$dst" 2>/dev/null || continue + fi + RS_HYDRATION_ACTIONS+=("${dst} — restore from backup") + copied=1 + fi + done < <(compgen -G "${backup_rootfs}${pattern}" 2>/dev/null || true) + done < <(hb_hydration_files_patterns) + (( copied )) && return 0 || return 1 +} + +# Orchestrator for the 4 phases. Two modes: +# plan — populates RS_HYDRATION_ACTIONS and RS_HYDRATION_SUMMARY +# without writing to any live file. Safe to call before +# the operator's final yes/no. +# commit — actually writes to the live target and exports +# HB_HYDRATION_APPLIED=1 if anything changed, so that +# plan.env carries the flag and apply_pending_restore.sh +# forces NEEDS_INITRAMFS/NEEDS_GRUB. +# Both modes are safe to call multiple times: writes are additive +# and idempotent, plan is side-effect free. +_rs_apply_bk_older_hydration() { + local staging_root="$1" mode="${2:-commit}" + local backup_rootfs="$staging_root/rootfs" + local manifest + manifest="$(_rs_hyd_manifest "$staging_root")" + + RS_HYDRATION_ACTIONS=() + RS_HYDRATION_SUMMARY="" + + [[ -d "$backup_rootfs" ]] || return 0 + + local bootloader + bootloader="$(hb_detect_bootloader)" + + local any=0 + case "$bootloader" in + systemd-boot) + _rs_hyd_kernel_cmdline "$manifest" "$mode" && any=1 || true + ;; + grub|*) + _rs_hyd_grub "$backup_rootfs" "$manifest" "$mode" && any=1 || true + ;; + esac + _rs_hyd_modules "$manifest" "$mode" && any=1 || true + _rs_hyd_files "$backup_rootfs" "$mode" && any=1 || true + + if (( any )); then + if [[ "$mode" == "commit" ]]; then + HB_HYDRATION_APPLIED=1 + export HB_HYDRATION_APPLIED + fi + local body a header + if [[ "$mode" == "plan" ]]; then + header="$(translate "Operator config that WILL be re-applied via kernel-agnostic merge")" + else + header="$(translate "Operator config re-applied via kernel-agnostic merge")" + fi + body="\Zb${header}\ZB"$'\n\n' + for a in "${RS_HYDRATION_ACTIONS[@]}"; do + body+=" \Z2•\Zn ${a}"$'\n' + done + RS_HYDRATION_SUMMARY="$body" + fi + return 0 +} + _rs_run_complete_guided() { local staging_root="$1" local -a all_paths=() @@ -2218,15 +2539,12 @@ _rs_run_complete_guided() { fi # ── Cross-version safe-restore filter ──────────────────── - # When the backup was taken on a different PVE major or a - # different kernel major.minor, restoring boot/kernel/apt - # config on top of the current install is what causes the - # post-reboot kernel panic. Fold those paths into RS_SKIP_PATHS - # so the same downstream machinery that already honours skips - # (_rs_apply, _rs_collect_pending_paths, apply_pending_restore) - # keeps them out of the restore. + # Only activates on `bk_older` (backup kernel older than target). + # The reverse direction (`bk_newer`) restores cleanly as-is — + # verified empirically on multiple 9.2 → 9.1 restores with GPU + # passthrough / IOMMU / DKMS drivers. RS_CROSS_VERSION_SKIPS="" - if [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]]; then + if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then local -a cv_skipped=() local cv_line cv_path cv_reason cv_rel # The drift block above trims the trailing newline off @@ -2265,6 +2583,15 @@ _rs_run_complete_guided() { RS_SKIP_PATHS="${RS_SKIP_PATHS#$'\n'}" RS_SKIP_PATHS="${RS_SKIP_PATHS%$'\n'}" export RS_SKIP_PATHS + + # Kernel-agnostic hydration of operator-authored bits that + # would otherwise be lost with the whole-file skip above. + # See _rs_apply_bk_older_hydration for the four merge phases. + # Runs in "plan" mode BEFORE the confirm dialog so + # RS_HYDRATION_SUMMARY can preview what will be re-applied; + # the commit runs AFTER the operator says yes, right before + # the hot apply. + _rs_apply_bk_older_hydration "$staging_root" plan fi # Build the rich confirmation body. Replaces the previous 4-strategy @@ -2334,6 +2661,12 @@ _rs_run_complete_guided() { if [[ -n "${RS_CROSS_VERSION_SKIPS:-}" ]]; then body+=$'\n'"${RS_CROSS_VERSION_SKIPS}" fi + # And the counterpart: what gets re-applied via kernel-agnostic + # merge. Rendered in green because it's what the operator gets + # back for free even though the whole file was skipped above. + if [[ -n "${RS_HYDRATION_SUMMARY:-}" ]]; then + body+=$'\n'"${RS_HYDRATION_SUMMARY}" + fi body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n' body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n' body+="\Zb$(translate "Continue?")\ZB" @@ -2385,6 +2718,15 @@ _rs_run_complete_guided() { show_proxmenux_logo msg_title "$(translate "Applying safe paths and preparing pending restore")" + # Commit the kernel-agnostic hydration BEFORE hot apply so + # /etc/modules, /etc/default/grub (or /etc/kernel/cmdline), + # and whitelisted vfio/nvidia files carry the operator's + # tokens by the time apply_pending_restore.sh reads its + # pending list. plan.env picks up HB_HYDRATION_APPLIED=1 + # and forces NEEDS_INITRAMFS/NEEDS_GRUB post-boot. + if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then + _rs_apply_bk_older_hydration "$staging_root" commit + fi [[ "$hot_count" -gt 0 ]] && _rs_apply "$staging_root" hot local -a pending_paths=() @@ -2581,12 +2923,69 @@ _rs_select_component_paths() { # ── Dialog checklist (one entry per real backup path) ── if [[ -z "$_rsp_selected" ]]; then + # In bk_older restores, kernel-tied paths are excluded from + # the list entirely (bash `dialog` has no gray-out primitive, + # so we hide the unsafe entries and surface their names in a + # msgbox up front). Uses the same list `hb_unsafe_paths_cross_version` + # returns — single source of truth with the safe filter used + # by Full restore. + local -A _rsp_blocked=() + local -a _rsp_blocked_list=() + if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then + local _rsp_bp _rsp_reason + while IFS=$'\t' read -r _rsp_bp _rsp_reason; do + [[ -z "$_rsp_bp" ]] && continue + local _rsp_bp_rel="${_rsp_bp#/}" + _rsp_blocked["$_rsp_bp_rel"]=1 + done < <(hb_unsafe_paths_cross_version) + fi + local -a _rsp_checklist=() local _rsp_p for _rsp_p in "${_rsp_backup[@]}"; do + # Skip paths whose parent (or itself) is on the blocked + # list: `/etc/systemd/system/foo` counts as blocked when + # `/etc/systemd/system` is on the list. + local _rsp_hit="" _rsp_probe="$_rsp_p" + while [[ -n "$_rsp_probe" ]]; do + if [[ -n "${_rsp_blocked[$_rsp_probe]:-}" ]]; then + _rsp_hit="/$_rsp_probe" + break + fi + [[ "$_rsp_probe" != *"/"* ]] && break + _rsp_probe="${_rsp_probe%/*}" + done + if [[ -n "$_rsp_hit" ]]; then + _rsp_blocked_list+=("$_rsp_hit") + continue + fi _rsp_checklist+=("$_rsp_p" "/$_rsp_p" "off") done + # If any paths were dropped, show them in a msgbox before the + # picker so the operator understands why they don't appear. + if (( ${#_rsp_blocked_list[@]} > 0 )); then + # Deduplicate — a folder can hide many child paths. + local -A _rsp_seen=() + local _rsp_list_str="" + local _rsp_e + for _rsp_e in "${_rsp_blocked_list[@]}"; do + [[ -n "${_rsp_seen[$_rsp_e]:-}" ]] && continue + _rsp_seen["$_rsp_e"]=1 + _rsp_list_str+=" • ${_rsp_e}"$'\n' + done + dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Cross-kernel — paths hidden from picker")" \ + --msgbox "$(translate "The following backup paths are kernel-tied and would break the target host if restored across kernel versions. They are excluded from the selection below:")"$'\n\n'"${_rsp_list_str}"$'\n'"$(translate "If you need any of these, restore this backup on a host with the same kernel major.minor.")" \ + 20 84 + fi + + if (( ${#_rsp_checklist[@]} == 0 )); then + dialog --backtitle "ProxMenux" --title "$(translate "Nothing selectable")" \ + --msgbox "$(translate "Every path in this backup is kernel-tied and cannot be restored across kernel versions.")" 9 78 + return 1 + fi + _rsp_selected=$(dialog --backtitle "ProxMenux" --separate-output \ --title "$(translate "Custom restore by paths")" \ --checklist "\n$(translate "Select the paths to restore (one per backup entry):")" \ @@ -2655,6 +3054,15 @@ _rs_run_custom_restore() { local hot_count="$RS_SEL_HOT" local pending_count=$(( RS_SEL_REBOOT + RS_SEL_DANGEROUS )) + # In bk_older, preview the kernel-agnostic hydration so the + # operator sees UPFRONT what will be re-applied automatically + # (IOMMU cmdline, VFIO modules, custom GRUB_TIMEOUT, etc.) + # even though the whole-file paths remain blocked from the + # custom picker. + if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then + _rs_apply_bk_older_hydration "$staging_root" plan + fi + local body body="\Zb$(translate "About to restore") ${RS_SEL_TOTAL} $(translate "selected path(s):")\ZB"$'\n\n' if (( hot_count > 0 )); then @@ -2663,6 +3071,9 @@ _rs_run_custom_restore() { if (( pending_count > 0 )); then body+=" • $(translate "Schedule") \Zb\Z4${pending_count}\Zn $(translate "for next boot (needs reboot to take effect)")"$'\n' fi + if [[ -n "${RS_HYDRATION_SUMMARY:-}" ]]; then + body+=$'\n'"${RS_HYDRATION_SUMMARY}" + fi if (( pending_count > 0 )); then body+=$'\n'"\Zb\Z4$(translate "A reboot will be required to complete the restore.")\Zn"$'\n' fi @@ -2670,13 +3081,20 @@ _rs_run_custom_restore() { if ! dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Confirm custom restore")" \ - --yesno "$body" 14 82; then + --yesno "$body" 22 82; then return 1 fi show_proxmenux_logo msg_title "$(translate "Applying custom restore")" + # Commit the hydration BEFORE hot apply. Same rationale as in + # the Complete flow: plan.env picks up HB_HYDRATION_APPLIED=1 + # so apply_pending_restore.sh forces initramfs+grub regen. + if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then + _rs_apply_bk_older_hydration "$staging_root" commit + fi + if (( hot_count > 0 )); then _rs_apply "$staging_root" hot "${selected_paths[@]}" fi @@ -2687,6 +3105,24 @@ _rs_run_custom_restore() { if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then msg_warn "$(translate "Reboot is required to complete the pending restore.")" fi + elif [[ "${HB_HYDRATION_APPLIED:-0}" == "1" ]]; then + # Custom restore picked only hot paths, but hydration + # merged tokens into /etc/default/grub or /etc/kernel/cmdline + # and/or added modules to /etc/modules. Those need + # initramfs + bootloader refresh before the next reboot to + # be effective. No post-boot dispatcher runs in this + # branch (plan.env is only written by + # _rs_prepare_pending_restore), so we do the reflows here. + msg_info "$(translate "Regenerating boot artifacts for the merged kernel-agnostic changes...")" + update-initramfs -u -k all >/dev/null 2>&1 || true + local _bl + _bl="$(hb_detect_bootloader)" + case "$_bl" in + systemd-boot) proxmox-boot-tool refresh >/dev/null 2>&1 || true ;; + grub|*) update-grub >/dev/null 2>&1 || true ;; + esac + stop_spinner 2>/dev/null || true + msg_ok "$(translate "Boot artifacts regenerated — reboot the host to activate the merged config.")" fi _rs_finish_flow @@ -2984,6 +3420,25 @@ _rs_apply_menu() { # confirmation dialog (option 1). It's still reachable on demand # from option 6 of this menu. + # ── Cross-kernel notice (only for bk_older) ─────────────── + # When the backup was taken on a kernel older than the target, + # let the operator know BEFORE they see the mode menu that + # Full restore will silently skip kernel-tied paths and that + # Custom will present them as blocked. Nothing to click through + # for the reverse direction (bk_newer) — those restores go + # through as-is because empirical testing shows they work. + if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then + local _bkk="" _cur_k _cur_mm + [[ -f "$staging_root/metadata/run_info.env" ]] && \ + _bkk=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null | cut -d= -f2-) + _cur_k=$(uname -r 2>/dev/null || echo "?") + _cur_mm=$(echo "$_cur_k" | cut -d. -f1-2) + dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Cross-kernel restore — safe subset only")" \ + --msgbox "$(translate "This backup was taken on kernel"): \Zb${_bkk}\ZB"$'\n'"$(translate "Target host runs"): \Zb${_cur_k}\ZB"$'\n\n'"\Zb$(translate "Only the essential configuration will be restored"):\ZB $(translate "/etc/pve, network, ssh, users, cron, ProxMenux state, etc.")"$'\n\n'"$(translate "Kernel-tied paths (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) will be skipped automatically to protect the target's boot.")"$'\n\n'"$(translate "If you need a complete bit-for-bit restore, use a backup taken on kernel") \Zb${_cur_mm}.x\ZB $(translate "or reinstall Proxmox with a version that ships kernel") \Zb${_bkk}\ZB." \ + 22 84 + fi + while true; do local choice choice=$(dialog --backtitle "ProxMenux" \ @@ -3071,39 +3526,6 @@ restore_menu() { # 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 617dc823..0d88eec6 100755 --- a/scripts/backup_restore/lib_host_backup_common.sh +++ b/scripts/backup_restore/lib_host_backup_common.sh @@ -2711,85 +2711,6 @@ 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 @@ -2811,15 +2732,116 @@ hb_unsafe_paths_cross_version() { EOF } -# HB_COMPAT_SAME_HOST → 1 if backup's hostname matches current -# HB_COMPAT_ANY_FAIL → 1 if at least one FAIL was raised -# HB_COMPAT_ANY_WARN → 1 if at least one WARN was raised -# HB_COMPAT_CROSS_VERSION → 1 if PVE major OR kernel major.minor differs -# (triggers the safe-restore path filter that -# skips grub/kernel/apt/dkms/... — see -# hb_unsafe_paths_cross_version) -# HB_COMPAT_RESULTS[] → array of "STATUS|category|message" entries -# where STATUS ∈ {PASS, INFO, WARN, FAIL} +# ========================================================== +# hb_detect_bootloader — "grub" | "systemd-boot" | "unknown" +# ========================================================== +# Proxmox uses systemd-boot on ZFS-on-root installs and GRUB on +# ext4/lvm. `proxmox-boot-tool status` reports both cases when +# the tool is present; on rescue paths without pbt we fall back +# to the presence of /etc/kernel/cmdline (systemd-boot) or +# /etc/default/grub (GRUB). +hb_detect_bootloader() { + if command -v proxmox-boot-tool >/dev/null 2>&1; then + local pbt + pbt="$(proxmox-boot-tool status 2>/dev/null || true)" + if grep -qi 'systemd-boot' <<<"$pbt"; then + echo "systemd-boot"; return 0 + elif grep -qiE 'grub|BIOS' <<<"$pbt"; then + echo "grub"; return 0 + fi + fi + if [[ -f /etc/kernel/cmdline ]]; then + echo "systemd-boot" + elif [[ -f /etc/default/grub ]]; then + echo "grub" + else + echo "unknown" + fi +} + +# hb_grub_keys_whitelist — GRUB /etc/default/grub keys that are +# stable across PVE 9.x / kernel majors and worth merging from the +# backup on a bk_older restore. Excludes GRUB_DISTRIBUTOR (changes +# with the distro package) and keys whose values reference boot +# entries the target install regenerates (GRUB_SAVEDEFAULT). +hb_grub_keys_whitelist() { + cat <<'EOF' +GRUB_TIMEOUT +GRUB_TIMEOUT_STYLE +GRUB_DEFAULT +GRUB_TERMINAL +GRUB_TERMINAL_INPUT +GRUB_TERMINAL_OUTPUT +GRUB_DISABLE_OS_PROBER +GRUB_SERIAL_COMMAND +GRUB_CMDLINE_LINUX_DEFAULT +GRUB_CMDLINE_LINUX +GRUB_GFXMODE +GRUB_GFXPAYLOAD_LINUX +EOF +} + +# hb_hydration_module_whitelist — module names safe to add to the +# target's /etc/modules from the backup's modules_loaded_at_boot +# on a bk_older restore. All are kernel-agnostic in name across +# 5.x → 7.x. Skips distro defaults (loop, dm-*) since a fresh +# install already loads them. +hb_hydration_module_whitelist() { + cat <<'EOF' +vfio +vfio_pci +vfio_iommu_type1 +vfio_virqfd +kvm +kvm_intel +kvm_amd +nvidia +nvidia_drm +nvidia_modeset +nvidia_uvm +i915 +xe +EOF +} + +# hb_hydration_files_patterns — glob patterns (relative to root) +# for files that are safe to copy verbatim from the backup rootfs +# in a bk_older restore. The rule for inclusion: operator- or +# ProxMenux-authored file whose content uses stable syntax across +# kernel majors. Excludes distro-owned files (pve-blacklist.conf, +# mdadm.conf, nvme.conf) whose contents can evolve between releases. +hb_hydration_files_patterns() { + cat <<'EOF' +/etc/modprobe.d/proxmenux-*.conf +/etc/modprobe.d/vfio.conf +/etc/modprobe.d/vfio-pci.conf +/etc/modprobe.d/kvm.conf +/etc/modprobe.d/blacklist-nvidia.conf +/etc/modprobe.d/blacklist-nouveau.conf +/etc/modprobe.d/nvidia*.conf +/etc/modules-load.d/proxmenux-*.conf +/etc/modules-load.d/vfio*.conf +/etc/modules-load.d/nvidia*.conf +/etc/udev/rules.d/10-proxmenux-vfio-bind.rules +/etc/udev/rules.d/70-nvidia.rules +EOF +} + +# HB_COMPAT_SAME_HOST → 1 if backup's hostname matches current +# HB_COMPAT_ANY_FAIL → 1 if at least one FAIL was raised +# HB_COMPAT_ANY_WARN → 1 if at least one WARN was raised +# HB_COMPAT_CROSS_VERSION → 1 if PVE major OR kernel major.minor differs +# HB_COMPAT_KERNEL_DIRECTION → "same" / "bk_older" / "bk_newer" — only +# "bk_older" activates the safe-subset +# restore that skips kernel-tied paths +# (grub, /etc/systemd/system, initramfs +# config, apt sources, ZFS state, …). +# "bk_newer" restores everything as +# same-version because empirical tests +# (9.2 → 9.1 with GPU passthrough + +# IOMMU) prove that direction is safe. +# HB_COMPAT_RESULTS[] → array of "STATUS|category|message" +# entries where STATUS ∈ {PASS,INFO,WARN,FAIL} # Use hb_show_compat_report to surface the result and let the user # decide whether to continue. # ========================================================== @@ -2830,6 +2852,32 @@ hb_compat_check() { HB_COMPAT_ANY_FAIL=0 HB_COMPAT_ANY_WARN=0 HB_COMPAT_CROSS_VERSION=0 + # HB_COMPAT_KERNEL_DIRECTION captures which direction the kernel + # major.minor drift goes: + # same — backup and target on the same kernel major.minor + # (or one side lacks a kernel string in the manifest). + # bk_older — backup kernel is OLDER than target kernel. This is + # the only direction that has been observed to break + # things: restoring older-kernel-era userspace onto a + # newer target can drag in old libzfs siblings, older + # systemd unit overrides, and DKMS modules built + # against a kernel that isn't the one now running. + # The restore flow uses this to skip the kernel-tied + # paths (grub, /etc/systemd/system, initramfs config, + # etc.) so Full mode still runs but only over the + # safe subset, and Custom mode grays out the unsafe + # paths so the operator can't shoot themself in the + # foot picking them. + # bk_newer — backup kernel is NEWER than target kernel. Empirical + # testing (multiple 9.2 → 9.1 restores with GPU + # passthrough / IOMMU) shows this direction works + # fine as-is: Proxmox repos don't ship future package + # versions on old series, so APT silently skips the + # backup's kernel-tied packages, and modules compile + # against whatever kernel the target is running. No + # safe-mode restrictions apply here; the restore runs + # as though it were same-version. + HB_COMPAT_KERNEL_DIRECTION="same" local meta="$staging_root/metadata" local rootfs="$staging_root/rootfs" @@ -2898,10 +2946,28 @@ hb_compat_check() { if [[ "$bk_kmaj" == "$cur_kmaj" ]]; then HB_COMPAT_RESULTS+=("PASS|Kernel|$(hb_translate "Same major.minor:") $bk_kernel → $cur_kernel") else - # Kernel major.minor drift — same reasoning as the - # PVE case above. Safe filter handles it. - HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Different kernel major.minor:") $bk_kernel → $cur_kernel $(hb_translate "— safe restore mode will skip kernel-tied config")") - HB_COMPAT_CROSS_VERSION=1 + # Kernel major.minor drift — decide direction so the + # rest of the restore knows whether to switch on the + # safe-subset mode. Bash `[[ ... < ... ]]` is a string + # compare, so we split into integers explicitly. + local _bk_maj _bk_min _cur_maj _cur_min + _bk_maj=${bk_kmaj%%.*} + _bk_min=${bk_kmaj##*.} + _cur_maj=${cur_kmaj%%.*} + _cur_min=${cur_kmaj##*.} + if (( _bk_maj < _cur_maj )) \ + || (( _bk_maj == _cur_maj && _bk_min < _cur_min )); then + HB_COMPAT_KERNEL_DIRECTION="bk_older" + HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Backup on older kernel:") $bk_kernel → $cur_kernel $(hb_translate "— Full/Custom restore will skip kernel-tied paths")") + HB_COMPAT_CROSS_VERSION=1 + else + # Backup NEWER than target — empirical evidence + # (multiple GPU passthrough tests) shows this + # direction restores cleanly with no filtering. + HB_COMPAT_KERNEL_DIRECTION="bk_newer" + HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Backup on newer kernel:") $bk_kernel → $cur_kernel $(hb_translate "— restore proceeds as same-version (verified safe)")") + HB_COMPAT_CROSS_VERSION=1 + fi fi fi fi diff --git a/scripts/backup_restore/restore/kernel_pin_plan.sh b/scripts/backup_restore/restore/kernel_pin_plan.sh deleted file mode 100644 index 75c281a1..00000000 --- a/scripts/backup_restore/restore/kernel_pin_plan.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/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