rails_nexus 2.0.3 → 2.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "json"
5
+ require "fileutils"
5
6
 
6
7
  module RailsNexus
7
8
  class BackupService
@@ -14,156 +15,336 @@ module RailsNexus
14
15
  @config = config
15
16
  @record = nil
16
17
  @errors = []
18
+ @temp_files = []
17
19
  end
18
20
 
19
21
  def run
20
22
  @record = RailsNexus::Backup.start!(
21
- model_name: @config.name,
23
+ config_name: @config.name,
22
24
  triggered_by: "service"
23
25
  )
24
26
 
25
27
  begin
26
- # Ensure storage directory exists
28
+ @stage = "preparing storage"
27
29
  FileUtils.mkdir_p(@config.storage_path_expanded)
28
30
 
29
31
  # Step 1: Dump database
32
+ @stage = "database dump"
30
33
  dump_path = dump_database
31
- return fail_record("Database dump failed") unless dump_path
34
+ raise "Database dump failed" unless dump_path
32
35
 
33
- # Step 2: Compress if configured
34
- final_path = @config.compress? ? compress(dump_path) : dump_path
36
+ # Step 2: Create archive if enabled (tar additional files/dirs)
37
+ @stage = "archive creation"
38
+ if @config.archive_enabled? && @config.archive_paths_list.any?
39
+ dump_path = create_archive(dump_path)
40
+ end
41
+
42
+ # Step 3: Split into chunks if enabled
43
+ @stage = "file splitting"
44
+ if @config.split_chunks? && File.size(dump_path) > 50_000_000 # 50MB
45
+ dump_path = split_file(dump_path)
46
+ end
35
47
 
36
- # Step 3: Encrypt if configured
37
- final_path = encrypt(final_path) if @config.encrypt?
48
+ # Step 4: Compress
49
+ @stage = "compression"
50
+ if @config.compress? && !@config.split_chunks?
51
+ dump_path = compress(dump_path)
52
+ elsif @config.bzip2_compress? && !@config.split_chunks?
53
+ dump_path = compress_bzip2(dump_path)
54
+ end
55
+
56
+ # Step 5: Encrypt
57
+ @stage = "encryption"
58
+ if @config.encrypted?
59
+ dump_path = encrypt_openssl(dump_path)
60
+ elsif @config.gpg_enabled?
61
+ dump_path = encrypt_gpg(dump_path)
62
+ end
38
63
 
39
- # Step 4: Sync to remote if configured
40
- rsync_result = sync_remote(final_path) if @config.rsync_enabled?
64
+ # Step 6: Sync to S3
65
+ @stage = "S3 sync"
66
+ sync_s3(dump_path) if @config.s3_enabled?
41
67
 
42
- # Step 5: Cleanup old backups
68
+ # Step 7: Sync to remote
69
+ @stage = "remote sync"
70
+ sync_remote(dump_path) if @config.rsync_enabled?
71
+
72
+ # Step 8: Cleanup old backups
73
+ @stage = "retention cleanup"
43
74
  cleanup_old_backups
44
75
 
45
- # Step 6: Record success
46
- file_size = File.exist?(final_path) ? File.size(final_path) : 0
47
- @record.succeed!(
48
- file_path: final_path,
49
- file_size: file_size
50
- )
76
+ # Step 9: Record success
77
+ @stage = "recording success"
78
+ file_size = calculate_size(dump_path)
79
+ @record.succeed!(file_path: dump_path, file_size: file_size)
51
80
 
52
- # Step 7: Notify
81
+ # Step 10: Notify
82
+ @stage = "success notification"
53
83
  notify_success
54
84
 
55
- { success: true, record: @record, file_path: final_path }
85
+ { success: true, record: @record, file_path: dump_path }
56
86
  rescue StandardError => e
57
- @record.fail!(error_message: e.message) if @record
58
- notify_failure(e.message)
59
- { success: false, error: e.message, record: @record }
87
+ full_error = failure_message(e)
88
+ @record&.fail!(error_message: full_error)
89
+ notify_failure(full_error)
90
+ { success: false, error: full_error, record: @record }
60
91
  ensure
