clawdie-iso/firstboot/zfs-pool-migrate.sh
Sam & Claude 8cc7b2dcaf Wire ZFS pool detection into firstboot pipeline (Sam & Claude)
New shell-zfs.sh module: detects existing clawdie pool, presents
boot mode menu (install/upgrade/maintenance/shell). Runs as first
step in firstboot.sh before wizard. Upgrade mode loads existing
.env and skips wizard. Maintenance mode exec's to maintenance-mode.sh.

Also: fix POSIX herestrings in maintenance-mode.sh, fix paren
mismatch in snapshot age calc, add nda (NVMe) to disk detection
patterns across all ZFS scripts for FreeBSD 15.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 20:04:22 +02:00

431 lines
15 KiB
Bash

#!/bin/sh
# zfs-pool-migrate.sh — Pool migration wizard
#
# Handles three migration methods:
# 1. Disk-by-disk replacement (4 bays needed)
# 2. New pool + transfer (6 bays needed, or USB enclosures)
# 3. Backup + restore (external storage needed)
set -e
POOL_NAME="clawdie"
LOG="/var/log/clawdie-migrate.log"
. "/usr/local/share/clawdie-iso/build.cfg"
dialog() { bsddialog --backtitle "Clawdie Pool Migration" "$@" ; }
die() { echo "ERROR: $1" >&2; exit 1; }
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG"; }
get_disk_info() {
local disk=$1
local info=""
info+="DEVICE: $disk\n"
local model=$(camcontrol inquiry $disk 2>/dev/null | head -1)
[ -n "$model" ] && info+="MODEL: $model\n"
local serial=$(camcontrol identify $disk 2>/dev/null | grep -i 'serial number' | cut -d: -f2 | tr -d ' ')
[ -n "$serial" ] && info+="SERIAL: $serial\n"
local size_bytes=$(diskinfo $disk 2>/dev/null | awk '{print $3}')
if [ -n "$size_bytes" ]; then
local size_gb=$((size_bytes / 1073741824))
info+="SIZE: ${size_gb} GB\n"
fi
local temp=$(smartctl -A $disk 2>/dev/null | grep -i temperature | awk '{print $10}')
[ -n "$temp" ] && info+="TEMP: ${temp}°C\n"
local health=$(smartctl -H $disk 2>/dev/null | grep -E '^SMART overall-health' | awk '{print $NF}')
[ -n "$health" ] && info+="HEALTH: $health\n"
local slot=$(sesutil map 2>/dev/null | grep "^$disk" | awk '{print $3}')
[ -n "$slot" ] && info+="SLOT: $slot\n"
echo "$info"
}
get_pool_status() {
local status=$(zpool status $POOL_NAME 2>/dev/null)
echo "$status"
}
get_pool_usage() {
local used=$(zfs list -Hp -o used $POOL_NAME 2>/dev/null)
local avail=$(zfs list -Hp -o avail $POOL_NAME 2>/dev/null)
local total=$((used + avail))
local used_gb=$((used / 1073741824))
local total_gb=$((total / 1073741824))
local percent=$((used * 100 / total))
echo "USED: ${used_gb} GB / ${total_gb} GB (${percent}%)"
}
count_disks_in_pool() {
zpool status $POOL_NAME 2>/dev/null | grep -E '^\s+(ada|da|nvd|nda)' | wc -l
}
get_pool_disks() {
zpool status $POOL_NAME 2>/dev/null | grep -E '^\s+(ada|da|nvd|nda)' | awk '{print $1}'
}
get_available_disks() {
local pool_disks=$(get_pool_disks | sort -u)
local all_disks=$(camcontrol devlist 2>/dev/null | grep -oE '(ada|da|nvd|nda)' | awk '{print $NF}' | sort -u)
for d in $all_disks; do
if ! echo "$pool_disks" | grep -qw "$d"; then
echo "$d"
fi
done
}
format_size() {
local bytes=$1
if [ $bytes -ge 1099511627776 ]; then
echo "$((bytes / 1099511627776)) TB"
elif [ $bytes -ge 1073741824 ]; then
echo "$((bytes / 1073741824)) GB"
elif [ $bytes -ge 1048576 ]; then
echo "$((bytes / 1048576)) MB"
else
echo "$bytes bytes"
fi
}
method_disk_by_disk() {
log "Starting disk-by-disk migration"
local pool_disks=$(get_pool_disks)
local disk_count=$(echo "$pool_disks" | wc -l)
local available=$(get_available_disks)
if [ -z "$available" ]; then
dialog --msgbox \
"\nNo replacement disks available.\n\nConnect replacement disks and restart." \
10 50
return 1
fi
local step=1
local total_steps=$disk_count
while [ $step -le $total_steps ]; do
local current_disk=$(echo "$pool_disks" | sed -n "${step}p")
local disk_info=$(get_disk_info $current_disk)
local replacement=$(dialog --menu \
"Step ${step} of ${total_steps}: Replace ${current_disk}\n\n${disk_info}\n\nSelect replacement disk:" \
20 70 10 \
$(echo "$available" | while read -r disk; do echo "$disk \"$(get_disk_info $disk | head -1)\""; done) \
3>&1 1>&2 2>&3)
[ $? -ne 0 ] && return 1
local replacement_size=$(diskinfo $replacement 2>/dev/null | awk '{print $3}')
local current_size=$(diskinfo $current_disk 2>/dev/null | awk '{print $3}')
if [ $replacement_size -lt $current_size ]; then
dialog --msgbox \
"\nReplacement disk is smaller than current disk.\n\nReplacement: $(format_size $replacement_size)\nCurrent: $(format_size $current_size)\n\nUse a disk of equal or larger size." \
12 60
continue
fi
local confirm=$(dialog --yesno \
"\nConfirm replacement:\n\n ${current_disk}${replacement}\n\nThis will take 4-8 hours.\nPool remains usable during resilver.\n\nContinue?" \
12 60)
[ "$confirm" != "yes" ] && continue
log "Replacing $current_disk with $replacement"
dialog --infobox "Starting resilver...\nThis screen will update periodically." 6 50
zpool replace $POOL_NAME $current_disk $replacement 2>&1 | tee -a "$LOG"
local progress=0
while true; do
local status=$(zpool status $POOL_NAME 2>/dev/null)
if echo "$status" | grep -q "resilver completed"; then
break
fi
local scan_line=$(echo "$status" | grep -A1 "scan:")
if echo "$scan_line" | grep -qE '[0-9]+%'; then
progress=$(echo "$scan_line" | grep -oE '[0-9]+' | head -1 | tr -d '%')
dialog --infobox "Resilver in progress: ${progress}%\n\nReplacing: ${current_disk}${replacement}\n\nDo not power off." 6 50
fi
sleep 30
done
log "Resilver completed for $current_disk$replacement"
pool_disks=$(echo "$pool_disks" | sed "s/${current_disk}/${replacement}/")
step=$((step + 1))
done
dialog --msgbox \
"\nDisk-by-disk migration complete!\n\nAll disks have been replaced.\nPool remains redundant throughout the process.\n\nConsider running:\n zpool scrub $POOL_NAME\n\nto verify data integrity." \
15 60
log "Disk-by-disk migration completed successfully"
return 0
}
method_new_pool_transfer() {
log "Starting new pool + transfer migration"
local available=$(get_available_disks | sort -u)
local available_count=$(echo "$available" | wc -l)
if [ $available_count -lt 3 ]; then
local use_usb=$(dialog --yesno \
"\nNot enough internal disks for new pool.\n\nFound: ${available_count} available\nNeeded: 3 disks\n\nUse USB enclosures for additional disks?" \
12 60)
if [ "$use_usb" != "yes" ]; then
dialog --msgbox \
"\nCannot proceed.\n\nConnect additional disks (USB enclosures work) and restart.\n\nAlternatively, use disk-by-disk method (4 bays) or backup/restore (external storage)." \
12 60
return 1
fi
fi
local selected=$(dialog --checklist \
"Select 3 disks for new migration pool:\n\nThese disks will be ERASED." \
20 70 10 \
$(echo "$available" | while read -r disk; do echo "$disk \"$(get_disk_info $disk | grep MODEL | cut -d: -f2-)\" off"; done) \
3>&1 1>&2 2>&3)
[ $? -ne 0 ] && return 1
local selected_count=$(echo "$selected" | wc -w)
if [ $selected_count -ne 3 ]; then
dialog --msgbox "\nPlease select exactly 3 disks.\n\nSelected: ${selected_count}" 10 50
return 1
fi
local raid_type=$(dialog --radiolist \
"RAID level for new pool:" 12 60 3 \
"raidz1" "3 disks, 1 fault tolerance (recommended)" on \
"raidz2" "4+ disks, 2 fault tolerance" off \
"mirror" "2 disks, 1 fault tolerance, faster reads" off \
3>&1 1>&2 2>&3)
[ $? -ne 0 ] && return 1
local confirm=$(dialog --yesno \
"\nCreate new pool and transfer data?\n\nNew pool: clawdie-new\nRAID: ${raid_type}\nDisks: ${selected}\n\n⚠ All data on selected disks will be DESTROYED.\n\nTransfer time: ~4-8 hours" \
15 70)
[ "$confirm" != "yes" ] && return 1
log "Creating new pool clawdie-new with disks: $selected"
local disk_args=""
for disk in $selected; do
disk_args="$disk_args /dev/$disk"
done
zpool create clawdie-new $raid_type $disk_args 2>&1 | tee -a "$LOG"
[ $? -ne 0 ] && die "Failed to create new pool"
dialog --infobox "Transferring data...\nThis will take 4-8 hours.\nProgress shown periodically." 6 60
log "Starting zfs send/recv from $POOL_NAME to clawdie-new"
local progress_file="/tmp/zfs-transfer-progress"
zfs snapshot -r $POOL_NAME@migration-transfer 2>&1 | tee -a "$LOG"
( zfs send -Rv $POOL_NAME@migration-transfer 2>&1 | tee "$progress_file" | \
pv -l -F -p -b -t -r -i > "$progress_file.stats" 2>/dev/null | \
zfs recv -v clawdie-new 2>&1 | tee -a "$LOG" ) || \
zfs send -R $POOL_NAME@migration-transfer 2>&1 | zfs recv -F clawdie-new 2>&1 | tee -a "$LOG"
[ $? -ne 0 ] && die "Transfer failed"
log "Transfer completed"
local verify=$(dialog --yesno \
"\nData transfer complete!\n\nVerify data integrity? (recommended)\nThis compares source and destination." \
10 60)
if [ "$verify" = "yes" ]; then
dialog --infobox "Verifying data integrity..." 5 50
zfs diff $POOL_NAME@migration-transfer clawdie-new 2>&1 | tee -a "$LOG"
fi
local destroy_old=$(dialog --yesno \
"\nMigration complete!\n\nOld pool '$POOL_NAME' is still intact.\n\nDestroy old pool now?\n(Recommended: keep old disks as backup for 1-2 weeks)" \
12 70)
if [ "$destroy_old" = "yes" ]; then
log "Destroying old pool $POOL_NAME"
zpool export $POOL_NAME 2>&1 | tee -a "$LOG"
dialog --msgbox \
"\nOld pool exported.\n\nOld disks can now be removed.\n\nNew pool 'clawdie-new' needs to be renamed:\n\n zpool export clawdie-new\n zpool import clawdie-new clawdie" \
12 60
else
dialog --msgbox \
"\nBoth pools exist:\n - $POOL_NAME (old)\n - clawdie-new (new)\n\nTo complete migration:\n 1. Boot from USB\n 2. Export old pool\n 3. Rename new pool:\n zpool export clawdie-new\n zpool import clawdie-new clawdie\n 4. Reboot" \
15 70
fi
log "New pool + transfer migration completed"
return 0
}
method_backup_restore() {
log "Starting backup + restore migration"
local backup_targets=""
backup_targets+="local External USB drive\n"
backup_targets+="network Network (SSH/SCP)\n"
backup_targets+="zfs Another ZFS pool\n"
local dest_type=$(dialog --menu \
"Select backup destination:" 12 60 3 \
$backup_targets \
3>&1 1>&2 2>&3)
[ $? -ne 0 ] && return 1
local dest_path=""
local dest_size=0
case "$dest_type" in
"local")
local usb_disks=$(camcontrol devlist 2>/dev/null | grep -oE '(da|ada)[0-9]+' | sort -u)
if [ -z "$usb_disks" ]; then
dialog --msgbox "\nNo external drives detected.\n\nConnect external drive and restart." 10 50
return 1
fi
dest_path=$(dialog --menu \
"Select external drive:" 12 60 5 \
$(echo "$usb_disks" | while read -r disk; do echo "$disk \"$(get_disk_info $disk | grep MODEL | cut -d: -f2-)\""; done) \
3>&1 1>&2 2>&3)
[ $? -ne 0 ] && return 1
dest_path="/dev/$dest_path"
dest_size=$(diskinfo $dest_path 2>/dev/null | awk '{print $3}')
;;
"network")
dest_path=$(dialog --inputbox \
"Network destination (user@host:/path):" 8 60 \
"user@backup-server:/backups/clawdie" \
3>&1 1>&2 2>&3)
[ $? -ne 0 ] && return 1
;;
"zfs")
local other_pools=$(zpool import 2>/dev/null | grep "pool:" | awk '{print $2}' | grep -v "^$POOL_NAME$")
if [ -z "$other_pools" ]; then
dialog --msgbox "\nNo other pools available.\n\nCreate a backup pool first." 10 50
return 1
fi
dest_path=$(dialog --menu \
"Select backup pool:" 10 60 3 \
$(echo "$other_pools" | while read -r pool; do echo "$pool \"ZFS pool\""; done) \
3>&1 1>&2 2>&3)
[ $? -ne 0 ] && return 1
;;
esac
local pool_used=$(zfs list -Hp -o used $POOL_NAME 2>/dev/null)
local pool_used_gb=$((pool_used / 1073741824))
local confirm=$(dialog --yesno \
"\nBackup configuration:\n\n Source: $POOL_NAME (${pool_used_gb} GB)\n Destination: $dest_path\n\nTime: ~4-8 hours\n\nStart backup?" \
12 60)
[ "$confirm" != "yes" ] && return 1
log "Starting backup to $dest_path"
dialog --infobox "Creating backup...\nThis will take 4-8 hours." 6 50
case "$dest_type" in
"local")
local mount_point="/mnt/backup"
mkdir -p "$mount_point"
newfs "$dest_path" 2>&1 | tee -a "$LOG" || mount -t ufs "$dest_path" "$mount_point" 2>&1 | tee -a "$LOG"
zfs send -R $POOL_NAME@backup-$(date +%Y.%m.%d) > "$mount_point/clawdie-backup.zfs" 2>&1 | tee -a "$LOG"
umount "$mount_point"
;;
"network")
zfs send -R $POOL_NAME@backup-$(date +%Y.%m.%d) | ssh "$dest_path" "cat > clawdie-backup.zfs" 2>&1 | tee -a "$LOG"
;;
"zfs")
zfs send -R $POOL_NAME@backup-$(date +%Y.%m.%d) | zfs recv "${dest_path}/clawdie-backup" 2>&1 | tee -a "$LOG"
;;
esac
[ $? -ne 0 ] && die "Backup failed"
log "Backup completed"
dialog --msgbox \
"\nBackup complete!\n\nNow:\n 1. Power off\n 2. Replace old disks with new disks\n 3. Boot USB again\n 4. Select 'Restore from backup'\n\nOld pool will be destroyed." \
12 60
log "Backup + restore migration - backup phase completed"
return 0
}
main() {
log "Pool migration wizard started"
kldload zfs 2>/dev/null || true
if ! zpool list $POOL_NAME >/dev/null 2>&1; then
dialog --msgbox \
"\nPool '$POOL_NAME' not found.\n\nMigration requires an existing pool." \
10 50
return 1
fi
local pool_status=$(get_pool_status)
local pool_usage=$(get_pool_usage)
local disk_count=$(count_disks_in_pool)
local method=$(dialog --radiolist \
"Pool Migration\n\nPool: $POOL_NAME\n${pool_usage}\nDisks: ${disk_count}\n\nSelect migration method:" \
18 70 3 \
"disk-by-disk" "Replace one disk at a time (4 bays)" on \
"new-pool" "Create new pool + transfer (6 bays)" off \
"backup-restore" "Backup + restore (external storage)" off \
3>&1 1>&2 2>&3)
[ $? -ne 0 ] && return 1
case "$method" in
"disk-by-disk")
method_disk_by_disk
;;
"new-pool")
method_new_pool_transfer
;;
"backup-restore")
method_backup_restore
;;
*)
die "Unknown method: $method"
;;
esac
}
main "$@"