diff --git a/plakar-backup.sh b/plakar-backup.sh new file mode 100755 index 0000000..aace951 --- /dev/null +++ b/plakar-backup.sh @@ -0,0 +1,180 @@ +#!/bin/bash +# +# Nightly plakar backup driver. 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 +# (mariadb-dump, all non-system DBs), native PostgreSQL (pg_dump +# per DB + pg_dumpall --globals-only, peer auth as postgres), +# and PostgreSQL inside docker containers (image name matching +# "postgres", user from $POSTGRES_USER). Dumps are staged plain +# (not gzipped, so plakar's chunking dedups near-identical +# consecutive dumps) in the dump dir, then backed up as one +# snapshot. +# 2. sources — every fs source listed in the sources file (one +# `plakar source add` name per line, #-comments allowed). +# 3. retention — prune + maintenance on the kloset. +# The kloset target is whatever `plakar at` accepts — normally the S3 +# store (`-k @s3`, direct-to-S3, no local staging); a path +# (e.g. -k /backup/plakar-test) only for testing. -S optionally +# replicates the kloset to a second store afterwards (`plakar sync`); +# unused in the current single-store setup. +# +# Usage: plakar-backup.sh -k [-f ] +# [-r ] [-S ] [-d ] +# +# The kloset passphrase comes from $PLAKAR_PASSPHRASE or, failing +# that, /root/.plakar-passphrase (mode 600). + +set -u + +KLOSET="" +SRC_FILE=/root/scripts/plakar-sources +# optional DB exclude list, one dump path per line as printed by the +# "dump" log lines (/ or //); every skip +# is logged loudly so the list can't rot silently like a manifest +EXCLUDE_FILE=/root/scripts/plakar-db-exclude +RETENTION=30 +SYNC_STORE="" +DUMP_DIR=/backup/plakar-dumps +LOCKFILE=/run/lock/plakar-backup.lock +PASSFILE=/root/.plakar-passphrase + +usage() { + echo "Usage: $0 -k [-f ] [-r ] [-S ] [-d ]" >&2 + exit 2 +} + +while getopts k:f:r:S:d: flag; do + case "${flag}" in + k) KLOSET=${OPTARG};; + f) SRC_FILE=${OPTARG};; + r) RETENTION=${OPTARG};; + S) SYNC_STORE=${OPTARG};; + d) DUMP_DIR=${OPTARG};; + *) usage;; + esac +done + +[ -n "$KLOSET" ] || usage +[ -r "$SRC_FILE" ] || { echo "sources file $SRC_FILE not readable" >&2; exit 2; } + +exec 9>"$LOCKFILE" +if ! flock -n 9; then + echo "another plakar-backup run holds $LOCKFILE, aborting" >&2 + exit 1 +fi + +log() { echo "[$(date '+%F %T')] $*"; } + +excluded() { + [ -r "$EXCLUDE_FILE" ] && grep -qx "$1" "$EXCLUDE_FILE" \ + && log "SKIP $1 (listed in $EXCLUDE_FILE)" +} + +if [ -z "${PLAKAR_PASSPHRASE:-}" ]; then + [ -r "$PASSFILE" ] || { echo "no PLAKAR_PASSPHRASE in env and $PASSFILE not readable" >&2; exit 1; } + PLAKAR_PASSPHRASE=$(cat "$PASSFILE") +fi +export PLAKAR_PASSPHRASE + +rc=0 + +# incus's own state (instance configs, profiles, devices) — needed to +# rebuild instances, not covered by any file/DB source +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: native +# MariaDB, native PostgreSQL, and PostgreSQL inside docker containers +# (matched on the docker image name containing "postgres"). +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) — binaries may carry + # either naming (e.g. mail: hand-installed mariadb-dump + mysql client) + 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 (the mariadb equivalent of pg_dumpall + # --globals-only): replayable SHOW GRANTS statements, portable + # across versions unlike a raw mysql-schema dump + 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 + +log "backup dumps ($DUMP_DIR)" +plakar -quiet at "$KLOSET" backup -tag dumps "$DUMP_DIR" \ + || { echo "backup of $DUMP_DIR failed" >&2; rc=1; } + +for src in $(grep -Ev '^[[:space:]]*(#|$)' "$SRC_FILE"); do + log "backup @$src" + plakar -quiet at "$KLOSET" backup -tag "$src" "@$src" \ + || { echo "backup @$src failed" >&2; rc=1; } +done + +log "prune: keep the last $RETENTION days of snapshots" +plakar at "$KLOSET" prune -days "$RETENTION" -apply || rc=1 +plakar at "$KLOSET" maintenance || rc=1 + +if [ -n "$SYNC_STORE" ]; then + log "sync to @$SYNC_STORE" + plakar at "$KLOSET" sync to "@$SYNC_STORE" \ + || { echo "sync to @$SYNC_STORE failed" >&2; rc=1; } +fi + +log "done (rc=$rc)" +exit $rc