From 9084280ea81701e5a4a87bb9b094744e22213e24 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 18 Aug 2026 05:28:20 +0100 Subject: [PATCH] backup: add a mongo driver for live, consistent dumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/backup/db/backup_db.sh | 35 +++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/backup/db/backup_db.sh b/scripts/backup/db/backup_db.sh index a982d26..d24c3aa 100644 --- a/scripts/backup/db/backup_db.sh +++ b/scripts/backup/db/backup_db.sh @@ -17,7 +17,7 @@ # labels: # libreportal.backup.db: ":::" # -# 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"