340 lines
11 KiB
Bash
Executable File
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 "$@"
|