update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-07-02 18:12:13 +02:00
parent f0e79e93b6
commit bc3c771137
7 changed files with 402 additions and 30 deletions

View File

@@ -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',

View File

@@ -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

View File

@@ -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,

View File

@@ -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" \

View File

@@ -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

View File

@@ -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

View File

@@ -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]="<old_ifname>|<new_ifname>|<mac>"
# — same MAC, different ifname. Rewrites the staging config
# so the restored files use <new_ifname>.
# HB_NIC_ORPHAN[i]="<old_ifname>|<mac>"
# — 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]="<ifname>|<old_mac>|<new_mac>"
# — 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: <abs_path>\t<reason>
# 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 <nic>`, `bridge-ports ... <nic>`, `bridge_ports ... <nic>`,
# `bond-slaves ... <nic>`, `bond_slaves ... <nic>`, `slaves ... <nic>`
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)