diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 2528e69e..c8987c1f 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -984,6 +984,7 @@ function InspectModal({ stagingPath: string mode: "full" | "custom" paths: string[] + rollbackExecute?: boolean } | null>(null) const beginRestore = async () => { @@ -1456,11 +1457,11 @@ function InspectModal({ }).catch(() => undefined) } }} - onLaunch={(mode, paths) => { + onLaunch={(mode, paths, rollbackExecute) => { if (!restoreOptions) return const sp = restoreOptions.stagingPath setRestoreOptions(null) - setRestoreTerminal({ stagingPath: sp, mode, paths }) + setRestoreTerminal({ stagingPath: sp, mode, paths, rollbackExecute }) }} /> @@ -1509,6 +1510,7 @@ function InspectModal({ ...(restoreTerminal.mode === "custom" && restoreTerminal.paths.length > 0 ? { PATHS: restoreTerminal.paths.join(",") } : {}), + ...(restoreTerminal.rollbackExecute ? { ROLLBACK_EXECUTE: "1" } : {}), }} /> )} @@ -7230,7 +7232,7 @@ function RestoreOptionsModal({ pathsAvailable: string[] rollbackPlan?: RollbackPlan display_id?: string - onLaunch: (mode: "full" | "custom", paths: string[]) => void + onLaunch: (mode: "full" | "custom", paths: string[], rollbackExecute?: boolean) => void }) { const [step, setStep] = useState<"choose" | "custom">("choose") const [selected, setSelected] = useState>(new Set()) @@ -7286,7 +7288,7 @@ function RestoreOptionsModal({ return } setError(null) - onLaunch(mode, Array.from(selected)) + onLaunch(mode, Array.from(selected), mode === "full" && hasDestructive && destructiveAck) } return ( diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh index 3110f622..b7c4f1b1 100755 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -1772,6 +1772,74 @@ WantedBy=multi-user.target EOF } +# Destructive rollback executor. Reads vms_to_remove / lxcs_to_remove +# from the rollback.json and runs `qm/pct stop + destroy --purge` +# on each. Component uninstall is not done here yet — the installers +# don't expose --auto-uninstall — so we just log them as TODO. +# Idempotent: missing/already-destroyed guests are skipped. +_rs_execute_rollback() { + local rb_file="$1" + [[ ! -s "$rb_file" ]] && return 0 + command -v jq >/dev/null 2>&1 || { msg_warn "$(translate 'jq not available — skipping rollback execution')"; return 0; } + + local vmids lxcids comps + vmids=$(jq -r '.vms_to_remove[]?' "$rb_file" 2>/dev/null) + lxcids=$(jq -r '.lxcs_to_remove[]?' "$rb_file" 2>/dev/null) + comps=$(jq -r '.components_to_uninstall[]?' "$rb_file" 2>/dev/null) + + if [[ -z "$vmids" && -z "$lxcids" && -z "$comps" ]]; then + msg_ok "$(translate 'Rollback: nothing to remove (host matches backup)')" + return 0 + fi + + msg_info2 "$(translate 'Executing destructive rollback (operator confirmed) ...')" + + local id + for id in $vmids; do + [[ -z "$id" ]] && continue + if qm status "$id" >/dev/null 2>&1; then + echo " → qm stop $id (extra VM, created after backup)" + qm stop "$id" --timeout 30 >/dev/null 2>&1 || true + echo " → qm destroy $id --purge" + if qm destroy "$id" --purge >/dev/null 2>&1; then + msg_ok "$(translate 'VM removed:') $id" + else + msg_error "$(translate 'Failed to destroy VM:') $id" + fi + else + echo " • VM $id no longer present — skip" + fi + done + + for id in $lxcids; do + [[ -z "$id" ]] && continue + if pct status "$id" >/dev/null 2>&1; then + echo " → pct stop $id (extra LXC, created after backup)" + pct stop "$id" >/dev/null 2>&1 || true + echo " → pct destroy $id --purge" + if pct destroy "$id" --purge >/dev/null 2>&1; then + msg_ok "$(translate 'LXC removed:') $id" + else + msg_error "$(translate 'Failed to destroy LXC:') $id" + fi + else + echo " • LXC $id no longer present — skip" + fi + done + + if [[ -n "$comps" ]]; then + local comp + for comp in $comps; do + [[ -z "$comp" ]] && continue + # Until each installer ships --auto-uninstall we can only + # flag these for the operator. Once nvidia/amd/intel/coral + # support it, this branch can shell out the same way the + # auto-reinstall path does. + msg_warn "$(translate 'Component to uninstall manually (no --auto-uninstall yet):') $comp" + done + fi +} + _rs_prepare_pending_restore() { local staging_root="$1" shift @@ -1821,12 +1889,17 @@ _rs_prepare_pending_restore() { [[ -d "$staging_root/metadata" ]] && cp -a "$staging_root/metadata/." "$pending_dir/metadata/" 2>/dev/null || true - # Persist the rollback plan so apply_cluster_postboot.sh can - # surface a clear "what differs vs backup" report after boot. - # Read-only here — the script just informs the operator about - # VMs/LXCs/components that exist now but not in the backup. - # Actual destroy is left manual until --auto-uninstall lands - # on every installer. + # Persist the rollback plan: VMs/LXCs/components that exist + # on the host but not in the backup. apply_cluster_postboot.sh + # surfaces a read-only "what differs" report from this file + # after the boot. When the operator opted into destructive + # rollback (HB_ROLLBACK_EXECUTE=1) we also destroy those + # guests RIGHT NOW — before the reboot — so the operator + # sees `qm destroy` live in the Monitor terminal AND the + # next boot comes up without those guests reserving hardware + # (critical for GPU passthrough: a stale VM with hostpci + # entries makes Proxmox auto-bind the GPU to vfio-pci before + # the nvidia driver can claim it). local _rb_script="${SCRIPT_DIR}/restore/compute_rollback_plan.sh" [[ ! -x "$_rb_script" ]] && _rb_script="${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/backup_restore/restore/compute_rollback_plan.sh" if [[ -x "$_rb_script" ]]; then @@ -1834,10 +1907,16 @@ _rs_prepare_pending_restore() { [[ ! -s "$pending_dir/rollback.json" ]] && rm -f "$pending_dir/rollback.json" fi + if [[ "${HB_ROLLBACK_EXECUTE:-0}" == "1" && -s "$pending_dir/rollback.json" ]] \ + && command -v jq >/dev/null 2>&1; then + _rs_execute_rollback "$pending_dir/rollback.json" + fi + cat > "$pending_dir/plan.env" </dev/null 2>&1; then + local _rb_json + _rb_json=$(bash "$_rb_script" "$staging_root" 2>/dev/null || true) + if [[ -n "$_rb_json" ]]; then + local _rb_vms _rb_lxcs _rb_comps + _rb_vms=$(jq -r '.vms_to_remove | join(", ")' <<<"$_rb_json" 2>/dev/null) + _rb_lxcs=$(jq -r '.lxcs_to_remove | join(", ")' <<<"$_rb_json" 2>/dev/null) + _rb_comps=$(jq -r '.components_to_uninstall| join(", ")' <<<"$_rb_json" 2>/dev/null) + if [[ -n "$_rb_vms" || -n "$_rb_lxcs" || -n "$_rb_comps" ]]; then + local rb_body + rb_body="\Zb\Z1$(translate "Destructive rollback")\ZB\Zn"$'\n\n' + rb_body+="$(translate "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:")"$'\n\n' + [[ -n "$_rb_vms" ]] && rb_body+=" \Z1•\Zn $(translate "VMs to destroy:") \Zb${_rb_vms}\ZB"$'\n' + [[ -n "$_rb_lxcs" ]] && rb_body+=" \Z1•\Zn $(translate "LXCs to destroy:") \Zb${_rb_lxcs}\ZB"$'\n' + [[ -n "$_rb_comps" ]] && rb_body+=" \Z1•\Zn $(translate "Components to uninstall (manual for now):") \Zb${_rb_comps}\ZB"$'\n' + rb_body+=$'\n'"\Zb\Z1$(translate "This is IRREVERSIBLE.")\ZB\Zn"$'\n\n' + rb_body+="$(translate "Pick Yes to destroy them now (before reboot). Pick No to keep the additive restore and clean up manually later.")" + if dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Execute destructive rollback?")" \ + --defaultno --yesno "$rb_body" 20 88; then + export HB_ROLLBACK_EXECUTE=1 + fi + fi + fi + fi + fi + show_proxmenux_logo msg_title "$(translate "Applying safe paths and preparing pending restore")" [[ "$hot_count" -gt 0 ]] && _rs_apply "$staging_root" hot diff --git a/scripts/backup_restore/restore/monitor_apply.sh b/scripts/backup_restore/restore/monitor_apply.sh index 11ef25d3..4bb71c2d 100755 --- a/scripts/backup_restore/restore/monitor_apply.sh +++ b/scripts/backup_restore/restore/monitor_apply.sh @@ -17,6 +17,12 @@ # Each must be present in /metadata/selected_paths.txt # or the wrapper drops it. Omit to let the TUI checklist # pop up so the operator can pick interactively. +# ROLLBACK_EXECUTE — "1" to ask _rs_prepare_pending_restore to +# run the destructive rollback (qm/pct destroy --purge) +# on guests created after the backup. The frontend only +# sets this after the operator ticks the "I understand…" +# checkbox in the Complete restore confirm dialog. Has +# no effect when MODE=custom. # ========================================================== # Note: NO `set -e` here. backup_host.sh + its sourced utils/lib # emit a few non-zero exit codes during load (locale checks, @@ -28,14 +34,16 @@ STAGING="${STAGING:-}" MODE="${MODE:-full}" PATHS="${PATHS:-}" +ROLLBACK_EXECUTE="${ROLLBACK_EXECUTE:-0}" -# Also accept --staging / --mode / --paths positional args for -# direct CLI use outside the Monitor (handy for debugging). +# Also accept --staging / --mode / --paths / --rollback-execute +# positional args for direct CLI use outside the Monitor. while [[ $# -gt 0 ]]; do case "$1" in - --staging) shift; STAGING="${1:-}" ;; - --mode) shift; MODE="${1:-full}" ;; - --paths) shift; PATHS="${1:-}" ;; + --staging) shift; STAGING="${1:-}" ;; + --mode) shift; MODE="${1:-full}" ;; + --paths) shift; PATHS="${1:-}" ;; + --rollback-execute) ROLLBACK_EXECUTE=1 ;; *) ;; esac shift @@ -78,6 +86,12 @@ fi # panel; no need for two consecutive yes/no prompts. export HB_MONITOR_FLOW=1 +# Propagate the destructive-rollback opt-in to _rs_prepare_pending_restore. +# Only honored for MODE=full — custom restores never trigger guest destroy. +if [[ "$MODE" == "full" && "$ROLLBACK_EXECUTE" == "1" ]]; then + export HB_ROLLBACK_EXECUTE=1 +fi + case "$MODE" in full) # The TUI path runs `_rs_collect_plan_stats` before calling