#!/usr/bin/env bash # rsyncshot # convenient backups using rsync and hard links # Craig Jennings # Inspired by Mike Rubel: http://www.mikerubel.org/computers/rsync_snapshots/ # requirements: bash, rsync, flock, cron, grep # - unix filesystem capable of hard links at destination # - core unix utilities: rm, mv, cp, touch # debugging: uncomment next 4 lines for debugging output # exec 5> >(logger -t $0) # BASH_XTRACEFD="5" # PS4='$LINENO: ' # set -x # default locations for setup # modify MOUNTDIR to point to the mount point of your backup # ---------------------------- Constants ---------------------------- MOUNTDIR=/media/backup; SCRIPTLOC=/usr/local/bin/rsyncshot; DESTINATION=$MOUNTDIR/$HOSTNAME INSTALLHOME=/etc/rsyncshot LOGFILE=/var/log/rsyncshot.log; INCLUDES="$INSTALLHOME/include.txt"; EXCLUDES="$INSTALLHOME/exclude.txt"; # copy, move, and rm commands have been aliased by the user or distro # creator to require confirmation on certain actions. # using variables allows us to sidestep any alias CP="/usr/bin/cp" MV="/usr/bin/mv" RM="/usr/bin/rm" # prevent overlapping runs with flock FLOCKCHECK="flock -x /tmp/rsyncshot.lock -c" # default cron job entries CRON_Q="15,30,45 * * * * "; # every quarter-hour except minute 0 CRON_H="0 1-23 * * * "; # hourly on minute 0 from 1am to 11pm CRON_D="0 12 * * 1-6 "; # daily at noon, monday - saturday CRON_W="0 12 * * 7 "; # weekly at noon on sundays # ------------------------ Utility Functions ------------------------ help() { printf "\nrsyncshot - compact snapshots on Linux using rsync and hard links.\n\n" printf "Usage:\nrsyncshot \n" printf " setup (installs rsyncshot and cron jobs)\n" printf " help (prints this info)\n" printf "Notes:\n" printf '%s\n' "- rsyncshot must be run as root" printf '%s\n\n' "- install and log locations defined in script." } error() { echo "ERROR: $0:" "$@" 1>&2; echo "See \"rsyncshot help\" for usage." exit 1; } setup() { # copy this file to directory on path and make executable $CP -f "$0" "$SCRIPTLOC" sudo chmod +x "$SCRIPTLOC" echo "$0 copied to $SCRIPTLOC and made executable" # make install home if it doesn't exist; if [ ! -d $INSTALLHOME ]; then mkdir -p $INSTALLHOME; echo "Created install home at $INSTALLHOME"; fi # create includes file and add default entries if [ -f $INCLUDES ]; then $RM $INCLUDES; fi printf "/home /etc /usr/local/bin" >> $INCLUDES; echo "modify include file at $INCLUDES"; # create excludes file and add default entries if [ -f $EXCLUDES ]; then $RM $EXCLUDES; fi printf "*.pyc\n*.pyo\n*.class\n*.elc\n*.o\n*.tmp\n.cache*" >> $EXCLUDES; echo "modify exclude file at $EXCLUDES"; # write out current crontab, append default entries, and install touch "$LOGFILE" crontab -l > crontemp; { echo "$CRON_H $FLOCKCHECK '$SCRIPTLOC hourly 22 >> $LOGFILE 2>&1'" echo "$CRON_D $FLOCKCHECK '$SCRIPTLOC daily 6 >> $LOGFILE 2>&1'" echo "$CRON_W $FLOCKCHECK '$SCRIPTLOC weekly 51 >> $LOGFILE 2>&1'" echo "$CRON_Q $FLOCKCHECK '$SCRIPTLOC quarter-hour 3 >> $LOGFILE 2>&1'" } >> crontemp crontab crontemp; $RM crontemp; echo "hourly, daily, and weekly cron jobs installed."; } # ----------------------------- Script ---------------------------- # uppercase for case-insensitivity TYPE=$(tr '[a-z]' '[A-Z]' <<< $1); if [ "$TYPE" = "HELP" ]; then help; exit; fi # ensure we're running as root if [ "$EUID" -ne 0 ]; then error "This script must be run as root."; fi # display start information echo "rsyncshot invoked on $(date -u) with: $0 $1 $2"; # if logfile was removed, recreate it. [ ! -f "$LOGFILE" ] || touch "$LOGFILE" # validate backup type # first argument must be alpha characters if ! [[ $1 =~ [a-zA-Z] ]]; then error "snapshot type not recognized."; fi if [ "$TYPE" = "SETUP" ]; then setup; exit; fi # validate max snapshots # second argument must be numeric if ! [[ $2 =~ [0-9] ]]; then error "max snapshots not a number."; fi MAX=$(($2-1)); # validate include file (source directories) exist # validates the include file exists, and checks the file contents are valid directories if [ ! -f "$INCLUDES" ]; then error "include file $INCLUDES not found."; fi SOURCES=$(<$INCLUDES); for SOURCE in $SOURCES do if [ ! -d "$SOURCE" ]; then error "source $SOURCE not found"; fi done # validate exclude file (exclusion patterns) exist if [ ! -f "$EXCLUDES" ]; then error "Exclude file $EXCLUDES not found."; fi [ -d $MOUNTDIR ] || error "$MOUNTDIR doesn't exist." # if destination filesystem not mounted attempt mounting; error if attempt fails if ! grep -qs "$MOUNTDIR" /proc/mounts >> /dev/null 2>&1; then mount "$MOUNTDIR" >> /dev/null 2>&1 if ! grep -qs "$MOUNTDIR" /proc/mounts >> /dev/null 2>&1; then error "$MOUNTDIR not mounted and mount attempt failed." fi fi [ -d "$DESTINATION" ] || mkdir "$DESTINATION" || \ error "$DESTINATION doesn't exist, and attempt to create directory failed." # sync each backup directory in turn for SOURCE in $SOURCES do rsync -avh -i --times \ --delete --delete-excluded \ --exclude-from="$EXCLUDES" \ --update "$SOURCE" "$DESTINATION"/latest ; done # delete max+1 snapshot if it exists if [ -d "$DESTINATION"/"$TYPE"."$MAX" ]; then $RM -rf "$DESTINATION"/"$TYPE"."$MAX"; fi # rotate remaining snapshots descending for (( start=$((MAX)); start>=0; start--)); do end=$((start+1)); if [ -d "$DESTINATION"/"$TYPE".$start ]; then $MV "$DESTINATION"/"$TYPE".$start "$DESTINATION"/"$TYPE".$end; fi done # reset directory timestamp touch "$DESTINATION"/latest # hard link / copy to destination $CP -al "$DESTINATION"/latest "$DESTINATION"/"$TYPE".0; # make directory type read-only chmod -w "$DESTINATION"/"$TYPE".0 # print time and exit echo "rsyncshot completed $(date -u) "; exit 0;