254 lines
8.6 KiB
Bash
Executable File
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
|