First cron run (2026-08-28 05:00) failed with 'restic: command not found'; interactive shells had /usr/local/bin, cron does not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
6.9 KiB
Bash
Executable File
162 lines
6.9 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# Nightly restic data backup (repo: restic-data). Three phases:
|
|
# 1. dumps — incus's own DBs, plus application-consistent database
|
|
# dumps taken with `incus exec` (container-local auth: no DB
|
|
# users, no network exposure). Auto-discovered in every RUNNING
|
|
# container — no manifest to go stale: native MariaDB/MySQL
|
|
# (either binary naming), native PostgreSQL (pg_dump per DB +
|
|
# pg_dumpall --globals-only), and PostgreSQL inside docker
|
|
# containers (image name matching "postgres"). Dumps staged
|
|
# plain (not gzipped — CDC dedup needs uncompressed input).
|
|
# 2. one `restic backup` invocation: the dump dir + every path in
|
|
# the paths file (one index load, one snapshot per night);
|
|
# exclude patterns applied globally.
|
|
# 3. retention — `restic forget --group-by host` (seed-era
|
|
# snapshots have different path sets and must age in one group).
|
|
# Prune/check live in restic-maintenance.sh (weekly).
|
|
#
|
|
# Usage: restic-backup.sh [-r <repo>] [-f <paths-file>] [-d <dump-dir>]
|
|
# [-s <stage>] # stage: dumps|backup|all (default all);
|
|
# # -s backup skips re-dumping (seeding aid)
|
|
#
|
|
# Env from /root/.restic-env (AWS creds, RESTIC_PASSWORD_FILE,
|
|
# RESTIC_CACHE_DIR).
|
|
|
|
set -u
|
|
|
|
REPO=s3:s3.sbg.io.cloud.ovh.net/restic-data
|
|
PATHS_FILE=/root/scripts/restic-paths
|
|
EXCLUDE_FILE=/root/scripts/restic-exclude
|
|
DB_EXCLUDE_FILE=/root/scripts/plakar-db-exclude # same opt-out list, same format
|
|
DUMP_DIR=/backup/dumps
|
|
LOCKFILE=/run/lock/restic-backup.lock
|
|
ENVFILE=/root/.restic-env
|
|
RESTIC=/usr/local/bin/restic # cron PATH lacks /usr/local/bin
|
|
STAGE=all
|
|
|
|
usage() {
|
|
echo "Usage: $0 [-r <repo>] [-f <paths-file>] [-d <dump-dir>] [-s dumps|backup|all]" >&2
|
|
exit 2
|
|
}
|
|
|
|
while getopts r:f:d:s: flag; do
|
|
case "${flag}" in
|
|
r) REPO=${OPTARG};;
|
|
f) PATHS_FILE=${OPTARG};;
|
|
d) DUMP_DIR=${OPTARG};;
|
|
s) STAGE=${OPTARG};;
|
|
*) usage;;
|
|
esac
|
|
done
|
|
|
|
[ -r "$PATHS_FILE" ] || { echo "paths file $PATHS_FILE not readable" >&2; exit 2; }
|
|
[ -r "$ENVFILE" ] || { echo "env file $ENVFILE not readable" >&2; exit 2; }
|
|
. "$ENVFILE"
|
|
|
|
exec 9>"$LOCKFILE"
|
|
if ! flock -n 9; then
|
|
echo "another restic-backup run holds $LOCKFILE, aborting" >&2
|
|
exit 1
|
|
fi
|
|
|
|
log() { echo "[$(date '+%F %T')] $*"; }
|
|
|
|
excluded() {
|
|
[ -r "$DB_EXCLUDE_FILE" ] && grep -qx "$1" "$DB_EXCLUDE_FILE" \
|
|
&& log "SKIP $1 (listed in $DB_EXCLUDE_FILE)"
|
|
}
|
|
|
|
rc=0
|
|
|
|
if [ "$STAGE" = all ] || [ "$STAGE" = dumps ]; then
|
|
|
|
# incus's own state (instance configs, profiles, devices)
|
|
mkdir -p "$DUMP_DIR/incus"
|
|
incus admin sql global .dump > "$DUMP_DIR/incus/incus-global-db.sql" || rc=1
|
|
incus admin sql local .dump > "$DUMP_DIR/incus/incus-local-db.sql" || rc=1
|
|
|
|
# Database dumps, auto-discovered per running container
|
|
PG_LIST="SELECT datname FROM pg_database WHERE NOT datistemplate AND datname <> 'postgres'"
|
|
for ct in $(incus list status=running -c n -f csv); do
|
|
# native MariaDB/MySQL (unix-socket root auth), either binary naming
|
|
mdump=$(incus exec "$ct" -- sh -c 'command -v mariadb-dump || command -v mysqldump' 2>/dev/null)
|
|
mclient=$(incus exec "$ct" -- sh -c 'command -v mariadb || command -v mysql' 2>/dev/null)
|
|
if [ -n "$mdump" ] && [ -n "$mclient" ]; then
|
|
dbs=$(incus exec "$ct" -- "$mclient" -N -B -e 'SHOW DATABASES') \
|
|
|| { echo "listing mariadb databases on $ct failed" >&2; rc=1; dbs=""; }
|
|
for db in $(printf '%s\n' "$dbs" \
|
|
| grep -Ev '^(information_schema|performance_schema|mysql|sys)$'); do
|
|
excluded "$ct/$db" && continue
|
|
log "dump $ct/$db (mariadb)"
|
|
mkdir -p "$DUMP_DIR/mariadb/$ct"
|
|
incus exec "$ct" -- "$mdump" --single-transaction --events --routines --triggers \
|
|
--databases "$db" > "$DUMP_DIR/mariadb/$ct/$db.sql" \
|
|
|| { echo "dump $ct/$db failed" >&2; rc=1; }
|
|
done
|
|
# users + grants: replayable SHOW GRANTS statements
|
|
log "dump $ct/grants (mariadb)"
|
|
incus exec "$ct" -- sh -c "$mclient -NBe \"SELECT CONCAT('SHOW GRANTS FOR ', QUOTE(user), '@', QUOTE(host), ';') FROM mysql.user\" | $mclient -NB | sed 's/\$/;/'" \
|
|
> "$DUMP_DIR/mariadb/$ct/grants.sql" \
|
|
|| { echo "grants dump on $ct failed" >&2; rc=1; }
|
|
elif [ -n "$mdump$mclient" ]; then
|
|
echo "$ct has only one of dump/client mariadb binaries, skipping" >&2; rc=1
|
|
fi
|
|
|
|
# native PostgreSQL (peer auth as the postgres user)
|
|
if incus exec "$ct" -- sh -c 'command -v pg_dump' >/dev/null 2>&1; then
|
|
mkdir -p "$DUMP_DIR/postgres/$ct"
|
|
incus exec "$ct" -- su -s /bin/sh postgres -c "pg_dumpall --globals-only" \
|
|
> "$DUMP_DIR/postgres/$ct/globals.sql" || rc=1
|
|
dbs=$(incus exec "$ct" -- su -s /bin/sh postgres -c "psql -AtX -c \"$PG_LIST\"") \
|
|
|| { echo "listing postgres databases on $ct failed" >&2; rc=1; dbs=""; }
|
|
for db in $dbs; do
|
|
excluded "$ct/$db" && continue
|
|
log "dump $ct/$db (postgres)"
|
|
incus exec "$ct" -- su -s /bin/sh postgres -c "pg_dump --clean --if-exists $db" \
|
|
> "$DUMP_DIR/postgres/$ct/$db.sql" \
|
|
|| { echo "dump $ct/$db failed" >&2; rc=1; }
|
|
done
|
|
fi
|
|
|
|
# PostgreSQL inside docker (e.g. outline, login)
|
|
incus exec "$ct" -- sh -c 'command -v docker' >/dev/null 2>&1 || continue
|
|
for dc in $(incus exec "$ct" -- docker ps --format '{{.Names}} {{.Image}}' 2>/dev/null \
|
|
| awk 'tolower($2) ~ /postgres/ {print $1}'); do
|
|
pguser=$(incus exec "$ct" -- docker exec "$dc" sh -c 'echo "${POSTGRES_USER:-postgres}"') \
|
|
|| { echo "reading POSTGRES_USER on $ct/$dc failed" >&2; rc=1; continue; }
|
|
mkdir -p "$DUMP_DIR/postgres/$ct/$dc"
|
|
incus exec "$ct" -- docker exec "$dc" pg_dumpall -U "$pguser" --globals-only \
|
|
> "$DUMP_DIR/postgres/$ct/$dc/globals.sql" || rc=1
|
|
dbs=$(incus exec "$ct" -- docker exec "$dc" psql -U "$pguser" -AtX -c "$PG_LIST") \
|
|
|| { echo "listing postgres databases on $ct/$dc failed" >&2; rc=1; dbs=""; }
|
|
for db in $dbs; do
|
|
excluded "$ct/$dc/$db" && continue
|
|
log "dump $ct/$dc/$db (postgres)"
|
|
incus exec "$ct" -- docker exec "$dc" pg_dump -U "$pguser" --clean --if-exists "$db" \
|
|
> "$DUMP_DIR/postgres/$ct/$dc/$db.sql" \
|
|
|| { echo "dump $ct/$dc/$db failed" >&2; rc=1; }
|
|
done
|
|
done
|
|
done
|
|
|
|
fi # stage dumps
|
|
|
|
if [ "$STAGE" = all ] || [ "$STAGE" = backup ]; then
|
|
|
|
log "restic backup -> $REPO"
|
|
$RESTIC -r "$REPO" backup \
|
|
--pack-size 64 --read-concurrency 8 -o s3.connections=8 \
|
|
--exclude-file "$EXCLUDE_FILE" \
|
|
--files-from-verbatim "$PATHS_FILE" "$DUMP_DIR" \
|
|
|| { echo "restic backup failed" >&2; rc=1; }
|
|
|
|
log "forget: keep 14d/8w/6m"
|
|
$RESTIC -r "$REPO" forget --group-by host \
|
|
--keep-daily 14 --keep-weekly 8 --keep-monthly 6 || rc=1
|
|
|
|
fi # stage backup
|
|
|
|
log "done (rc=$rc)"
|
|
exit $rc
|