Files
homelab-infra/ops/borg-ui/scripts/pre-backup-dumps.sh
T

340 lines
11 KiB
Bash
Executable File

#!/bin/sh
set -eu
# Run this on the Unraid host before Borg starts.
# It refreshes the latest database dumps in a stable directory so Borg can
# version the dump artifacts instead of raw live database files.
DUMP_ROOT="${DUMP_ROOT:-/mnt/user/backups/borg/dumps}"
LATEST_DIR="$DUMP_ROOT/latest"
TMP_DIR="$DUMP_ROOT/.tmp"
SHARED_PG_ADMIN_USER="${SHARED_PG_ADMIN_USER:-mailarchiver}"
SHARED_PG_PASSWORD_FILE="${SHARED_PG_PASSWORD_FILE:-/mnt/user/appdata/secrets/postgres_password.txt}"
log() {
printf '%s %s\n' "[borg-dumps]" "$*"
}
warn() {
printf '%s %s\n' "[borg-dumps][warn]" "$*" >&2
}
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
warn "Required command missing: $1"
exit 1
fi
}
need_container() {
docker inspect "$1" >/dev/null 2>&1
}
ensure_dirs() {
mkdir -p "$LATEST_DIR" "$TMP_DIR"
}
atomic_write() {
target="$1"
tmp="$2"
mode="${3:-644}"
mkdir -p "$(dirname "$target")"
# Standard 0644, damit der Nearline-Pull-Workflow (docs/H_DRIVE_NEARLINE_PULL.md)
# und Restore-Test-Skripte die Dumps per SMB-Read-Share oder unprivilegiert
# lesen koennen. Sensible Sonderfaelle wie unraid-flash-config rufen mit
# explizitem 600 auf, damit die bewusste Beschraenkung erhalten bleibt.
chmod "$mode" "$tmp"
mv "$tmp" "$target"
}
dump_pg_db() {
container="$1"
password="$2"
user="$3"
db="$4"
output="$5"
tmp="$TMP_DIR/$(basename "$output").tmp"
log "Dumping PostgreSQL database '$db' from $container"
docker exec -e "PGPASSWORD=$password" "$container" \
pg_dump -U "$user" -d "$db" -Fc > "$tmp"
atomic_write "$output" "$tmp"
}
dump_pg_globals() {
container="$1"
password="$2"
user="$3"
output="$4"
tmp="$TMP_DIR/$(basename "$output").tmp"
log "Dumping PostgreSQL globals from $container"
docker exec -e "PGPASSWORD=$password" "$container" \
pg_dumpall -U "$user" --globals-only > "$tmp"
atomic_write "$output" "$tmp"
}
dump_sqlite_file() {
source="$1"
output="$2"
label="$3"
if [ ! -f "$source" ]; then
warn "Skipping missing SQLite database for $label: $source"
return 0
fi
tmp="$TMP_DIR/$(basename "$output").tmp"
log "Dumping SQLite database '$label' from $source"
rm -f "$tmp"
if ! sqlite3 "$source" ".backup $tmp"; then
warn "SQLite backup failed for $label"
rm -f "$tmp"
return 1
fi
if [ "$(sqlite3 "$tmp" 'PRAGMA quick_check;')" != "ok" ]; then
warn "SQLite quick_check failed for $label"
rm -f "$tmp"
return 1
fi
atomic_write "$output" "$tmp"
}
dump_sqlite_container() {
container="$1"
db_path="$2"
output="$3"
host_source="${4:-}"
if ! need_container "$container"; then
warn "Skipping missing container: $container"
return 0
fi
if ! docker exec "$container" sh -lc 'command -v sqlite3 >/dev/null 2>&1'; then
if [ -n "$host_source" ]; then
warn "Container $container has no sqlite3; using host-side SQLite backup for $host_source"
dump_sqlite_file "$host_source" "$output" "$container"
return
fi
warn "Skipping SQLite backup for $container because sqlite3 is missing in the container and no host fallback is configured"
return 1
fi
container_tmp="/tmp/$(basename "$output").bak"
tmp="$TMP_DIR/$(basename "$output").tmp"
log "Dumping SQLite database '$db_path' from $container"
rm -f "$tmp"
docker exec "$container" rm -f "$container_tmp" >/dev/null 2>&1 || true
if ! docker exec "$container" sqlite3 "$db_path" ".backup $container_tmp"; then
warn "SQLite backup failed for $container:$db_path"
docker exec "$container" rm -f "$container_tmp" >/dev/null 2>&1 || true
rm -f "$tmp"
return 1
fi
docker cp "$container:$container_tmp" "$tmp"
docker exec "$container" rm -f "$container_tmp" >/dev/null 2>&1 || true
if [ "$(sqlite3 "$tmp" 'PRAGMA quick_check;')" != "ok" ]; then
warn "SQLite quick_check failed for $container:$db_path"
rm -f "$tmp"
return 1
fi
atomic_write "$output" "$tmp"
}
dump_file_copy() {
source="$1"
output="$2"
label="$3"
if [ ! -f "$source" ]; then
warn "Skipping missing file dump for $label: $source"
return 0
fi
tmp="$TMP_DIR/$(basename "$output").tmp"
log "Copying file-backed state '$label' from $source"
rm -f "$tmp"
cp "$source" "$tmp"
atomic_write "$output" "$tmp"
}
backup_unraid_flash_config() {
output="$LATEST_DIR/unraid-flash-config.tar.gz"
checksum="$LATEST_DIR/unraid-flash-config.tar.gz.sha256"
manifest="$LATEST_DIR/unraid-flash-config.manifest.txt"
tmp="$TMP_DIR/unraid-flash-config.tar.gz.tmp"
tmp_checksum="$TMP_DIR/unraid-flash-config.tar.gz.sha256.tmp"
tmp_manifest="$TMP_DIR/unraid-flash-config.manifest.txt.tmp"
if [ ! -d /boot/config ]; then
warn "Skipping Unraid flash config backup because /boot/config is missing"
return 1
fi
log "Backing up Unraid flash configuration from /boot/config"
rm -f "$tmp" "$tmp_checksum" "$tmp_manifest"
tar -C /boot \
--exclude='config/plugins/*/*.txz' \
--exclude='config/plugins/*/*.tgz' \
--exclude='config/plugins/*/*.tar' \
--exclude='config/plugins/*/*.tar.*' \
--exclude='config/plugins/*/*.zip' \
--exclude='config/plugins/*/*.md5' \
-czf "$tmp" config
# Flash-Config ist sensibel (enthaelt /boot/config inkl. Plugin-/SMB-/Network-Settings);
# bewusst 0600, damit der Nearline-Pull ueber SMB sie nicht versehentlich greift.
atomic_write "$output" "$tmp" 600
(
cd "$LATEST_DIR"
sha256sum "$(basename "$output")"
) > "$tmp_checksum"
atomic_write "$checksum" "$tmp_checksum" 600
{
printf 'created_utc=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf 'host=%s\n' "$(hostname)"
if [ -f /etc/unraid-version ]; then
sed 's/^/unraid_/' /etc/unraid-version
fi
printf 'source=/boot/config\n'
printf 'archive=%s\n' "$(basename "$output")"
printf 'checksum=%s\n' "$(basename "$checksum")"
printf 'note=%s\n' 'Contains Unraid configuration and must be treated as secret backup material.'
printf 'excluded=%s\n' 'downloadable plugin package archives under /boot/config/plugins/*/'
} > "$tmp_manifest"
atomic_write "$manifest" "$tmp_manifest" 600
}
dump_optional_pg_db() {
container="$1"
password="$2"
user="$3"
db="$4"
output="$5"
if docker exec -e "PGPASSWORD=$password" "$container" \
psql -U "$user" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'" \
| grep -q 1; then
dump_pg_db "$container" "$password" "$user" "$db" "$output"
else
warn "Skipping missing PostgreSQL database '$db' in $container"
fi
}
dump_mysql_container() {
container="$1"
output="$2"
if ! need_container "$container"; then
warn "Skipping missing container: $container"
return 0
fi
info="$(docker exec "$container" sh -lc 'printf "%s|%s|%s" "${MARIADB_DATABASE:-${MYSQL_DATABASE:-}}" "${MARIADB_USER:-${MYSQL_USER:-root}}" "${MARIADB_PASSWORD:-${MYSQL_PASSWORD:-}}"' || true)"
db="$(printf '%s' "$info" | cut -d'|' -f1)"
user="$(printf '%s' "$info" | cut -d'|' -f2)"
password="$(printf '%s' "$info" | cut -d'|' -f3)"
if [ -z "$db" ] || [ -z "$password" ]; then
warn "Skipping MySQL/MariaDB dump for $container because DB credentials were not discoverable"
return 0
fi
tmp="$TMP_DIR/$(basename "$output").tmp"
log "Dumping MariaDB/MySQL database '$db' from $container"
docker exec "$container" sh -lc "mysqldump --single-transaction --quick -u\"$user\" -p\"$password\" \"$db\"" > "$tmp"
atomic_write "$output" "$tmp"
}
dump_mongo_container() {
container="$1"
output="$2"
if ! need_container "$container"; then
warn "Skipping missing container: $container"
return 0
fi
if ! docker exec "$container" sh -lc 'command -v mongodump >/dev/null 2>&1'; then
warn "Skipping Mongo dump for $container because mongodump is not available in the container image"
return 0
fi
tmp="$TMP_DIR/$(basename "$output").tmp"
log "Dumping MongoDB archive from $container"
docker exec "$container" sh -lc 'mongodump --archive --gzip --username "$MONGO_INITDB_ROOT_USERNAME" --password "$(cat /run/secrets/mongo_password)" --authenticationDatabase admin' > "$tmp"
atomic_write "$output" "$tmp"
}
main() {
need_cmd docker
need_cmd sqlite3
need_cmd tar
need_cmd sha256sum
ensure_dirs
# Shared PostgreSQL 18 (historischer Containername: postgresql17)
if need_container "postgresql17"; then
# Use the cluster admin/superuser for all shared-cluster dumps. The
# application roles exist, but they can have different passwords from the
# bootstrap postgres secret used by the shared container.
shared_pg_password="$(cat "$SHARED_PG_PASSWORD_FILE")"
dump_pg_globals "postgresql17" "$shared_pg_password" "$SHARED_PG_ADMIN_USER" "$LATEST_DIR/postgresql17-globals.sql"
dump_pg_db "postgresql17" "$shared_pg_password" "$SHARED_PG_ADMIN_USER" "mailarchiver" "$LATEST_DIR/postgresql17-mailarchiver.dump"
dump_pg_db "postgresql17" "$shared_pg_password" "$SHARED_PG_ADMIN_USER" "paperless" "$LATEST_DIR/postgresql17-paperless.dump"
dump_optional_pg_db "postgresql17" "$shared_pg_password" "$SHARED_PG_ADMIN_USER" "authelia" "$LATEST_DIR/postgresql17-authelia.dump"
else
warn "Skipping shared PostgreSQL dumps because container 'postgresql17' is missing"
fi
# Dedicated PostgreSQL databases
if need_container "mealie-postgres"; then
mealie_password="$(cat /mnt/user/appdata/secrets/mealie_postgres_password.txt)"
dump_pg_db "mealie-postgres" "$mealie_password" "mealie" "mealie" "$LATEST_DIR/mealie.dump"
else
warn "Skipping missing container: mealie-postgres"
fi
if need_container "immich_postgres"; then
immich_password="$(cat /mnt/user/appdata/secrets/immich_postgres_password.txt)"
dump_pg_db "immich_postgres" "$immich_password" "immich" "immich" "$LATEST_DIR/immich.dump"
else
warn "Skipping missing container: immich_postgres"
fi
if need_container "nextcloud-postgres"; then
nextcloud_password="$(cat /mnt/user/appdata/secrets/nextcloud_postgres_password.txt)"
dump_pg_db "nextcloud-postgres" "$nextcloud_password" "nextcloud" "nextcloud" "$LATEST_DIR/nextcloud.dump"
else
warn "Skipping missing container: nextcloud-postgres"
fi
# SQLite databases
dump_sqlite_container "gitea" "/data/gitea/gitea.db" "$LATEST_DIR/gitea.sqlite.dump" "/mnt/user/services/gitea/data/gitea/gitea.db"
dump_sqlite_container "vaultwarden" "/data/db.sqlite3" "$LATEST_DIR/vaultwarden.sqlite.dump" "/mnt/user/appdata/vaultwarden/db.sqlite3"
dump_sqlite_container "speedtest-tracker" "/config/database.sqlite" "$LATEST_DIR/speedtest-tracker.sqlite.dump" "/mnt/user/appdata/speedtest-tracker/config/database.sqlite"
# Filebrowser uses file-backed app state, but this installation is not SQLite.
dump_file_copy "/mnt/user/appdata/filebrowser/database/filebrowser.db" "$LATEST_DIR/filebrowser.bolt.dump" "filebrowser"
# Additional host-side SQLite dumps for admin tooling with appdata files.
dump_sqlite_file "/mnt/user/appdata/borg-ui/data/borg.db" "$LATEST_DIR/borg-ui.sqlite" "borg-ui"
dump_sqlite_file "/mnt/user/appdata/grafana/grafana.db" "$LATEST_DIR/grafana.sqlite" "grafana"
# MongoDB
dump_mongo_container "komodo-mongo" "$LATEST_DIR/komodo-mongo.archive.gz"
# Unraid USB flash configuration. This is generated into the existing dump
# set so Borg carries it off-site together with the database artifacts.
backup_unraid_flash_config
log "Finished refreshing dump set in $LATEST_DIR"
}
main "$@"