Last active
December 10, 2025 14:13
-
-
Save mprymek/427aa070d83ddf1f8775f3a0840938e1 to your computer and use it in GitHub Desktop.
Script to efficiently dump/restore whole disks using `sfdisk`&`fsarchiver` - useful for RasPi SD cards backup
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Dumps and restores disk partition table and all disk partitions contents | |
| # using fsarchiver. Useful for backing up and restoring complete SD cards | |
| # in a size-efficient way. | |
| # | |
| # Supports testing that the dumped archives are restorable. | |
| # Bash strict mode. | |
| # see https://olivergondza.github.io/2019/10/01/bash-strict-mode.html | |
| set -euo pipefail | |
| trap 's=$?; $echo -e >&2 "\nERROR: line "$LINENO": $BASH_COMMAND\n"; exit $s' ERR | |
| PTABLE_FILE=ptable.sfdisk | |
| DISK_START_FILE=disk_start.img | |
| DISK_START_LEN_MB=5 | |
| MD5SUM_FILE=md5sums.txt | |
| USE_CORES=$(($(nproc) - 1)) | |
| #FSA_VERBOSE="-v" | |
| FSA_VERBOSE="" | |
| FSA_OPTS="-j$USE_CORES $FSA_VERBOSE" | |
| # Define maximum disk size (in bytes) to avoid accidental operations on large disks. | |
| MAX_DISK_SIZE=$((32 * 1024 * 1024 * 1024)) # 32GB | |
| dd=/bin/dd | |
| echo="/bin/echo -e" | |
| lsblk=/bin/lsblk | |
| sfdisk=/sbin/sfdisk | |
| fsarchiver=/sbin/fsarchiver | |
| wipefs=/sbin/wipefs | |
| losetup=/sbin/losetup | |
| truncate=/bin/truncate | |
| md5sum=/bin/md5sum | |
| title() { | |
| MSG="$1" | |
| $echo "**** $MSG" | |
| } | |
| debug() { | |
| MSG="$1" | |
| if [ ! -z "${DEBUG-}" ]; then | |
| $echo "D: $MSG" | |
| fi | |
| } | |
| check_device() { | |
| DEVICE="$1" | |
| if [ ! -b "$DEVICE" ]; then | |
| bad_arg "Device $DEVICE is not a block device." | |
| fi | |
| DEV_SIZE=$($lsblk -dnb -o size "$DEVICE") | |
| debug "Device size: ${DEV_SIZE}B" | |
| if [ "$DEV_SIZE" -gt "$MAX_DISK_SIZE" ]; then | |
| panic "Device $DEVICE size ${DEV_SIZE}B is greater than ${MAX_DISK_SIZE}B => probably not an SD card!" | |
| fi | |
| } | |
| check_command() { | |
| CMD="$1" | |
| if [ ! -x "$CMD" ]; then | |
| panic "Command $CMD not found or not executable." | |
| fi | |
| } | |
| usage() { | |
| $echo "Usage:" | |
| $echo | |
| $echo "$0 dump DEVICE" | |
| $echo "$0 restore DEVICE" | |
| $echo "$0 test" | |
| $echo | |
| $echo "dump - dump partition table and start of disk" | |
| $echo "restore - restore partition table and start of disk" | |
| $echo "test - test that archives are restorable" | |
| $echo "DEVICE - block device to dump or restore to (e.g. /dev/sda)" | |
| $echo | |
| $echo "Examples:" | |
| $echo | |
| $echo "sudo $0 dump /dev/sdd 2>&1 | tee dump.log" | |
| $echo "sudo $0 restore /dev/sdd 2>&1 | tee restore.log" | |
| $echo "sudo $0 test 2>&1 | tee test.log" | |
| } | |
| bad_arg() { | |
| MSG="$1" | |
| $echo "\nERROR: $MSG\n" | |
| usage | |
| exit 1 | |
| } | |
| panic() { | |
| MSG="$1" | |
| $echo "\nERROR: $MSG\n" | |
| exit 1 | |
| } | |
| args_num_at_least() { | |
| AT_LEAST="$1" | |
| ACTUAL="$2" | |
| MSG="$3" | |
| if [ "$ACTUAL" -lt $AT_LEAST ]; then | |
| bad_arg "$MSG" | |
| fi | |
| } | |
| dump() { | |
| args_num_at_least 1 $# "DEVICE argument missing." | |
| DEVICE=$1 | |
| check_command "$sfdisk" | |
| check_command "$dd" | |
| check_command "$fsarchiver" | |
| check_command "$md5sum" | |
| check_device "$DEVICE" | |
| title "Dumping partition table of $DEVICE to $PTABLE_FILE" | |
| $sfdisk -d "$DEVICE" > "$PTABLE_FILE" | |
| title "Dumping first ${DISK_START_LEN_MB}MB of $DEVICE to $DISK_START_FILE" | |
| $dd "if=$DEVICE" "of=$DISK_START_FILE" bs=1M "count=$DISK_START_LEN_MB" | |
| PARTITIONS=$($lsblk -n -o path "$DEVICE" | tail -n +2 | xargs) | |
| debug "Partitions found: $PARTITIONS" | |
| debug "Will use $USE_CORES cores for fsarchiver." | |
| for PARTITION in $PARTITIONS; do | |
| PARTNUM=$(echo "$PARTITION" | tr -d -c 0-9 ) | |
| DUMPFILE="partition-${PARTNUM}.fsa" | |
| title "Dumping partition $PARTITION to $DUMPFILE" | |
| $fsarchiver savefs $FSA_OPTS -o "$DUMPFILE" "$PARTITION" | |
| done | |
| title "Storing MD5 checksums of dump files to $MD5SUM_FILE" | |
| $md5sum "$PTABLE_FILE" "$DISK_START_FILE" partition-*.fsa > "$MD5SUM_FILE" | |
| # Print date in case script output is logged to file. | |
| title "Dump OK. Date: $(date)" | |
| } | |
| restore() { | |
| args_num_at_least 1 $# "DEVICE argument missing." | |
| DEVICE=$1 | |
| # Determine partition number prefix. | |
| # Needed for loop devices used in test_archives() where device name is | |
| # something like /dev/loop0 and partitions are /dev/loop0p1, /dev/loop0p2, ... | |
| # => prefix is "p". | |
| # For regular devices like /dev/sda, partitions are /dev/sda1, /dev/sda2, ... | |
| # => prefix is "". | |
| PARTNUM_PREFIX="${2-}" | |
| check_command "$wipefs" | |
| check_command "$dd" | |
| check_command "$sfdisk" | |
| check_command "$fsarchiver" | |
| check_device "$DEVICE" | |
| title "Wiping all partitions on $DEVICE" | |
| $wipefs -a "$DEVICE" | |
| title "Restoring first ${DISK_START_LEN_MB}MB of $DISK_START_FILE to $DEVICE" | |
| $dd "if=$DISK_START_FILE" "of=$DEVICE" | |
| title "Restoring partition table from $PTABLE_FILE to $DEVICE" | |
| $sfdisk "$DEVICE" < "$PTABLE_FILE" | |
| DUMPFILES=$(ls partition-*.fsa | sort | xargs) | |
| debug "Dump files found: $DUMPFILES" | |
| for DUMPFILE in $DUMPFILES; do | |
| PARTNUM=$(echo "$DUMPFILE" | tr -d -c 0-9 ) | |
| PARTITION_DEVICE="${DEVICE}${PARTNUM_PREFIX}${PARTNUM}" | |
| title "Restoring $DUMPFILE to $PARTITION_DEVICE" | |
| $fsarchiver restfs "$DUMPFILE" "id=0,dest=$PARTITION_DEVICE" | |
| done | |
| # Print date in case script output is logged to file. | |
| title "Restore OK. Date: $(date)" | |
| } | |
| test_archives_cleanup() { | |
| IMAGE_FILE="$1" | |
| LOOP_DEVICE="$2" | |
| title "Destroying loop device $LOOP_DEVICE" | |
| $losetup -d "$LOOP_DEVICE" | |
| title "Deleting virtual disk image $IMAGE_FILE" | |
| rm "$IMAGE_FILE" | |
| } | |
| test_archives() { | |
| check_command "$md5sum" | |
| check_command "$truncate" | |
| check_command "$losetup" | |
| check_command "$sfdisk" | |
| title "Verifying MD5 checksums of dump files from $MD5SUM_FILE" | |
| $md5sum -c "$MD5SUM_FILE" | |
| IMAGE_FILE="tmp-virtual-disk.img" | |
| LOOP_DEVICE=$($losetup -f) | |
| title "Creating temporary virtual disk image $IMAGE_FILE" | |
| $truncate -s $MAX_DISK_SIZE "$IMAGE_FILE" | |
| title "Setting up loop device $LOOP_DEVICE for $IMAGE_FILE" | |
| $losetup "$LOOP_DEVICE" "$IMAGE_FILE" | |
| trap "test_archives_cleanup '$IMAGE_FILE' '$LOOP_DEVICE'" EXIT | |
| # We must dump partition table first to create partitions on the loop device | |
| # because partprobe /dev/loopX does not work reliably. | |
| title "Restoring partition table from $PTABLE_FILE and reattaching loop device" | |
| $sfdisk "$LOOP_DEVICE" < "$PTABLE_FILE" | |
| $losetup -d "$LOOP_DEVICE" | |
| # Wait a bit to ensure the device is fully detached. | |
| sleep 1 | |
| $losetup -P "$LOOP_DEVICE" "$IMAGE_FILE" | |
| restore "$LOOP_DEVICE" p | |
| # Print date in case script output is logged to file. | |
| title "Test OK. Date: $(date)" | |
| } | |
| args_num_at_least 1 $# "Command not specified." | |
| COMMAND=$1 | |
| shift | |
| case $COMMAND in | |
| dump) | |
| dump $@ | |
| ;; | |
| restore) | |
| restore $@ | |
| ;; | |
| test) | |
| test_archives $@ | |
| ;; | |
| *) | |
| bad_arg "Invalid command: $COMMAND" | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment