conductor/scripts/production/backup-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

82 lines
3.5 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"
RETENTION_DAYS=7
SERVICE="conductor"
TIMESTAMP="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
usage() {
printf '%s\n' "Usage: $0 [--compose-file PATH] [--env-file PATH] [--backup-dir PATH] [--retention-days N]" >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--compose-file) COMPOSE_FILE="$2"; shift 2 ;;
--env-file) ENV_FILE="$2"; shift 2 ;;
--backup-dir) BACKUP_DIR="$2"; shift 2 ;;
--retention-days) RETENTION_DAYS="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
[[ -f "$COMPOSE_FILE" ]] || { echo "Compose file not found: $COMPOSE_FILE" >&2; exit 1; }
[[ -f "$ENV_FILE" ]] || { echo "Environment file not found: $ENV_FILE (it contains required recovery keys)." >&2; exit 1; }
[[ "$RETENTION_DAYS" =~ ^[0-9]+$ ]] || { echo "--retention-days must be a non-negative integer." >&2; exit 1; }
compose=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE")
container_id="$("${compose[@]}" ps -q "$SERVICE")"
[[ -n "$container_id" ]] || { echo "Conductor container is not created." >&2; exit 1; }
[[ "$(docker inspect -f '{{.State.Running}}' "$container_id")" == "true" ]] || { echo "Conductor container is not running." >&2; exit 1; }
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
work_dir="$(mktemp -d)"
cleanup() { rm -rf -- "$work_dir"; }
trap cleanup EXIT
mkdir -p "$work_dir/database" "$work_dir/configuration"
staged="/data/.conductor-backup-${TIMESTAMP}.db"
echo "[*] Creating consistent online SQLite backup..." >&2
"${compose[@]}" exec -T "$SERVICE" node dist/scripts/backupDatabase.js "$staged" >&2
docker cp "${container_id}:${staged}" "$work_dir/database/conductor.db"
"${compose[@]}" exec -T "$SERVICE" node -e 'const fs=require("fs");fs.unlinkSync(process.argv[1])' "$staged"
cp "$ENV_FILE" "$work_dir/configuration/.env"
cp "$COMPOSE_FILE" "$work_dir/configuration/compose.production.yml"
image_ref="$(docker inspect -f '{{.Config.Image}}' "$container_id")"
image_id="$(docker inspect -f '{{.Image}}' "$container_id")"
cat > "$work_dir/manifest.json" <<EOF
{
"format": "conductor-backup-v1",
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"image": "${image_ref}",
"imageId": "${image_id}",
"database": "database/conductor.db",
"environment": "configuration/.env"
}
EOF
(cd "$work_dir" && sha256sum database/conductor.db configuration/.env configuration/compose.production.yml manifest.json > SHA256SUMS)
archive="${BACKUP_DIR}/conductor_${TIMESTAMP}.tar.gz"
tar -czf "$archive" -C "$work_dir" database configuration manifest.json SHA256SUMS
chmod 600 "$archive"
sha256sum "$archive" > "${archive}.sha256"
chmod 600 "${archive}.sha256"
removed=0
while IFS= read -r -d '' old; do rm -f -- "$old" "${old}.sha256"; removed=$((removed + 1)); done < <(find "$BACKUP_DIR" -maxdepth 1 -name 'conductor_*.tar.gz' -mtime +"$RETENTION_DAYS" -print0)
size_bytes="$(stat -c '%s' "$archive")"
result="${BACKUP_DIR}/backup-result.json"
cat > "$result" <<EOF
{"script":"backup-conductor","status":"success","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","backup":{"file":"${archive}","sizeBytes":${size_bytes},"checksumFile":"${archive}.sha256"},"rotation":{"retentionDays":${RETENTION_DAYS},"filesRemoved":${removed}},"coverage":{"database":true,"encryptionKeys":true,"composeMetadata":true}}
EOF
chmod 600 "$result"
echo "[*] Backup complete: $archive" >&2
cat "$result"