backup: add a mongo driver for live, consistent dumps

Rocket.Chat and Stoat are both MongoDB-backed, and the backup engine only
understood postgres, mysql/mariadb and sqlite — so a live snapshot of either
would have captured a torn data directory that may not even mount.

Adds mongo as a fourth kind: mongodump --archive on the backup side,
mongorestore --archive --drop on the restore side (idempotent, so the caller's
retry loop works the same as it does for pg_dump --clean), and a ping-based
readiness probe that also waits out a replica set electing its primary.

Credentials are optional. The shared sh preamble sets them from
MONGO_INITDB_ROOT_USERNAME/PASSWORD when present and passes nothing when not,
built with 'set --' so a password containing spaces survives word splitting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 05:28:20 +01:00
parent 1e9e042d41
commit 9084280ea8

View File

@ -17,7 +17,7 @@
# labels:
# libreportal.backup.db: "<kind>:<container>:<datadir>:<path>"
#
# kind mysql | mariadb | postgres | sqlite
# kind mysql | mariadb | postgres | mongo | sqlite
# container service container_name to `docker exec` into (server engines)
# datadir app-dir-relative folder holding raw DB files, excluded on live
# path app-dir-relative path to the sqlite file (sqlite only)
@ -25,6 +25,7 @@
# Examples:
# "mysql:nextcloud-db:db_data:" MariaDB/MySQL in nextcloud-db, raw db_data/ excluded
# "postgres:mastodon-db:postgres_data:" Postgres in mastodon-db
# "mongo:rocketchat-db:mongo_data:" MongoDB in rocketchat-db
# "sqlite:::data/gitea.db" sqlite file at data/gitea.db
#
# An app with no database can still opt into live snapshots (its files are
@ -130,10 +131,19 @@ _backupDbDumpName()
local kind="$1" container="$2" path="$3"
case "$kind" in
sqlite) echo "sqlite-$(echo "$path" | tr '/' '_').sqlite.gz" ;;
# mongodump emits a binary archive, not SQL text — name it honestly so a
# human poking at .lp-backup/db doesn't try to `zcat | psql` it.
mongo) echo "db-${container}.archive.gz" ;;
*) echo "db-${container}.sql.gz" ;;
esac
}
# Positional-arg preamble for the mongo tools: sets "$@" to the credential flags
# when the container was started with root auth, and to nothing when it wasn't.
# Built with `set --` rather than a flat string so a password containing spaces
# or globbing characters survives word splitting intact.
_backup_mongo_auth_sh='if [ -n "${MONGO_INITDB_ROOT_USERNAME:-}" ]; then set -- -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin; else set --; fi;'
# Wait until a server database is genuinely ready for a load. On a fresh init
# (the restore case) the engine starts a throwaway temp server, runs its setup,
# then stops it and starts the real one — a simple ping passes against the temp
@ -149,6 +159,11 @@ _backupDbWaitReady()
case "$kind" in
postgres)
runFileOp docker exec "$container" sh -c 'export PGPASSWORD="${POSTGRES_PASSWORD:-}"; psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" -tAc "SELECT 1"' >/dev/null 2>&1 && good=1 ;;
mongo)
# mongosh on 6.0+, the legacy mongo shell on older images. A
# ping that returns ok:1 means the node is past init AND (for a
# replica set, which Rocket.Chat requires) has a primary.
runFileOp docker exec "$container" sh -c "$_backup_mongo_auth_sh"' (mongosh "$@" --quiet --eval "db.adminCommand({ping:1}).ok" 2>/dev/null || mongo "$@" --quiet --eval "db.adminCommand({ping:1}).ok" 2>/dev/null) | grep -q 1' >/dev/null 2>&1 && good=1 ;;
*)
runFileOp docker exec "$container" sh -c 'RP="${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}"; mariadb -uroot -p"$RP" -N -e "SELECT 1" 2>/dev/null || mysql -uroot -p"$RP" -N -e "SELECT 1"' >/dev/null 2>&1 && good=1 ;;
esac
@ -172,6 +187,12 @@ _backupDbImport()
postgres)
runFileOp gzip -dc "$dump" | docker exec -i "$container" sh -c \
'export PGPASSWORD="${POSTGRES_PASSWORD:-}"; psql -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}"' >/dev/null 2>&1 ;;
mongo)
# --drop replaces each collection as it is restored, which is what
# makes a re-run idempotent (the retry loop in the caller depends on
# that, exactly like pg_dump --clean --if-exists).
runFileOp gzip -dc "$dump" | docker exec -i "$container" sh -c \
"$_backup_mongo_auth_sh"' mongorestore "$@" --archive --drop --quiet' >/dev/null 2>&1 ;;
*)
runFileOp gzip -dc "$dump" | docker exec -i "$container" sh -c \
'RP="${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}"; (mariadb -uroot -p"$RP" 2>/dev/null || mysql -uroot -p"$RP")' >/dev/null 2>&1 ;;
@ -217,6 +238,18 @@ backupDbDump()
isError "$kind dump failed ($container)"; rc=1
fi
;;
mongo)
isNotice "Dumping mongo ($container) — live, consistent"
# No --gzip on mongodump: the pipeline below already gzips, and
# compressing twice just burns CPU for nothing.
if runFileOp docker exec "$container" sh -c \
"$_backup_mongo_auth_sh"' mongodump "$@" --archive --quiet' \
2>/dev/null | gzip | runFileWrite "$dump"; then
isSuccessful "mongo dump written ($container)"
else
isError "mongo dump failed ($container)"; rc=1
fi
;;
sqlite)
isNotice "Dumping sqlite ($path) — live, consistent"
local src="$app_dir/$path"