61
- # Clean up temp files
62
92
  cleanup_temp_files
63
93
  end
64
94
  end
65
95
 
66
96
  private
67
97
 
98
+ # ════════════════════════════════════════════════════════════════
99
+ # DATABASE DUMP
100
+ # ════════════════════════════════════════════════════════════════
101
+
68
102
  def dump_database
69
103
  case @config.adapter
70
- when "mysql"
71
- dump_mysql
72
- when "postgresql"
73
- dump_postgresql
74
- when "sqlite"
75
- dump_sqlite
76
- else
77
- raise "Unsupported adapter: #{@config.adapter}"
104
+ when "mysql" then dump_mysql
105
+ when "postgresql" then dump_postgresql
106
+ when "sqlite" then dump_sqlite
107
+ when "mongodb" then dump_mongodb
108
+ when "redis" then dump_redis
109
+ else raise "Unsupported adapter: #{@config.adapter}"
78
110
  end
79
111
  end
80
112
 
81
- # ─── MySQL Dump ──────────────────────────────────────────────────
113
+ # ─── MySQL ─────────────────────────────────────────────────────
82
114
  def dump_mysql
83
- raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
84
- cmd = build_mysql_command(raw_path)
115
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
116
+ password = resolve_password(@config.password)
117
+ cmd = ["mysqldump"]
118
+ cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
119
+ cmd << "--password=#{password}" if password.present?
120
+ cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
121
+ cmd << "--port=#{@config.port}" if @config.port.present?
122
+ cmd << "--result-file=#{raw_path}"
123
+ cmd << "--single-transaction"
124
+ cmd << "--quick"
125
+ cmd << "--routines"
126
+ cmd << "--triggers"
127
+ cmd << "--events"
128
+ # Additional MySQL options (e.g. --defaults-extra-file)
129
+ additional_options = if @config.respond_to?(:mysql_additional_options)
130
+ @config.mysql_additional_options
131
+ end
132
+ if additional_options.present?
133
+ additional_options.split(" ").each { |opt| cmd << opt }
134
+ end
85
135
 
86
- stdout, stderr, status = Open3.capture3(cmd)
136
+ # Skip tables
137
+ @config.skip_tables_list.each { |t| cmd << "--ignore-table=#{@config.database_name}.#{t}" }
138
+
139
+ cmd << @config.database_name
140
+
141
+ stdout, stderr, status = capture_command(cmd.join(" "))
87
142
  unless status.success?
88
- @errors << "mysqldump failed: #{stderr}"
89
- return nil
143
+ raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
90
144
  end
91
-
145
+ register_temp_file(raw_path)
92
146
  raw_path
93
147
  end
94
148
 
95
- def build_mysql_command(output_path)
96
- parts = ["mysqldump"]
97
- parts << "--user=#{@config.username}" if @config.username.present?
98
- parts << "--password=#{@config.password}" if @config.password.present?
99
- parts << "--host=#{@config.host}" if @config.host.present?
100
- parts << "--port=#{@config.port}" if @config.port.present?
101
- parts << "--result-file=#{output_path}"
102
- parts << "--quick"
103
- parts << "--single-transaction" if @config.mysql?
149
+ # ─── PostgreSQL ────────────────────────────────────────────────
150
+ def dump_postgresql
151
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
152
+ password = resolve_password(@config.password)
153
+ cmd = ["pg_dump"]
154
+ cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
155
+ cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
156
+ cmd << "--port=#{@config.port}" if @config.port.present?
157
+ cmd << "--file=#{raw_path}"
158
+ cmd << "--format=plain"
159
+ cmd << "--no-owner"
160
+ cmd << "--no-privileges"
104
161
 
105
162
  # Skip tables
106
- skip = @config.skip_tables_list
107
- skip.each { |t| parts << "--ignore-table=#{@config.database_name}.#{t}" }
163
+ @config.skip_tables_list.each { |t| cmd << "--exclude-table=#{t}" }
108
164
 
