198 lines
7.0 KiB
Bash
Executable File
198 lines
7.0 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
|
|
|
|
# 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 ==="
|