#!/bin/sh
# $Id: backup,v 1.4 2021/08/31 21:57:42 nanons Exp $
#
# This script backs up filesystems using dump(8), by default making a
# complete backup every month, making incremental backups on top of it
# every day, and consolidating incremental backups into one every week.
#
# Usage
# =====
#
# Parameters are controlled by variables below this comment block.
# The script should be executed by the system every day via daily(8):
#
#    # echo /path/to/script >> /etc/daily.local
#
# Ignoring files
# ==============
#
# Files or directories can be excluded from backups using chflags(1):
#
#    $ chflags nodump /path/to/file
#                                                           \__/
# To un-ignore files:                                      \/  \/
#                                                        /\/  ..\/
#    $ chflags dump /path/to/file                        \/\___o/
#                                                          / /\ \
# To check if a file has the "nodump" flag set:
#                                                          Puffy!
#    $ ls -lo /path/to/file
#
# Restoring backups
# =================
#
# To restore backups, use the restore(8) utility.  Its interactive mode
# is the easiest to use for extracting specific files and directories.
# If compressed, the ".gz" backup files first have to be gunzip(1)'d.
# To navigate the backup file of /home as of October 18, 1995:
# (Type "help" to print available commands)
#
#    $ restore -i -f home-19951018-10.3
#
# The "3" extension indicates the dump level.  Level 0 contains a full
# backup.  Non-zero levels (i.e., incremental backups) only contain
# files that have changed since the last backup.  Level 1 backups
# contain a week's worth of incremental backups.
#
# To fully recover an entire filesystem from backups, restore dump
# level 0 first, followed by level 1 (if it exists), 2, 3, and so on.
# Incremental backups only apply to the newest level 0 dump.
# When there are multiple incremental backups with the same level,
# always use the newest.
# To restore all files until the backup from the previous example,
# levels 0, 1, 2, and 3 need to be restored:
#
#    # cd /home
#    # restore -r -f home-19951008-00.0
#    # restore -r -f home-19951015-07.1
#    # restore -r -f home-19951017-09.2
#    # restore -r -f home-19951018-10.3
#    # rm restoresymtable
#
# If restore(8) is accidentally interrupted, delete "restoresymtable"
# and start over from level 0.

# Path to backup destination.  Not included in backups.
ROOT="/home/backup"

# Directories to backup.  Complete backups are always done for
# directories that aren't mountpoints (such as /etc) instead of
# incremental backups.
SOURCES="/home /var /etc"

# "gzip" for compression, "cat" for no compression.
COMPRESS="gzip"

# Backup file extension.
EXT=".gz"

# Number of days to keep old backups.  When reached, delete all backups
# older than the given value.  Set to "0" to never delete backups.
PRUNE="0"

# Number of days to keep daily incremental backups.  When reached,
# consolidate all incremental backups into a level 1 dump.
RESET="7"

# Number of days between complete backups.  When reached, delete all
# incremental backups and do a level 0 dump.
FULL="28"

# Sequence of daily dump levels, followed between RESETs.
PATTERN="2 2 3 3 4 4"

set -eu
set -- $PATTERN
exec >&2
[ -d "$ROOT" ] || mkdir -p "$ROOT"
chflags nodump "$ROOT"
sync

# check free space
USED=$(df -P "$ROOT")
USED=${USED%%%*}
USED=${USED##* }
if [ -n "$USED" ] && [ "$USED" -gt 75 ]; then
	echo "--------------------------------------------------------"
	echo "INSUFFICIENT DISK SPACE"
	echo "--------------------------------------------------------"
	df -h "$ROOT"
	exit 1
fi

if [ -f "$ROOT/day" ]; then
	DAY=$(< "$ROOT/day")
	if [ -z "$DAY" ] || ! [ "$DAY" -eq "$DAY" ] 2>/dev/null; then
		echo "$ROOT/day: invalid day: $DAY"
		exit 1
	fi
else
	DAY=0
fi

if [ $((DAY % FULL)) -eq 0 ]; then
	LEVEL=0
elif [ $((DAY % RESET)) -eq 0 ]; then
	LEVEL=1
else
	eval LEVEL=\${$((DAY % RESET))}
fi

DATE=$(date +%Y%m%d)
for i in $SOURCES; do
	NAME=$(echo "$i" | sed 's/^\/$/rootfs/; s/^\///; s/\/$//; s/\//_/g')
	TARGET=$ROOT/$NAME
	NAME=$NAME-$DATE-$DAY.$LEVEL
	FILE=$TARGET/$NAME$EXT
	LOG=$TARGET/logs/$NAME.log

	[ -d "$TARGET/logs" ] || mkdir -p "$TARGET/logs"

	{
		echo "# dump -u$LEVEL -h 0 -f - $i | $COMPRESS > $NAME$EXT"
		echo "--------------------------------------------------------"
		{ dump -u"$LEVEL" -h 0 -f - "$i" || > "$FILE.err"; } |
		    $COMPRESS > "$FILE" || > "$FILE.err"
		echo "--------------------------------------------------------"
		df -h "$i" "$ROOT" | uniq || :
	} > "$LOG" 2>&1

	if [ -f "$FILE.err" ]; then
		cat "$LOG" || :
		rm -f "$FILE" "$FILE.err" "$LOG"
		exit 1
	fi

	if [ "$LEVEL" -le 1 ]; then
		# clean up old incremental backups
		rm -f "$TARGET"/{,logs/}*-*-*.[$((LEVEL+1))-9]{"$EXT",.log}
	fi
done

if [ "$PRUNE" -ne 0 ]; then
	# clean up old backups
	for i in "$ROOT"/*/{,logs/}*-*-*.[0-9]{"$EXT",.log}; do
		[ -f "$i" ] || continue
		j=${i#*-*-}
		j=${j%%.*}
		[ -n "$j" ] && [ "$j" -eq "$j" ] 2>/dev/null || continue
		[ $((DAY - j)) -gt "$PRUNE" ] || continue
		rm -f "$i"
	done
fi

# update day
echo $((DAY + 1)) > "$ROOT/day"
