rails_nexus 2.1.0 → 2.1.3

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +68 -13
  4. data/SECURITY.md +11 -0
  5. data/THIRD_PARTY_NOTICES.md +14 -0
  6. data/app/assets/config/rails_nexus_manifest.js +1 -1
  7. data/app/assets/javascripts/rails_nexus/application.js +55 -0
  8. data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
  9. data/app/controllers/rails_nexus/application_controller.rb +6 -2
  10. data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
  11. data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
  12. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
  13. data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
  14. data/app/controllers/rails_nexus/settings_controller.rb +1 -39
  15. data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
  16. data/app/controllers/rails_nexus/stats_controller.rb +33 -159
  17. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
  18. data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
  19. data/app/javascript/rails_nexus/application.js +15 -3
  20. data/app/models/rails_nexus/logged_exception.rb +27 -13
  21. data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +0 -11
  22. data/app/views/layouts/rails_nexus/application.html.erb +2 -13
  23. data/app/views/rails_nexus/stats/index.html.erb +1 -102
  24. data/config/routes.rb +0 -11
  25. data/lib/generators/rails_nexus/install_generator.rb +1 -1
  26. data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
  27. data/lib/generators/rails_nexus/templates/migration.rb +0 -82
  28. data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
  29. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
  30. data/lib/rails_nexus/configuration.rb +10 -45
  31. data/lib/rails_nexus/database_adapter.rb +53 -0
  32. data/lib/rails_nexus/engine.rb +6 -17
  33. data/lib/rails_nexus/logger.rb +1 -12
  34. data/lib/rails_nexus/notifications.rb +27 -20
  35. data/lib/rails_nexus/version.rb +1 -1
  36. data/lib/rails_nexus/webhook_client.rb +151 -0
  37. data/lib/rails_nexus.rb +37 -23
  38. data/lib/tasks/rails_nexus.rake +0 -80
  39. data/rails_nexus.gemspec +4 -5
  40. metadata +12 -41
  41. data/app/controllers/rails_nexus/backup_controller.rb +0 -120
  42. data/app/jobs/rails_nexus/backup_job.rb +0 -18
  43. data/app/models/rails_nexus/backup.rb +0 -107
  44. data/app/models/rails_nexus/backup_config.rb +0 -158
  45. data/app/services/rails_nexus/backup_runner.rb +0 -62
  46. data/app/services/rails_nexus/backup_service.rb +0 -682
  47. data/app/views/rails_nexus/backup/_form.html.erb +0 -334
  48. data/app/views/rails_nexus/backup/config_history.html.erb +0 -67
  49. data/app/views/rails_nexus/backup/edit.html.erb +0 -15
  50. data/app/views/rails_nexus/backup/history.html.erb +0 -75
  51. data/app/views/rails_nexus/backup/index.html.erb +0 -112
  52. data/app/views/rails_nexus/backup/new.html.erb +0 -15
  53. data/config/importmap.rb +0 -15
  54. data/db/seeds/backup_configs.rb +0 -140
  55. data/lib/generators/rails_nexus/backup_generator.rb +0 -70
  56. data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +0 -152
  57. data/lib/generators/rails_nexus/templates/backup/config.rb +0 -27
  58. data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +0 -24
  59. data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +0 -35
  60. data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +0 -43
  61. data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +0 -15
  62. data/lib/generators/rails_nexus/templates/backup/schedule.rb +0 -28
