backup_nexus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +82 -0
  5. data/Rakefile +10 -0
  6. data/app/controllers/backup_nexus/application_controller.rb +31 -0
  7. data/app/controllers/backup_nexus/backup_controller.rb +119 -0
  8. data/app/helpers/backup_nexus/application_helper.rb +51 -0
  9. data/app/jobs/backup_nexus/backup_job.rb +18 -0
  10. data/app/models/backup_nexus/backup.rb +107 -0
  11. data/app/models/backup_nexus/backup_config.rb +189 -0
  12. data/app/services/backup_nexus/backup_runner.rb +62 -0
  13. data/app/services/backup_nexus/backup_service.rb +817 -0
  14. data/app/views/backup_nexus/backup/_form.html.erb +334 -0
  15. data/app/views/backup_nexus/backup/config_history.html.erb +67 -0
  16. data/app/views/backup_nexus/backup/edit.html.erb +15 -0
  17. data/app/views/backup_nexus/backup/history.html.erb +75 -0
  18. data/app/views/backup_nexus/backup/index.html.erb +112 -0
  19. data/app/views/backup_nexus/backup/new.html.erb +15 -0
  20. data/backup_nexus.gemspec +30 -0
  21. data/config/routes.rb +15 -0
  22. data/db/seeds/backup_configs_safe.rb +61 -0
  23. data/lib/backup_nexus/base_record.rb +7 -0
  24. data/lib/backup_nexus/configuration.rb +19 -0
  25. data/lib/backup_nexus/engine.rb +51 -0
  26. data/lib/backup_nexus/version.rb +3 -0
  27. data/lib/backup_nexus.rb +26 -0
  28. data/lib/generators/backup_nexus/backup_generator.rb +70 -0
  29. data/lib/generators/backup_nexus/install_generator.rb +27 -0
  30. data/lib/generators/backup_nexus/templates/backup/backup/backup_helper.sh +152 -0
  31. data/lib/generators/backup_nexus/templates/backup/backup/config.rb +27 -0
  32. data/lib/generators/backup_nexus/templates/backup/backup/models/daily_backup.rb +24 -0
  33. data/lib/generators/backup_nexus/templates/backup/backup/models/full_backup.rb +35 -0
  34. data/lib/generators/backup_nexus/templates/backup/backup/models/sync_backup.rb +43 -0
  35. data/lib/generators/backup_nexus/templates/backup/backup/mysql-config/db_config.cnf +15 -0
  36. data/lib/generators/backup_nexus/templates/backup/backup/schedule.rb +28 -0
  37. data/lib/generators/backup_nexus/templates/migration.rb +105 -0
  38. data/lib/tasks/backup_nexus.rake +81 -0
  39. metadata +138 -0
