geocrop-platform./apps/nextgen/scripts/backup/verify-restore.sh

254 lines
8.6 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# verify-restore.sh
# -----------------
# Smoke-test a backup file by booting the API server against a copy of it
# and asserting that admin login succeeds.
#
# Workflow:
# 1. Pick a free high port.
# 2. Copy the backup file to a temp directory.
# 3. Set DB_PATH to the copy, PORT to the free port, and spawn the
# server with `node src/index.js` in the background.
# 4. Poll /api/auth/login with admin creds until 200 OR timeout.
# 5. Send SIGTERM to the server, wait for it to exit, clean up the
# temp directory.
# 6. Exit 0 on success, 1 on failure.
#
# Usage:
# ./verify-restore.sh /backups/school-20260716-120000.db
# ./verify-restore.sh # auto-pick the most recent
#
# This script is meant to be run on the production Ubuntu 22.04 host.
# It is NOT a Windows script. On a Windows dev box, only
# `bash -n verify-restore.sh` (syntax check) is meaningful.
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
SERVER_DIR="${PROJECT_ROOT}/server"
LOG_DIR="${LOG_DIR:-${PROJECT_ROOT}/logs}"
LOG_FILE="${LOG_DIR}/backup.log"
VERIFY_LOG="${LOG_DIR}/verify-restore.log"
# Test parameters (override on the env if you must):
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@school.com}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}"
STARTUP_TIMEOUT="${STARTUP_TIMEOUT:-15}" # seconds to wait for server
LOGIN_TIMEOUT="${LOGIN_TIMEOUT:-10}" # seconds for the login probe
# --- Pre-flight --------------------------------------------------------------
if ! command -v curl >/dev/null 2>&1; then
echo "ERROR: curl not found in PATH" >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: node not found in PATH" >&2
exit 1
fi
if [ ! -d "${SERVER_DIR}" ]; then
echo "ERROR: server dir not found: ${SERVER_DIR}" >&2
exit 1
fi
# Pick the backup file: positional arg, or newest in /backups.
if [ $# -ge 1 ]; then
BACKUP_FILE="$1"
else
if [ -d "/backups" ]; then
# shellcheck disable=SC2012
BACKUP_FILE="$(ls -1t /backups/school-*.db 2>/dev/null | head -n 1 || true)"
fi
if [ -z "${BACKUP_FILE:-}" ]; then
echo "ERROR: no backup file given and /backups/school-*.db not found" >&2
echo "usage: $0 <backup-file>" >&2
exit 64
fi
fi
if [ ! -f "${BACKUP_FILE}" ]; then
echo "ERROR: backup file not found: ${BACKUP_FILE}" >&2
exit 1
fi
mkdir -p "${LOG_DIR}"
touch "${VERIFY_LOG}"
log() {
local msg="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [verify-restore] $*"
echo "${msg}" | tee -a "${VERIFY_LOG}" >&2
}
# --- Pick a free port --------------------------------------------------------
# Use Node to ask the kernel for a free ephemeral port. We prefer Node over
# python because Node is guaranteed to be present (the project requires it
# to run, and we just used it above) and there is no Python-on-Windows
# aliasing ambiguity to worry about. Falls back to a constant high port
# if neither python nor node is available.
pick_free_port() {
if command -v node >/dev/null 2>&1; then
node -e 'const s=require("net").createServer(); s.listen(0,()=>{process.stdout.write(String(s.address().port));s.close();});'
return 0
fi
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'
return 0
fi
if command -v python >/dev/null 2>&1; then
# On Windows the Microsoft Store "python" alias can hijack the binary.
# Probe it: if 'python --version' fails, fall through to the constant.
if python --version >/dev/null 2>&1; then
python -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'
return 0
fi
fi
echo "30101"
}
TEST_PORT="$(pick_free_port)"
log "using test port ${TEST_PORT}"
# --- Stage a temp working dir for the test DB --------------------------------
WORK_DIR="$(mktemp -d -t africa-alert-verify.XXXXXX)"
trap 'cleanup' EXIT INT TERM
TEST_DB="${WORK_DIR}/school.db"
cleanup() {
local rc=$?
if [ -n "${SERVER_PID:-}" ] && kill -0 "${SERVER_PID}" 2>/dev/null; then
log "stopping test server pid=${SERVER_PID}"
kill "${SERVER_PID}" 2>/dev/null || true
# Give it 5s to flush and exit; then SIGKILL.
for _ in 1 2 3 4 5; do
kill -0 "${SERVER_PID}" 2>/dev/null || break
sleep 1
done
if kill -0 "${SERVER_PID}" 2>/dev/null; then
kill -9 "${SERVER_PID}" 2>/dev/null || true
fi
fi
if [ -d "${WORK_DIR}" ]; then
# Best-effort cleanup. On Windows the SQLite -wal/-shm sidecars and
# even the .db file itself can be held open briefly by the OS after
# the Node child exits, so rm returns EBUSY. That's fine — the temp
# dir is in the OS-managed scratch space and will be reclaimed.
rm -rf "${WORK_DIR}" 2>/dev/null || log "WARN: temp dir cleanup failed (rc=$?); ignoring"
fi
if [ "${rc}" -eq 0 ]; then
log "verify-restore PASSED"
else
log "verify-restore FAILED (rc=${rc})"
fi
}
cp -f "${BACKUP_FILE}" "${TEST_DB}"
# If the backup has sidecars, copy them too so the test reflects a faithful
# restore.
for ext in wal shm; do
if [ -f "${BACKUP_FILE}-${ext}" ]; then
cp -f "${BACKUP_FILE}-${ext}" "${TEST_DB}-${ext}"
fi
done
log "staged test db at ${TEST_DB}"
# --- Spawn the server -------------------------------------------------------
SERVER_STDERR="${WORK_DIR}/server.stderr.log"
SERVER_STDOUT="${WORK_DIR}/server.stdout.log"
# Run in background; capture PIDs. We use bash to redirect so the file
# handles are owned by the subshell, not this script.
(
cd "${SERVER_DIR}"
DB_PATH="${TEST_DB}" \
PORT="${TEST_PORT}" \
NODE_ENV=development \
node src/index.js \
>"${SERVER_STDOUT}" 2>"${SERVER_STDERR}"
) &
SERVER_PID=$!
log "spawned test server pid=${SERVER_PID}"
# --- Wait for readiness ------------------------------------------------------
# Probe the login endpoint with bad creds; the server is ready when we
# get a 401 (route mounted, DB queryable) instead of a connection error.
ready=0
deadline=$(( $(date +%s) + STARTUP_TIMEOUT ))
while [ "$(date +%s)" -lt "${deadline}" ]; do
if ! kill -0 "${SERVER_PID}" 2>/dev/null; then
log "ERROR: server process exited before becoming ready"
log "--- server stdout ---"
sed 's/^/ /' "${SERVER_STDOUT}" | tee -a "${VERIFY_LOG}" >&2 || true
log "--- server stderr ---"
sed 's/^/ /' "${SERVER_STDERR}" | tee -a "${VERIFY_LOG}" >&2 || true
exit 1
fi
CODE="$(curl -s -o /dev/null -w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
-d '{"email":"__probe__","password":"__probe__"}' \
--max-time 2 \
"http://127.0.0.1:${TEST_PORT}/api/auth/login" || echo 000)"
case "${CODE}" in
400|401) ready=1; break ;; # 400=missing creds, 401=invalid creds — both mean route+DB are alive
000) sleep 0.3 ;; # connection refused / timeout
*) sleep 0.3 ;; # anything else (500?) — keep trying
esac
done
if [ "${ready}" -ne 1 ]; then
log "ERROR: server did not become ready within ${STARTUP_TIMEOUT}s"
log "--- server stdout ---"
sed 's/^/ /' "${SERVER_STDOUT}" | tee -a "${VERIFY_LOG}" >&2 || true
log "--- server stderr ---"
sed 's/^/ /' "${SERVER_STDERR}" | tee -a "${VERIFY_LOG}" >&2 || true
exit 1
fi
log "server ready on :${TEST_PORT}"
# --- Real login probe -------------------------------------------------------
log "probing /api/auth/login as ${ADMIN_EMAIL}"
LOGIN_RESPONSE="$(mktemp)"
LOGIN_DEADLINE=$(( $(date +%s) + LOGIN_TIMEOUT ))
LOGIN_CODE=000
while [ "$(date +%s)" -lt "${LOGIN_DEADLINE}" ]; do
LOGIN_CODE="$(curl -s -o "${LOGIN_RESPONSE}" -w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
-d "{\"email\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\"}" \
--max-time 5 \
"http://127.0.0.1:${TEST_PORT}/api/auth/login" || echo 000)"
if [ "${LOGIN_CODE}" = "200" ]; then
break
fi
sleep 0.5
done
log "login response code: ${LOGIN_CODE}"
# Print only the first 200 bytes of the response body — we don't want to
# leak the JWT into the log file.
head -c 200 "${LOGIN_RESPONSE}" | sed 's/^/ /' | tee -a "${VERIFY_LOG}" >&2 || true
echo "" | tee -a "${VERIFY_LOG}" >&2
if [ "${LOGIN_CODE}" != "200" ]; then
log "ERROR: admin login did not return 200 (got ${LOGIN_CODE})"
rm -f "${LOGIN_RESPONSE}"
exit 1
fi
# Sanity check: response should look like JSON with a `token` field.
if ! head -c 200 "${LOGIN_RESPONSE}" | grep -q '"token"'; then
log "ERROR: response body did not contain a 'token' field"
rm -f "${LOGIN_RESPONSE}"
exit 1
fi
rm -f "${LOGIN_RESPONSE}"
log "login OK — backup is restorable"
# cleanup() will run on EXIT and stop the server + remove WORK_DIR.
exit 0