rpi-clone.sh
#!/bin/bash

VERSION=1.2
# Version 1.2	2015/02/17
#	* add -x option
#	* tweak some echo messages
# Version 1.1	2015/02/16
#	* Do not include dphys swapfile in the clone.
#	* Add a missing -s flag to one of the parted commands.
#	* Dump parted stderr to /dev/null since it now complains about destination
#	    disk partitions ending outside of disk before I get chance to resize
#	    the partitions.
#	* Strip trailing s from PART_START - (fdisk now doesn't seem to accept...)
#

PGM=`basename $0`

RSYNC_OPTIONS="--force -rltWDEgopt"

# List of extra dirs to create under /mnt.
OPTIONAL_MNT_DIRS="clone mnt sda sdb rpi0 rpi1"

# Where to mount the disk filesystems to be rsynced.
CLONE=/mnt/clone

CLONE_LOG=/var/log/$PGM.log

HOSTNAME=`hostname`

SRC_BOOT_PARTITION_TYPE=`parted /dev/mmcblk0 -ms p | grep "^1" | cut -f 5 -d:`
SRC_ROOT_PARTITION_TYPE=`parted /dev/mmcblk0 -ms p | grep "^2" | cut -f 5 -d:`

if [ `id -u` != 0 ]
then
    echo -e "$PGM needs to be run as root.\n"
    exit 1
fi

if ! rsync --version > /dev/null
then
	echo -e "\nOoops! rpi-clone needs the rsync program but cannot find it."
	echo "Make sure rsync is installed:"
	echo "    $ sudo apt-get update"
	echo -e "    $ sudo apt-get install rsync\n"
	exit 0
fi

if test -e /sbin/fsck.vfat
then
	HAVE_FSCK_VFAT="yes"
else

	echo "[Note] fsck.vfat was not found."
	echo "It is recommended to install dosfstools:"
	echo "    $ sudo apt-get update"
	echo "    $ sudo apt-get install dosfstools"
fi

usage()
	{
	echo ""
	echo "usage: $PGM sdN {-f|--force-initialize} {-v|--verbose} {-x}"
	echo "    Example:  $PGM sda"
	echo "    -v - list all files as they are copied."
	echo "    -f - force initialize the destination partitions"
	echo "    -x - use set -x for very verbose bash shell script debugging"
	echo ""
	echo "    Clone (rsync) a running Raspberry Pi file system to a destination"
	echo "    SD card 'sdN' plugged into a Pi USB port (via a USB card reader)."
	echo "    $PGM can clone the running system to a new SD card or can"
	echo "    incrementally rsync to existing backup Raspberry Pi SD cards."
	echo ""
	echo "    If the destination SD card has an existing $SRC_BOOT_PARTITION_TYPE partition 1 and a"
	echo "    $SRC_ROOT_PARTITION_TYPE partition 2, $PGM assumes (unless using the -f option)"
	echo "    that the SD card is an existing backup with the partitions"
	echo "    properly sized and set up for a Raspberry Pi.  All that is needed"
	echo "    is to mount the partitions and rsync them to the running system."
	echo ""
	echo "    If these partitions are not found (or -f), then $PGM will ask"
	echo "    if it is OK to initialize the destination SD card partitions."
	echo "    This is done by a partial 'dd' from the running booted device"
	echo "    /dev/mmcblk0 to the destination SD card /dev/sdN followed by a"
	echo "    fdisk resize and mkfs.ext4 of /dev/sdN partition 2."
	echo "    This creates a completed $SRC_BOOT_PARTITION_TYPE partition 1 containing all boot"
	echo "    files and an empty but properly sized partition 2 rootfs."
	echo "    The SD card  partitions are then mounted and rsynced to the"
	echo "    running system."
	echo ""
	echo "    The SD card destination partitions will be mounted on $CLONE."
	echo "    A log will be written to $CLONE_LOG."
	echo "    It's better to avoid running other disk writing programs"
	echo "    when running $PGM."
	echo ""
	echo "    Version $VERSION"
	exit 0
	}

VERBOSE=off

while [ "$1" ]
do
	case "$1" in
		-v|--verbose)
			VERBOSE=on
			RSYNC_OPTIONS=${RSYNC_OPTIONS}v
			;;
		-f|--force-initialize)
			FORCE_INITIALIZE=true
			;;
		-x)
			set -x
			;;
		-h|--help)
			usage
			;;
		*)
			if [ "$DST_DISK" != "" ]
			then
				echo "Bad args"
				usage
			fi
			DST_DISK=$1
			;;
	esac
	shift