@@ -0,0 +1,817 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "fileutils"
6
+ require "shellwords"
7
+ require "tempfile"
8
+
9
+ module BackupNexus
10
+ class BackupService
11
+ # Execute a backup for a given config
12
+ def self.run(config)
13
+ new(config).run
14
+ end
15
+
16
+ def initialize(config)
17
+ @config = config
18
+ @record = nil
19
+ @errors = []
20
+ @temp_files = []
21
+ end
22
+
23
+ def run
24
+ @record = BackupNexus::Backup.start!(
25
+ config_name: @config.name,
26
+ triggered_by: "service"
27
+ )
28
+
29
+ begin
30
+ @stage = "preparing storage"
31
+ validate_identifier!(@config.name, "backup name")
32
+ FileUtils.mkdir_p(validated_path(@config.storage_path_expanded, "storage path"))
33
+
34
+ # Step 1: Dump database
35
+ @stage = "database dump"
36
+ dump_path = dump_database
37
+ raise "Database dump failed" unless dump_path
38
+
39
+ # Step 2: Create archive if enabled (tar additional files/dirs)
40
+ @stage = "archive creation"
41
+ if @config.archive_enabled? && @config.archive_paths_list.any?
42
+ dump_path = create_archive(dump_path)
43
+ end
44
+
45
+ # Step 3: Split into chunks if enabled
46
+ @stage = "file splitting"
47
+ if @config.split_chunks? && File.size(dump_path) > 50_000_000 # 50MB
48
+ dump_path = split_file(dump_path)
49
+ end
50
+
51
+ # Step 4: Compress
52
+ @stage = "compression"
53
+ if @config.compress? && !@config.split_chunks?
54
+ dump_path = compress(dump_path)
55
+ elsif @config.bzip2_compress? && !@config.split_chunks?
56
+ dump_path = compress_bzip2(dump_path)
57
+ end
58
+
59
+ # Step 5: Encrypt
60
+ @stage = "encryption"
61
+ if @config.encrypted?
62
+ dump_path = encrypt_openssl(dump_path)
63
+ elsif @config.gpg_enabled?
64
+ dump_path = encrypt_gpg(dump_path)
65
+ end
66
+
67
+ # Step 6: Sync to S3
68
+ @stage = "S3 sync"
69
+ sync_s3(dump_path) if @config.s3_enabled?
70
+
71
+ # Step 7: Sync to remote
72
+ @stage = "remote sync"
73
+ sync_remote(dump_path) if @config.rsync_enabled?
74
+
75
+ # Step 8: Cleanup old backups
76
+ @stage = "retention cleanup"
77
+ cleanup_old_backups
78
+
79
+ # Step 9: Record success
80
+ @stage = "recording success"
81
+ file_size = calculate_size(dump_path)
82
+ @record.succeed!(file_path: dump_path, file_size: file_size)
83
+
84
+ # Step 10: Notify
85
+ @stage = "success notification"
86
+ notify_success
87
+
88
+ { success: true, record: @record, file_path: dump_path }
89
+ rescue StandardError => e
90
+ full_error = failure_message(e)
91
+ @record&.fail!(error_message: full_error)
92
+ notify_failure(full_error)
93
+ { success: false, error: full_error, record: @record }
94
+ ensure
95
+ cleanup_temp_files
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ # ════════════════════════════════════════════════════════════════
102
+ # DATABASE DUMP
103
+ # ════════════════════════════════════════════════════════════════
104
+
105
+ def dump_database
106
+ case @config.adapter
107
+ when "mysql" then dump_mysql
108
+ when "postgresql" then dump_postgresql
109
+ when "sqlite" then dump_sqlite
110
+ when "mongodb" then dump_mongodb
111
+ when "redis" then dump_redis
112
+ else raise "Unsupported adapter: #{@config.adapter}"
113
+ end
114
+ end
115
+
116
+ # ─── MySQL ─────────────────────────────────────────────────────
117
+ def dump_mysql
118
+ validate_identifier!(@config.database_name, "database name")
119
+ validate_host!(@config.host) if @config.host.present?
120
+ validate_port!(@config.port) if @config.port.present?
121
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
122
+ password = resolve_password(@config.password)
123
+ cmd = ["mysqldump"]
124
+ cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
125
+ cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
126
+ cmd << "--port=#{@config.port}" if @config.port.present?
127
+ cmd << "--result-file=#{raw_path}"
128
+ cmd << "--single-transaction"
129
+ cmd << "--quick"
130
+ cmd << "--routines"
131
+ cmd << "--triggers"
132
+ cmd << "--events"
133
+ # Additional MySQL options (e.g. --defaults-extra-file)
134
+ additional_options = if @config.respond_to?(:mysql_additional_options)
135
+ @config.mysql_additional_options
136
+ end
137
+ if additional_options.present?
138
+ cmd.concat(validated_mysql_options(additional_options))
139
+ end
140
+
141
+ # Skip tables
142
+ @config.skip_tables_list.each do |table|
143
+ validate_identifier!(table, "table name")
144
+ cmd << "--ignore-table=#{@config.database_name}.#{table}"
145
+ end
146
+
147
+ cmd << @config.database_name
148
+
149
+ env = {}
150
+ env["MYSQL_PWD"] = password if password.present?
151
+ stdout, stderr, status = capture_command(env, *cmd)
152
+ unless status.success?
153
+ raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
154
+ end
155
+ register_temp_file(raw_path)
156
+ raw_path
157
+ end
158
+
159
+ # ─── PostgreSQL ────────────────────────────────────────────────
160
+ def dump_postgresql
161
+ validate_identifier!(@config.database_name, "database name")
162
+ validate_host!(@config.host) if @config.host.present?
163
+ validate_port!(@config.port) if @config.port.present?
164
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
165
+ password = resolve_password(@config.password)
166
+ cmd = ["pg_dump"]
167
+ cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
168
+ cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
169
+ cmd << "--port=#{@config.port}" if @config.port.present?
170
+ cmd << "--file=#{raw_path}"
171
+ cmd << "--format=plain"
172
+ cmd << "--no-owner"
173
+ cmd << "--no-privileges"
174
+
175
+ # Skip tables
176
+ @config.skip_tables_list.each do |table|
177
+ validate_identifier!(table, "table name")
178
+ cmd << "--exclude-table=#{table}"
179
+ end
180
+
181
+ cmd << @config.database_name
182
+
183
+ env = {}
184
+ env["PGPASSWORD"] = password if password.present?
185
+
186
+ stdout, stderr, status = capture_command(env, *cmd)
187
+ unless status.success?
188
+ raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
189
+ end
190
+ register_temp_file(raw_path)
191
+ raw_path
192
+ end
193
+
194
+ def capture_command(*command, **options)
195
+ Open3.capture3(*command, **options)
196
+ end
197
+
198
+ # ─── SQLite ────────────────────────────────────────────────────
199
+ def dump_sqlite
200
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
201
+ db_path = validated_path(@config.database_name, "SQLite database path")
202
+
203
+ unless File.exist?(db_path)
204
+ raise "SQLite database not found: #{db_path}"
205
+ end
206
+
207
+ stdout, stderr, status = capture_command("sqlite3", db_path, ".dump")
208
+ unless status.success?
209
+ raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
210
+ end
211
+
212
+ File.write(raw_path, stdout)
213
+ register_temp_file(raw_path)
214
+ raw_path
215
+ end
216
+
217
+ # ─── MongoDB ───────────────────────────────────────────────────
218
+ def dump_mongodb
219
+ validate_identifier!(@config.database_name, "database name")
220
+ validate_host!(@config.host.presence || "localhost")
221
+ validate_port!(@config.port || 27_017)
222
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
223
+ dir_path = raw_path.gsub(/\.sql$/, "")
224
+ FileUtils.mkdir_p(dir_path)
225
+
226
+ password = resolve_password(@config.password)
227
+ cmd = ["mongodump"]
228
+ cmd << "--host=#{resolve_env(@config.host) || 'localhost'}"
229
+ cmd << "--port=#{@config.port || 27017}"
230
+ cmd << "--db=#{@config.database_name}"
231
+ cmd << "--out=#{dir_path}"
232
+ cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
233
+ cmd << "--authenticationDatabase=admin" if @config.username.present?
234
+
235
+ # Skip collections
236
+ @config.skip_tables_list.each do |collection|
237
+ validate_identifier!(collection, "collection name")
238
+ cmd << "--excludeCollection=#{collection}"
239
+ end
240
+
241
+ stdout, stderr, status = if password.present?
242
+ with_secret_file("backup_nexus_mongo", "password: #{password.to_json}\n") do |config_path|
243
+ capture_command(*cmd, "--config=#{config_path}")
244
+ end
245
+ else
246
+ capture_command(*cmd)
247
+ end
248
+ unless status.success?
249
+ FileUtils.rm_rf(dir_path)
250
+ raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
251
+ end
252
+
253
+ # Tar the dump directory
254
+ tar_path = "#{dir_path}.tar"
255
+ stdout, stderr, status = capture_command(
256
+ "tar", "-cf", tar_path, "-C", File.dirname(dir_path), File.basename(dir_path)
257
+ )
258
+ unless status.success?
259
+ raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
260
+ end
261
+
262
+ FileUtils.rm_rf(dir_path)
263
+ register_temp_file(tar_path)
264
+ tar_path
265
+ end
266
+
267
+ # ─── Redis ─────────────────────────────────────────────────────
268
+ def dump_redis
269
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
270
+ raw_path = raw_path.gsub(/\.sql$/, ".rdb")
271
+
272
+ host = @config.host.presence || "localhost"
273
+ port = @config.port || 6379
274
+ password = resolve_password(@config.password)
275
+ validate_host!(host)
276
+ validate_port!(port)
277
+
278
+ # Trigger BGSAVE first
279
+ _, stderr, status = capture_redis(host, port, password, "BGSAVE")
280
+ unless status.success?
281
+ @errors << "redis BGSAVE failed: #{stderr}"
282
+ return nil
283
+ end
284
+
285
+ # Wait a moment for BGSAVE to complete
286
+ sleep 2
287
+
288
+ # Try to find the dump.rdb on the server or use redis-cli to copy
289
+ # If local Redis, copy the dump.rdb directly
290
+ dir_out, = capture_redis(host, port, password, "CONFIG", "GET", "dir")
291
+ redis_dir = dir_out.split("\n").last || "/var/lib/redis"
292
+
293
+ redis_rdb = File.join(redis_dir, "dump.rdb")
294
+ if File.exist?(redis_rdb)
295
+ FileUtils.cp(redis_rdb, raw_path)
296
+ else
297
+ # Fallback: use redis-cli to dump keys
298
+ dump_all_keys(host, port, password, raw_path)
299
+ end
300
+
301
+ register_temp_file(raw_path)
302
+ raw_path
303
+ end
304
+
305
+ def dump_all_keys(host, port, password, output_path)
306
+ keys_out, = capture_redis(host, port, password, "KEYS", "*")
307
+ keys = keys_out.split("\n").reject { |k| k.start_with?("redis") }
308
+
309
+ File.open(output_path, "w") do |f|
310
+ keys.each do |key|
311
+ type_out, = capture_redis(host, port, password, "TYPE", key)
312
+ type = type_out.strip.split("\n").last
313
+
314
+ val_out, = case type
315
+ when "string" then capture_redis(host, port, password, "GET", key)
316
+ when "list" then capture_redis(host, port, password, "LRANGE", key, "0", "-1")
317
+ when "set" then capture_redis(host, port, password, "SMEMBERS", key)
318
+ when "hash" then capture_redis(host, port, password, "HGETALL", key)
319
+ else capture_redis(host, port, password, "DUMP", key)
320
+ end
321
+
322
+ f.puts("SET #{key.inspect} #{val_out.strip.inspect}")
323
+ end
324
+ end
325
+ end
326
+
327
+ # ════════════════════════════════════════════════════════════════
328
+ # ARCHIVES
329
+ # ════════════════════════════════════════════════════════════════
330
+
331
+ def create_archive(dump_path)
332
+ archive_path = dump_path.gsub(/\.(sql|tar)$/, ".tar")
333
+
334
+ # If the dump is already a tar (MongoDB), merge into it
335
+ if dump_path.end_with?(".tar")
336
+ archive_path = dump_path
337
+ end
338
+
339
+ cmd = ["tar"]
340
+ cmd << (dump_path.end_with?(".tar") ? "-rf" : "-cf")
341
+ cmd << archive_path
342
+
343
+ unless dump_path.end_with?(".tar")
344
+ cmd << "-C"
345
+ cmd << File.dirname(dump_path)
346
+ cmd << File.basename(dump_path)
347
+ # Remove the original SQL file after tar
348
+ @temp_files << dump_path
349
+ end
350
+
351
+ @config.archive_paths_list.each do |path|
352
+ cmd << validated_path(path, "archive path")
353
+ end
354
+
355
+ # Exclude patterns
356
+ if @config.archive_excludes_list.any?
357
+ @config.archive_excludes_list.each do |pattern|
358
+ validate_text_argument!(pattern, "archive exclusion")
359
+ cmd << "--exclude=#{pattern}"
360
+ end
361
+ end
362
+
363
+ _, stderr, status = capture_command(*cmd)
364
+ unless status.success?
365
+ @errors << "archive tar failed: #{stderr}"
366
+ return dump_path
367
+ end
368
+
369
+ archive_path
370
+ end
371
+
372
+ # ════════════════════════════════════════════════════════════════
373
+ # COMPRESSION
374
+ # ════════════════════════════════════════════════════════════════
375
+
376
+ def compress(file_path)
377
+ gz_path = "#{file_path}.gz"
378
+ File.open(file_path, "rb") do |input|
379
+ Zlib::GzipWriter.open(gz_path) do |gz|
380
+ gz.write(input.read)
381
+ end
382
+ end
383
+ @temp_files << file_path if File.exist?(gz_path)
384
+ gz_path
385
+ end
386
+
387
+ def compress_bzip2(file_path)
388
+ bz2_path = "#{file_path}.bz2"
389
+ _, stderr, status = capture_command("bzip2", "-zk", file_path)
390
+ unless status.success?
391
+ @errors << "bzip2 failed: #{stderr}"
392
+ return file_path
393
+ end
394
+ @temp_files << file_path if File.exist?(bz2_path)
395
+ bz2_path
396
+ end
397
+
398
+ # ════════════════════════════════════════════════════════════════
399
+ # ENCRYPTION
400
+ # ════════════════════════════════════════════════════════════════
401
+
402
+ def encrypt_openssl(file_path)
403
+ enc_path = "#{file_path}.enc"
404
+ password = resolve_secret(@config.encryption_password)
405
+
406
+ # Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
407
+ # (AES-GCM not available in all OpenSSL builds)
408
+ env = { "BACKUP_NEXUS_OPENSSL_PASSWORD" => password.to_s }
409
+ cmd = [
410
+ "openssl", "enc", "-aes-256-cbc", "-pbkdf2", "-iter", "600000",
411
+ "-salt", "-in", file_path, "-out", enc_path,
412
+ "-pass", "env:BACKUP_NEXUS_OPENSSL_PASSWORD"
413
+ ]
414
+
415
+ _, stderr, status = capture_command(env, *cmd)
416
+ unless status.success?
417
+ @errors << "OpenSSL encryption failed: #{stderr}"
418
+ return file_path
419
+ end
420
+
421
+ @temp_files << file_path if File.exist?(enc_path)
422
+ enc_path
423
+ end
424
+
425
+ def encrypt_gpg(file_path)
426
+ enc_path = "#{file_path}.gpg"
427
+ password = resolve_secret(@config.gpg_password)
428
+
429
+ # Best practice: AES256 with an iteration-counted S2K. The passphrase is
430
+ # provided through a mode-0600 temporary file, never process arguments.
431
+ _, stderr, status = with_secret_file("backup_nexus_gpg", password.to_s) do |password_path|
432
+ capture_command(
433
+ "gpg", "--batch", "--yes", "--symmetric",
434
+ "--cipher-algo", "AES256", "--s2k-mode", "3",
435
+ "--s2k-count", "65011712", "--passphrase-file", password_path,
436
+ "-o", enc_path, file_path
437
+ )
438
+ end
439
+ unless status.success?
440
+ @errors << "GPG encryption failed: #{stderr}"
441
+ return file_path
442
+ end
443
+
444
+ @temp_files << file_path if File.exist?(enc_path)
445
+ enc_path
446
+ end
447
+
448
+ # ════════════════════════════════════════════════════════════════
449
+ # SPLIT INTO CHUNKS
450
+ # ════════════════════════════════════════════════════════════════
451
+
452
+ def split_file(file_path)
453
+ chunk_dir = "#{file_path}.parts"
454
+ FileUtils.mkdir_p(chunk_dir)
455
+
456
+ # Split 50MB chunks
457
+ _, stderr, status = capture_command("split", "-b", "50m", file_path, File.join(chunk_dir, "part_"))
458
+
459
+ unless status.success?
460
+ @errors << "split failed: #{stderr}"
461
+ return file_path
462
+ end
463
+
464
+ @temp_files << file_path
465
+ chunk_dir
466
+ end
467
+
468
+ # ════════════════════════════════════════════════════════════════
469
+ # REMOTE SYNC
470
+ # ════════════════════════════════════════════════════════════════
471
+
472
+ def sync_remote(file_path)
473
+ return unless @config.rsync_enabled?
474
+ return unless @config.rsync_host.present?
475
+
476
+ validate_host!(@config.rsync_host)
477
+ validate_port!(@config.rsync_port.presence || 22)
478
+ validate_remote_component!(@config.rsync_user, "rsync user")
479
+ validate_remote_path!(@config.rsync_path)
480
+ remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
481
+
482
+ # Build rsync command
483
+ cmd = ["rsync"]
484
+ cmd << "-a"
485
+ cmd << "-z" # compress
486
+ cmd << "--progress"
487
+ cmd << "--archive" if @config.respond_to?(:rsync_archive?) && @config.rsync_archive?
488
+ cmd << "--delete" if @config.respond_to?(:rsync_mirror?) && @config.rsync_mirror?
489
+ cmd << "-e"
490
+ cmd << "ssh -p #{@config.rsync_port.presence || 22}"
491
+
492
+ # Add the dump file
493
+ cmd << file_path
494
+
495
+ # Add additional directories if configured
496
+ if @config.respond_to?(:rsync_directories) && @config.rsync_directories.present?
497
+ @config.rsync_directories.split(",").map(&:strip).reject(&:blank?).each do |dir|
498
+ expanded = dir.gsub("~", Dir.home)
499
+ cmd << expanded
500
+ end
501
+ end
502
+
503
+ # Add excludes
504
+ if @config.respond_to?(:rsync_excludes) && @config.rsync_excludes.present?
505
+ @config.rsync_excludes.split(",").map(&:strip).reject(&:blank?).each do |excl|
506
+ validate_text_argument!(excl, "rsync exclusion")
507
+ cmd << "--exclude=#{excl}"
508
+ end
509
+ end
510
+
511
+ cmd << remote
512
+
513
+ stdout, stderr, status = capture_command(*cmd)
514
+ unless status.success?
515
+ @errors << "RSync failed: #{stderr}"
516
+ end
517
+
518
+ { success: status.success?, output: stdout }
519
+ end
520
+
521
+ # ─── S3 Upload ─────────────────────────────────────────────────
522
+ def sync_s3(file_path)
523
+ return unless @config.s3_enabled?
524
+
525
+ # Use aws cli if available, otherwise use s3cmd
526
+ if command_exists?("aws")
527
+ sync_s3_aws(file_path)
528
+ elsif command_exists?("s3cmd")
529
+ sync_s3_s3cmd(file_path)
530
+ else
531
+ @errors << "Neither aws-cli nor s3cmd found. Cannot upload to S3."
532
+ end
533
+ end
534
+
535
+ def sync_s3_aws(file_path)
536
+ validate_s3_bucket!(@config.s3_bucket)
537
+ bucket = @config.s3_bucket
538
+ prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
539
+
540
+ env = {
541
+ "AWS_ACCESS_KEY_ID" => @config.s3_access_key.to_s,
542
+ "AWS_SECRET_ACCESS_KEY" => @config.s3_secret_key.to_s
543
+ }
544
+ cmd = [
545
+ "aws", "s3", "cp", file_path, "s3://#{bucket}/#{prefix}",
546
+ "--region", @config.s3_region.presence || "us-east-1"
547
+ ]
548
+
549
+ stdout, stderr, status = capture_command(env, *cmd)
550
+ unless status.success?
551
+ @errors << "S3 upload failed: #{stderr}"
552
+ end
553
+
554
+ { success: status.success?, output: stdout }
555
+ end
556
+
557
+ def sync_s3_s3cmd(file_path)
558
+ validate_s3_bucket!(@config.s3_bucket)
559
+ bucket = @config.s3_bucket
560
+ prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
561
+
562
+ env = {
563
+ "AWS_ACCESS_KEY" => @config.s3_access_key.to_s,
564
+ "AWS_SECRET_KEY" => @config.s3_secret_key.to_s
565
+ }
566
+ cmd = [
567
+ "s3cmd", "put", file_path, "s3://#{bucket}/#{prefix}",
568
+ "--region=#{@config.s3_region.presence || 'us-east-1'}"
569
+ ]
570
+
571
+ stdout, stderr, status = capture_command(env, *cmd)
572
+ unless status.success?
573
+ @errors << "S3 upload (s3cmd) failed: #{stderr}"
574
+ end
575
+
576
+ { success: status.success?, output: stdout }
577
+ end
578
+
579
+ # ════════════════════════════════════════════════════════════════
580
+ # CLEANUP
581
+ # ════════════════════════════════════════════════════════════════
582
+
583
+ def cleanup_old_backups
584
+ storage = @config.storage_path_expanded
585
+ return unless Dir.exist?(storage)
586
+
587
+ pattern = File.join(storage, "#{@config.name}_*")
588
+ files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
589
+
590
+ while files.size > @config.keep_count
591
+ old_file = files.shift
592
+ FileUtils.rm_rf(old_file)
593
+ end
594
+ end
595
+
596
+ def cleanup_temp_files
597
+ @temp_files.each { |file| FileUtils.rm_rf(file) }
598
+ end
599
+
600
+ # ════════════════════════════════════════════════════════════════
601
+ # NOTIFICATIONS
602
+ # ════════════════════════════════════════════════════════════════
603
+
604
+ def notify_success
605
+ return unless @config.notify_on_success?
606
+ execute_notify_command("success")
607
+ send_email_notification("success")
608
+ end
609
+
610
+ def notify_failure(error_message)
611
+ return unless @config.notify_on_failure?
612
+ execute_notify_command("failure", error_message)
613
+ send_email_notification("failure", error_message)
614
+ end
615
+
616
+ def execute_notify_command(status, error = nil)
617
+ return if @config.notify_command.blank?
618
+
619
+ command = Shellwords.split(@config.notify_command.to_s)
620
+ raise ArgumentError, "notify command is empty" if command.empty?
621
+ if %w[sh bash dash zsh ksh fish env].include?(File.basename(command.first))
622
+ raise ArgumentError, "shell-based notify commands are not supported"
623
+ end
624
+
625
+ replacements = {
626
+ "{STATUS}" => status.to_s,
627
+ "{MODEL}" => @config.name.to_s,
628
+ "{ERROR}" => error.to_s,
629
+ "{TIME}" => Time.current.iso8601
630
+ }
631
+ command.map! do |argument|
632
+ replacements.reduce(argument) { |value, (placeholder, replacement)| value.gsub(placeholder, replacement) }
633
+ end
634
+
635
+ capture_command(*command)
636
+ rescue StandardError
637
+ # Don't fail backup because notification failed
638
+ end
639
+
640
+ def send_email_notification(status, error = nil)
641
+ return unless @config.email_notify?
642
+ return if @config.email_to.blank?
643
+
644
+ subject = "[BackupNexus Backup] #{status.upcase}: #{@config.name}"
645
+ body = "Backup #{@config.name} #{status}.\n\n"
646
+ body += "Error: #{error}\n\n" if error.present?
647
+ body += "Time: #{Time.current.iso8601}\n"
648
+ body += "Record ID: #{@record&.id}\n"
649
+
650
+ # Build mail command based on available MTA
651
+ if command_exists?("mail")
652
+ capture_command("mail", "-s", subject, @config.email_to.to_s, stdin_data: body)
653
+ elsif command_exists?("sendmail")
654
+ mail_content = "To: #{@config.email_to}\nSubject: #{subject}\n\n#{body}"
655
+ capture_command("sendmail", "-t", stdin_data: mail_content)
656
+ else
657
+ @errors << "No mail command found (mail/sendmail)"
658
+ end
659
+ rescue StandardError
660
+ # Don't fail backup because email notification failed
661
+ end
662
+
663
+ # ════════════════════════════════════════════════════════════════
664
+ # HELPERS
665
+ # ════════════════════════════════════════════════════════════════
666
+
667
+ def calculate_size(path)
668
+ if File.directory?(path)
669
+ Dir.glob(File.join(path, "**", "*")).sum { |f| File.exist?(f) ? File.size(f) : 0 }
670
+ else
671
+ File.exist?(path) ? File.size(path) : 0
672
+ end
673
+ end
674
+
675
+ def register_temp_file(path)
676
+ @temp_files << path unless @temp_files.include?(path)
677
+ end
678
+
679
+ # ─── Password Resolution ──────────────────────────────────────
680
+ # Supports:
681
+ # "plain_text" → used as-is
682
+ # "ENV[MY_VAR]" → resolved from ENV
683
+ # "${MY_VAR}" → resolved from ENV
684
+ # "credentials[:key]" → from Rails encrypted credentials
685
+ # "credentials[:a][:b]" → nested credentials key
686
+ def resolve_password(value)
687
+ return nil if value.blank?
688
+ resolve_secret(value)
689
+ end
690
+
691
+ alias_method :resolve_env, :resolve_password
692
+
693
+ def resolve_secret(value)
694
+ return nil if value.blank?
695
+ case value
696
+ when /^ENV\[(.+?)\]$/, /^\$\{(.+?)\}$/
697
+ ENV[$1]
698
+ when /^credentials\[/
699
+ # credentials[:key] or credentials[:key][:subkey]
700
+ keys = value.scan(/\[:([^\]]+)\]/).flatten.map(&:to_sym)
701
+ Rails.application.credentials.dig(*keys)
702
+ else
703
+ value
704
+ end
705
+ end
706
+
707
+ def command_exists?(cmd)
708
+ executable = cmd.to_s
709
+ return false unless executable.match?(/\A[a-zA-Z0-9_.+-]+\z/)
710
+
711
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
712
+ path = File.join(directory, executable)
713
+ File.file?(path) && File.executable?(path)
714
+ end
715
+ end
716
+
717
+ def capture_redis(host, port, password, *arguments)
718
+ env = {}
719
+ env["REDISCLI_AUTH"] = password if password.present?
720
+ capture_command(env, "redis-cli", "-h", host.to_s, "-p", port.to_s, *arguments.map(&:to_s))
721
+ end
722
+
723
+ def with_secret_file(prefix, contents)
724
+ Tempfile.create([prefix, ".conf"]) do |file|
725
+ file.chmod(0o600)
726
+ file.write(contents)
727
+ file.flush
728
+ yield file.path
729
+ end
730
+ end
731
+
732
+ def validated_mysql_options(value)
733
+ allowed = %r{\A(?:
734
+ --ssl-mode=(?:DISABLED|PREFERRED|REQUIRED|VERIFY_CA|VERIFY_IDENTITY)|
735
+ --ssl-(?:ca|cert|key)=\S+|
736
+ --protocol=(?:TCP|SOCKET)|
737
+ --socket=\S+|
738
+ --default-character-set=[a-zA-Z0-9_-]+|
739
+ --max-allowed-packet=\d+[KMG]?|
740
+ --set-gtid-purged=(?:OFF|ON|AUTO)|
741
+ --column-statistics=[01]|
742
+ --no-tablespaces|
743
+ --skip-lock-tables
744
+ )\z}x
745
+
746
+ Shellwords.split(value.to_s).tap do |options|
747
+ invalid = options.reject { |option| option.match?(allowed) }
748
+ raise ArgumentError, "unsupported MySQL option: #{invalid.first}" if invalid.any?
749
+ end
750
+ rescue ArgumentError => error
751
+ raise ArgumentError, "invalid MySQL additional options: #{error.message}"
752
+ end
753
+
754
+ def validate_identifier!(value, label)
755
+ candidate = value.to_s
756
+ unless candidate.match?(/\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/) && !candidate.include?("..")
757
+ raise ArgumentError, "invalid #{label}"
758
+ end
759
+ candidate
760
+ end
761
+
762
+ def validate_port!(value)
763
+ port = Integer(value)
764
+ raise ArgumentError, "invalid port" unless port.between?(1, 65_535)
765
+ port
766
+ rescue ArgumentError, TypeError
767
+ raise ArgumentError, "invalid port"
768
+ end
769
+
770
+ def validate_host!(value)
771
+ host = value.to_s
772
+ unless host.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
773
+ raise ArgumentError, "invalid host"
774
+ end
775
+ host
776
+ end
777
+
778
+ def validated_path(value, label)
779
+ validate_text_argument!(value, label)
780
+ File.expand_path(value.to_s)
781
+ end
782
+
783
+ def validate_remote_component!(value, label)
784
+ candidate = value.to_s
785
+ raise ArgumentError, "invalid #{label}" unless candidate.match?(/\A[a-zA-Z0-9_.-]+\z/)
786
+ candidate
787
+ end
788
+
789
+ def validate_remote_path!(value)
790
+ path = value.to_s
791
+ raise ArgumentError, "invalid rsync path" unless path.start_with?("/")
792
+ validate_text_argument!(path, "rsync path")
793
+ end
794
+
795
+ def validate_s3_bucket!(value)
796
+ bucket = value.to_s
797
+ unless bucket.length.between?(3, 63) && bucket.match?(/\A[a-z0-9][a-z0-9.-]*[a-z0-9]\z/) && !bucket.include?("..")
798
+ raise ArgumentError, "invalid S3 bucket"
799
+ end
800
+ bucket
801
+ end
802
+
803
+ def validate_text_argument!(value, label)
804
+ candidate = value.to_s
805
+ if candidate.empty? || candidate.include?("\0") || candidate.include?("\n") || candidate.include?("\r")
806
+ raise ArgumentError, "invalid #{label}"
807
+ end
808
+ candidate
809
+ end
810
+
811
+ def failure_message(exception)
812
+ heading = "#{@stage || 'backup'} failed: #{exception.class}: #{exception.message}"
813
+ trace = Array(exception.backtrace).first(5).join("\n")
814
+ [heading, trace.presence, @errors.join("\n").presence].compact.join("\n\n")
815
+ end
816
+ end
817
+ end