#!/bin/bash # ME="backup-manager"; VER="2008-09-05a" # # Manage taking backups and removing stale backups, intended to be run as # a cron job, perhaps call the script each 1/2 hour for back snapshots # and perhaps once per day for stale backup removal. # COPY="Copyright (C) 2006-2008 Grant Coady GPLv2" # # Home site: # # Backup strategy # - create backup directory name based on time, # - read last backup directory name # - hardlink the previous backup to this backup # - save this backup directory name # - use rsync to overwrite old files creating this backup # # 2008-09-07 # Fix the freakin' backup -- it's overwriting older versions of files :( # # 2008-09-05 # Add exceptions to reduce web backups due to cron job generated files # # 2008-09-04 # Fix the backups, they've been broken for months, test well :) # # 2008-05-13 # Add log file pruning, and break the backup function :( # # 2006-10-25 # Create a backup-new area containing only new files found since the last # snapshot. # # 2006-10-09 # Remove snapshots where no file changes detected, allows for more frequent # backups as backup snapshots where no changes occurred are now discarded. # A file, 'backup-diff-errors', catches any errors reported by diff, observed # are errors thrown due to dangling symlinks -- these need to be fixed # manually and the backup-diff-errors file to be deleted afterwards. # # 2006-10-01 # Remove stale backups # set a bound on how far back we keep backups, note that this process # does not remove backed up files until the last directory holding a # reference a partcular file is removed, also files are copied to a # 'last resort' directory so the most recent version is available. # # globals, leave empty, use CLI options to set DIR_DST= # destination directory DIR_SRC= # source NO_COPY= # no make backup removal copy VERBOSE= # be chatty TEST= # no delete files EXCLUDE_FILE= # name of file holds exclude file list # global constants BKP_LAST="backup-last" # store last backup name BKP_DELD="backup-retired" # retired files, most recent BKP_LOG="backup-log" # comment out to defeat logging BKP_NEW="backup-new" # new files BKP_NAME="$(date +%F-%H.%M.%S)" # yyyy-mm-dd-hh.mm.ss # defaults, do not change, use CLI options BKP_AGE=91 # default age in days, CLI -a N display_CLI_options() { echo " Usage examples: backup-manager -s -d take a backup backup-manager -d -r [-a ] remove stale backups backup-manager -V display version Options: -a X, --age=X backup age X number of days, default $BKP_AGE -d X, --destination=X set backup destination dir, no trailing slash -h, --help display this message and exit -n, --no-copy do NOT make backup-removed copy of deleted files -r, --remove remove stale files using default age, removed files copied to /$BKP_DELD -s X, --source=X set backup source dir, no trailing slash -t, --test test mode, script will not delete files -v, --verbose script outputs progress information -V, --version display version and exit -x X --exclude=X name of file holding exclude file listing " } perform_hardlink_backup() { [ -n "$VERBOSE" ] && echo "cd $DIR_DST" # grab hardlinked copy of last backup, if we have one... cd $DIR_DST if [ -r $BKP_LAST ]; then read bkp_last < $BKP_LAST if [ -d "$bkp_last" ]; then [ -n "$VERBOSE" ] && \ echo "cp -al $bkp_last $BKP_NAME" cp -al "$bkp_last" "$BKP_NAME" fi fi # now freshen the last backup [ -n "$VERBOSE" ] && echo "rsync -av $DIR_SRC/ $BKP_NAME" if [ -n "$EXCLUDE_FILE" ]; then exc_opt="--exclude-from=$EXCLUDE_FILE " else exc_opt=" " fi rsync -aH $VERBOSE --exclude "*~" $exc_opt --delete-excluded \ --checksum --link-dest=$BKP_NAME \ $DIR_SRC/ $BKP_NAME || exit 2 # strip world readable flag find "$BKP_NAME" -type f -exec chmod o-r {} + # add to wheel group as group readable chgrp -R wheel "$BKP_NAME" chmod -R g+r "$BKP_NAME" find "$BKP_NAME" -type d -exec chmod g+x {} + # check if first backup if [ -z "$bkp_last" ] || [ ! -d "$bkp_last" ]; then # save this backup name for next snapshot [ -n "$VERBOSE" ] && echo "new, valid snapshot" echo "$BKP_NAME" > $BKP_LAST return # we're done fi # we have a prior backup... [ -n "$VERBOSE" ] && \ echo "check if this snapshot contains new files" changed=$(find "$BKP_NAME" -type f -links 1 -print) if [ -z "$changed" ]; then # remove new snapshot if no change [ -n "$VERBOSE" ] && echo "unchanged, removing $BKP_NAME" rm -rf "$DIR_DST/$BKP_NAME" return # we're done fi # we have a new backup snapshot [ -n "$VERBOSE" ] && echo -e "changed files:\n$changed" [ -n "$BKP_LOG" ] && echo "$changed" >> $BKP_LOG echo "$BKP_NAME" > $BKP_LAST # for next run touch "$BKP_NAME" # datestamp new backup # copy new files with path to separate 'backup-new' directory [ -n "$VERBOSE" ] && echo "copy new files to $BKP_NEW directory" [ ! -d $BKP_NEW ] && mkdir $BKP_NEW find "$BKP_NAME" -type f -links 1 -exec cp -al --parents {} $BKP_NEW \; } retire_stale_backups() # { local cut_stamp=$1 local dir_list=$(mktemp) || exit 2 ls | sort > $dir_list # get directory list while read dir_name do [ -n "$VERBOSE" ] && echo -en "$dir_name\t" [ -n "$TEST" ] && echo -n "test " case $dir_name in backup-* ) [ -n "$VERBOSE" ] && echo "skipped" continue ;; esac # datestamp from directory name, not the filesystem!! dir_date=$(awk -v dir_name="$dir_name" ' BEGIN { split(dir_name, a, "[-.]") print mktime(a[1]" "a[2]" "a[3]" "a[4]" "a[5]" "a[6]) exit }') if [ $dir_date -lt $cut_stamp ]; then if [ -z "$TEST" ]; then if [ ! $NO_COPY ]; then [ ! -d $BKP_DELD ] && mkdir $BKP_DELD rsync -a $dir_name/* $BKP_DELD [ -n "$VERBOSE" ] && echo -n "copied " fi rm -rf $dir_name fi [ -n "$VERBOSE" ] && echo "removed" else if [ -z "$VERBOSE" ]; then break # we're done else echo "skipped" fi fi done < $dir_list rm $dir_list } perform_stale_backup_removal() # - { local now_stamp=$(date +%s) # current epoch seconds local cut_stamp=$((now_stamp - BKP_AGE * 24 * 60 * 60)) # cutoff age # do the snapshots cd $DIR_DST || exit 1 retire_stale_backups $cut_stamp # prune the log file local tempfile=$(mktemp) || exit 2 awk -v cut_stamp=$cut_stamp -v tempfile="$tempfile" ' { split($0, a, "[-./]") t = mktime(a[1]" "a[2]" "a[3]" "a[4]" "a[5]" "a[6]) if (t >= cut_stamp) print $0 > tempfile } ' < $BKP_LOG && [ -r $tempfile ] && mv $tempfile $BKP_LOG # perhaps do the new files area too if [ -d $BKP_NEW ]; then cd $BKP_NEW || exit 1 [ -n "$VERBOSE" ] && echo "process new files area:" retire_stale_backups $cut_stamp fi } #### CLI interpreter support report_error() # error_message { echo -e "\n\t$ME: fatal: $1" display_CLI_options } say_no_src_dir() # missing_directory { report_error "missing source directory: $1" } say_version() # - { echo -e "\t$ME: version $VER\n\t$COPY" } #### CLI interpreter [ $# -eq 0 ] && say_version && display_CLI_options && exit # no parms action="backup" while [ -n "$1" ] do case $1 in -a ) [ $2 -gt 0 ] && BKP_AGE=$2 && shift && action="remove";; --age=[0-9] | --age=[0-9][0-9] | --age=[0-9][0-9][0-9] ) BKP_AGE=${1#--age=}; action="remove" ;; -d ) DIR_DST=$2; shift ;; --destination=* ) td=${1#--destination=} DIR_DST=$td ;; -h | --help ) echo "$ME: help:"; display_CLI_options; exit;; -n | --no-copy ) NO_COPY=1;; -r | --remove ) action="remove";; -s ) if [ -d $2 ]; then DIR_SRC=$2; shift else say_no_src_dir $2; exit 1 fi ;; --source=* ) td=${1#--source=} if [ -d $td ]; then DIR_SRC=$td else say_no_src_dir $td; exit 1 fi ;; -t | --test ) TEST=1;; -v | --verbose ) VERBOSE="-v";; -V | --version ) say_version; exit;; -x ) if [ -r $2 ]; then EXCLUDE_FILE=$2; shift else echo "no file $2"; exit 1 fi ;; --exclude=* ) xf=${1#--exclude=} if [ -r $xf ]; then EXCLUDE_FILE=$xf; shift else echo "no file $xf"; exit 1 fi ;; * ) report_error "unknown option $1" exit 1 ;; esac shift done # bomb if no backup source if [ "$action" == "backup" ]; then if [ -z "$DIR_SRC" ]; then report_error "missing source directory name" exit 1 elif [ ! -d $DIR_SRC ]; then report_error "no source directory: $DIR_SRC" exit 1 fi fi # perhaps create destination for backup if [ "$action" == "backup" ] && [ -z "$DIR_DST" ]; then if [ ! $(mkdir -p $DIR_DST) ]; then report_error "error creating destination: $DIR_DST" exit 1 fi fi # bomb if no destination for pruning backup if [ "$action" == "remove" ]; then if [ -z "$DIR_DST" ]; then report_error "missing destination directory name" exit 1 elif [ ! -d $DIR_DST ]; then report_error "no destination directory: $DIR_DST" exit 1 fi fi if [ -n "$VERBOSE" ]; then echo "$ME" echo " action: $action" echo " backup age: $BKP_AGE" echo " source dir: $DIR_SRC" echo " destination: $DIR_DST" echo " backup name: $BKP_NAME" echo " no copy flag: $NO_COPY" echo " test flag: $TEST" fi [ "$action" == "backup" ] && perform_hardlink_backup [ "$action" == "remove" ] && perform_stale_backup_removal [ -n "$VERBOSE" ] && echo "done!" # end