geocrop-platform./apps/nextgen/scripts/backup/africa-alert-backup.sh

155 lines
6.1 KiB
Bash
Executable File

#!/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: <project root>/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/<this>.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