rails_nexus 2.0.2 → 2.0.6

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