done


if [ "$DST_DISK" = "" ]
then
	usage
	exit 0
fi

if ! cat /proc/partitions | grep -q $DST_DISK
then
	echo "Destination disk '$DST_DISK' does not exist."
	echo "Plug the destination SD card into a card reader connected to a"
	echo "USB port.  If it does not show up  as '$DST_DISK', then do a"
	echo -e "'cat /proc/partitions' to see where it might be.\n"
	exit 0
fi

unmount_or_abort()
	{
	echo -n "Do you want to unmount $1? (yes/no): "
	read resp
	if [ "$resp" = "y" ] || [ "$resp" = "yes" ]
	then
		if ! umount $1
		then
			echo "Sorry, $PGM could not unmount $1."
			echo -e "Aborting!\n"
			exit 0
		fi
	else
		echo -e "Aborting!\n"
		exit 0
	fi
	}

DST_ROOT_PARTITION=/dev/${DST_DISK}2
DST_BOOT_PARTITION=/dev/${DST_DISK}1

# Check that none of the destination partitions are busy (mounted).
#
DST_ROOT_CURMOUNT=`fgrep "$DST_ROOT_PARTITION " /etc/mtab | cut -f 2 -d ' ' `
DST_BOOT_CURMOUNT=`fgrep "$DST_BOOT_PARTITION " /etc/mtab | cut -f 2 -d ' ' `

if [ "$DST_ROOT_CURMOUNT" != "" ] || [ "$DST_BOOT_CURMOUNT" != "" ]
then
	echo "A destination partition is busy (mounted).  Mount status:"
	echo "    $DST_ROOT_PARTITION:  $DST_ROOT_CURMOUNT"
	echo "    $DST_BOOT_PARTITION:  $DST_BOOT_CURMOUNT"
	if [ "$DST_BOOT_CURMOUNT" != "" ]
	then
		unmount_or_abort $DST_BOOT_CURMOUNT
	fi
	if [ "$DST_ROOT_CURMOUNT" != "" ]
	then
		unmount_or_abort $DST_ROOT_CURMOUNT
	fi
fi


TEST_MOUNTED=`fgrep " $CLONE " /etc/mtab | cut -f 1 -d ' ' `
if [ "$TEST_MOUNTED" != "" ]
then
	echo "This script uses $CLONE for mounting filesystems, but"
	echo "$CLONE is already mounted with $TEST_MOUNTED."
	unmount_or_abort $CLONE 
fi

if [ ! -d $CLONE ]
then
	MNT_MOUNT=`fgrep " /mnt " /etc/mtab | cut -f 1 -d ' ' `
	if [ "$MNT_MOUNT" = "" ]
	then
		mkdir $CLONE
	else
		echo "$MNT_MOUNT is currently mounted on /mnt."
		unmount_or_abort /mnt
		mkdir $CLONE
	fi
fi


# Borrowed from do_expand_rootfs in raspi-config
expand_rootfs()
	{
	# Get the starting offset of the root partition
	# (with Jessie's parted, now need to strip trailing 's' from PART_START)
	PART_START=$(parted /dev/mmcblk0 -ms unit s p  \
			| grep "^2" | cut -f 2 -d: | cut -f 1 -d s)
	[ "$PART_START" ] || return 1
	# Return value will likely be error for fdisk as it fails to reload the
	# partition table because the root fs is mounted
	fdisk /dev/$DST_DISK > /dev/null <<EOF
p
d
2
n
p
2
$PART_START

p
w
q
EOF
	}


# =========== Disk Setup and Checks ===========
#
# Check that destination partitions are the right type.
#
DST_BOOT_PARTITION_TYPE=`parted /dev/$DST_DISK -ms p 2>/dev/null \
		| grep "^1" | cut -f 5 -d:`
DST_ROOT_PARTITION_TYPE=`parted /dev/$DST_DISK -ms p 2>/dev/null \
		| grep "^2" | cut -f 5 -d:`

CLONE_MODE="rsync modified files to existing $DST_DISK file systems"

