#!/usr/bin/env nu # VAPORA Backup & Recovery Orchestrator # Coordinates S3 + Restic backups and recovery procedures # 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" } # Export SurrealDB database def export-surrealdb [ surreal_url: string surreal_user: string surreal_pass: string output_dir: string ]: nothing -> record { print $"Exporting SurrealDB from [$surreal_url]..." let backup_file = $"($output_dir)/vapora-db-$(get-timestamp).sql" let result = do { ^mkdir -p $output_dir ^surreal export \ --conn $surreal_url \ --user $surreal_user \ --pass $surreal_pass \ --output $backup_file } | complete if ($result.exit_code == 0) { { success: true file: $backup_file size: ( do { ^ls -lh $backup_file } | complete | if ($in.exit_code == 0) { ($in.stdout | str trim) } else { "unknown" } ) error: null } } else { { success: false file: null error: ($result.stderr | str trim) } } } # Export Kubernetes configuration def export-k8s-config [ namespace: string output_dir: string ]: nothing -> record { print $"Exporting Kubernetes config from namespace [$namespace]..." let config_file = $"($output_dir)/k8s-config-$(get-timestamp).yaml" let result = do { ^mkdir -p $output_dir ^kubectl get configmaps,secrets,services,ingresses,deployments,statefulsets \ -n $namespace \ -o yaml \ > $config_file } | complete if ($result.exit_code == 0) { { success: true file: $config_file resource_count: ( do { ^grep "^kind:" $config_file } | complete | if ($in.exit_code == 0) { ($in.stdout | lines | length) } else { 0 } ) error: null } } else { { success: false file: null error: ($result.stderr | str trim) } } } # Run S3 direct backup def run-s3-backup [ database_export: record s3_bucket: string s3_prefix: string encryption_key: string ]: nothing -> record { print "Running S3 direct backup..." if (not $database_export.success) { return { success: false method: "s3-direct" location: null error: "Database export failed" } } # Compress let compress = do { ^gzip --force $database_export.file } | complete if (not ($compress.exit_code == 0)) { return { success: false method: "s3-direct" location: null error: "Compression failed" } } let compressed = $"($database_export.file).gz" # Encrypt let encrypt = do { ^openssl enc -aes-256-cbc \ -in $compressed \ -out $"($compressed).enc" \ -pass file:$encryption_key } | complete if (not ($encrypt.exit_code == 0)) { return { success: false method: "s3-direct" location: null error: "Encryption failed" } } # Upload let encrypted = $"($compressed).enc" let s3_key = $"($s3_prefix)/database-$(get-timestamp).sql.gz.enc" let upload = do { ^aws s3 cp $encrypted \ $"s3://($s3_bucket)/($s3_key)" \ --sse AES256 } | complete if ($upload.exit_code == 0) { { success: true method: "s3-direct" location: $"s3://($s3_bucket)/($s3_key)" error: null } } else { { success: false method: "s3-direct" location: $"s3://($s3_bucket)/($s3_key)" error: ($upload.stderr | str trim) } } } # Run Restic backup def run-restic-backup [ database_export: record k8s_export: record restic_repo: string restic_password: string iac_dir: string ]: nothing -> record { print "Running Restic backup..." let timestamp = (get-timestamp) # Build backup paths let backup_paths = if ($database_export.success and $k8s_export.success) { $"($database_export.file) ($k8s_export.file) ($iac_dir)" } else if $database_export.success { $"($database_export.file) ($iac_dir)" } else if $k8s_export.success { $"($k8s_export.file) ($iac_dir)" } else { $iac_dir } let backup_cmd = ( $"RESTIC_PASSWORD=($restic_password) restic -r ($restic_repo) " + $"backup ($backup_paths) --tag ($timestamp) --tag automated" ) let result = do { ^bash -c $backup_cmd } | complete if ($result.exit_code == 0) { { success: true method: "restic" repo: $restic_repo timestamp: $timestamp error: null } } else { { success: false method: "restic" repo: $restic_repo timestamp: $timestamp error: ($result.stderr | str trim) } } } # Collect backup results def collect-results [items: list]: nothing -> list { $items | reduce --fold [] {|item, acc| $acc | append $item } } # Cleanup files def cleanup-files [paths: list]: nothing -> record { print "Cleaning up temporary files..." let cleanup-item = { path: string | do { ^rm -rf $path } | complete } let results = $paths | each {|p| ($cleanup-item | call {path: $p})} let failures = ($results | where {|r| not ($r.exit_code == 0)}) if (($failures | length) > 0) { { success: false cleaned: ($paths | length) failed: ($failures | length) error: "Some files failed to clean" } } else { { success: true cleaned: ($paths | length) failed: 0 error: null } } } # Main orchestration def main [ --operation: string = "backup" # backup | recovery --mode: string = "full" # full | database-only --surreal-url: string = "ws://localhost:8000" --surreal-user: string = "root" --surreal-pass: string = "" --namespace: string = "vapora" --s3-bucket: string = "" --s3-prefix: string = "backups/database" --encryption-key: string = "" --restic-repo: string = "" --restic-password: string = "" --iac-dir: string = "provisioning" --s3-location: string = "" --work-dir: string = "/tmp/vapora-backup-recovery" --no-cleanup: bool = false ]: nothing { print "=== VAPORA Backup & Recovery Orchestrator ===" print $"Operation: [$operation]" print $"Mode: [$mode]" print "" if ($operation == "backup") { # Backup mode if ($surreal_pass == "") { print "ERROR: --surreal-pass required" exit 1 } if ($s3_bucket == "") { print "ERROR: --s3-bucket required" exit 1 } print "Starting backup sequence..." print "" # Create work directory let work_path = $"($work_dir)/$(get-timestamp)" let create = do { ^mkdir -p $work_path } | complete if (not ($create.exit_code == 0)) { print "ERROR: Failed to create work directory" exit 1 } # Export database let db_export = (export-surrealdb $surreal_url $surreal_user $surreal_pass $work_path) if (not $db_export.success) { print $"ERROR: Database export failed: [$db_export.error]" exit 1 } print "✓ Database exported" # Export Kubernetes config let k8s_export = (export-k8s-config $namespace $work_path) if (not $k8s_export.success) { print $"WARNING: Kubernetes export failed: [$k8s_export.error]" } else { print $"✓ Kubernetes config exported ([$k8s_export.resource_count] resources)" } # Run backups let s3_result = (run-s3-backup $db_export $s3_bucket $s3_prefix $encryption_key) let restic_result = (run-restic-backup $db_export $k8s_export $restic_repo $restic_password $iac_dir) let backup_results = (collect-results [$s3_result, $restic_result]) print "" print "Backup Results:" print $"S3: [$s3_result.location]" print $"Restic: [$restic_result.repo] (tag: [$restic_result.timestamp])" # Cleanup if (not $no_cleanup) { cleanup-files [$work_path] | ignore } else { print $"Work files preserved at: [$work_path]" } print "" print "=== Backup Complete ===" print $"Timestamp: [$(get-timestamp)]" } else if ($operation == "recovery") { # Recovery mode if ($surreal_pass == "") { print "ERROR: --surreal-pass required" exit 1 } 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 } print "Starting recovery sequence..." print "" # Create work directory let work_path = $"($work_dir)/$(get-timestamp)" let create = do { ^mkdir -p $work_path } | complete if (not ($create.exit_code == 0)) { print "ERROR: Failed to create work directory" exit 1 } # Download backup let encrypted_file = $"($work_path)/backup.sql.gz.enc" let download = do { ^aws s3 cp $s3_location $encrypted_file } | complete if (not ($download.exit_code == 0)) { print $"ERROR: S3 download failed" exit 1 } print "✓ Backup downloaded" # Decrypt let compressed_file = $"($work_path)/backup.sql.gz" let decrypt = do { ^openssl enc -d -aes-256-cbc \ -in $encrypted_file \ -out $compressed_file \ -pass file:$encryption_key } | complete if (not ($decrypt.exit_code == 0)) { print "ERROR: Decryption failed" exit 1 } print "✓ Backup decrypted" # Decompress let backup_file = $"($work_path)/backup.sql" let decompress = do { ^gunzip --force $compressed_file } | complete if (not ($decompress.exit_code == 0)) { print "ERROR: Decompression failed" exit 1 } print "✓ Backup decompressed" # Import to database let import = do { ^surreal import --conn $surreal_url \ --user $surreal_user \ --pass $surreal_pass \ --input $backup_file } | complete if (not ($import.exit_code == 0)) { print "ERROR: Database import failed" exit 1 } print "✓ Backup imported" # Cleanup if (not $no_cleanup) { cleanup-files [$work_path] | ignore } else { print $"Work files preserved at: [$work_path]" } print "" print "=== Recovery Complete ===" print $"Database: [$surreal_url]" print $"Timestamp: [$(get-timestamp)]" } else { print $"ERROR: Unknown operation [$operation]" exit 1 } }