halkyon/deploy/backup.sh

103 lines
4.4 KiB
Shell
Raw Permalink Normal View History

#!/bin/sh
# Backs up learn.halkyon.eu off-site. Runs every hour from
# deploy/backup/halkyon-backup.timer, as deploy on the server; deploy/backups.md says
# how it was set up and how to restore.
#
# Everything people keep is in the database, their files too (Serverpod's database
# cloud storage), and in Train's where .env switches Train on (compose.train.yaml,
# Halkyon Train's ADR 0030, D46), but for the releases of published studysets, which are git
# repositories in the server's volume: a dump and a bundle of each repository cover
# the instance; the secrets in deploy/.env are kept in Proton Pass instead. restic stores the dump encrypted in a Hetzner Storage Box
# and keeps 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots (decided 2026-10-03).
# Hourly, because a restore loses what came after the backup, and for someone who
# started since, that is everything: their app no longer finds its session and starts
# anew (deploy/backups.md). After 3 months, a deleted account is gone from every
# backup (the privacy page says so). The box's own automatic snapshots, which only
# Hetzner's Console can remove, keep the backups safe from a taken-over server,
# which can delete what restic wrote.
#
# Failures go to the journal only (decided 2026-10-03):
#
# systemctl status halkyon-backup; journalctl -u halkyon-backup
#
# restic finds the repository and its password in deploy's files
# ~/.config/restic/repository and ~/.config/restic/password (both 0600); a run by hand
# can point elsewhere with RESTIC_REPOSITORY_FILE and RESTIC_PASSWORD_FILE.
set -eu
export RESTIC_REPOSITORY_FILE="${RESTIC_REPOSITORY_FILE:-$HOME/.config/restic/repository}"
export RESTIC_PASSWORD_FILE="${RESTIC_PASSWORD_FILE:-$HOME/.config/restic/password}"
cd "$(dirname "$0")"
# The dump goes to a file first: Ubuntu 24.04's restic (v0.16) would store a dump
# piped into it even when pg_dump fails halfway, and the file is the newest copy at
# hand for a quick restore. Uncompressed, so restic finds what hasn't changed since
# the last hour and stores it only once; restic compresses what it stores.
mkdir -p -m 700 "$HOME/backups"
dump="$HOME/backups/latest.dump"
train_dump="$HOME/backups/latest-train.dump"
train=
if docker compose config --services | grep -qx train-server; then
train=yes
fi
(
umask 077
# Never during a deploy (redeploy.sh holds this lock): its migrations would wait
# for the dump, or the dump for them.
exec 9>"$HOME/.redeploy.lock"
flock 9
docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 halkyon_learn >"$dump.partial"
if [ -n "$train" ]; then
docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 halkyon_train >"$train_dump.partial"
fi
)
mv "$dump.partial" "$dump"
if [ -n "$train" ]; then
mv "$train_dump.partial" "$train_dump"
fi
# Each repository as a git bundle, after the dump: a release the dump lacks is never
# numbered again (the server numbers past the highest tag), while one the bundles
# lacked would be lost. A bundle is whole whenever it is made: git reads the refs, then
# packs what they reach. A repository being created this moment has nothing to bundle
# yet, and comes next hour.
releases="$HOME/backups/repositories.tar"
(
umask 077
docker compose exec -T server sh -c '
out=$(mktemp -d)
for repository in /app/repositories/*.git; do
[ -d "$repository" ] || continue
git -C "$repository" bundle create --quiet \
"$out/$(basename "$repository" .git).bundle" --all ||
echo "Not bundled: $repository" >&2
done
tar -C "$out" -cf - .
rm -rf "$out"
' >"$releases.partial"
)
mv "$releases.partial" "$releases"
if [ -n "$train" ]; then
restic backup --quiet "$dump" "$train_dump" "$releases"
else
restic backup --quiet "$dump" "$releases"
fi
# By host alone: restic groups by the paths backed up too, so a path added (Train's
# dump) would leave the snapshots before it a group of their own, never forgotten, and
# a deleted account in them for good.
restic forget --quiet --group-by host --keep-hourly 24 --keep-daily 7 --keep-weekly 4 --keep-monthly 3
# Once a day, in the run at 03:00: free the space of forgotten snapshots, and read the
# repository's structure; on Sundays also a tenth of the stored data, chosen at random,
# so damage to what was stored long ago shows within weeks.
if [ "$(date +%H)" = 03 ]; then
restic prune --quiet
if [ "$(date +%u)" = 7 ]; then
restic check --read-data-subset=10%
else
restic check
fi
fi
restic snapshots --compact --latest 1