conductor/scripts/production/restore-conductor.sh
Victor Wiebe ade2b10033
Some checks failed
Release production image / production-image (push) Has been cancelled
Add production packaging and operations
2026-08-07 16:48:40 -04:00

103 lines
4.9 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
umask 077
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/compose.production.yml"
ENV_FILE="${ROOT_DIR}/.env"
BACKUP_DIR="${ROOT_DIR}/backups"
BACKUP_FILE=""
SERVICE="conductor"
LIST_ONLY=false
FORCE=false
SKIP_SAFETY_BACKUP=false
usage() {
printf '%s\n' "Usage: $0 [--list] [--backup-file PATH] [--backup-dir PATH] [--compose-file PATH] [--env-file PATH] [--force]" >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--list) LIST_ONLY=true; shift ;;
--backup-file) BACKUP_FILE="$2"; shift 2 ;;
--backup-dir) BACKUP_DIR="$2"; shift 2 ;;
--compose-file) COMPOSE_FILE="$2"; shift 2 ;;
--env-file) ENV_FILE="$2"; shift 2 ;;
--force) FORCE=true; shift ;;
--skip-safety-backup) SKIP_SAFETY_BACKUP=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
mapfile -t available < <(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'conductor_*.tar.gz' -print 2>/dev/null | sort -r)
if $LIST_ONLY; then printf '%s\n' "${available[@]}"; exit 0; fi
if [[ -z "$BACKUP_FILE" ]]; then
[[ -t 0 ]] || { echo "--backup-file is required for non-interactive restore." >&2; exit 1; }
[[ ${#available[@]} -gt 0 ]] || { echo "No backups found in $BACKUP_DIR" >&2; exit 1; }
for i in "${!available[@]}"; do printf '%d) %s\n' "$((i + 1))" "${available[$i]}" >&2; done
read -r -p "Select backup number: " selection
if [[ ! "$selection" =~ ^[0-9]+$ ]] || (( selection < 1 || selection > ${#available[@]} )); then
echo "Invalid selection." >&2
exit 1
fi
BACKUP_FILE="${available[$((selection - 1))]}"
fi
BACKUP_FILE="$(realpath "$BACKUP_FILE")"
[[ -f "$BACKUP_FILE" ]] || { echo "Backup not found: $BACKUP_FILE" >&2; exit 1; }
if ! $FORCE; then
[[ -t 0 ]] || { echo "Restore is destructive; pass --force for non-interactive use." >&2; exit 1; }
read -r -p "Replace the live Conductor database and recovery keys? Type RESTORE: " confirmation
[[ "$confirmation" == "RESTORE" ]] || { echo "Restore cancelled." >&2; exit 1; }
fi
work_dir="$(mktemp -d)"
cleanup() { rm -rf -- "$work_dir"; }
trap cleanup EXIT
if tar -tzf "$BACKUP_FILE" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then echo "Unsafe archive paths detected." >&2; exit 1; fi
tar -xzf "$BACKUP_FILE" -C "$work_dir"
[[ -f "$work_dir/manifest.json" && -f "$work_dir/SHA256SUMS" && -f "$work_dir/database/conductor.db" && -f "$work_dir/configuration/.env" ]] || { echo "Backup archive is incomplete." >&2; exit 1; }
(cd "$work_dir" && sha256sum -c SHA256SUMS >/dev/null)
grep -q '"format": "conductor-backup-v1"' "$work_dir/manifest.json" || { echo "Unsupported backup format." >&2; exit 1; }
grep -Eq '^CONDUCTOR_SECRET_KEY=.{32,}$' "$work_dir/configuration/.env" || { echo "Backup is missing its Conductor encryption key." >&2; exit 1; }
grep -Eq '^CONDUCTOR_SESSION_KEY=.{32,}$' "$work_dir/configuration/.env" || { echo "Backup is missing its Conductor session key." >&2; exit 1; }
if ! $SKIP_SAFETY_BACKUP && [[ -f "$ENV_FILE" && -f "$COMPOSE_FILE" ]]; then
echo "[*] Creating pre-restore safety backup..." >&2
"${ROOT_DIR}/scripts/production/backup-conductor.sh" --compose-file "$COMPOSE_FILE" --env-file "$ENV_FILE" --backup-dir "$BACKUP_DIR" >&2
fi
install -m 600 "$work_dir/configuration/.env" "$ENV_FILE"
[[ -f "$COMPOSE_FILE" ]] || install -m 600 "$work_dir/configuration/compose.production.yml" "$COMPOSE_FILE"
compose=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE")
container_id="$("${compose[@]}" ps -aq "$SERVICE")"
[[ -n "$container_id" ]] || { echo "Conductor container does not exist; run docker compose up once before restore." >&2; exit 1; }
image_ref="$(docker inspect -f '{{.Config.Image}}' "$container_id")"
echo "[*] Stopping Conductor and replacing database..." >&2
"${compose[@]}" stop "$SERVICE" >&2
docker cp "$work_dir/database/conductor.db" "${container_id}:/data/conductor.db.restore"
docker run --rm --user 0 --volumes-from "$container_id" --entrypoint node "$image_ref" -e '
const fs=require("fs");
for(const file of ["/data/conductor.db-wal","/data/conductor.db-shm"])if(fs.existsSync(file))fs.unlinkSync(file);
fs.renameSync("/data/conductor.db.restore","/data/conductor.db");
fs.chownSync("/data/conductor.db",10001,10001);
fs.chmodSync("/data/conductor.db",0o600);
' >&2
"${compose[@]}" up -d "$SERVICE" >&2
healthy=false
for _ in $(seq 1 30); do
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$("${compose[@]}" ps -q "$SERVICE")")"
if [[ "$status" == "healthy" ]]; then healthy=true; break; fi
sleep 1
done
$healthy || { echo "Conductor did not become healthy after restore." >&2; exit 1; }
result="${BACKUP_DIR}/restore-result.json"
cat > "$result" <<EOF
{"script":"restore-conductor","status":"success","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","backupFile":"${BACKUP_FILE}","health":"healthy"}
EOF
chmod 600 "$result"
echo "[*] Restore complete and healthy." >&2
cat "$result"