109
- parts << @config.database_name
110
- parts.join(" ")
111
- end
112
-
113
- # ─── PostgreSQL Dump ─────────────────────────────────────────────
114
- def dump_postgresql
115
- raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
116
- cmd = build_postgresql_command(raw_path)
165
+ cmd << @config.database_name
117
166
 
118
167
  env = {}
119
- env["PGPASSWORD"] = @config.password if @config.password.present?
168
+ env["PGPASSWORD"] = password if password.present?
120
169
 
121
- stdout, stderr, status = Open3.capture3(env, cmd)
170
+ stdout, stderr, status = Open3.capture3(env, cmd.join(" "))
122
171
  unless status.success?
123
- @errors << "pg_dump failed: #{stderr}"
124
- return nil
172
+ raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
125
173
  end
126
-
174
+ register_temp_file(raw_path)
127
175
  raw_path
128
176
  end
129
177
 
130
- def build_postgresql_command(output_path)
131
- parts = ["pg_dump"]
132
- parts << "--username=#{@config.username}" if @config.username.present?
133
- parts << "--host=#{@config.host}" if @config.host.present?
134
- parts << "--port=#{@config.port}" if @config.port.present?
135
- parts << "--file=#{output_path}"
136
- parts << "--format=plain"
137
-
138
- # Skip tables
139
- skip = @config.skip_tables_list
140
- skip.each { |t| parts << "--exclude-table=#{t}" }
141
-
142
- parts << @config.database_name
143
- parts.join(" ")
178
+ def capture_command(*command)
179
+ Open3.capture3(*command)
144
180
  end
145
181
 
146
- # ─── SQLite Dump ─────────────────────────────────────────────────
182
+ # ─── SQLite ────────────────────────────────────────────────────
147
183
  def dump_sqlite
148
- raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
184
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
149
185
  db_path = @config.database_name
150
186
 
151
187
  unless File.exist?(db_path)
152
- @errors << "SQLite database not found: #{db_path}"
153
- return nil
188
+ raise "SQLite database not found: #{db_path}"
154
189
  end
155
190
 
156
- stdout, stderr, status = Open3.capture3("sqlite3 #{db_path} .dump")
191
+ stdout, stderr, status = Open3.capture3("sqlite3 \"#{db_path}\" .dump")
157
192
  unless status.success?
158
- @errors << "sqlite3 dump failed: #{stderr}"
159
- return nil
193
+ raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
160
194
  end
161
195
 
162
196
  File.write(raw_path, stdout)
197
+ register_temp_file(raw_path)
163
198
  raw_path
164
199
  end
165
200
 
