KEEP=30 (last 30 archives) approximated 30 days only if there was exactly one dump per day. Prune by file mtime instead (find -mtime +30) so a catch-up burst after time offline doesn't prematurely evict recent dumps, and a quiet stretch doesn't hoard month-old ones.
168 lines
6.5 KiB
Bash
168 lines
6.5 KiB
Bash
#!/bin/bash
|
|
# vlda-01 side of the alpha -> vlda-01 Gitea backup strategy
|
|
# (see SYNC-PLAN.md "Ongoing backup" section). Pulls whatever nightly
|
|
# gitea-dump archives exist on alpha that aren't already here yet - so a
|
|
# run after several days offline just catches up on all of them, not
|
|
# just the latest. Verifies each dump's sha256 checksum after pulling
|
|
# it, and only then deletes the alpha-side copy - a failed/incomplete
|
|
# verification leaves the remote copy alone for a retry next run.
|
|
#
|
|
# Uses SFTP (not real rsync) because the alpha-side account is
|
|
# restricted to `ForceCommand internal-sftp` - that blocks a real
|
|
# `rsync --server` invocation, so plain SFTP get/rm is the compatible
|
|
# transport here.
|
|
#
|
|
# Stored on the WSD2L840 Unassigned Device (not the array), which isn't
|
|
# guaranteed to already be mounted - this script mounts it itself first
|
|
# and refuses to proceed if that didn't actually result in a real
|
|
# mountpoint, rather than risk silently writing backups onto the array.
|
|
# Unmounts it again once done, but only if this run is the one that
|
|
# mounted it - never unmounts a disk something else already had mounted
|
|
# for its own reasons.
|
|
|
|
set -u
|
|
KEY=/root/.ssh/gitea-backup/id_ed25519
|
|
KNOWN_HOSTS=/root/.ssh/gitea-backup/known_hosts
|
|
REMOTE=giteabackup@alpha.jayfield.org
|
|
REMOTE_PORT=10022
|
|
UD_LABEL=WSD2L840
|
|
UD_MOUNTPOINT=/mnt/disks/WSD2L840
|
|
LOCAL_DIR="$UD_MOUNTPOINT/git"
|
|
KEEP_DAYS=30
|
|
|
|
# WSD2L840 is an Unassigned Device, not part of the array - not
|
|
# guaranteed to already be mounted when this runs. Resolve by label
|
|
# (not a hardcoded /dev/sdX - USB/UD device paths aren't stable across
|
|
# reboots) and mount it via the Unassigned Devices plugin's own tool,
|
|
# which is safe to call even if it's already mounted.
|
|
UD_DEV=$(findfs LABEL="$UD_LABEL" 2>/dev/null)
|
|
if [ -z "$UD_DEV" ]; then
|
|
echo "$(date -Is) ERROR: no device labeled $UD_LABEL found - is it plugged in?" >&2
|
|
exit 1
|
|
fi
|
|
|
|
UD_ALREADY_MOUNTED=false
|
|
mountpoint -q "$UD_MOUNTPOINT" && UD_ALREADY_MOUNTED=true
|
|
|
|
/usr/local/sbin/rc.unassigned mount "$UD_DEV" >/dev/null 2>&1
|
|
|
|
if ! mountpoint -q "$UD_MOUNTPOINT"; then
|
|
echo "$(date -Is) ERROR: $UD_MOUNTPOINT did not come up as a real mountpoint after mount attempt - refusing to write backups onto the array by mistake" >&2
|
|
exit 1
|
|
fi
|
|
|
|
GET_BATCH=$(mktemp)
|
|
DEL_BATCH=$(mktemp)
|
|
cleanup() {
|
|
rm -f "$GET_BATCH" "$DEL_BATCH"
|
|
if [ "$UD_ALREADY_MOUNTED" = false ]; then
|
|
# cd out first - our own cwd on the device would otherwise
|
|
# make the fuser check below always find "something" using it
|
|
cd / || true
|
|
|
|
# rc.unassigned's umount always does a lazy (-l) unmount and
|
|
# reports "success" no matter what - it does NOT refuse or
|
|
# error out just because another process still has the
|
|
# mountpoint open (verified live: a process with its cwd on
|
|
# the device does not stop it, and does not get killed
|
|
# either, unless this were a forced "stop array"-style
|
|
# unmount, which a single-device call never is). So the only
|
|
# way to actually detect and respect another user of the
|
|
# disk is to check first, ourselves, and simply not attempt
|
|
# the unmount at all if anything is found - no ERROR, just
|
|
# leave it mounted for next time.
|
|
if fuser -m "$UD_MOUNTPOINT" >/dev/null 2>&1; then
|
|
echo "$(date -Is) $UD_MOUNTPOINT still in use by another process - leaving it mounted"
|
|
else
|
|
/usr/local/sbin/rc.unassigned umount "$UD_DEV" >/dev/null 2>&1
|
|
fi
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
mkdir -p "$LOCAL_DIR"
|
|
|
|
SFTP="sftp -P $REMOTE_PORT -i $KEY -o UserKnownHostsFile=$KNOWN_HOSTS -o BatchMode=yes"
|
|
|
|
REMOTE_LISTING=$($SFTP "$REMOTE" <<< 'ls -1 backups/' 2>/dev/null)
|
|
if [ -z "$REMOTE_LISTING" ]; then
|
|
echo "$(date -Is) ERROR: could not list remote backups (host down or auth failed)" >&2
|
|
exit 1
|
|
fi
|
|
REMOTE_ZIPS=$(echo "$REMOTE_LISTING" | grep '\.zip$')
|
|
|
|
TO_FETCH=0
|
|
while read -r fname; do
|
|
[ -z "$fname" ] && continue
|
|
base=$(basename "$fname")
|
|
if [ ! -f "$LOCAL_DIR/$base" ]; then
|
|
echo "get $fname $LOCAL_DIR/" >> "$GET_BATCH"
|
|
TO_FETCH=$((TO_FETCH + 1))
|
|
# grab the checksum alongside it if alpha actually has one
|
|
# (older dumps from before checksums existed won't)
|
|
if echo "$REMOTE_LISTING" | grep -qxF "${fname}.sha256"; then
|
|
echo "get ${fname}.sha256 $LOCAL_DIR/" >> "$GET_BATCH"
|
|
fi
|
|
fi
|
|
done <<< "$REMOTE_ZIPS"
|
|
|
|
if [ "$TO_FETCH" -eq 0 ]; then
|
|
echo "$(date -Is) up to date, nothing new to pull"
|
|
exit 0
|
|
fi
|
|
|
|
if ! $SFTP -b "$GET_BATCH" "$REMOTE" 2>&1; then
|
|
echo "$(date -Is) ERROR: sftp batch fetch failed partway through" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# verify each freshly-fetched dump before trusting it enough to delete
|
|
# the alpha-side copy
|
|
VERIFIED=0
|
|
UNVERIFIED=0
|
|
while read -r fname; do
|
|
[ -z "$fname" ] && continue
|
|
base=$(basename "$fname")
|
|
local_zip="$LOCAL_DIR/$base"
|
|
local_sum="$LOCAL_DIR/$base.sha256"
|
|
[ -f "$local_zip" ] || continue # wasn't newly fetched this run
|
|
|
|
if [ ! -f "$local_sum" ]; then
|
|
echo "$(date -Is) WARNING: no checksum available for $base (older dump, predates checksums?) - keeping local copy, leaving alpha's copy alone" >&2
|
|
UNVERIFIED=$((UNVERIFIED + 1))
|
|
continue
|
|
fi
|
|
|
|
if (cd "$LOCAL_DIR" && sha256sum -c "$(basename "$local_sum")") >/dev/null 2>&1; then
|
|
echo "rm $fname" >> "$DEL_BATCH"
|
|
echo "rm ${fname}.sha256" >> "$DEL_BATCH"
|
|
VERIFIED=$((VERIFIED + 1))
|
|
else
|
|
echo "$(date -Is) ERROR: checksum mismatch for $base - deleting the bad local copy, leaving alpha's copy for a retry" >&2
|
|
rm -f "$local_zip" "$local_sum"
|
|
UNVERIFIED=$((UNVERIFIED + 1))
|
|
fi
|
|
done <<< "$REMOTE_ZIPS"
|
|
|
|
if [ -s "$DEL_BATCH" ]; then
|
|
if ! $SFTP -b "$DEL_BATCH" "$REMOTE" 2>&1; then
|
|
echo "$(date -Is) WARNING: some verified files could not be deleted on alpha - harmless, alpha's own retention prune will clean them up eventually" >&2
|
|
fi
|
|
fi
|
|
|
|
# prune anything older than $KEEP_DAYS calendar days (vlda-01 has far
|
|
# more headroom than alpha, so this is the real long-term history now
|
|
# that alpha deletes its own copies right after a verified pull). A
|
|
# calendar-day window rather than a fixed count on purpose: a burst of
|
|
# catch-up pulls after being offline a while shouldn't prematurely evict
|
|
# genuinely recent dumps just because there are suddenly more than a
|
|
# fixed count, and a quiet stretch with few new dumps shouldn't keep
|
|
# month-old ones around just because too few newer ones have arrived to
|
|
# push them out.
|
|
cd "$LOCAL_DIR" || exit 1
|
|
find . -maxdepth 1 -name 'gitea-dump-*.zip' -mtime +"$KEEP_DAYS" -print0 | while IFS= read -r -d '' old; do
|
|
rm -f "$old" "${old}.sha256"
|
|
done
|
|
|
|
echo "$(date -Is) pulled $TO_FETCH new archive(s), $VERIFIED verified+deleted from alpha, $UNVERIFIED kept unverified, $(ls -1 gitea-dump-*.zip 2>/dev/null | wc -l) retained locally"
|