#!/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 # USB backup drive — ASMedia ASM1153E bridge (174c:55aa) # Used to disable UAS (which causes resets under load) and spin-down readonly USB_VENDOR_PRODUCT="174c:55aa" readonly UAS_QUIRKS_FILE="/etc/modprobe.d/usb-backup-uas-quirk.conf" # 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 } # Ensure the UAS quirk is configured for the backup drive. # UAS causes device resets under sustained write load with the ASM1153E bridge. # If the config is missing, write it and update initramfs (requires reboot to take effect). ensure_uas_quirk() { local vid="${USB_VENDOR_PRODUCT%%:*}" local pid="${USB_VENDOR_PRODUCT##*:}" local expected="options usb-storage quirks=${vid}:${pid}:u" if grep -qsF "$expected" "$UAS_QUIRKS_FILE" 2>/dev/null; then # Check if the drive is still bound to uas (quirk active only after reboot) if ls /sys/bus/usb/drivers/uas/ 2>/dev/null | grep -q .; then log "WARNING: UAS quirk is configured but drive is still using the uas driver." log " A reboot is required for the quirk to take effect." log " Backup will proceed but may encounter USB resets." else log "UAS quirk active — drive is using usb-storage driver." fi else log "UAS quirk not found — writing $UAS_QUIRKS_FILE and updating initramfs." echo "$expected" > "$UAS_QUIRKS_FILE" update-initramfs -u log "Initramfs updated. Please reboot for the UAS quirk to take effect." log "Continuing backup with current (uas) driver — may encounter resets." fi } # Disable drive spin-down and APM for the duration of the backup. # The drive going to standby mid-write causes "Logical unit not ready" errors. disable_spindown() { local dev # Resolve the underlying block device from the mount point (strip partition number) dev=$(findmnt -n -o SOURCE "$MOUNT_POINT" | sed 's/[0-9]*$//') if [[ -b "$dev" ]]; then log "Disabling spin-down and APM on $dev for backup duration." hdparm -B 255 -S 0 "$dev" > /dev/null \ && log " hdparm: spin-down disabled on $dev." \ || log "WARNING: hdparm failed on $dev — drive may still spin down." else log "WARNING: Could not determine block device for $MOUNT_POINT (got: $dev) — skipping hdparm." 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 ensure_uas_quirk disable_spindown # 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 ==="