166
- # ─── Compression ─────────────────────────────────────────────────
201
+ # ─── MongoDB ───────────────────────────────────────────────────
202
+ def dump_mongodb
203
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
204
+ dir_path = raw_path.gsub(/\.sql$/, "")
205
+ FileUtils.mkdir_p(dir_path)
206
+
207
+ password = resolve_password(@config.password)
208
+ cmd = ["mongodump"]
209
+ cmd << "--host=#{resolve_env(@config.host) || 'localhost'}"
210
+ cmd << "--port=#{@config.port || 27017}"
211
+ cmd << "--db=#{@config.database_name}"
212
+ cmd << "--out=#{dir_path}"
213
+ cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
214
+ cmd << "--password=#{password}" if password.present?
215
+ cmd << "--authenticationDatabase=admin" if @config.username.present?
216
+
217
+ # Skip collections
218
+ @config.skip_tables_list.each { |t| cmd << "--excludeCollection=#{t}" }
219
+
220
+ stdout, stderr, status = Open3.capture3(cmd.join(" "))
221
+ unless status.success?
222
+ FileUtils.rm_rf(dir_path) if Dir.exist?(dir_path)
223
+ raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
224
+ end
225
+
226
+ # Tar the dump directory
227
+ tar_path = "#{dir_path}.tar"
228
+ stdout, stderr, status = Open3.capture3("tar -cf \"#{tar_path}\" -C \"#{File.dirname(dir_path)}\" \"#{File.basename(dir_path)}\"")
229
+ unless status.success?
230
+ raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
231
+ end
232
+
233
+ FileUtils.rm_rf(dir_path)
234
+ register_temp_file(tar_path)
235
+ tar_path
236
+ end
237
+
238
+ # ─── Redis ─────────────────────────────────────────────────────
239
+ def dump_redis
240
+ raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
241
+ raw_path = raw_path.gsub(/\.sql$/, ".rdb")
242
+
243
+ host = @config.host || "localhost"
244
+ port = @config.port || 6379
245
+ password = resolve_password(@config.password)
246
+
247
+ # Trigger BGSAVE first
248
+ cmd = "redis-cli -h #{host} -p #{port}"
249
+ cmd += " -a #{password}" if password.present?
250
+ cmd += " BGSAVE"
251
+
252
+ stdout, stderr, status = Open3.capture3(cmd)
253
+ unless status.success?
254
+ @errors << "redis BGSAVE failed: #{stderr}"
255
+ return nil
256
+ end
257
+
258
+ # Wait a moment for BGSAVE to complete
259
+ sleep 2
260
+
261
+ # Try to find the dump.rdb on the server or use redis-cli to copy
262
+ # If local Redis, copy the dump.rdb directly
263
+ redis_dir_cmd = "#{cmd.gsub(' BGSAVE', '')} CONFIG GET dir"
264
+ dir_out, = Open3.capture3(redis_dir_cmd)
265
+ redis_dir = dir_out.split("\n").last || "/var/lib/redis"
266
+
267
+ redis_rdb = File.join(redis_dir, "dump.rdb")
268
+ if File.exist?(redis_rdb)
269
+ FileUtils.cp(redis_rdb, raw_path)
270
+ else
271
+ # Fallback: use redis-cli to dump keys
272
+ dump_all_keys(host, port, password, raw_path)
273
+ end
274
+
275
+ register_temp_file(raw_path)
276
+ raw_path
277
+ end
278
+
279
+ def dump_all_keys(host, port, password, output_path)
280
+ cmd = "redis-cli -h #{host} -p #{port}"
281
+ cmd += " -a #{password}" if password.present?
282
+
283
+ keys_out, = Open3.capture3("#{cmd} KEYS '*'")
284
+ keys = keys_out.split("\n").reject { |k| k.start_with?("redis") }
285
+
286
+ File.open(output_path, "w") do |f|
287
+ keys.each do |key|
288
+ type_out, = Open3.capture3("#{cmd} TYPE \"#{key}\"")
289
+ type = type_out.strip.split("\n").last
290
+
291
+ val_out, = case type
292
+ when "string" then Open3.capture3("#{cmd} GET \"#{key}\"")
293
+ when "list" then Open3.capture3("#{cmd} LRANGE \"#{key}\" 0 -1")
294
+ when "set" then Open3.capture3("#{cmd} SMEMBERS \"#{key}\"")
295
+ when "hash" then Open3.capture3("#{cmd} HGETALL \"#{key}\"")
296
+ else Open3.capture3("#{cmd} DUMP \"#{key}\"")
297
+ end
298
+
299
+ f.puts("SET #{key.inspect} #{val_out.strip.inspect}")
300
+ end
301
+ end
302
+ end
303
+
304
+ # ════════════════════════════════════════════════════════════════
305
+ # ARCHIVES
306
+ # ════════════════════════════════════════════════════════════════
307
+
308
+ def create_archive(dump_path)
309
+ archive_path = dump_path.gsub(/\.(sql|tar)$/, ".tar")
310
+
311
+ # If the dump is already a tar (MongoDB), merge into it
312
+ if dump_path.end_with?(".tar")
313
+ archive_path = dump_path
314
+ end
315
+
316
+ cmd = ["tar"]
317
+ cmd << (dump_path.end_with?(".tar") ? "-rf" : "-cf")
318
+ cmd << archive_path
319
+
320
+ unless dump_path.end_with?(".tar")
321
+ cmd << "-C"
322
+ cmd << File.dirname(dump_path)
323
+ cmd << File.basename(dump_path)
324
+ # Remove the original SQL file after tar
325
+ @temp_files << dump_path
326
+ end
327
+
328
+ @config.archive_paths_list.each { |p| cmd << p }
329
+
330
+ # Exclude patterns
331
+ if @config.archive_excludes_list.any?
332
+ @config.archive_excludes_list.each { |e| cmd << "--exclude=#{e}" }
333
+ end
334
+
335
+ stdout, stderr, status = Open3.capture3(cmd.join(" "))
336
+ unless status.success?
337
+ @errors << "archive tar failed: #{stderr}"
338
+ return dump_path
339
+ end
340
+
341
+ archive_path
342
+ end
343
+
344
+ # ════════════════════════════════════════════════════════════════
345
+ # COMPRESSION
346
+ # ════════════════════════════════════════════════════════════════
347
+
167
348
  def compress(file_path)