if [ "$DST_BOOT_PARTITION_TYPE" != "$SRC_BOOT_PARTITION_TYPE" ] || \
   [ "$DST_ROOT_PARTITION_TYPE" != "$SRC_ROOT_PARTITION_TYPE" ] || \
   [ "$FORCE_INITIALIZE" = "true" ]
then
	CLONE_MODE="rsync all files to $DST_DISK root file system"
	echo ""
	if [ "$FORCE_INITIALIZE" = "true" ]
	then
		echo "Forcing a partition initialization of destination disk $DST_DISK"
	else
		echo "The destination disk $DST_DISK partition table does not"
		echo "match the booted disk /dev/mmcblk0 partition table so a"
		echo "destination disk initialize is required."
	fi

	echo "The existing destination disk '$DST_DISK' partitions are:"
	parted /dev/$DST_DISK -s unit MB p 2>/dev/null \
		| sed "/^Model/d ; /^Sector/d ; /^Disk Flags/d"

#	if [ "$DST_BOOT_PARTITION_TYPE" != "$SRC_BOOT_PARTITION_TYPE" ]
#	then
#		echo -e "  ... Cannot find a destination boot file system of type: $SRC_BOOT_PARTITION_TYPE\n"
#	fi
#	if [ "$DST_ROOT_PARTITION_TYPE" != "$SRC_ROOT_PARTITION_TYPE" ]
#	then
#		echo -e "  ... Cannot find a destination root file system of type: $SRC_ROOT_PARTITION_TYPE\n"
#	fi

	echo "*** All data on destination disk $DST_DISK will be overwritten! ***"
	echo ""
	echo -n "Do you want to initialize the destination disk /dev/$DST_DISK? (yes/no): "

	read resp
	if [ "$resp" = "y" ] || [ "$resp" = "yes" ]
	then
		# Image onto the destination disk a beginning fragment of the
		# running SD card file structure that spans at least more than
		# the start of partition 2.
		#
		# Calculate the start of partition 2 in MB for the dd.
		PART2_START=$(parted /dev/mmcblk0 -ms unit MB p | grep "^2" \
				| cut -f 2 -d: | sed s/MB// | tr "," "." | cut -f 1 -d.)
		# and add some slop
		DD_COUNT=`expr $PART2_START + 8`

		echo ""
		echo "Imaging the partition structure, copying $DD_COUNT megabytes..."
		sync
		dd if=/dev/mmcblk0 of=/dev/$DST_DISK bs=1M count=$DD_COUNT

		# Partition was copied live so fsck to clean up for possible future
		# "Volume was not properly unmounted" warnings.
		if [ "$HAVE_FSCK_VFAT" = "yes" ]
		then
			echo "Running fsck on $DST_BOOT_PARTITION..."
			fsck -p $DST_BOOT_PARTITION &> /dev/null
		fi

		# But, though Partion 1 is now imaged, partition 2 is incomplete and
		# maybe the wrong size for the destination SD card.  So fdisk it to
		# make it fill the rest of the disk and mkfs it to clean it out.
		#
		echo "Sizing partition 2 (root partition) to use all SD card space..."
		expand_rootfs
		mkfs.ext4 $DST_ROOT_PARTITION > /dev/null

		echo ""
		echo "/dev/$DST_DISK is initialized and resized.  Its partitions are:"
#		fdisk -l /dev/$DST_DISK | grep $DST_DISK
		parted /dev/$DST_DISK unit MB p \
			| sed "/^Model/d ; /^Sector/d ; /^Disk Flags/d"

		SRC_ROOT_VOL_NAME=`e2label /dev/mmcblk0p2`
		echo ""
		echo "Your booted /dev/mmcblk0p2 rootfs existing label: $SRC_ROOT_VOL_NAME"
		echo -n "You may enter a label for the destination rootfs $DST_ROOT_PARTITION: "
		read resp
		if [ "$resp" != "" ]
		then
			e2label $DST_ROOT_PARTITION $resp
		fi
	else
		echo -e "Aborting\n"
		exit 0
	fi
fi


# =========== Setup Summary ===========
#
DST_ROOT_VOL_NAME=`e2label $DST_ROOT_PARTITION`

if [ "$DST_ROOT_VOL_NAME" = "" ]
then
	DST_ROOT_VOL_NAME="no label"
fi

echo ""
echo "======== Clone Summary ========"
echo "Clone mode               :  $CLONE_MODE"
echo "Clone destination disk   :  $DST_DISK"
echo "Clone destination rootfs :  $DST_ROOT_PARTITION ($DST_ROOT_VOL_NAME) on ${CLONE}"
echo "Clone destination bootfs :  $DST_BOOT_PARTITION on ${CLONE}/boot"
echo "Verbose mode             :  $VERBOSE"
echo "==============================="


# If this is an SD card initialization, can watch progress of the clone
# in another terminal with:  watch df -h
#
echo -n "Final check, is it Ok to proceed with the clone (yes/no)?: "
read resp
if [ "$resp" != "y" ] && [ "$resp" != "yes" ]
then
	echo -e "Aborting the disk clone.\n"
	exit 0
fi

#
# =========== End of Setup  ===========




# Mount destination filesystems.

echo "=> Mounting $DST_ROOT_PARTITION ($DST_ROOT_VOL_NAME) on $CLONE"
if ! mount $DST_ROOT_PARTITION $CLONE
then
	echo -e "Mount failure of $DST_ROOT_PARTITION, aborting!\n"
	exit 0
fi

if [ ! -d $CLONE/boot ]
then
	mkdir $CLONE/boot
fi

echo "=> Mounting $DST_BOOT_PARTITION on $CLONE/boot"
if ! mount $DST_BOOT_PARTITION $CLONE/boot
then
	umount $CLONE
	echo -e "Mount failure of $DST_BOOT_PARTITION, aborting!\n"
	exit 0
fi

echo "==============================="

# Do not include a dhpys swapfile in the clone.  dhpys-swapfile will
# regenerate it at boot.
#
if [ -f /etc/dphys-swapfile ]
then
	SWAPFILE=`cat /etc/dphys-swapfile | grep ^CONF_SWAPFILE | cut -f 2 -d=`
	if [ "$SWAPFILE" = "" ]
	then
		SWAPFILE=/var/swap
	fi
	EXCLUDE_SWAPFILE="--exclude $SWAPFILE"
fi

START_TIME=`date '+%H:%M:%S'`

# Exclude fuse mountpoint .gvfs, various other mount points, and tmpfs
# file systems from the rsync.
#
sync
echo "Starting the filesystem rsync to $DST_DISK"
echo -n "(This may take several minutes)..."
rsync $RSYNC_OPTIONS --delete \
		$EXCLUDE_SWAPFILE \
		--exclude '.gvfs' \
		--exclude '/dev' \
		--exclude '/media' \
		--exclude '/mnt' \
		--exclude '/proc' \
		--exclude '/run' \
		--exclude '/sys' \
		--exclude '/tmp' \
		--exclude 'lost\+found' \
	// \
	$CLONE



# Fixup some stuff
#

for i in dev media mnt proc run sys
do
	if [ ! -d $CLONE/$i ]
	then
		mkdir $CLONE/$i
	fi
done

if [ ! -d $CLONE/tmp ]
then
	mkdir $CLONE/tmp
	chmod a+w $CLONE/tmp
fi

# Some extra optional dirs I create under /mnt
for i in $OPTIONAL_MNT_DIRS
do
	if [ ! -d $CLONE/mnt/$i ]
	then
		mkdir $CLONE/mnt/$i
	fi
done

rm -f $CLONE/etc/udev/rules.d/70-persistent-net.rules


DATE=`date '+%F %H:%M'`

echo "$DATE  $HOSTNAME $PGM : clone to $DST_DISK ($DST_ROOT_VOL_NAME)" \
		>> $CLONE_LOG
echo "$DATE  $HOSTNAME $PGM : clone to $DST_DISK ($DST_ROOT_VOL_NAME)" \
		>> $CLONE/$CLONE_LOG


STOP_TIME=`date '+%H:%M:%S'`

echo ""
echo "*** Done with clone to /dev/$DST_DISK ***"
echo "    Started: $START_TIME    Finished: $STOP_TIME"
echo ""

# Pause before unmounting in case I want to inspect the clone results
# or need to custom modify any files on the destination SD clone.
# Eg. modify $CLONE/etc/hostname, $CLONE/etc/network/interfaces, etc
# if I'm cloning into a card to be installed on another Pi.
#
echo -n "Hit Enter when ready to unmount the /dev/$DST_DISK partitions..."
read resp

echo "unmounting $CLONE/boot"
umount $CLONE/boot

echo "unmounting $CLONE"
umount $CLONE


echo "==============================="

exit 0
