From 3df43e497960cbcb241d3d713f34b0ab32f4d908 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Mon, 5 Oct 2026 00:27:31 +0200 Subject: [PATCH] Backups, restore and rollback runbook (#10, #12) - deploy/backup.sh / deploy/restore.sh (compose service or local container; side-by-side test restore) - restore: chown copied backup for sqlservr, fail loudly on unreadable file list - README: update, backup, test-restore and rollback steps Validated locally: backup 194 MB, restored into a test DB with identical counts, test DB dropped. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + README.md | 21 +++++++++++++++++++++ deploy/backup.sh | 20 ++++++++++++++++++++ deploy/restore.sh | 30 ++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100755 deploy/backup.sh create mode 100755 deploy/restore.sh diff --git a/.gitignore b/.gitignore index 8753b34e..59eddffb 100644 --- a/.gitignore +++ b/.gitignore @@ -262,3 +262,4 @@ __pycache__/ /Museum/dist/js /Museum/src/dist .env +backups/ diff --git a/README.md b/README.md index 3b59f049..f152bcdc 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,30 @@ Re-running the import adds new poems and leaves existing ones untouched, so it c ### Update ```sh +deploy/backup.sh # always back up first git pull && docker compose up -d --build ``` +Database migrations run automatically when the API starts. + +### Backups + +```sh +deploy/backup.sh # -> backups/divan-.bak (copy it off the server) +TARGET_DB=divan_restoretest deploy/restore.sh backups/divan-.bak # test a backup side by side +``` + +Schedule `deploy/backup.sh` with cron (e.g. daily) and copy `backups/` off the server. A backup counts only once a test restore succeeds. + +### Rollback + +1. `docker compose stop api site` +2. `git checkout ` +3. If the failed version applied database migrations: `deploy/restore.sh backups/.bak` +4. `docker compose up -d --build` + +Restoring replaces the `divan` database, so anything written after that backup (comments, edits) is lost. + ## Run locally (macOS/Linux) ```sh diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 00000000..b48cb313 --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Back up the Divan SQL Server database to ./backups/divan-.bak +# deploy/backup.sh (docker-compose stack: service "db") +# DB_CONTAINER=divan-mssql deploy/backup.sh (local run-local.sh container) +# Needs MSSQL_SA_PASSWORD (read from .env when present). +set -euo pipefail +cd "$(dirname "$0")/.." +[ -f .env ] && set -a && . ./.env && set +a +: "${MSSQL_SA_PASSWORD:?set MSSQL_SA_PASSWORD}" +DB=${DB_NAME:-divan} +C=${DB_CONTAINER:-$(docker compose ps -q db)} +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +FILE=/var/opt/mssql/backup/$DB-$STAMP.bak +docker exec "$C" mkdir -p /var/opt/mssql/backup +docker exec "$C" /opt/mssql-tools18/bin/sqlcmd -C -b -S localhost -U sa -P "$MSSQL_SA_PASSWORD" \ + -Q "BACKUP DATABASE [$DB] TO DISK = N'$FILE' WITH INIT, CHECKSUM" +mkdir -p backups +docker cp "$C:$FILE" "backups/$DB-$STAMP.bak" +docker exec "$C" rm -f "$FILE" +echo "backups/$DB-$STAMP.bak" diff --git a/deploy/restore.sh b/deploy/restore.sh new file mode 100755 index 00000000..afe3c91c --- /dev/null +++ b/deploy/restore.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Restore a backup made by deploy/backup.sh. +# deploy/restore.sh backups/divan-.bak (replaces database "divan"; stop the api first) +# TARGET_DB=divan_restoretest deploy/restore.sh (restore side by side, e.g. to test a backup) +# DB_CONTAINER as in backup.sh. Needs MSSQL_SA_PASSWORD. +set -euo pipefail +cd "$(dirname "$0")/.." +[ -f .env ] && set -a && . ./.env && set +a +: "${MSSQL_SA_PASSWORD:?set MSSQL_SA_PASSWORD}" +BAK=${1:?usage: deploy/restore.sh } +TARGET=${TARGET_DB:-divan} +C=${DB_CONTAINER:-$(docker compose ps -q db)} +SQL() { docker exec "$C" /opt/mssql-tools18/bin/sqlcmd -C -b -S localhost -U sa -P "$MSSQL_SA_PASSWORD" "$@"; } +docker exec "$C" mkdir -p /var/opt/mssql/backup +docker cp "$BAK" "$C:/var/opt/mssql/backup/restore.bak" +docker exec -u 0 "$C" chown mssql /var/opt/mssql/backup/restore.bak # docker cp keeps the host uid; sqlservr runs as mssql +# logical file names inside the backup, moved to files named after the target database +FILES=$(SQL -h -1 -W -s '|' -Q "SET NOCOUNT ON; RESTORE FILELISTONLY FROM DISK = N'/var/opt/mssql/backup/restore.bak'" | awk -F'|' 'NF>2 {print $1"|"$3}') +[ -n "$FILES" ] || { echo "could not read the backup's file list" >&2; exit 1; } +MOVE="" +while IFS='|' read -r logical type; do + [ -z "$logical" ] && continue + ext=mdf; [ "$type" = L ] && ext=ldf + MOVE="$MOVE, MOVE N'$logical' TO N'/var/opt/mssql/data/${TARGET}_${logical}.$ext'" +done <<< "$FILES" +SQL -Q "IF DB_ID(N'$TARGET') IS NOT NULL ALTER DATABASE [$TARGET] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + RESTORE DATABASE [$TARGET] FROM DISK = N'/var/opt/mssql/backup/restore.bak' WITH REPLACE, CHECKSUM$MOVE; + ALTER DATABASE [$TARGET] SET MULTI_USER;" +docker exec "$C" rm -f /var/opt/mssql/backup/restore.bak +echo "restored $BAK into [$TARGET]"