@@ -1,682 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
- require "json"
5
- require "fileutils"
6
-
7
- module RailsNexus
8
- class BackupService
9
- # Execute a backup for a given config
10
- def self.run(config)
11
- new(config).run
12
- end
13
-
14
- def initialize(config)
15
- @config = config
16
- @record = nil
17
- @errors = []
18
- @temp_files = []
19
- end
20
-
21
- def run
22
- @record = RailsNexus::Backup.start!(
23
- config_name: @config.name,
24
- triggered_by: "service"
25
- )
26
-
27
- begin
28
- @stage = "preparing storage"
29
- FileUtils.mkdir_p(@config.storage_path_expanded)
30
-
31
- # Step 1: Dump database
32
- @stage = "database dump"
33
- dump_path = dump_database
34
- raise "Database dump failed" unless dump_path
35
-
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
47
-
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
63
-
64
- # Step 6: Sync to S3
65
- @stage = "S3 sync"
66
- sync_s3(dump_path) if @config.s3_enabled?
67
-
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"
74
- cleanup_old_backups
75
-
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)
80
-
81
- # Step 10: Notify
82
- @stage = "success notification"
83
- notify_success
84
-
85
- { success: true, record: @record, file_path: dump_path }
86
- rescue StandardError => e
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 }
91
- ensure
92
- cleanup_temp_files
93
- end
94
- end
95
-
96
- private
97
-
98
- # ════════════════════════════════════════════════════════════════
99
- # DATABASE DUMP
100
- # ════════════════════════════════════════════════════════════════
101
-
102
- def dump_database
103
- case @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}"
110
- end
111
- end
112
-
113
- # ─── MySQL ─────────────────────────────────────────────────────
114
- def dump_mysql
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
135
-
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(" "))
142
- unless status.success?
143
- raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
144
- end
145
- register_temp_file(raw_path)
146
- raw_path
147
- end
148
-
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"
161
-
162
- # Skip tables
163
- @config.skip_tables_list.each { |t| cmd << "--exclude-table=#{t}" }
164
-
165
- cmd << @config.database_name
166
-
167
- env = {}
168
- env["PGPASSWORD"] = password if password.present?
169
-
170
- stdout, stderr, status = Open3.capture3(env, cmd.join(" "))
171
- unless status.success?
172
- raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
173
- end
174
- register_temp_file(raw_path)
175
- raw_path
176
- end
177
-
178
- def capture_command(*command)
179
- Open3.capture3(*command)
180
- end
181
-
182
- # ─── SQLite ────────────────────────────────────────────────────
183
- def dump_sqlite
184
- raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
185
- db_path = @config.database_name
186
-
187
- unless File.exist?(db_path)
188
- raise "SQLite database not found: #{db_path}"
189
- end
190
-
191
- stdout, stderr, status = Open3.capture3("sqlite3 \"#{db_path}\" .dump")
192
- unless status.success?
193
- raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
194
- end
195
-
196
- File.write(raw_path, stdout)
197
- register_temp_file(raw_path)
198
- raw_path
199
- end
200
-
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
-
348
- def compress(file_path)
349
- gz_path = "#{file_path}.gz"
350
- File.open(file_path, "rb") do |input|
351
- Zlib::GzipWriter.open(gz_path) do |gz|
352
- gz.write(input.read)
353
- end
354
- end
355
- @temp_files << file_path if File.exist?(gz_path)
356
- gz_path
357
- end
358
-
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)
375
- enc_path = "#{file_path}.enc"
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}"
386
-
387
- stdout, stderr, status = Open3.capture3(cmd)
388
- unless status.success?
389
- @errors << "OpenSSL encryption failed: #{stderr}"
390
- return file_path
391
- end
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)
410
- unless status.success?
411
- @errors << "GPG encryption failed: #{stderr}"
412
- return file_path
413
- end
414
-
415
- @temp_files << file_path if File.exist?(enc_path)
416
- enc_path
417
- end
418
-
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
-
444
- def sync_remote(file_path)
445
- return unless @config.rsync_enabled?
446
- return unless @config.rsync_host.present?
447
-
448
- remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
449
-
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
470
-
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)
481
- unless status.success?
482
- @errors << "RSync failed: #{stderr}"
483
- end
484
-
485
- { success: status.success?, output: stdout }
486
- end
487
-
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
-
542
- def cleanup_old_backups
543
- storage = @config.storage_path_expanded
544
- return unless Dir.exist?(storage)
545
-
546
- pattern = File.join(storage, "#{@config.name}_*")
547
- files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
548
-
549
- while files.size > @config.keep_count
550
- old_file = files.shift
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
556
- end
557
- end
558
-
559
- def cleanup_temp_files
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
566
- end
567
- end
568
-
569
- # ════════════════════════════════════════════════════════════════
570
- # NOTIFICATIONS
571
- # ════════════════════════════════════════════════════════════════
572
-
573
- def notify_success
574
- return unless @config.notify_on_success?
575
- execute_notify_command("success")
576
- send_email_notification("success")
577
- end
578
-
579
- def notify_failure(error_message)
580
- return unless @config.notify_on_failure?
581
- execute_notify_command("failure", error_message)
582
- send_email_notification("failure", error_message)
583
- end
584
-
585
- def execute_notify_command(status, error = nil)
586
- return if @config.notify_command.blank?
587
-
588
- cmd = @config.notify_command
589
- .gsub("{STATUS}", status.to_s)
590
- .gsub("{MODEL}", @config.name)
591
- .gsub("{ERROR}", error.to_s)
592
- .gsub("{TIME}", Time.current.iso8601)
593
-
594
- Open3.capture3(cmd)
595
- rescue StandardError
596
- # Don't fail backup because notification failed
597
- end
598
-
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")
680
- end
681
- end
682
- end