#!/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 ] [-f ] [-d ] # [-s ] # 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 STAGE=all usage() { echo "Usage: $0 [-r ] [-f ] [-d ] [-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