Script: Migrating data between drives.

My main server at home has a simple 48TB mergerfs pool where my non-critical data is stored. In the past, I’ve just manually run rsync to move data over. This time, I opted to put together a script to make the process a little easier.

Disclaimer: I can’t stand AI, but in the past month or two, I’ve found ChatGPT4 (which I’ve now decided to call SKYNET moving forward) pretty handy for creating quick scripts like this so most of this was generated and sculpted by wasting tremendous environmental resources instead of investing the time to Google and come up with what I wanted by hand. That said, getting a script to perform and appear exactly as you envision still involves a lot of back and forth to get dialed in.

I downed all of my Docker containers that reference the mount point of my mergerfs pool, then used the following script to migrate data from the old 10TB drives to the new 12TB drives. The script offers the user the ability to save a log file, then presents all attached storage devices to the user with assigned numbers. The user chooses the drive(s) they’d like to migrate from, then the drive(s) to migrate to. We’re moving the contents of partitions here, not cloning the disks themselves.

Since my workflow sometimes involves connecting a drive via USB enclosure, the script will also tell you how the drive is connected (SATA, NVME, USB) and run a quick check with smartmontools to give an indicator of the drive’s perceived health status. If the partitions selected are not yet mounted, it will create a temporary mount path for the transfer.

Afterward, I’ll remove the now-empty drives and repopulate the mergerfs pool with the appropriate disk UUIDs, then run a balance & deduplicate and bring my Docker containers back online. One note: This script will only unmount file systems that it mounts during the execution. If you’re migrating to or from a mounted file system, you’ll have to unmount it yourself.

I ended up calling this “DiskHopper” and putting it on GitHub if you prefer to grab it that way. Original script below, just don’t forget to chmod +x!

#!/bin/bash

RSYNC_OPTS="-aAXH --whole-file --inplace --no-compress --info=stats2"
TEMP_MOUNT_BASE="/mnt/migratetemp"
TEMP_MOUNTS=()

LOG_ENABLED=false
LOG_FILE=""

RED='\033[1;31m'
GREEN='\033[1;32m'
YELLOW='\033[1;33m'
BLUE='\033[1;34m'
NC='\033[0m'

cleanup() {
    echo -e "\n${BLUE}Cleaning up temp mounts...${NC}"
    echo -e "${YELLOW}TEMP_MOUNTS to cleanup:${NC} ${TEMP_MOUNTS[*]}"
    
    for mnt in "${TEMP_MOUNTS[@]}"; do
        echo -e "${YELLOW}Unmounting $mnt...${NC}"
        sudo umount "$mnt" 2>/dev/null
        sudo rmdir "$mnt" 2>/dev/null
    done

    if [ -d "$TEMP_MOUNT_BASE" ]; then
        if [ -z "$(ls -A "$TEMP_MOUNT_BASE")" ]; then
            echo -e "${GREEN}✅ Temp mount base $TEMP_MOUNT_BASE is empty. Removing...${NC}"
            sudo rmdir "$TEMP_MOUNT_BASE"
        else
            echo -e "${RED}⚠️ Warning:${NC} $TEMP_MOUNT_BASE is not empty after cleanup."
            echo -e "${RED}⚠️ You may need to investigate or manually clean it up.${NC}"
            echo -e "${YELLOW}Suggested command:${NC} sudo rm -rf $TEMP_MOUNT_BASE/*"
        fi
    fi
}

trap cleanup SIGINT

log() {
    if $LOG_ENABLED; then
        echo -e "$@" >> "$LOG_FILE"
    fi
}

get_os_disks() {
    ROOT_DEV=$(findmnt -n -o SOURCE /)
    EFI_DEV=$(findmnt -n -o SOURCE /boot/efi 2>/dev/null)
    ROOT_DISK=$(lsblk -no PKNAME "$ROOT_DEV")
    EFI_DISK=$(lsblk -no PKNAME "$EFI_DEV" 2>/dev/null)

    echo "$ROOT_DISK $EFI_DISK" | tr ' ' '\n' | sort -u | grep -v '^$'
}

get_smart_status() {
    local disk="$1"
    if smartctl -H "$disk" &>/dev/null; then
        health=$(smartctl -H "$disk" | grep "SMART overall-health" | awk '{print $NF}')
        case "$health" in
            PASSED) echo "✅" ;;
            FAILED) echo "❌" ;;
            *) echo "⚠️" ;;
        esac
    else
        echo "⚠️"
    fi
}

