#!/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" <&2 cat "$result"