From 65219d94699b6d2d986b78c556a885a3b8ee7426 Mon Sep 17 00:00:00 2001 From: MickLesk Date: Sun, 28 Jun 2026 15:02:36 +0200 Subject: [PATCH] host-migrate: add CIFS, logging, and export/import improvements Enhance tools/pve/host-migrate.sh with several usability and reliability features: - Add run logfile and mlog helper for traceability (writes /var/log/host-migrate-.log). - Add CIFS/SMB share support to the location picker, auto-install cifs-utils, and mount CIFS shares for exports. - Make export component selection more granular (pvecfg, network, hostid, dns, apt sources/keyrings, pkglist, cron, systemd, roothome, etc) and reorganize collection logic accordingly. - Capture per-guest requirements (bridges and storages) into host/guest-requirements.info for import preflight checks. - Generate bundle manifest with additional metadata (kernel, tool version, cluster flag) and produce sha256sums.txt; offer optional integrity verification on import. - Add optional rsync/SSH push after export and install rsync if missing. - Import-side: warn on architecture and cluster source, verify checksums, preflight missing bridges/storages, and allow package reinstallation from selection file. - Add cleanup UI to manage and delete stored bundles. - Various UI tweaks and messaging improvements, and minor safety/backups when restoring files. These changes improve cross-host portability, provide better diagnostics, and make bundle management and restoration safer and more transparent. --- tools/pve/host-migrate.sh | 387 +++++++++++++++++++++++++++++++++++--- 1 file changed, 359 insertions(+), 28 deletions(-) diff --git a/tools/pve/host-migrate.sh b/tools/pve/host-migrate.sh index cd9c3cdb4..87e19ea4c 100644 --- a/tools/pve/host-migrate.sh +++ b/tools/pve/host-migrate.sh @@ -20,6 +20,14 @@ TEMP_MOUNTS=() # Result holders for the storage picker / disk preparation helpers. BROWSE_RESULT="" PREPARED_MP="" +# Run logfile for traceability / support. +LOGFILE="/var/log/host-migrate-$(date +%Y%m%d_%H%M%S).log" + +# Append a timestamped line to the run logfile. +function mlog { + echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >>"$LOGFILE" 2>/dev/null +} +mlog "host-migrate started on $(hostname)" function header_info { clear @@ -295,10 +303,11 @@ function choose_location { local choice choice=$(whiptail --backtitle "$BACKTITLE" --title "$prompt_title" --menu \ - "\nWhere is the migration bundle located?" 16 74 4 \ + "\nWhere is the migration bundle located?" 17 74 5 \ "browse" "Pick / prepare storage (mounts, disks, LVM)" \ "local" "Type a local path manually (SSD, USB, mount)" \ "nfs" "NFS share (mount on demand)" \ + "cifs" "SMB/CIFS share (mount on demand)" \ 3>&1 1>&2 2>&3) || return 1 if [ "$choice" = "browse" ]; then @@ -355,6 +364,53 @@ function choose_location { sleep 2 return 1 fi + elif [ "$choice" = "cifs" ]; then + local cifs_unc cifs_user cifs_pass + cifs_unc=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nCIFS/SMB share (UNC path):\ne.g. //192.168.1.10/proxmox-backups" 11 68 \ + --title "CIFS Share" 3>&1 1>&2 2>&3) || return 1 + [ -z "$cifs_unc" ] && { + msg_error "Share path is required." + sleep 2 + return 1 + } + cifs_unc="${cifs_unc//\\//}" + cifs_user=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nUsername (leave empty for guest):" 10 60 \ + --title "CIFS User" 3>&1 1>&2 2>&3) || return 1 + cifs_pass=$(whiptail --backtitle "$BACKTITLE" --passwordbox \ + "\nPassword (leave empty for guest):" 10 60 \ + --title "CIFS Password" 3>&1 1>&2 2>&3) || return 1 + + if ! command -v mount.cifs >/dev/null 2>&1; then + msg_info "Installing cifs-utils" + apt-get update &>/dev/null + apt-get install -y cifs-utils &>/dev/null || { + msg_error "Failed to install cifs-utils" + return 1 + } + msg_ok "Installed cifs-utils" + fi + + NFS_MOUNTPOINT="/mnt/${BUNDLE_PREFIX}-cifs-$$" + mkdir -p "$NFS_MOUNTPOINT" + local opts="vers=3.0" + if [ -n "$cifs_user" ]; then + opts="${opts},username=${cifs_user},password=${cifs_pass}" + else + opts="${opts},guest" + fi + msg_info "Mounting ${cifs_unc}" + if mount -t cifs "$cifs_unc" "$NFS_MOUNTPOINT" -o "$opts" 2>/tmp/host-migrate-cifs.log; then + NFS_MOUNTED=1 + msg_ok "Mounted CIFS share at ${NFS_MOUNTPOINT}" + BASE_DIR="$NFS_MOUNTPOINT" + else + msg_error "Could not mount ${cifs_unc} (see /tmp/host-migrate-cifs.log)" + rmdir "$NFS_MOUNTPOINT" 2>/dev/null + sleep 2 + return 1 + fi else local path path=$(whiptail --backtitle "$BACKTITLE" --inputbox \ @@ -394,6 +450,32 @@ function collect_guests { fi } +# Record bridges and storages referenced by a guest config into the bundle's +# requirements file, so the import side can pre-flight them on the new host. +# $1=type(vm|ct) $2=id $3=requirements_file +function capture_guest_requirements { + local type="$1" id="$2" reqfile="$3" conf bridges storages + if [ "$type" = "vm" ]; then + conf=$(qm config "$id" 2>/dev/null) + else + conf=$(pct config "$id" 2>/dev/null) + fi + [ -z "$conf" ] && return + # bridges from netX: ...,bridge=vmbrY,... + bridges=$(echo "$conf" | grep -oE 'bridge=[^,]+' | cut -d= -f2 | sort -u | tr '\n' ' ') + # storages: parse disk-bearing keys and take the storage id before the colon. + # Handles block (local-lvm:vm-100-disk-0) and file (local:100/...) volumes, + # while ignoring non-volume values (e.g. "none,media=cdrom") and MAC addresses. + storages=$(echo "$conf" | + grep -E '^(scsi|sata|ide|virtio|efidisk|tpmstate|rootfs|mp|unused)[0-9]*:' | + sed -E 's/^[^:]+:[[:space:]]*//' | + cut -d, -f1 | + grep -E '^[a-zA-Z0-9_.-]+:' | + cut -d: -f1 | + sort -u | tr '\n' ' ') + echo -e "${type}\t${id}\tbridges=${bridges}\tstorages=${storages}" >>"$reqfile" +} + # ---------------------------------------------------------------------------- # EXPORT # ---------------------------------------------------------------------------- @@ -425,36 +507,61 @@ function do_export { # --- Component selection ------------------------------------------------- local components components=$(whiptail --backtitle "$BACKTITLE" --title "Export Components" --checklist \ - "\nSelect what to export into the bundle:" 18 78 7 \ - "hostcfg" "Host configs (/etc/pve, network, storage, users, fw)" ON \ - "etc" "Full /etc tarball (extra safety net)" OFF \ + "\nSelect what to export into the bundle (SPACE toggles):" 22 86 13 \ + "pvecfg" "All Proxmox config /etc/pve (guests,storage,users,fw,jobs,sdn)" ON \ + "network" "Network config (/etc/network/interfaces*)" ON \ + "hostid" "Hostname + /etc/hosts" ON \ + "dns" "DNS (/etc/resolv.conf)" ON \ "ssh" "SSH host keys + /root/.ssh" ON \ - "apt" "APT sources + installed package list" ON \ + "apt" "APT sources (/etc/apt: sources.list + .list.d + keyrings)" ON \ + "pkglist" "Installed package list (dpkg selections)" ON \ + "cron" "Cron jobs (/etc/cron*, user crontabs)" ON \ + "systemd" "Custom systemd units (/etc/systemd/system)" OFF \ + "roothome" "/root dotfiles (.bashrc, .profile, .bash_aliases)" OFF \ + "etcfull" "Full /etc tarball (extra safety net)" OFF \ "guests" "LXC / VM guests" ON \ 3>&1 1>&2 2>&3) || return components="${components//\"/}" + [ -z "$components" ] && { + msg_warn "Nothing selected" + sleep 2 + return + } + mlog "export bundle=$bundle components=$components" - # --- Host config --------------------------------------------------------- - if [[ "$components" == *hostcfg* ]]; then - msg_info "Collecting host configuration" - mkdir -p "$bundle/host/etc-pve" "$bundle/host/network" + # --- /etc/pve (full PVE config) ------------------------------------------ + if [[ "$components" == *pvecfg* ]]; then + msg_info "Collecting Proxmox config (/etc/pve)" + mkdir -p "$bundle/host/etc-pve" # pmxcfs content is readable as normal files [ -d /etc/pve ] && cp -a /etc/pve/. "$bundle/host/etc-pve/" 2>/dev/null + msg_ok "Collected /etc/pve" + fi + + # --- Network ------------------------------------------------------------- + if [[ "$components" == *network* ]]; then + msg_info "Collecting network config" + mkdir -p "$bundle/host/network" [ -f /etc/network/interfaces ] && cp -a /etc/network/interfaces "$bundle/host/network/" [ -d /etc/network/interfaces.d ] && cp -a /etc/network/interfaces.d "$bundle/host/network/" 2>/dev/null + msg_ok "Collected network config" + fi + + # --- Hostname / hosts ---------------------------------------------------- + if [[ "$components" == *hostid* ]]; then [ -f /etc/hostname ] && cp -a /etc/hostname "$bundle/host/" [ -f /etc/hosts ] && cp -a /etc/hosts "$bundle/host/" - [ -f /etc/resolv.conf ] && cp -a /etc/resolv.conf "$bundle/host/" 2>/dev/null - msg_ok "Collected host configuration" + msg_ok "Collected hostname + hosts" fi - if [[ "$components" == *etc* ]]; then - msg_info "Creating /etc tarball" - tar -czf "$bundle/host/etc-full.tar.gz" --absolute-names /etc 2>/dev/null - msg_ok "Created /etc tarball" + # --- DNS ----------------------------------------------------------------- + if [[ "$components" == *dns* ]]; then + cp -aL /etc/resolv.conf "$bundle/host/resolv.conf" 2>/dev/null + msg_ok "Collected DNS" fi + # --- SSH ----------------------------------------------------------------- if [[ "$components" == *ssh* ]]; then msg_info "Collecting SSH keys" mkdir -p "$bundle/host/ssh" @@ -463,18 +570,65 @@ function do_export { msg_ok "Collected SSH keys" fi + # --- APT ----------------------------------------------------------------- if [[ "$components" == *apt* ]]; then - msg_info "Collecting APT state" + msg_info "Collecting APT sources" mkdir -p "$bundle/host/apt" cp -a /etc/apt/sources.list "$bundle/host/apt/" 2>/dev/null cp -a /etc/apt/sources.list.d "$bundle/host/apt/" 2>/dev/null + cp -a /etc/apt/trusted.gpg.d "$bundle/host/apt/" 2>/dev/null + [ -d /etc/apt/keyrings ] && cp -a /etc/apt/keyrings "$bundle/host/apt/" 2>/dev/null + msg_ok "Collected APT sources" + fi + + if [[ "$components" == *pkglist* ]]; then + mkdir -p "$bundle/host/apt" dpkg --get-selections >"$bundle/host/apt/packages.selections" 2>/dev/null - msg_ok "Collected APT state" + msg_ok "Collected package list" + fi + + # --- Cron ---------------------------------------------------------------- + if [[ "$components" == *cron* ]]; then + msg_info "Collecting cron jobs" + mkdir -p "$bundle/host/cron" + [ -f /etc/crontab ] && cp -a /etc/crontab "$bundle/host/cron/" + local cdir + for cdir in /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly; do + [ -d "$cdir" ] && cp -a "$cdir" "$bundle/host/cron/" 2>/dev/null + done + [ -d /var/spool/cron/crontabs ] && cp -a /var/spool/cron/crontabs "$bundle/host/cron/user-crontabs" 2>/dev/null + msg_ok "Collected cron jobs" + fi + + # --- Custom systemd units ------------------------------------------------ + if [[ "$components" == *systemd* ]]; then + msg_info "Collecting custom systemd units" + mkdir -p "$bundle/host/systemd" + cp -a /etc/systemd/system "$bundle/host/systemd/etc-systemd-system" 2>/dev/null + msg_ok "Collected systemd units" + fi + + # --- root dotfiles ------------------------------------------------------- + if [[ "$components" == *roothome* ]]; then + mkdir -p "$bundle/host/roothome" + local rf + for rf in .bashrc .profile .bash_aliases .vimrc .tmux.conf; do + [ -f "/root/$rf" ] && cp -a "/root/$rf" "$bundle/host/roothome/" 2>/dev/null + done + msg_ok "Collected /root dotfiles" + fi + + # --- Full /etc tarball --------------------------------------------------- + if [[ "$components" == *etcfull* ]]; then + msg_info "Creating /etc tarball" + tar -czf "$bundle/host/etc-full.tar.gz" --absolute-names /etc 2>/dev/null + msg_ok "Created /etc tarball" fi # --- Guests -------------------------------------------------------------- local guest_method="" guest_mode="snapshot" : >"$bundle/guests.tsv" + : >"$bundle/host/guest-requirements.info" if [[ "$components" == *guests* ]]; then collect_guests if [ "${#GUEST_ROWS[@]}" -eq 0 ]; then @@ -520,6 +674,9 @@ function do_export { if [ "$r_type" = "$type" ] && [ "$r_id" = "$id" ]; then name="$r_name"; fi done + # Record bridge/storage requirements for the import preflight. + capture_guest_requirements "$type" "$id" "$bundle/host/guest-requirements.info" + if [ "$guest_method" = "vzdump" ]; then msg_info "vzdump ${type} ${id} (${name})" if vzdump "$id" --dumpdir "$bundle/guests" --mode "$guest_mode" --compress zstd &>>"$bundle/guests/vzdump.log"; then @@ -556,25 +713,67 @@ function do_export { fi fi + # --- Reference info ------------------------------------------------------ + ip -br link >"$bundle/host/network-links.info" 2>/dev/null + ip -br addr >"$bundle/host/network-addr.info" 2>/dev/null + pvesm status >"$bundle/host/storage.info" 2>/dev/null + ip -br link 2>/dev/null | awk '$1 ~ /^vmbr/ {print $1}' >"$bundle/host/bridges.info" 2>/dev/null + + # Cluster detection (corosync) - relevant warning for the import side. + local in_cluster="no" + [ -f /etc/pve/corosync.conf ] && in_cluster="yes" + # --- Manifest ------------------------------------------------------------ { echo "EXPORT_HOSTNAME=\"$(hostname)\"" echo "EXPORT_DATE=\"$(date -Iseconds)\"" echo "EXPORT_PVE_VERSION=\"$(pveversion | head -n1)\"" + echo "EXPORT_KERNEL=\"$(uname -r)\"" echo "EXPORT_ARCH=\"$(uname -m)\"" echo "EXPORT_COMPONENTS=\"${components}\"" echo "EXPORT_GUEST_METHOD=\"${guest_method}\"" + echo "EXPORT_GUEST_MODE=\"${guest_mode}\"" + echo "EXPORT_IN_CLUSTER=\"${in_cluster}\"" + echo "EXPORT_TOOL_VERSION=\"1\"" } >"$bundle/manifest.env" - ip -br link >"$bundle/host/network-links.info" 2>/dev/null - ip -br addr >"$bundle/host/network-addr.info" 2>/dev/null - pvesm status >"$bundle/host/storage.info" 2>/dev/null + # --- Integrity: checksums ------------------------------------------------ + msg_info "Generating checksums" + (cd "$bundle" && find . -type f ! -name 'sha256sums.txt' -print0 | + xargs -0 sha256sum >"sha256sums.txt" 2>/dev/null) + msg_ok "Wrote sha256sums.txt" + mlog "export finished bundle=$bundle" + + # --- Optional: push bundle to a remote host via rsync/SSH ---------------- + if whiptail --backtitle "$BACKTITLE" --yesno \ + "Also copy this bundle to a remote host via SSH/rsync now?\n\n(e.g. directly to the NEW Proxmox target)" 11 72; then + local rhost rpath + rhost=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nRemote target (user@host):\ne.g. root@192.168.1.50" 11 68 \ + --title "SSH Target" 3>&1 1>&2 2>&3) || rhost="" + rpath=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nRemote directory:\ne.g. /mnt/backup/" 11 68 \ + --title "Remote Path" "/root/" 3>&1 1>&2 2>&3) || rpath="" + if [ -n "$rhost" ] && [ -n "$rpath" ]; then + if ! command -v rsync >/dev/null 2>&1; then + msg_info "Installing rsync" + apt-get install -y rsync &>/dev/null && msg_ok "Installed rsync" + fi + msg_info "Copying bundle to ${rhost}:${rpath}" + if rsync -aH --info=progress2 "$bundle" "${rhost}:${rpath}" 2>>"$LOGFILE"; then + msg_ok "Copied bundle to ${rhost}:${rpath}" + else + msg_error "rsync failed (see $LOGFILE)" + fi + fi + fi header_info msg_ok "Export finished" - echo -e "\nBundle: \e[1;33m${bundle}\e[0m\n" + echo -e "\nBundle: \e[1;33m${bundle}\e[0m" + echo -e "Logfile: \e[1;33m${LOGFILE}\e[0m\n" if [ "$NFS_MOUNTED" -eq 1 ]; then - echo -e "Copied to NFS share (will be unmounted on exit).\n" + echo -e "Network share will be unmounted on exit.\n" fi read -rp "Press ENTER to return to the menu..." } @@ -731,12 +930,17 @@ function import_hostcfg { local sel sel=$(whiptail --backtitle "$BACKTITLE" --title "Restore Host Configuration" --checklist \ - "\nSelect host components to restore.\nNETWORK and HOSTNAME are DANGEROUS - read the warnings!" 20 82 8 \ + "\nSelect host components to restore (SPACE toggles).\nNETWORK and HOSTNAME are DANGEROUS - read the warnings!" 23 84 12 \ "storage" "storage.cfg (storage definitions)" OFF \ "users" "user.cfg / firewall (PVE users + ACLs)" OFF \ "ssh" "SSH host keys + /root/.ssh" OFF \ - "apt" "APT sources + package selections" OFF \ + "apt" "APT sources + keyrings" OFF \ + "pkginstall" "Re-install package list (apt, needs network)" OFF \ + "dns" "/etc/resolv.conf" OFF \ "hosts" "/etc/hosts" OFF \ + "cron" "Cron jobs (/etc/cron*, user crontabs)" OFF \ + "systemd" "Custom systemd units (/etc/systemd/system)" OFF \ + "roothome" "/root dotfiles" OFF \ "network" "/etc/network/interfaces (!! DANGER !!)" OFF \ "hostname" "hostname (!! DANGER !!)" OFF \ 3>&1 1>&2 2>&3) || return @@ -766,13 +970,62 @@ function import_hostcfg { if [[ "$sel" == *apt* ]]; then [ -f "$bundle/host/apt/sources.list" ] && cp "$bundle/host/apt/sources.list" /etc/apt/sources.list && msg_ok "Restored sources.list" [ -d "$bundle/host/apt/sources.list.d" ] && cp -a "$bundle/host/apt/sources.list.d/." /etc/apt/sources.list.d/ 2>/dev/null && msg_ok "Restored sources.list.d" - msg_warn "Package selections saved as reference: apt/packages.selections (not auto-installed)" + [ -d "$bundle/host/apt/trusted.gpg.d" ] && cp -a "$bundle/host/apt/trusted.gpg.d/." /etc/apt/trusted.gpg.d/ 2>/dev/null && msg_ok "Restored APT trusted keys" + [ -d "$bundle/host/apt/keyrings" ] && mkdir -p /etc/apt/keyrings && cp -a "$bundle/host/apt/keyrings/." /etc/apt/keyrings/ 2>/dev/null && msg_ok "Restored APT keyrings" + fi + + if [[ "$sel" == *pkginstall* ]]; then + if [ -f "$bundle/host/apt/packages.selections" ]; then + if whiptail --backtitle "$BACKTITLE" --yesno \ + "Re-install the package selection from the source host?\nThis runs apt-get and needs working network + repos." 10 74; then + msg_info "Updating package lists" + apt-get update &>>"$LOGFILE" + msg_info "Applying package selections" + dpkg --set-selections <"$bundle/host/apt/packages.selections" 2>>"$LOGFILE" + if DEBIAN_FRONTEND=noninteractive apt-get -y dselect-upgrade &>>"$LOGFILE"; then + msg_ok "Package selection applied" + else + msg_error "apt dselect-upgrade had errors (see $LOGFILE)" + fi + fi + else + msg_error "No package list in bundle" + fi + fi + + if [[ "$sel" == *dns* ]]; then + [ -f "$bundle/host/resolv.conf" ] && cp /etc/resolv.conf "/etc/resolv.conf.bak.$(date +%s)" 2>/dev/null + [ -f "$bundle/host/resolv.conf" ] && cp "$bundle/host/resolv.conf" /etc/resolv.conf && msg_ok "Restored /etc/resolv.conf" fi if [[ "$sel" == *hosts* ]]; then [ -f "$bundle/host/hosts" ] && cp /etc/hosts "/etc/hosts.bak.$(date +%s)" && cp "$bundle/host/hosts" /etc/hosts && msg_ok "Restored /etc/hosts" fi + if [[ "$sel" == *cron* ]]; then + [ -f "$bundle/host/cron/crontab" ] && cp "$bundle/host/cron/crontab" /etc/crontab && msg_ok "Restored /etc/crontab" + local cd2 + for cd2 in cron.d cron.hourly cron.daily cron.weekly cron.monthly; do + [ -d "$bundle/host/cron/$cd2" ] && cp -a "$bundle/host/cron/$cd2/." "/etc/$cd2/" 2>/dev/null + done + [ -d "$bundle/host/cron/user-crontabs" ] && mkdir -p /var/spool/cron/crontabs && cp -a "$bundle/host/cron/user-crontabs/." /var/spool/cron/crontabs/ 2>/dev/null + msg_ok "Restored cron jobs" + fi + + if [[ "$sel" == *systemd* ]]; then + if [ -d "$bundle/host/systemd/etc-systemd-system" ]; then + cp -a "$bundle/host/systemd/etc-systemd-system/." /etc/systemd/system/ 2>/dev/null + systemctl daemon-reload 2>/dev/null + msg_ok "Restored systemd units (review + enable manually)" + else + msg_error "No systemd units in bundle" + fi + fi + + if [[ "$sel" == *roothome* ]]; then + [ -d "$bundle/host/roothome" ] && cp -a "$bundle/host/roothome/." /root/ 2>/dev/null && msg_ok "Restored /root dotfiles" + fi + if [[ "$sel" == *network* ]]; then import_network "$bundle" fi @@ -861,7 +1114,35 @@ function do_import { # shellcheck disable=SC1090 source "$BUNDLE/manifest.env" 2>/dev/null whiptail --backtitle "$BACKTITLE" --title "Bundle Information" --scrolltext --msgbox \ - "Origin host : ${EXPORT_HOSTNAME:-?}\nExported : ${EXPORT_DATE:-?}\nPVE version : ${EXPORT_PVE_VERSION:-?}\nArch : ${EXPORT_ARCH:-?}\nComponents : ${EXPORT_COMPONENTS:-?}\nGuest method: ${EXPORT_GUEST_METHOD:-?}\n\nThis (target) host: $(hostname) / $(pveversion | head -n1)" 18 82 + "Origin host : ${EXPORT_HOSTNAME:-?}\nExported : ${EXPORT_DATE:-?}\nPVE version : ${EXPORT_PVE_VERSION:-?}\nKernel : ${EXPORT_KERNEL:-?}\nArch : ${EXPORT_ARCH:-?}\nComponents : ${EXPORT_COMPONENTS:-?}\nGuest method: ${EXPORT_GUEST_METHOD:-?}\nFrom cluster: ${EXPORT_IN_CLUSTER:-?}\n\nThis (target) host: $(hostname) / $(pveversion | head -n1)" 20 84 + mlog "import bundle=$BUNDLE origin=${EXPORT_HOSTNAME:-?}" + + # Architecture mismatch warning + if [ -n "${EXPORT_ARCH:-}" ] && [ "${EXPORT_ARCH}" != "$(uname -m)" ]; then + whiptail --backtitle "$BACKTITLE" --title "Architecture Mismatch" --msgbox \ + "Source arch (${EXPORT_ARCH}) differs from this host ($(uname -m)).\nVM/CT restores may not be portable." 10 74 + fi + + # Cluster warning + if [ "${EXPORT_IN_CLUSTER:-no}" = "yes" ]; then + whiptail --backtitle "$BACKTITLE" --title "!! Cluster Source !!" --scrolltext --msgbox \ + "The source host was part of a Proxmox CLUSTER.\n\nNEVER restore corosync.conf onto a standalone/new host - it will break clustering. This tool will NOT restore cluster membership; only standalone configs are offered.\n\nJoin the new host to a cluster manually if required." 15 80 + fi + + # Integrity verify (optional) + if [ -f "$BUNDLE/sha256sums.txt" ]; then + if whiptail --backtitle "$BACKTITLE" --yesno "Verify bundle integrity (sha256) before restoring?" 9 70; then + msg_info "Verifying checksums" + if (cd "$BUNDLE" && sha256sum --quiet -c sha256sums.txt) &>/tmp/host-migrate-verify.log; then + msg_ok "Integrity OK" + else + msg_error "Checksum verification FAILED (see /tmp/host-migrate-verify.log)" + if ! whiptail --backtitle "$BACKTITLE" --yesno "Integrity check FAILED. Continue anyway?" 9 70; then + return + fi + fi + fi + fi # Preflight: storage comparison if [ -f "$BUNDLE/host/storage.info" ]; then @@ -878,6 +1159,21 @@ function do_import { fi fi + # Preflight: bridges + storages actually used by the exported guests + if [ -f "$BUNDLE/host/guest-requirements.info" ]; then + local need_bridges need_stores cur_bridges cur_stores2 miss_b="" miss_s="" b st + need_bridges=$(awk -F'\t' '{sub(/^bridges=/,"",$3); print $3}' "$BUNDLE/host/guest-requirements.info" | tr ' ' '\n' | sort -u | grep -v '^$') + need_stores=$(awk -F'\t' '{sub(/^storages=/,"",$4); print $4}' "$BUNDLE/host/guest-requirements.info" | tr ' ' '\n' | sort -u | grep -v '^$') + cur_bridges=$(ip -br link 2>/dev/null | awk '{print $1}') + cur_stores2=$(pvesm status 2>/dev/null | awk 'NR>1 {print $1}') + for b in $need_bridges; do grep -qx "$b" <<<"$cur_bridges" || miss_b+="$b "; done + for st in $need_stores; do grep -qx "$st" <<<"$cur_stores2" || miss_s+="$st "; done + if [ -n "$miss_b" ] || [ -n "$miss_s" ]; then + whiptail --backtitle "$BACKTITLE" --title "!! Guest Preflight !!" --scrolltext --msgbox \ + "Some guests reference resources that are MISSING on this host.\nThey may restore but FAIL TO START until you create them:\n\nMissing bridges : ${miss_b:-none}\nMissing storages: ${miss_s:-none}\n\nCreate the bridges (e.g. vmbr0) and storages first, or adjust the guest configs after restore." 18 82 + fi + fi + while true; do local choice choice=$(whiptail --backtitle "$BACKTITLE" --title "Import Menu" --menu \ @@ -894,21 +1190,56 @@ function do_import { done } +# ---------------------------------------------------------------------------- +# CLEANUP / RETENTION +# ---------------------------------------------------------------------------- +function do_cleanup { + choose_location "Manage / Cleanup Bundles" "/mnt/" || return + local menu=() d size + while IFS= read -r d; do + [ -f "$d/manifest.env" ] || continue + size=$(du -sh "$d" 2>/dev/null | awk '{print $1}') + menu+=("$d" "${size:-?}" OFF) + done < <(find "$BASE_DIR" -maxdepth 2 -type d -name "${BUNDLE_PREFIX}-*" 2>/dev/null | sort) + + if [ "${#menu[@]}" -eq 0 ]; then + msg_error "No migration bundles found under ${BASE_DIR}" + sleep 2 + return + fi + + local todelete + todelete=$(whiptail --backtitle "$BACKTITLE" --title "Delete Bundles" --checklist \ + "\nSelect bundles to DELETE permanently:" 20 100 10 "${menu[@]}" 3>&1 1>&2 2>&3) || return + todelete="${todelete//\"/}" + [ -z "$todelete" ] && return + + if whiptail --backtitle "$BACKTITLE" --yesno "Permanently delete the selected bundle(s)?" 9 70; then + local b + for b in $todelete; do + rm -rf "$b" && msg_ok "Deleted $(basename "$b")" && mlog "deleted bundle $b" + done + read -rp "Press ENTER to continue..." + fi +} + # ---------------------------------------------------------------------------- # Main menu # ---------------------------------------------------------------------------- while true; do header_info ACTION=$(whiptail --backtitle "$BACKTITLE" --title "Proxmox VE Host Migrate" --menu \ - "\nExport this host or import a bundle onto a new host." 15 78 3 \ - "export" "Export host + guests to a bundle (mount/SSD/NFS)" \ + "\nExport this host or import a bundle onto a new host." 16 78 4 \ + "export" "Export host + guests to a bundle (mount/SSD/NFS/CIFS)" \ "import" "Import a bundle onto THIS host" \ + "cleanup" "Manage / delete old bundles" \ "quit" "Exit" \ 3>&1 1>&2 2>&3) || break case "$ACTION" in export) do_export ;; import) do_import ;; + cleanup) do_cleanup ;; quit) break ;; esac done