truncate_text() {
    local text="$1"
    local length="$2"
    if [ ${#text} -gt $length ]; then
        echo "${text:0:$(($length - 3))}..."
    else
        printf "%-${length}s" "$text"
    fi
}

list_partitions() {
    OS_DISKS=($(get_os_disks))
    PARTITION_LIST=()

    echo -e "${BLUE}SMART Health Legend:${NC} ✅ Healthy   ⚠️ Unknown   ❌ Failed"
    echo -e "${BLUE}========== Available Partitions (Excluding OS Disk) ==========${NC}"
    printf "${BLUE}%-4s| %-15s| %-6s| %-17s| %-22s| %-15s| %-22s${NC}\n" \
        "#" "Partition" "Size" "Model" "Mount Point" "Usage" "Host Disk (Type)"
    printf "${BLUE}%s\n${NC}" "----|-----------------|--------|-------------------|------------------------|----------------|------------------------"

    local count=1
    while read -r line; do
        eval "$line"
        PARTITION="/dev/$NAME"
        PARENT="/dev/$PKNAME"

        if printf '%s\n' "${OS_DISKS[@]}" | grep -q -w "$PKNAME"; then continue; fi
        if [ -z "$PKNAME" ]; then continue; fi

        TRANSPORT=$(lsblk -dn -o TRAN "$PARENT")
        [ -z "$TRANSPORT" ] && TRANSPORT="unknown"
        SMART_ICON=$(get_smart_status "$PARENT")

        MOUNT_POINT=$(lsblk -nr -o MOUNTPOINT "$PARTITION" | grep '/' || echo "UNMOUNTED")
        SHORT_MOUNT="$MOUNT_POINT"
        if [[ "$MOUNT_POINT" =~ ^/srv/dev-disk-by-uuid- ]]; then
            UUID=$(echo "$MOUNT_POINT" | sed 's|/srv/dev-disk-by-uuid-||')
            SHORT_MOUNT="/srv/uuid-${UUID:0:8}...${UUID: -8}"
        elif [[ ${#MOUNT_POINT} -gt 22 ]]; then
            SHORT_MOUNT="${MOUNT_POINT:0:19}..."
        fi

        PARTITION_SIZE=$(lsblk -n -o SIZE "$PARTITION")

        if [[ "$MOUNT_POINT" != "UNMOUNTED" ]]; then
            USAGE_INFO=$(df -h --output=used,size,pcent "$MOUNT_POINT" | tail -1 | awk '{print $1" / "$2" ("$3")"}')
            MOUNT_COLOR=$GREEN
        else
            USAGE_INFO="N/A"
            MOUNT_COLOR=$YELLOW
        fi

        MODEL=$(udevadm info --query=all --name="$PARENT" | grep "ID_MODEL=" | cut -d= -f2)
        [[ -z "$MODEL" ]] && MODEL="Unknown"

        printf "%-4s| %-15s| %-6s| %-17s| ${MOUNT_COLOR}%-22s${NC}| %-15s| %-22s\n" \
            "$count" "$(truncate_text "$PARTITION" 15)" "$(truncate_text "$PARTITION_SIZE" 6)" \
            "$(truncate_text "$MODEL" 17)" "$(truncate_text "$SHORT_MOUNT" 22)" \
            "$(truncate_text "$USAGE_INFO" 15)" "$(truncate_text "$PARENT (${TRANSPORT^^}) $SMART_ICON" 22)"

        PARTITION_LIST+=("$PARTITION")
        ((count++))
    done < <(lsblk -P -o NAME,SIZE,MODEL,PKNAME | grep -v "loop")

    export PARTITION_LIST
}

select_partitions() {
    local prompt=$1
    local result_var=$2
    read -p "Select $prompt partition numbers (space-separated): " -a selection
    eval "$result_var=(${selection[@]})"
}

ensure_mounted() {
    local partition=$1
    local index=$2
    local temp_mount="$TEMP_MOUNT_BASE/partition$index"

    sudo mkdir -p "$temp_mount"
    sudo mount "$partition" "$temp_mount" || {
        echo -e "${RED}❌ Failed to mount $partition. Exiting.${NC}"
        exit 1
    }

    echo "$temp_mount"
}

migrate_data() {
    local total_bytes_moved=0

    for src in "${SOURCE_MOUNTS[@]}"; do
        echo -e "\n📂 ${BLUE}Processing source:${NC} $src"
        log "Processing source: $src"

        mapfile -t items < <(find "$src" -mindepth 1 -maxdepth 1)

        if [ ${#items[@]} -eq 0 ]; then
            echo -e "${YELLOW}⚠️  No files to transfer in $src${NC}"
            log "No files to transfer in $src"
            continue
        fi

        for item in "${items[@]}"; do
            dst="${DEST_MOUNTS[$((RANDOM % ${#DEST_MOUNTS[@]}))]}"
            echo -e "➡️  ${BLUE}Copying${NC} $(basename "$item") ${BLUE}to${NC} $dst"
            log "Copying $(basename "$item") to $dst"

            RSYNC_OUTPUT=$(rsync $RSYNC_OPTS "$item" "$dst"/)
            result=$?

            echo "$RSYNC_OUTPUT"

            transferred_bytes=$(echo "$RSYNC_OUTPUT" | grep "Total transferred file size:" | awk '{print $5}')

            if [[ "$transferred_bytes" =~ ^[0-9]+$ ]]; then
                total_bytes_moved=$((total_bytes_moved + transferred_bytes))
            fi

            if [ $result -eq 0 ]; then
                echo -e "${GREEN}✅ rsync OK, removing source:${NC} $(basename "$item")"
                log "✅ rsync OK, removing source: $(basename "$item")"
                rm -rf "$item"
            else
                echo -e "${RED}❌ rsync failed:${NC} $item"
                log "❌ rsync failed: $item"
                echo -e "${YELLOW}Cleaning up failed destination...${NC}"
                rm -rf "$dst/$(basename "$item")"
                log "Cleaned up destination: $dst/$(basename "$item")"
            fi
        done
    done

    total_gb_moved=$(awk "BEGIN {printf \"%.2f\", $total_bytes_moved / (1024*1024*1024)}")
    echo -e "\n${BLUE}Total Data Transferred:${NC} ${GREEN}${total_gb_moved} GB${NC}"
    log "Total Data Transferred: ${total_gb_moved} GB"
}

# ===== MAIN =====
clear
echo -e "${BLUE}=== Partition-Based Data Migration Tool ===${NC}"

LOG_DIR="$(pwd)"
LOG_FILE="$LOG_DIR/migration-log-$(date +%F_%H-%M-%S).txt"

echo
echo -e "${BLUE}Enable logging to file? (y/n):${NC}"
echo -e "Logs will be saved to: ${YELLOW}${LOG_FILE}${NC}"
read -p "> " LOG_CHOICE

if [[ "$LOG_CHOICE" == "y" ]]; then
    LOG_ENABLED=true
    echo "Logging to $LOG_FILE"
    echo "Migration Log - $(date)" > "$LOG_FILE"
fi

list_partitions
select_partitions "SOURCE" SOURCE_SELECTION
select_partitions "DESTINATION" DEST_SELECTION

SOURCE_MOUNTS=()
for num in "${SOURCE_SELECTION[@]}"; do
    index=$((num-1))
    partition="${PARTITION_LIST[$index]}"
    mount_point=$(ensure_mounted "$partition" "$index")

    echo -e "${GREEN}Mounted: $partition -> $mount_point${NC}"

    SOURCE_MOUNTS+=("$mount_point")
    TEMP_MOUNTS+=("$mount_point")
    echo -e "${YELLOW}TEMP_MOUNTS now:${NC} ${TEMP_MOUNTS[*]}"
done

DEST_MOUNTS=()
for num in "${DEST_SELECTION[@]}"; do
    index=$((num-1))
    partition="${PARTITION_LIST[$index]}"
    mount_point=$(ensure_mounted "$partition" "$index")

    echo -e "${GREEN}Mounted: $partition -> $mount_point${NC}"

    DEST_MOUNTS+=("$mount_point")
    TEMP_MOUNTS+=("$mount_point")
    echo -e "${YELLOW}TEMP_MOUNTS now:${NC} ${TEMP_MOUNTS[*]}"
done

echo -e "${BLUE}✅ Sources:${NC} ${SOURCE_MOUNTS[*]}"
echo -e "${BLUE}✅ Destinations:${NC} ${DEST_MOUNTS[*]}"
echo -e "${BLUE}✅ Temp mounts to cleanup:${NC} ${TEMP_MOUNTS[*]}"

read -p "Ready to start migration? (y/n): " CONFIRM
if [[ "$CONFIRM" != "y" ]]; then
    echo -e "${RED}Exiting.${NC}"
    cleanup
    exit 0
fi

migrate_data

echo -e "\n${GREEN}✅ Migration complete!${NC}"
log "✅ Migration complete!"

cleanup