Some checks failed
Release production image / production-image (push) Has been cancelled
103 lines
4.9 KiB
Bash
Executable File
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"
|