Files
homeserver/backup.sh
2026-02-20 21:28:24 +01:00

149 lines
4.8 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
shopt -s inherit_errexit nullglob
# -----------------------------------------------------------------------------
# CONFIGURATION
# -----------------------------------------------------------------------------
readonly BACKUP_BASE=/media/extension/backup/ubuntu
readonly MOUNT_POINT=/media/extension
readonly NOW=$(date +'%Y-%m-%d')
readonly THIS_RUN="${BACKUP_BASE}/${NOW}"
readonly LATEST="${BACKUP_BASE}/latest"
# Retention policy
readonly KEEP_DAILY=3 # keep this many daily snapshots
readonly KEEP_WEEKLY=2 # keep this many weekly (Sunday) snapshots
# Directories (or files) to back up (without DB volumes)
declare -a SOURCE_ITEMS=(
"/home/oster/server"
# "/home/oster/photo-lib"
)
# Rsync exclusions — exclude DB-related dirs that are dumped separately + common junk
declare -a EXCLUDES=(
--exclude='.cache'
--exclude='node_modules'
--exclude='*.tmp'
--exclude='**/postgresql'
--exclude='**/mysql'
--exclude='**/db'
--exclude='**/database'
)
# Rsync options
RSYNC_OPTS=(
-aAXHv # preserve everything, human-friendly, verbose
--delete # mirror deletions
--partial # keep partials if interrupted
--stats # summary
"${EXCLUDES[@]}"
)
# Link-dest array — populated later; using an array avoids unquoted-variable issues
LINK_DEST=()
# -----------------------------------------------------------------------------
# LOGGING
# -----------------------------------------------------------------------------
log() { echo "[$(date +'%F %T')] $*"; }
die() { log "FATAL: $*"; exit 1; }
# -----------------------------------------------------------------------------
# FUNCTIONS
# -----------------------------------------------------------------------------
check_mount() {
if ! mountpoint -q "$MOUNT_POINT"; then
die "Backup mountpoint $MOUNT_POINT is NOT mounted."
fi
}
# Rotation: keep KEEP_DAILY daily and KEEP_WEEKLY weekly snapshots
rotate_old() {
log "Applying retention policy (${KEEP_DAILY} daily, ${KEEP_WEEKLY} weekly)."
# Pre-compute the dates to keep so we don't fork date(1) inside every inner loop iteration
declare -A keep_dates=()
for i in $(seq 0 $(( KEEP_DAILY - 1 ))); do
keep_dates["$(date -d "-$i days" +%F)"]=1
done
last_sunday=$(date -d "last sunday" +%F)
for i in $(seq 0 $(( KEEP_WEEKLY - 1 ))); do
keep_dates["$(date -d "${last_sunday} -$(( i * 7 )) days" +%F)"]=1
done
# Collect snapshot dirs matching YYYY-MM-DD using a glob (nullglob makes empty glob safe)
for dir in "$BACKUP_BASE"/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]; do
[ -d "$dir" ] || continue
name=$(basename "$dir")
if [[ -v keep_dates["$name"] ]]; then
log " Keeping snapshot $name"
else
log " Deleting expired snapshot $name"
rm -rf "${BACKUP_BASE:?}/$name"
fi
done
}
# -----------------------------------------------------------------------------
# MAIN
# -----------------------------------------------------------------------------
log "=== Starting backup run: $NOW ==="
check_mount
# Fail early if the backup base directory doesn't exist (drive not mounted / path wrong)
[ -d "$BACKUP_BASE" ] || die "Backup base directory $BACKUP_BASE does not exist."
# Must be able to write into the backup base (e.g. run as root via sudo/cron)
[ -w "$BACKUP_BASE" ] || die "No write permission on $BACKUP_BASE — run as root (sudo)."
# Locking — base dir is confirmed to exist, so the lockfile write will succeed
lockfile="${BACKUP_BASE}/.backup.lock"
cleanup() { rm -f "$lockfile"; }
trap cleanup EXIT
if ! ( set -o noclobber; echo "$$" > "$lockfile" ) 2>/dev/null; then
# Check if the PID in the lockfile is still alive; if not, it's a stale lock
stale_pid=$(cat "$lockfile" 2>/dev/null || true)
if [[ -n "$stale_pid" ]] && ! kill -0 "$stale_pid" 2>/dev/null; then
log "Removing stale lockfile (PID $stale_pid is no longer running)."
rm -f "$lockfile"
echo "$$" > "$lockfile"
else
die "Another backup is already running (PID ${stale_pid:-unknown}, lockfile: $lockfile)."
fi
fi
mkdir -p "$THIS_RUN"
# Incremental snapshot?
if [ -L "$LATEST" ] && [ -d "$LATEST" ]; then
latest_real=$(readlink -f "$LATEST")
LINK_DEST=("--link-dest=${latest_real}")
log "Using link-dest for incremental snapshot → $latest_real"
else
LINK_DEST=()
log "No previous backup; creating full snapshot."
fi
# Run backups
for src in "${SOURCE_ITEMS[@]}"; do
base=$(basename "$src")
dst="${THIS_RUN}/${base}"
mkdir -p "$dst"
log "Backing up $src$dst"
nice -n 10 ionice -c2 -n7 \
rsync "${RSYNC_OPTS[@]}" "${LINK_DEST[@]}" \
"$src"/ "$dst"/
done
# Update latest symlink atomically
ln -sfn "$(basename "$THIS_RUN")" "$BACKUP_BASE/.latest.tmp"
mv -T "$BACKUP_BASE/.latest.tmp" "$LATEST"
log "Snapshots done. Running rotation policy..."
rotate_old
log "=== Backup completed successfully ==="