168
349
  gz_path = "#{file_path}.gz"
169
350
  File.open(file_path, "rb") do |input|
@@ -171,44 +352,132 @@ module RailsNexus
171
352
  gz.write(input.read)
172
353
  end
173
354
  end
174
- File.delete(file_path) if File.exist?(gz_path)
355
+ @temp_files << file_path if File.exist?(gz_path)
175
356
  gz_path
176
357
  end
177
358
 
178
- # ─── Encryption ──────────────────────────────────────────────────
179
- def encrypt(file_path)
359
+ def compress_bzip2(file_path)
360
+ bz2_path = "#{file_path}.bz2"
361
+ stdout, stderr, status = Open3.capture3("bzip2 -zk \"#{file_path}\"")
362
+ unless status.success?
363
+ @errors << "bzip2 failed: #{stderr}"
364
+ return file_path
365
+ end
366
+ @temp_files << file_path if File.exist?(bz2_path)
367
+ bz2_path
368
+ end
369
+
370
+ # ════════════════════════════════════════════════════════════════
371
+ # ENCRYPTION
372
+ # ════════════════════════════════════════════════════════════════
373
+
374
+ def encrypt_openssl(file_path)
180
375
  enc_path = "#{file_path}.enc"
181
- password = @config.encrypt_password
376
+ password = resolve_secret(@config.encryption_password)
377
+
378
+ # Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
379
+ # (AES-GCM not available in all OpenSSL builds)
380
+ cmd = "openssl enc -aes-256-cbc"
381
+ cmd += " -pbkdf2 -iter 600000"
382
+ cmd += " -salt"
383
+ cmd += " -in \"#{file_path}\""
384
+ cmd += " -out \"#{enc_path}\""
385
+ cmd += " -pass pass:#{password}"
182
386
 
183
- cmd = "openssl aes-256-cbc -salt -pbkdf2 -in #{file_path} -out #{enc_path} -pass pass:#{password}"
184
387
  stdout, stderr, status = Open3.capture3(cmd)
388
+ unless status.success?
389
+ @errors << "OpenSSL encryption failed: #{stderr}"
390
+ return file_path
391
+ end
185
392
 
393
+ @temp_files << file_path if File.exist?(enc_path)
394
+ enc_path
395
+ end
396
+
397
+ def encrypt_gpg(file_path)
398
+ enc_path = "#{file_path}.gpg"
399
+ password = resolve_secret(@config.gpg_password)
400
+
401
+ # Best practice: AES256 with s2k-mode=iter for key derivation
402
+ cmd = "echo #{password.shellescape} | gpg --batch --yes --symmetric " \
403
+ "--cipher-algo AES256 " \
404
+ "--s2k-mode 3 " \
405
+ "--s2k-count 65011712 " \
406
+ "--passphrase-fd 0 " \
407
+ "-o \"#{enc_path}\" \"#{file_path}\""
408
+
409
+ stdout, stderr, status = Open3.capture3(cmd)
186
410
  unless status.success?
