#!/usr/bin/env nu # VAPORA Database Recovery Script # Restore SurrealDB from backups (S3 or Restic) # Follows NUSHELL_GUIDELINES.md strictly (0.109.0+) # Get timestamp def get-timestamp []: nothing -> string { date now | format date "%Y%m%d-%H%M%S" } # Download backup from S3 def download-from-s3 [ s3_location: string output_file: string ]: nothing -> record { print $"Downloading from S3 [$s3_location]..." let result = do { ^aws s3 cp $s3_location $output_file } | complete if ($result.exit_code == 0) { { success: true file: $output_file size: ( do { ^ls -lh $output_file } | complete | if ($in.exit_code == 0) { ($in.stdout | str trim) } else { "unknown" } ) error: null } } else { { success: false file: $output_file size: null error: ($result.stderr | str trim) } } } # Decrypt backup file def decrypt-backup [ encrypted_file: string key_file: string output_file: string ]: nothing -> record { print $"Decrypting backup [$encrypted_file]..." let result = do { ^openssl enc -d -aes-256-cbc \ -in $encrypted_file \ -out $output_file \ -pass file:$key_file } | complete if ($result.exit_code == 0) { { success: true decrypted_file: $output_file error: null } } else { { success: false decrypted_file: $output_file error: ($result.stderr | str trim) } } } # Decompress backup def decompress-backup [input_file: string]: nothing -> record { print $"Decompressing [$input_file]..." let decompressed = ($input_file | str replace ".gz" "") let result = do { ^gunzip --force $input_file } | complete if ($result.exit_code == 0) { { success: true decompressed_file: $decompressed error: null } } else { { success: false decompressed_file: $decompressed error: ($result.stderr | str trim) } } } # Verify database is running def check-database-ready [ surreal_url: string surreal_user: string surreal_pass: string max_retries: int ]: nothing -> record { print $"Checking database readiness at [$surreal_url]..." let wait-recursive = { max_attempts: int, current: int | if ($current >= $max_attempts) { { success: false error: "Database not ready after maximum attempts" } } else { let result = do { ^surreal list namespaces --conn $surreal_url \ --user $surreal_user --pass $surreal_pass } | complete if ($result.exit_code == 0) { { success: true ready_after_attempts: $current error: null } } else { print $"Attempt ($current + 1) failed, waiting..." do { sleep 2sec } | complete ($wait-recursive | call {max_attempts: $max_attempts, current: ($current + 1)}) } } } ($wait-recursive | call {max_attempts: $max_retries, current: 0}) } # Import backup to temporary database def import-to-temp-database [ backup_file: string surreal_url: string surreal_user: string surreal_pass: string ]: nothing -> record { print $"Importing backup to temporary database..." let result = do { ^surreal import --conn $surreal_url \ --user $surreal_user \ --pass $surreal_pass \ --input $backup_file } | complete if ($result.exit_code == 0) { { success: true database_url: $surreal_url timestamp: (get-timestamp) error: null } } else { { success: false database_url: $surreal_url error: ($result.stderr | str trim) } } } # Verify restored data def verify-database [ surreal_url: string surreal_user: string surreal_pass: string ]: nothing -> record { print "Verifying restored database..." let result = do { ^bash -c ( $"surreal query --conn ($surreal_url) " + $"--user ($surreal_user) --pass ($surreal_pass) " + "\"SELECT COUNT() FROM projects\" 2>&1" ) } | complete if ($result.exit_code == 0) { { success: true verification: ($result.stdout | str trim) error: null } } else { { success: false verification: null error: ($result.stderr | str trim) } } } # Scale down StatefulSet def scale-statefulset-down [ namespace: string statefulset_name: string ]: nothing -> record { print $"Scaling down StatefulSet [$statefulset_name]..." let result = do { ^kubectl scale statefulset $statefulset_name \ --replicas 0 -n $namespace } | complete if ($result.exit_code == 0) { { success: true statefulset: $statefulset_name action: "scaled-down" error: null } } else { { success: false statefulset: $statefulset_name error: ($result.stderr | str trim) } } } # Delete PVC (persistent volume claim) def delete-pvc [ namespace: string pvc_name: string ]: nothing -> record { print $"Deleting PVC [$pvc_name]..." let result = do { ^kubectl delete pvc $pvc_name -n $namespace } | complete if ($result.exit_code == 0) { { success: true pvc: $pvc_name error: null } } else { { success: false pvc: $pvc_name error: ($result.stderr | str trim) } } } # Scale up StatefulSet def scale-statefulset-up [ namespace: string statefulset_name: string replicas: int ]: nothing -> record { print $"Scaling up StatefulSet [$statefulset_name] to [$replicas] replicas..." let result = do { ^kubectl scale statefulset $statefulset_name \ --replicas $replicas -n $namespace } | complete if ($result.exit_code == 0) { { success: true statefulset: $statefulset_name replicas: $replicas error: null } } else { { success: false statefulset: $statefulset_name error: ($result.stderr | str trim) } } } # Wait for pod to be ready def wait-for-pod-ready [ namespace: string pod_name: string timeout_secs: int ]: nothing -> record { print $"Waiting for pod [$pod_name] to be ready (timeout: [$timeout_secs]s)..." let result = do { ^kubectl wait --for condition=Ready \ pod/$pod_name -n $namespace \ --timeout="${timeout_secs}s" } | complete if ($result.exit_code == 0) { { success: true pod: $pod_name error: null } } else { { success: false pod: $pod_name error: ($result.stderr | str trim) } } } # Cleanup temporary files def cleanup-temp-files [work_dir: string]: nothing -> record { print $"Cleaning up temporary files [$work_dir]..." let result = do { ^rm -rf $work_dir } | complete if ($result.exit_code == 0) { { success: true removed: $work_dir error: null } } else { { success: false removed: $work_dir error: ($result.stderr | str trim) } } } # Main recovery function def main [ --s3-location: string = "" --encryption-key: string = "" --surreal-url: string = "ws://localhost:8000" --surreal-user: string = "root" --surreal-pass: string = "" --namespace: string = "vapora" --statefulset: string = "surrealdb" --pvc: string = "surrealdb-data-surrealdb-0" --verify --work-dir: string = "/tmp/vapora-recovery" ]: nothing { print "=== VAPORA Database Recovery ===" print "" # Validate inputs if ($s3_location == "") { print "ERROR: --s3-location required (s3://bucket/path/backup.sql.gz.enc)" exit 1 } if ($encryption_key == "") { print "ERROR: --encryption-key required" exit 1 } if ($surreal_pass == "") { print "ERROR: --surreal-pass required" exit 1 } # Create work directory let work_path = $"($work_dir)/$(get-timestamp)" let create_result = do { ^mkdir -p $work_path } | complete if (not ($create_result.exit_code == 0)) { print "ERROR: Failed to create work directory" exit 1 } # Download from S3 let encrypted_file = $"($work_path)/backup.sql.gz.enc" let download_result = (download-from-s3 $s3_location $encrypted_file) if (not $download_result.success) { print $"ERROR: S3 download failed: [$download_result.error]" exit 1 } print "✓ Backup downloaded from S3" # Decrypt let compressed_file = $"($work_path)/backup.sql.gz" let decrypt_result = (decrypt-backup $encrypted_file $encryption_key $compressed_file) if (not $decrypt_result.success) { print $"ERROR: Decryption failed: [$decrypt_result.error]" cleanup-temp-files $work_path exit 1 } print "✓ Backup decrypted" # Decompress let backup_file = $"($work_path)/backup.sql" let decompress_result = (decompress-backup $compressed_file) if (not $decompress_result.success) { print $"ERROR: Decompression failed: [$decompress_result.error]" cleanup-temp-files $work_path exit 1 } print "✓ Backup decompressed" # Scale down database (for PVC replacement) let scale_down_result = (scale-statefulset-down $namespace $statefulset) if (not $scale_down_result.success) { print $"WARNING: Scale down failed: [$scale_down_result.error]" } else { print "✓ StatefulSet scaled down" } # Wait for pod termination print "Waiting for pod termination..." sleep 5sec # Delete PVC let delete_pvc_result = (delete-pvc $namespace $pvc) if (not $delete_pvc_result.success) { print $"WARNING: PVC deletion failed: [$delete_pvc_result.error]" } else { print "✓ PVC deleted" } # Scale up database (creates new PVC) let scale_up_result = (scale-statefulset-up $namespace $statefulset 1) if (not $scale_up_result.success) { print $"ERROR: Scale up failed: [$scale_up_result.error]" exit 1 } print "✓ StatefulSet scaled up" # Wait for pod ready let wait_result = (wait-for-pod-ready $namespace $"($statefulset)-0" 120) if (not $wait_result.success) { print $"ERROR: Pod failed to become ready: [$wait_result.error]" exit 1 } print "✓ Pod is ready" # Check database readiness let db_ready = (check-database-ready $surreal_url $surreal_user $surreal_pass 30) if (not $db_ready.success) { print $"ERROR: Database not ready: [$db_ready.error]" exit 1 } print "✓ Database is ready" # Import backup let import_result = (import-to-temp-database $decompress_result.decompressed_file $surreal_url $surreal_user $surreal_pass) if (not $import_result.success) { print $"ERROR: Database import failed: [$import_result.error]" cleanup-temp-files $work_path exit 1 } print "✓ Backup imported" # Verify data if $verify { let verify_result = (verify-database $surreal_url $surreal_user $surreal_pass) if (not $verify_result.success) { print $"WARNING: Verification failed: [$verify_result.error]" } else { print "✓ Database verified" print $verify_result.verification } } # Cleanup cleanup-temp-files $work_path # Summary print "" print "=== Recovery Complete ===" print $"Database URL: [$surreal_url]" print $"Namespace: [$namespace]" print $"Timestamp: [$(get-timestamp)]" }