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>
431 lines
15 KiB
Bash
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 "$@"
|