187
- @errors << "Encryption failed: #{stderr}"
411
+ @errors << "GPG encryption failed: #{stderr}"
188
412
  return file_path
189
413
  end
190
414
 
191
- File.delete(file_path) if File.exist?(enc_path)
415
+ @temp_files << file_path if File.exist?(enc_path)
192
416
  enc_path
193
417
  end
194
418
 
195
- # ─── RSync Remote Sync ──────────────────────────────────────────
419
+ # ════════════════════════════════════════════════════════════════
420
+ # SPLIT INTO CHUNKS
421
+ # ════════════════════════════════════════════════════════════════
422
+
423
+ def split_file(file_path)
424
+ chunk_dir = "#{file_path}.parts"
425
+ FileUtils.mkdir_p(chunk_dir)
426
+
427
+ # Split 50MB chunks
428
+ cmd = "split -b 50m \"#{file_path}\" \"#{chunk_dir}/part_\""
429
+ stdout, stderr, status = Open3.capture3(cmd)
430
+
431
+ unless status.success?
432
+ @errors << "split failed: #{stderr}"
433
+ return file_path
434
+ end
435
+
436
+ @temp_files << file_path
437
+ chunk_dir
438
+ end
439
+
440
+ # ════════════════════════════════════════════════════════════════
441
+ # REMOTE SYNC
442
+ # ════════════════════════════════════════════════════════════════
443
+
196
444
  def sync_remote(file_path)
197
445
  return unless @config.rsync_enabled?
198
446
  return unless @config.rsync_host.present?
199
447
 
200
448
  remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
201
449
 
202
- cmd = [
203
- "rsync",
204
- "-avz",
205
- "--progress",
206
- "-e", "ssh -p #{@config.rsync_port}",
207
- file_path,
208
- remote
209
- ].join(" ")
450
+ # Build rsync command
451
+ cmd = ["rsync"]
452
+ cmd << "-a"
453
+ cmd << "-z" # compress
454
+ cmd << "--progress"
455
+ cmd << "--archive" if @config.respond_to?(:rsync_archive?) && @config.rsync_archive?
456
+ cmd << "--delete" if @config.respond_to?(:rsync_mirror?) && @config.rsync_mirror?
457
+ cmd << "-e"
458
+ cmd << "ssh -p #{@config.rsync_port.presence || 22}"
459
+
460
+ # Add the dump file
461
+ cmd << file_path
462
+
463
+ # Add additional directories if configured
464
+ if @config.respond_to?(:rsync_directories) && @config.rsync_directories.present?
465
+ @config.rsync_directories.split(",").map(&:strip).reject(&:blank?).each do |dir|
466
+ expanded = dir.gsub("~", Dir.home)
467
+ cmd << expanded
468
+ end
469
+ end
210
470
 
211
- stdout, stderr, status = Open3.capture3(cmd)
471
+ # Add excludes
472
+ if @config.respond_to?(:rsync_excludes) && @config.rsync_excludes.present?
473
+ @config.rsync_excludes.split(",").map(&:strip).reject(&:blank?).each do |excl|
474
+ cmd << "--exclude=#{excl}"
475
+ end
476
+ end
477
+
478
+ cmd << remote
479
+
480
+ stdout, stderr, status = capture_command(*cmd)
212
481
  unless status.success?
213
482
  @errors << "RSync failed: #{stderr}"
214
483
  end
@@ -216,7 +485,60 @@ module RailsNexus
216
485
  { success: status.success?, output: stdout }
217
486
  end
218
487
 
