From bc3c771137c5cc9dcc5532bcd857ae532f076853 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Thu, 2 Jul 2026 18:12:13 +0200 Subject: [PATCH] update 1.2.2.2 beta --- AppImage/scripts/flask_notification_routes.py | 12 +- AppImage/scripts/flask_server.py | 25 ++- AppImage/scripts/notification_templates.py | 11 +- .../backup_restore/apply_cluster_postboot.sh | 75 ++++++- .../backup_restore/apply_pending_restore.sh | 16 +- scripts/backup_restore/backup_host.sh | 87 ++++++++ .../backup_restore/lib_host_backup_common.sh | 206 ++++++++++++++++-- 7 files changed, 402 insertions(+), 30 deletions(-) diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 6a358faa..e6803411 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -1586,10 +1586,18 @@ def internal_restore_event(): stale_nodes = data.get('stale_nodes', '0') components = data.get('components', 'none') duration = data.get('duration', 'unknown') + # Boot sanity-check warnings surfaced by apply_cluster_postboot.sh. + # Empty on a clean restore; populated on the cross-version path + # when the sanity check found something the operator should know + # about (missing /lib/modules for the default kernel, no ESP + # configured, dangling /vmlinuz, ...). + warnings = data.get('warnings', '').strip() + severity = 'WARNING' if warnings else 'INFO' + warnings_block = f'\n⚠️ Boot sanity: {warnings}\n' if warnings else '' notification_manager.emit_event( event_type='system_restore_completed', - severity='INFO', + severity=severity, data={ 'hostname': hostname, 'guests': guests, @@ -1597,6 +1605,8 @@ def internal_restore_event(): 'stale_nodes': stale_nodes, 'components': components, 'duration': duration, + 'warnings': warnings, + 'warnings_block': warnings_block, }, source='proxmenux', entity='node', diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index c440522d..1306f321 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -9576,7 +9576,10 @@ def api_smart_run_test(disk_name): capture_output=True, text=True, timeout=10 ) if check_proc.returncode != 0: - return jsonify({'error': f'Cannot access NVMe device: {check_proc.stderr.strip() or "Device not responding"}'}), 500 + # Device-level failure, not a server bug — return 400 so + # the UI surfaces the real stderr instead of a generic + # "500 INTERNAL SERVER ERROR". + return jsonify({'error': f'Cannot access NVMe device: {check_proc.stderr.strip() or "Device not responding"}'}), 400 # Check if device supports self-test by looking at OACS field # OACS bit 4 (0x10) indicates Device Self-test support @@ -9616,8 +9619,8 @@ def api_smart_run_test(disk_name): # Check for permission errors if 'permission' in error_msg.lower() or 'operation not permitted' in error_msg.lower(): return jsonify({'error': f'Permission denied. Run as root: {error_msg}'}), 403 - return jsonify({'error': f'Failed to start test: {error_msg}'}), 500 - + return jsonify({'error': f'Failed to start test: {error_msg}'}), 400 + # Start background monitor to save JSON when test completes # Check 'Current Device Self-Test Operation' field - if > 0, test is running sleep_interval = 10 if test_type == 'short' else 60 @@ -9648,7 +9651,21 @@ def api_smart_run_test(disk_name): ) if proc.returncode not in (0, 4): # 4 = test started successfully - return jsonify({'error': f'Failed to start test: {proc.stderr}'}), 500 + # smartctl scribbles the useful diagnostic on stderr for + # most failures but some device-level errors (USB SATA + # bridges that reject the SMART command, drives whose + # ATA passthrough is broken, ...) print the reason on + # stdout instead. Concatenate both so the operator sees + # the real reason in the toast rather than an opaque + # "500 INTERNAL SERVER ERROR". Status is 400: the smartctl + # process ran fine, the device is what rejected the test. + err_detail = (proc.stderr or '').strip() + out_detail = (proc.stdout or '').strip() + combined = err_detail + if out_detail and out_detail not in err_detail: + combined = f'{err_detail}\n{out_detail}' if err_detail else out_detail + combined = combined or f'smartctl exited with code {proc.returncode}' + return jsonify({'error': f'Failed to start test: {combined}'}), 400 # Start background monitor to save JSON when test completes sleep_interval = 10 if test_type == 'short' else 60 diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 2ca01306..1c7d69aa 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -901,7 +901,16 @@ TEMPLATES = { }, 'system_restore_completed': { 'title': '{hostname}: Host restore finished', - 'body': 'Post-restore tasks completed in background.\n\nGuests applied: {guests}\nBind-mount stubs: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n\nThe node is now fully ready to use.', + 'body': ( + 'Post-restore tasks completed in background.\n\n' + 'Guests applied: {guests}\n' + 'Bind-mount stubs: {stubs}\n' + 'Stale node dirs removed: {stale_nodes}\n' + 'Components reinstalled: {components}\n' + 'Duration: {duration}\n' + '{warnings_block}\n' + 'The node is now fully ready to use.' + ), 'label': 'Host restore completed', 'group': 'services', 'default_enabled': True, diff --git a/scripts/backup_restore/apply_cluster_postboot.sh b/scripts/backup_restore/apply_cluster_postboot.sh index 3e03233a..03b59aaf 100755 --- a/scripts/backup_restore/apply_cluster_postboot.sh +++ b/scripts/backup_restore/apply_cluster_postboot.sh @@ -412,6 +412,49 @@ if [[ -n "$ROLLBACK_PLAN_FILE" ]] && command -v jq >/dev/null 2>&1; then fi fi +# ── Boot sanity check ────────────────────────────────────────── +# Cross-version restores are the most likely path to a broken +# boot: even with the safe-restore filter, catch situations where +# the default kernel has no matching /lib/modules, no ESP is +# configured, or a stale reference survived. Runs on every restore +# (cheap); warnings feed the completion notification so it tells +# the truth instead of a blanket "all fine". +SANITY_WARNINGS="" +_sanity_warn() { + if [[ -z "$SANITY_WARNINGS" ]]; then + SANITY_WARNINGS="$1" + else + SANITY_WARNINGS="${SANITY_WARNINGS}; $1" + fi +} + +if command -v proxmox-boot-tool >/dev/null 2>&1; then + if ! proxmox-boot-tool status 2>/dev/null | grep -q 'configured with'; then + _sanity_warn "proxmox-boot-tool reports no ESP configured" + fi +fi + +if [[ -d /boot ]]; then + for _vmlinuz in /boot/vmlinuz-*; do + [[ -e "$_vmlinuz" ]] || continue + _kver="${_vmlinuz##*vmlinuz-}" + if [[ ! -d "/lib/modules/$_kver" ]]; then + _sanity_warn "kernel $_kver has no /lib/modules" + fi + done +fi + +if [[ -L /vmlinuz && ! -e /vmlinuz ]]; then + _sanity_warn "/vmlinuz symlink is dangling" +fi + +if [[ -n "$SANITY_WARNINGS" ]]; then + echo "" + echo "── Boot sanity check ──" + echo "$SANITY_WARNINGS" + echo "Cross-version: ${HB_COMPAT_CROSS_VERSION:-0}" +fi + # ── Notify ProxMenux Monitor that we're done ─────────────────── # Routes through the user's configured channels (Telegram, Discord, # ntfy, etc.). Localhost-only endpoint, no auth needed. We try @@ -429,13 +472,31 @@ if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then fi if command -v curl >/dev/null 2>&1; then - PAYLOAD=$(printf '{"hostname":"%s","guests":"%s","stubs":"%s","stale_nodes":"%s","components":"%s","duration":"%s"}' \ - "$(hostname)" \ - "${copied_guests:-0}" \ - "${stub_created:-0}" \ - "${removed_nodes:-0}" \ - "${COMPONENTS_REINSTALLED_CSV:-none}" \ - "$POSTBOOT_DURATION_FMT") + # jq builds a proper JSON when available so SANITY_WARNINGS with + # special chars can't break the payload. Falls back to printf on + # hosts without jq — we already restrict SANITY_WARNINGS content + # to plain ASCII in the sanity check above, so the fallback is + # safe too. + if command -v jq >/dev/null 2>&1; then + PAYLOAD=$(jq -cn \ + --arg hostname "$(hostname)" \ + --arg guests "${copied_guests:-0}" \ + --arg stubs "${stub_created:-0}" \ + --arg stale_nodes "${removed_nodes:-0}" \ + --arg components "${COMPONENTS_REINSTALLED_CSV:-none}" \ + --arg duration "$POSTBOOT_DURATION_FMT" \ + --arg warnings "$SANITY_WARNINGS" \ + '{hostname:$hostname, guests:$guests, stubs:$stubs, stale_nodes:$stale_nodes, components:$components, duration:$duration, warnings:$warnings}') + else + PAYLOAD=$(printf '{"hostname":"%s","guests":"%s","stubs":"%s","stale_nodes":"%s","components":"%s","duration":"%s","warnings":"%s"}' \ + "$(hostname)" \ + "${copied_guests:-0}" \ + "${stub_created:-0}" \ + "${removed_nodes:-0}" \ + "${COMPONENTS_REINSTALLED_CSV:-none}" \ + "$POSTBOOT_DURATION_FMT" \ + "$SANITY_WARNINGS") + fi NOTIFY_HTTP=$(curl -s -o /dev/null -w '%{http_code}' \ -X POST "http://127.0.0.1:8008/api/internal/restore-event" \ -H "Content-Type: application/json" \ diff --git a/scripts/backup_restore/apply_pending_restore.sh b/scripts/backup_restore/apply_pending_restore.sh index 5b347a1d..eff08c4c 100755 --- a/scripts/backup_restore/apply_pending_restore.sh +++ b/scripts/backup_restore/apply_pending_restore.sh @@ -39,6 +39,7 @@ if [[ -f "$PLAN_ENV" ]]; then fi : "${HB_RESTORE_INCLUDE_ZFS:=0}" +: "${HB_COMPAT_CROSS_VERSION:=0}" if [[ ! -f "$APPLY_LIST" ]]; then echo "Apply list missing: $APPLY_LIST" @@ -46,9 +47,10 @@ 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 "Pending dir: $PENDING_DIR" +echo "Apply list: $APPLY_LIST" +echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS" +echo "Cross-version: $HB_COMPAT_CROSS_VERSION" # Hardware-drift skips persisted by _rs_prepare_pending_restore. # Each line is an absolute path; we drop any rel path that matches @@ -60,7 +62,12 @@ RS_SKIP_PATHS="" SKIP_PATHS_FILE="$PENDING_DIR/rs-skip-paths.txt" if [[ -f "$SKIP_PATHS_FILE" ]]; then RS_SKIP_PATHS=$(cat "$SKIP_PATHS_FILE") - echo "Skip paths: $(wc -l <"$SKIP_PATHS_FILE") entries (drift)" + # Skips come from two sources today: hardware drift and + # cross-version safe restore. The label is generic so the log + # is truthful without having to reconcile the two upstream. + _skip_source_label="drift" + [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]] && _skip_source_label="drift + cross-version" + echo "Skip paths: $(wc -l <"$SKIP_PATHS_FILE") entries (${_skip_source_label})" fi echo "running" >"$STATE_FILE" @@ -265,6 +272,7 @@ EOF printf 'PENDING_DIR=%s\n' "$PENDING_DIR" printf 'NEEDS_INITRAMFS=%s\n' "$NEEDS_INITRAMFS" printf 'NEEDS_GRUB=%s\n' "$NEEDS_GRUB" + printf 'HB_COMPAT_CROSS_VERSION=%s\n' "${HB_COMPAT_CROSS_VERSION:-0}" } > /var/lib/proxmenux/cluster-apply-pending chmod 600 /var/lib/proxmenux/cluster-apply-pending diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh index b409eed8..7cdeb78f 100755 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -2057,6 +2057,7 @@ RESTORE_ID=${restore_id} 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} 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 @@ -2179,6 +2180,72 @@ _rs_run_complete_guided() { (( dialog_signal == 1 )) && RS_DRIFT_SUMMARY="$plan_body" fi + # ── NIC remap (silent, staging_root) ───────────────────── + # If hb_plan_nic_remaps found NIC(s) with the same MAC as the + # backup but a different ifname on the target (motherboard + # swap that shifted PCI addresses), rewrite the staging config + # in-place so the restored /etc/network/interfaces references + # the ifname that actually exists on this host. Also builds + # a small summary block for the confirm dialog — informative + # only, no yes/no. + RS_NIC_REMAP_SUMMARY="" + if (( ${#HB_NIC_REMAP[@]:-0} > 0 || ${#HB_NIC_MAC_CHANGED[@]:-0} > 0 )); then + local nr_body entry old_if new_if nic_mac old_mac + nr_body="\Zb$(translate "NIC changes detected — adjusted automatically")\ZB"$'\n\n' + for entry in "${HB_NIC_REMAP[@]}"; do + IFS='|' read -r old_if new_if nic_mac <<<"$entry" + nr_body+=" \Z4•\Zn $(translate "Renamed"): \Zb${old_if}\ZB → \Zb${new_if}\ZB ($(translate "same MAC"): ${nic_mac})"$'\n' + done + for entry in "${HB_NIC_MAC_CHANGED[@]}"; do + IFS='|' read -r new_if old_mac nic_mac <<<"$entry" + nr_body+=" \Z4•\Zn \Zb${new_if}\ZB $(translate "has a new MAC"): ${nic_mac} ($(translate "was") ${old_mac})"$'\n' + nr_body+=" $(translate "If your DHCP has a static reservation for the old MAC, update it.")"$'\n' + done + RS_NIC_REMAP_SUMMARY="$nr_body" + hb_apply_nic_remaps "$staging_root" + 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. + RS_CROSS_VERSION_SKIPS="" + if [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]]; then + local -a cv_skipped=() + local cv_line cv_path cv_reason cv_rel + while IFS=$'\t' read -r cv_path cv_reason; do + [[ -z "$cv_path" ]] && continue + cv_rel="${cv_path#/}" + # Only skip when the backup actually carries the path — + # otherwise the "excluded" line is misleading. + if [[ -e "$staging_root/rootfs/$cv_rel" ]]; then + cv_skipped+=("${cv_path}"$'\t'"${cv_reason}") + RS_SKIP_PATHS+="${cv_path}"$'\n' + fi + done < <(hb_unsafe_paths_cross_version) + + if (( ${#cv_skipped[@]} > 0 )); then + local cv_body + cv_body="\Zb$(translate "Cross-version detected — safe restore mode")\ZB"$'\n\n' + cv_body+="$(translate "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:")"$'\n\n' + for cv_line in "${cv_skipped[@]}"; do + cv_path="${cv_line%%$'\t'*}" + cv_reason="${cv_line#*$'\t'}" + cv_body+=" \Z1•\Zn \Zb${cv_path}\ZB"$'\n' + cv_body+=" $(translate "${cv_reason}")"$'\n\n' + done + RS_CROSS_VERSION_SKIPS="$cv_body" + fi + # Trim any leading duplicate newlines the concat may introduce. + RS_SKIP_PATHS="${RS_SKIP_PATHS#$'\n'}" + RS_SKIP_PATHS="${RS_SKIP_PATHS%$'\n'}" + export RS_SKIP_PATHS + fi + # Build the rich confirmation body. Replaces the previous 4-strategy # menu — by design a Proxmox host restore always requires a reboot # for predictable end state (pmxcfs live writes + initramfs + driver @@ -2222,6 +2289,13 @@ _rs_run_complete_guided() { body+=" • \Zb${label}\ZB (${eta})"$'\n' done fi + # Silent NIC remap block first (informational, no action needed + # from the operator — the staging config has already been + # rewritten). Kept above drift/cross-version because it's the + # least alarming and the most common on hardware refreshes. + if [[ -n "${RS_NIC_REMAP_SUMMARY:-}" ]]; then + body+=$'\n'"${RS_NIC_REMAP_SUMMARY}" + fi # If smart restore flagged drift skips earlier, surface them here # so the operator sees everything in one screen instead of two # consecutive yes/no popups. @@ -2233,6 +2307,12 @@ _rs_run_complete_guided() { _drift_bullets=$(printf '%s\n' "$RS_DRIFT_SUMMARY" | sed -n '/\Z1•\Zn/,$p') body+="$_drift_bullets"$'\n' fi + # Same treatment for cross-version skips (PVE major or kernel + # major.minor differ between backup and target). The stashed + # body already carries its own header — merge it as-is. + if [[ -n "${RS_CROSS_VERSION_SKIPS:-}" ]]; then + body+=$'\n'"${RS_CROSS_VERSION_SKIPS}" + 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" @@ -2799,6 +2879,13 @@ restore_menu() { esac if [[ $ok -eq 1 ]] && _rs_check_layout "$staging_root"; then + # Plan NIC remaps FIRST so hb_compat_check knows which + # "missing" NICs are actually renames (same MAC, new + # ifname after a motherboard swap) and can downgrade + # them from FAIL to INFO. Also fills HB_NIC_MAC_CHANGED + # for same-name-different-MAC hosts. + hb_plan_nic_remaps "$staging_root" + # Run the compatibility check BEFORE the apply menu so # the operator sees PVE-version / hostname / network / # storage drift up front. This also sets diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh index 1c67c8a2..5eb60482 100755 --- a/scripts/backup_restore/lib_host_backup_common.sh +++ b/scripts/backup_restore/lib_host_backup_common.sh @@ -2539,10 +2539,148 @@ hb_ensure_pv() { # storage / network / VMID drift BEFORE the apply menu opens. # # After running hb_compat_check, the caller can read: -# 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_RESULTS[] → array of "STATUS|category|message" entries +# ========================================================== +# NIC REMAP PLAN + APPLY +# ========================================================== +# Reconciles NICs recorded in the backup manifest against the +# live host. Covers the "motherboard swap" scenario: same NIC +# chip lands on a different PCI slot after the swap, so the +# ifname changes (enp0s31f6 → enp0s25) even though the MAC is +# the same. Without a remap the restored /etc/network/interfaces +# references a name that no longer exists → no network at boot. +# +# Populates arrays (only when jq + manifest are available): +# HB_NIC_REMAP[i]="||" +# — same MAC, different ifname. Rewrites the staging config +# so the restored files use . +# HB_NIC_ORPHAN[i]="|" +# — MAC from the backup not present on target at all. +# Real hardware removal, left to hb_compat_check to +# report as WARN/FAIL depending on wired-ness. +# HB_NIC_MAC_CHANGED[i]="||" +# — same ifname on target, but MAC differs. Motherboard +# replacement with same PCI layout but different NIC chip. +# Boot succeeds; surfaces INFO so the operator can update +# any DHCP static reservation that keyed on the old MAC. +# ========================================================== +hb_plan_nic_remaps() { + local staging_root="$1" + HB_NIC_REMAP=() + HB_NIC_ORPHAN=() + HB_NIC_MAC_CHANGED=() + + local manifest="$staging_root/manifest.json" + [[ -f "$manifest" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + + declare -A _dest_mac_by_if=() + declare -A _dest_if_by_mac=() + local dev_path _if _mac + for dev_path in /sys/class/net/*; do + _if="$(basename "$dev_path")" + case "$_if" in + lo|veth*|tap*|fwln*|fwbr*|fwpr*|vmbr*|bond*) continue ;; + esac + [[ -e "$dev_path/device" ]] || continue + _mac="$(cat "$dev_path/address" 2>/dev/null || true)" + [[ -z "$_mac" ]] && continue + _dest_mac_by_if["$_if"]="$_mac" + _dest_if_by_mac["$_mac"]="$_if" + done + + local src_if src_mac + while IFS=$'\t' read -r src_if src_mac; do + [[ -z "$src_if" || -z "$src_mac" ]] && continue + # 1) Same ifname on target — compare MAC. + if [[ -n "${_dest_mac_by_if[$src_if]:-}" ]]; then + local cur_mac="${_dest_mac_by_if[$src_if]}" + if [[ "$cur_mac" != "$src_mac" ]]; then + HB_NIC_MAC_CHANGED+=("${src_if}|${src_mac}|${cur_mac}") + fi + continue + fi + # 2) MAC found on a different ifname — remap. + if [[ -n "${_dest_if_by_mac[$src_mac]:-}" ]]; then + HB_NIC_REMAP+=("${src_if}|${_dest_if_by_mac[$src_mac]}|${src_mac}") + continue + fi + # 3) Neither ifname nor MAC — real removal. + HB_NIC_ORPHAN+=("${src_if}|${src_mac}") + done < <(jq -r '.hardware_inventory.nic[]? | "\(.ifname)\t\(.mac)"' "$manifest" 2>/dev/null) +} + +# Rewrites NIC names in the staging_root's network config so the +# restored /etc/network/interfaces (and interfaces.d/*, and +# systemd-networkd .link files if present) uses the new names. +# Only touches the staging copy — nothing on the live host changes +# until the normal apply step runs. GNU sed word boundaries (\b) +# keep eth10 safe when renaming eth1. +hb_apply_nic_remaps() { + local staging_root="$1" + (( ${#HB_NIC_REMAP[@]} == 0 )) && return 0 + + local -a targets=() + [[ -f "$staging_root/rootfs/etc/network/interfaces" ]] && \ + targets+=("$staging_root/rootfs/etc/network/interfaces") + if [[ -d "$staging_root/rootfs/etc/network/interfaces.d" ]]; then + while IFS= read -r -d '' f; do targets+=("$f"); done \ + < <(find "$staging_root/rootfs/etc/network/interfaces.d" -type f -print0 2>/dev/null) + fi + if [[ -d "$staging_root/rootfs/etc/systemd/network" ]]; then + while IFS= read -r -d '' f; do targets+=("$f"); done \ + < <(find "$staging_root/rootfs/etc/systemd/network" -type f -print0 2>/dev/null) + fi + (( ${#targets[@]} == 0 )) && return 0 + + local entry old_if new_if _mac t + for entry in "${HB_NIC_REMAP[@]}"; do + IFS='|' read -r old_if new_if _mac <<<"$entry" + [[ -z "$old_if" || -z "$new_if" ]] && continue + for t in "${targets[@]}"; do + sed -i "s/\\b${old_if}\\b/${new_if}/g" "$t" 2>/dev/null || true + done + done +} + +# ========================================================== +# CROSS-VERSION UNSAFE PATHS +# ========================================================== +# Curated list of paths that reliably break the boot when a backup +# taken on PVE major X or kernel major.minor Y is applied on a +# different X/Y. Restoring these is what caused the field-reported +# kernel panic (backup on 9.1.9 / kernel 6.x restored on 9.2.3 / +# kernel 7.x). We ship a static list rather than a heuristic so +# behaviour is auditable — the operator can inspect it once and +# know what "safe restore mode" means. +# +# Output: one line per path, tab-separated: \t +# The reason is a short label used verbatim in the confirm dialog. +# ========================================================== +hb_unsafe_paths_cross_version() { + cat <<'EOF' +/etc/default/grub GRUB defaults tied to prior kernel order +/etc/kernel proxmox-boot-tool state (cmdline, ESP UUIDs, hooks) +/etc/modules-load.d autoload list may reference renamed modules +/etc/modprobe.d module options may not apply on new kernel +/etc/apt source suites may trigger downgrade on next upgrade +/etc/initramfs-tools initramfs hooks tied to prior kernel +/etc/fstab UUIDs may not exist on this install +/etc/multipath multipath drivers change between kernels +/etc/iscsi iSCSI parameters evolve between kernels +/etc/udev/rules.d udev rules may bind to nonexistent subsystems +/etc/zfs zpool.cache + hostid can lock the pool as foreign +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} # Use hb_show_compat_report to surface the result and let the user # decide whether to continue. # ========================================================== @@ -2552,6 +2690,7 @@ hb_compat_check() { HB_COMPAT_SAME_HOST=0 HB_COMPAT_ANY_FAIL=0 HB_COMPAT_ANY_WARN=0 + HB_COMPAT_CROSS_VERSION=0 local meta="$staging_root/metadata" local rootfs="$staging_root/rootfs" @@ -2594,8 +2733,13 @@ hb_compat_check() { elif [[ "$bk_major" == "$cur_major" ]]; then HB_COMPAT_RESULTS+=("PASS|PVE version|$(hb_translate "Same major series:") $bk_pve → $cur_pve") else - HB_COMPAT_RESULTS+=("FAIL|PVE version|$(hb_translate "Major version mismatch:") $bk_pve → $cur_pve $(hb_translate "(default paths and packages may have changed)")") - HB_COMPAT_ANY_FAIL=1 + # Major PVE bump — used to hard-FAIL, but the restore now + # automatically drops boot/kernel/apt-critical paths in + # this case (see hb_unsafe_paths_cross_version). Downgrade + # to INFO so the user knows what's happening; the safe + # filter does the actual protection. + HB_COMPAT_RESULTS+=("INFO|PVE version|$(hb_translate "Major version differs:") $bk_pve → $cur_pve $(hb_translate "— safe restore mode will skip boot/kernel/apt config")") + HB_COMPAT_CROSS_VERSION=1 fi fi @@ -2615,8 +2759,10 @@ hb_compat_check() { if [[ "$bk_kmaj" == "$cur_kmaj" ]]; then HB_COMPAT_RESULTS+=("PASS|Kernel|$(hb_translate "Same major.minor:") $bk_kernel → $cur_kernel") else - HB_COMPAT_RESULTS+=("WARN|Kernel|$(hb_translate "Different kernel:") $bk_kernel → $cur_kernel") - HB_COMPAT_ANY_WARN=1 + # 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 fi fi fi @@ -2676,7 +2822,19 @@ hb_compat_check() { fi done # Classify each missing NIC as wired vs orphan declaration. + # A "missing" NIC that hb_plan_nic_remaps already matched by + # MAC to a new ifname is NOT missing in any meaningful sense + # — the restored config will be rewritten to use the new + # name before apply. Skip it here. + local -A _remap_old=() + local _re_entry _re_old + for _re_entry in "${HB_NIC_REMAP[@]:-}"; do + [[ -z "$_re_entry" ]] && continue + _re_old="${_re_entry%%|*}" + [[ -n "$_re_old" ]] && _remap_old["$_re_old"]=1 + done for i in "${missing_ifaces[@]}"; do + [[ -n "${_remap_old[$i]:-}" ]] && continue # Match: `auto `, `bridge-ports ... `, `bridge_ports ... `, # `bond-slaves ... `, `bond_slaves ... `, `slaves ... ` if grep -qE "(^auto[[:space:]]+${i}\$|bridge[-_]ports[[:space:]]+.*\b${i}\b|bond[-_]slaves[[:space:]]+.*\b${i}\b|^[[:space:]]*slaves[[:space:]]+.*\b${i}\b)" "$ifaces_file"; then @@ -2686,6 +2844,25 @@ hb_compat_check() { fi done + # Surface renames + MAC changes as INFO so the panel shows them + # if there's something else to see, but doesn't force it open. + # Separated `local` declarations because bash `set -u` in caller + # scopes gets grumpy about chained `local a=$b c=$a` forms. + local _re _o _rest _n + for _re in "${HB_NIC_REMAP[@]:-}"; do + [[ -z "$_re" ]] && continue + _o="${_re%%|*}" + _rest="${_re#*|}" + _n="${_rest%%|*}" + HB_COMPAT_RESULTS+=("INFO|Network|$(hb_translate "Renamed NIC:") ${_o} → ${_n} $(hb_translate "(same MAC — restored config adjusted automatically)")") + done + local _mc _ifn + for _mc in "${HB_NIC_MAC_CHANGED[@]:-}"; do + [[ -z "$_mc" ]] && continue + _ifn="${_mc%%|*}" + HB_COMPAT_RESULTS+=("INFO|Network|$(hb_translate "NIC") ${_ifn} $(hb_translate "has a different MAC than the backup — update any DHCP static reservation")") + done + if [[ ${#bk_ifaces[@]} -eq 0 ]]; then : # nothing to check elif [[ ${#missing_ifaces[@]} -eq 0 ]]; then @@ -2727,8 +2904,10 @@ hb_compat_check() { else list_str="${missing_pkgs[*]:0:6}… (+ $((${#missing_pkgs[@]} - 6)) $(hb_translate "more"))" fi - HB_COMPAT_RESULTS+=("WARN|Packages|${#missing_pkgs[@]} $(hb_translate "user-installed packages from backup are missing here:") $list_str") - HB_COMPAT_ANY_WARN=1 + # Missing packages are auto-installed by _rs_run_complete_extras + # regardless of restore strategy — the user doesn't have to + # decide anything, so INFO instead of WARN. + HB_COMPAT_RESULTS+=("INFO|Packages|${#missing_pkgs[@]} $(hb_translate "user packages missing — will be installed automatically:") $list_str") fi fi @@ -2766,13 +2945,14 @@ hb_compat_check() { # report and lets the user proceed; an all-PASS report only shows up # briefly so the user can see it succeeded. hb_show_compat_report() { - local pass=0 warn=0 fail=0 line status rest cat msg + local pass=0 info=0 warn=0 fail=0 line status rest cat msg local report="" for line in "${HB_COMPAT_RESULTS[@]}"; do status="${line%%|*}"; rest="${line#*|}" cat="${rest%%|*}"; msg="${rest#*|}" case "$status" in - PASS) ((pass++)); report+=$' [OK] '"${cat}"$' — '"${msg}"$'\n' ;; + PASS) ((pass++)); report+=$' [OK] '"${cat}"$' — '"${msg}"$'\n' ;; + INFO) ((info++)); report+=$' [INFO] '"${cat}"$' — '"${msg}"$'\n' ;; WARN) ((warn++)); report+=$' [WARN] '"${cat}"$' — '"${msg}"$'\n' ;; FAIL) ((fail++)); report+=$' [FAIL] '"${cat}"$' — '"${msg}"$'\n' ;; esac @@ -2780,7 +2960,7 @@ hb_show_compat_report() { local summary summary="$(hb_translate "Compatibility check"): " - summary+="${pass} pass, ${warn} warn, ${fail} fail" + summary+="${pass} pass, ${info} info, ${warn} warn, ${fail} fail" local tmpfile tmpfile=$(mktemp)