#!/usr/bin/env bash # # africa-alert-backup.sh # ---------------------- # Hot online backup of the Africa Alert PWA SQLite database. # # Uses the SQLite `.backup` command (NOT plain `cp`) so the snapshot is taken # safely while the server is running and the database is in WAL mode. Plain # `cp` of a WAL-mode DB can produce a corrupted or torn snapshot because the # -wal and -shm sidecars are still being written to. # # This script is meant to be run from cron on the production host # (Ubuntu 22.04). It is NOT the recommended path on Windows. On a Windows # dev box, run `node scripts/backup/africa-alert-backup.js` instead — that # script uses better-sqlite3's online backup API and does not need the # sqlite3 CLI. If you MUST run this .sh on Windows and the sqlite3 CLI is # missing, the script will automatically delegate to the Node fallback so # Windows `bash` invocations still produce a usable backup. # # Required tooling on the host (Linux production): # - bash 4+ (Ubuntu 22.04 ships 5.1) # - sqlite3 CLI (apt: `sudo apt-get install -y sqlite3`) # - write access to $BACKUP_DIR and $LOG_DIR # # Environment variables (all optional): # DB_PATH Absolute path to the live SQLite database file. # Default: /opt/africa-alert/server/data/school.db # BACKUP_DIR Directory to write the timestamped .db snapshot into. # Default: /backups # LOG_DIR Directory to write the human-readable log into. # Default: /logs (i.e. the directory that # contains scripts/, two levels up from this script) # RETAIN_DAILY Number of most-recent daily snapshots to keep. # Default: 30 # # Exit codes: # 0 backup succeeded # 1 configuration error (missing tool, unreadable DB, etc.) # 2 sqlite3 .backup itself failed # 3 retention sweep failed (the snapshot itself is still on disk) set -eu # --- Configuration ----------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # scripts/backup/.sh -> project root is two levels up. PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" DB_PATH="${DB_PATH:-/opt/africa-alert/server/data/school.db}" BACKUP_DIR="${BACKUP_DIR:-/backups}" LOG_DIR="${LOG_DIR:-${PROJECT_ROOT}/logs}" RETAIN_DAILY="${RETAIN_DAILY:-30}" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" BACKUP_FILE="${BACKUP_DIR}/school-${TIMESTAMP}.db" LOG_FILE="${LOG_DIR}/backup.log" # --- Pre-flight -------------------------------------------------------------- log() { # Tee to stdout (so cron can mail it) and to the log file. local msg="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [backup] $*" echo "${msg}" | tee -a "${LOG_FILE}" >&2 } mkdir -p "${LOG_DIR}" touch "${LOG_FILE}" if ! command -v sqlite3 >/dev/null 2>&1; then # Linux production hosts always have sqlite3 (apt: `sudo apt-get install # -y sqlite3`). The absence of sqlite3 here means either a misconfigured # production host, or a Windows dev box where the CLI is not installed # by default. In either case, fall through to the Node fallback which # uses the same primitive via better-sqlite3's online backup API. log "WARN: sqlite3 CLI not found in PATH; delegating to Node fallback (scripts/backup/africa-alert-backup.js)" if ! command -v node >/dev/null 2>&1; then log "ERROR: neither sqlite3 nor node is available. Install one (apt: sudo apt-get install -y sqlite3) before retrying." exit 1 fi exec node "${SCRIPT_DIR}/africa-alert-backup.js" fi if [ ! -f "${DB_PATH}" ]; then log "ERROR: DB_PATH does not exist or is not a regular file: ${DB_PATH}" exit 1 fi if [ ! -r "${DB_PATH}" ]; then log "ERROR: DB_PATH is not readable by $(id -un): ${DB_PATH}" exit 1 fi # --- Backup ------------------------------------------------------------------ # Idempotency note: mkdir -p on BACKUP_DIR is what makes this script safe to # run before the operator has manually created the destination. mkdir -p "${BACKUP_DIR}" log "starting backup of ${DB_PATH} -> ${BACKUP_FILE}" # `.backup` is a SQLite dot-command; we invoke it via the CLI's stdin # mechanism. The trailing path is the destination file. SQLite creates the # destination atomically and flushes the WAL before copying. if ! sqlite3 "${DB_PATH}" ".backup '${BACKUP_FILE}'"; then log "ERROR: sqlite3 .backup failed for ${DB_PATH}" exit 2 fi if [ ! -s "${BACKUP_FILE}" ]; then log "ERROR: backup file was not created or is empty: ${BACKUP_FILE}" exit 2 fi # Copy the -wal and -shm sidecars if they exist, so the snapshot is fully # self-contained for restore. The sidecars may be absent if the live DB # was idle (WAL checkpointed) — that is fine. for ext in wal shm; do if [ -f "${DB_PATH}-${ext}" ]; then cp "${DB_PATH}-${ext}" "${BACKUP_FILE}-${ext}" log "copied sidecar ${DB_PATH}-${ext} -> ${BACKUP_FILE}-${ext}" fi done # Compute a checksum for tamper detection / off-host transfer verification. if command -v sha256sum >/dev/null 2>&1; then ( cd "$(dirname "${BACKUP_FILE}")" && sha256sum "$(basename "${BACKUP_FILE}")" ) >> "${LOG_FILE}" log "wrote sha256 checksum next to backup file" fi # --- Retention sweep --------------------------------------------------------- # Keep the most recent $RETAIN_DAILY daily snapshots. Old ones beyond that # are deleted. (Weekly retention is handled separately by docs/BACKUP.md # using a different selector that mirrors to /backups/weekly/.) DELETED=0 if [ -d "${BACKUP_DIR}" ]; then # List backups newest-first, skip the first RETAIN_DAILY, delete the rest. # `2>/dev/null || true` keeps set -e from blowing up if there are fewer # than RETAIN_DAILY+1 files. TO_DELETE="$(ls -1t "${BACKUP_DIR}"/school-*.db 2>/dev/null \ | tail -n +"$((RETAIN_DAILY + 1))" || true)" if [ -n "${TO_DELETE}" ]; then while IFS= read -r f; do [ -z "${f}" ] && continue rm -f -- "${f}" "${f}-wal" "${f}-shm" "${f}.sha256" 2>/dev/null || true DELETED=$((DELETED + 1)) log "retention: deleted old backup ${f}" done <<< "${TO_DELETE}" fi fi log "done. file=${BACKUP_FILE} size=$(stat -c %s "${BACKUP_FILE}" 2>/dev/null || echo unknown) bytes retention_deleted=${DELETED}" exit 0