219
- # ─── Cleanup ─────────────────────────────────────────────────────
488
+ # ─── S3 Upload ─────────────────────────────────────────────────
489
+ def sync_s3(file_path)
490
+ return unless @config.s3_enabled?
491
+
492
+ # Use aws cli if available, otherwise use s3cmd
493
+ if command_exists?("aws")
494
+ sync_s3_aws(file_path)
495
+ elsif command_exists?("s3cmd")
496
+ sync_s3_s3cmd(file_path)
497
+ else
498
+ @errors << "Neither aws-cli nor s3cmd found. Cannot upload to S3."
499
+ end
500
+ end
501
+
502
+ def sync_s3_aws(file_path)
503
+ bucket = @config.s3_bucket
504
+ prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
505
+
506
+ cmd = "AWS_ACCESS_KEY_ID=#{@config.s3_access_key} " \
507
+ "AWS_SECRET_ACCESS_KEY=#{@config.s3_secret_key} " \
508
+ "aws s3 cp \"#{file_path}\" " \
509
+ "s3://#{bucket}/#{prefix}" \
510
+ "--region #{@config.s3_region || 'us-east-1'}"
511
+
512
+ stdout, stderr, status = Open3.capture3(cmd)
513
+ unless status.success?
514
+ @errors << "S3 upload failed: #{stderr}"
515
+ end
516
+
517
+ { success: status.success?, output: stdout }
518
+ end
519
+
520
+ def sync_s3_s3cmd(file_path)
521
+ bucket = @config.s3_bucket
522
+ prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
523
+
524
+ cmd = "s3cmd put \"#{file_path}\" " \
525
+ "s3://#{bucket}/#{prefix} " \
526
+ "--access_key=#{@config.s3_access_key} " \
527
+ "--secret_key=#{@config.s3_secret_key} " \
528
+ "--region=#{@config.s3_region || 'us-east-1'}"
529
+
530
+ stdout, stderr, status = Open3.capture3(cmd)
531
+ unless status.success?
532
+ @errors << "S3 upload (s3cmd) failed: #{stderr}"
533
+ end
534
+
535
+ { success: status.success?, output: stdout }
536
+ end
537
+
538
+ # ════════════════════════════════════════════════════════════════
539
+ # CLEANUP
540
+ # ════════════════════════════════════════════════════════════════
541
+
220
542
  def cleanup_old_backups
221
543
  storage = @config.storage_path_expanded
222
544
  return unless Dir.exist?(storage)
@@ -224,32 +546,40 @@ module RailsNexus
224
546
  pattern = File.join(storage, "#{@config.name}_*")
225
547
  files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
226
548
 
227
- # Keep only keep_count most recent
228
549
  while files.size > @config.keep_count
229
550
  old_file = files.shift
230
- File.delete(old_file) if File.exist?(old_file)
551
+ if File.directory?(old_file)
552
+ FileUtils.rm_rf(old_file)
553
+ else
554
+ File.delete(old_file) if File.exist?(old_file)
555
+ end
231
556
  end
232
557
  end
233
558
 
234
559
  def cleanup_temp_files
235
- # Clean up any intermediate files (uncompressed, unencrypted)
236
- storage = @config.storage_path_expanded
237
- return unless Dir.exist?(storage)
238
-
239
- Dir.glob(File.join(storage, "#{@config.name}_*.sql")).each do |f|
240
- File.delete(f) if File.exist?("#{f}.gz") || File.exist?("#{f}.enc")
560
+ @temp_files.each do |f|
561
+ if File.directory?(f)
562
+ FileUtils.rm_rf(f)
563
+ else
564
+ File.delete(f) if File.exist?(f)
565
+ end
241
566
  end
242
567
  end
243
568
 
244
- # ─── Notifications ───────────────────────────────────────────────
569
+ # ════════════════════════════════════════════════════════════════
570
+ # NOTIFICATIONS
571
+ # ════════════════════════════════════════════════════════════════
572
+
245
573
  def notify_success
246
574
  return unless @config.notify_on_success?
247
575
  execute_notify_command("success")
576
+ send_email_notification("success")
248
577
  end
249
578
 
250
579
  def notify_failure(error_message)
251
580
  return unless @config.notify_on_failure?
252
581
  execute_notify_command("failure", error_message)
582
+ send_email_notification("failure", error_message)
253
583
  end
254
584
 
255
585
  def execute_notify_command(status, error = nil)
@@ -266,11 +596,87 @@ module RailsNexus
266
596
  # Don't fail backup because notification failed
267
597
  end
268
598
 
269
- # ─── Fail Record ─────────────────────────────────────────────────
270
- def fail_record(message)
271
- @record.fail!(error_message: message) if @record
272
- notify_failure(message)
273
- { success: false, error: message, record: @record }
599
+ def send_email_notification(status, error = nil)
600
+ return unless @config.email_notify?
601
+ return if @config.email_to.blank?
602
+
603
+ subject = "[RailsNexus Backup] #{status.upcase}: #{@config.name}"
604
+ body = "Backup #{@config.name} #{status}.\n\n"
605
+ body += "Error: #{error}\n\n" if error.present?
606
+ body += "Time: #{Time.current.iso8601}\n"
607
+ body += "Record ID: #{@record&.id}\n"
608
+
609
+ # Build mail command based on available MTA
610
+ if command_exists?("mail")
611
+ cmd = "echo #{body.shellescape} | mail -s #{subject.shellescape} #{@config.email_to}"
612
+ Open3.capture3(cmd)
613
+ elsif command_exists?("sendmail")
614
+ mail_content = "To: #{@config.email_to}\nSubject: #{subject}\n\n#{body}"
615
+ cmd = "echo #{mail_content.shellescape} | sendmail -t"
616
+ Open3.capture3(cmd)
617
+ else
618
+ @errors << "No mail command found (mail/sendmail)"
619
+ end
620
+ rescue StandardError
621
+ # Don't fail backup because email notification failed
622
+ end
623
+
624
+ # ════════════════════════════════════════════════════════════════
625
+ # HELPERS
626
+ # ════════════════════════════════════════════════════════════════
627
+
628
+ def calculate_size(path)
629
+ if File.directory?(path)
630
+ Dir.glob(File.join(path, "**", "*")).sum { |f| File.exist?(f) ? File.size(f) : 0 }
631
+ else
632
+ File.exist?(path) ? File.size(path) : 0
633
+ end
634
+ end
635
+
636
+ def register_temp_file(path)
637
+ @temp_files << path unless @temp_files.include?(path)
638
+ end
639
+
640
+ # ─── Password Resolution ──────────────────────────────────────
641
+ # Supports:
642
+ # "plain_text" → used as-is
643
+ # "ENV[MY_VAR]" → resolved from ENV
644
+ # "${MY_VAR}" → resolved from ENV
645
+ # "credentials[:key]" → from Rails encrypted credentials
646
+ # "credentials[:a][:b]" → nested credentials key
647
+ def resolve_password(value)
648
+ return nil if value.blank?
649
+ resolve_secret(value)
650
+ end
651
+
652
+ def resolve_env(value)
653
+ resolve_secret(value)
654
+ end
655
+
656
+ def resolve_secret(value)
657
+ return nil if value.blank?
658
+ case value
659
+ when /^ENV\[(.+?)\]$/
660
+ ENV[$1]
661
+ when /^\$\{(.+?)\}$/
662
+ ENV[$1]
663
+ when /^credentials\[/
664
+ # credentials[:key] or credentials[:key][:subkey]
665
+ keys = value.scan(/\[:([^\]]+)\]/).flatten.map(&:to_sym)
666
+ Rails.application.credentials.dig(*keys)
667
+ else
668
+ value
669
+ end
670
+ end
671
+
672
+ def command_exists?(cmd)
673
+ system("which #{cmd} > /dev/null 2>&1")
674
+ end
675
+
676
+ def failure_message(exception)
677
+ heading = "#{@stage || 'backup'} failed: #{exception.class}: #{exception.message}"
678
+ trace = Array(exception.backtrace).first(5).join("\n")
679
+ [heading, trace.presence, @errors.join("\n").presence].compact.join("\n\n")
274
680
  end
275
681
  end
276
682
  end