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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8eb79bf4aa85274b82292897294246021d89ee85ae8c5f09e080ae82bd526129
4
+ data.tar.gz: a087f50f451ca3e635830042c04e7ccaa56c541b75d0dbbf88f3a27717bea340
5
+ SHA512:
6
+ metadata.gz: 8b2338600d937308f2ca82d7bdf7b1c3d769bbf339b40800a1535a9f65e3cf5a7d1658e14cd0aa6f25a10669c73cd04b29912f0060ee02ef18b0b84fa7478871
7
+ data.tar.gz: 37829dbe4fa5b948f9c00fff551b15d67d6ae27a6bbecf841af7ee43170d0d83342d272b915808930ef5197e8afc9b68f2c58d8b5d2476c7f4d75000cc5e909d
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Extract backup management from RailsNexus into the BackupNexus engine.
6
+ - Preserve the RailsNexus layout and stylesheet.
7
+ - Add safe environment-based seed examples, migrations, generators, CI, and release automation.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Tamiru Hailu
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # BackupNexus
2
+
3
+ Backup management for Rails applications, extracted from RailsNexus.
4
+
5
+ BackupNexus keeps the same RailsNexus layout and stylesheet while owning the
6
+ backup dashboard, database models, backup runner, generators, migrations, and
7
+ rake tasks in a separate gem.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ gem "rails_nexus"
13
+ gem "backup_nexus"
14
+ ```
15
+
16
+ Run the installer:
17
+
18
+ ```bash
19
+ bin/rails generate backup_nexus:install
20
+ bin/rails db:migrate
21
+ ```
22
+
23
+ The installer mounts the engine at `/backup_nexus`. Configure authorization
24
+ through `RailsNexus.configure`, then enable the backup dashboard:
25
+
26
+ ```ruby
27
+ BackupNexus.configure do |config|
28
+ config.enabled = true
29
+ end
30
+ ```
31
+
32
+ Run the safe example seed with:
33
+
34
+ ```bash
35
+ bundle exec rake backup_nexus:seed_backups
36
+ ```
37
+
38
+ The migration renames legacy `rails_nexus_backups` and
39
+ `rails_nexus_backup_configs` tables when they exist, preserving existing
40
+ backup records during the extraction.
41
+
42
+ ## Official Backup gem integration
43
+
44
+ BackupNexus includes its existing safe native runner by default and also
45
+ generates models compatible with the official [Backup](https://github.com/backup/backup)
46
+ DSL. The upstream project is not currently under active development and
47
+ targets older Rails dependencies, so it remains optional instead of being a
48
+ hard runtime dependency.
49
+
50
+ To use the DSL templates:
51
+
52
+ ```ruby
53
+ gem "backup", "~> 4.4"
54
+ ```
55
+
56
+ Then run:
57
+
58
+ ```bash
59
+ bin/rails generate backup_nexus:backup
60
+ bundle exec backup perform -t daily_backup
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ bundle exec rails test
67
+ bundle exec rake test
68
+ gem build backup_nexus.gemspec
69
+ ```
70
+
71
+ ## CI/CD
72
+
73
+ GitHub Actions runs the test suite on pushes and pull requests. A matching
74
+ version tag publishes the gem to RubyGems and creates a GitHub release.
75
+
76
+ Create a scoped RubyGems API key that can push only the `backup_nexus` gem,
77
+ then add it to the GitHub repository (or its `release` environment) as an
78
+ Actions secret named `RUBYGEMS_API_KEY`. Never commit the key to the
79
+ repository.
80
+
81
+ To release a version, update `lib/backup_nexus/version.rb`, merge the change to
82
+ `main`, and push a matching tag such as `v0.1.0`.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/setup"
2
+
3
+ require "rails"
4
+ require "bundler/gem_tasks"
5
+
6
+ task default: :test
7
+
8
+ task :test do
9
+ ruby "-Itest", "-e", "ARGV.each { |file| require File.expand_path(file) }", *Dir["test/**/*_test.rb"]
10
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackupNexus
4
+ class ApplicationController < RailsNexus::ApplicationController
5
+ include BackupNexus::ApplicationHelper
6
+
7
+ layout "layouts/rails_nexus/application"
8
+
9
+ helper BackupNexus::ApplicationHelper
10
+ helper BackupNexus::Engine.helpers
11
+ helper BackupNexus::Engine.routes.url_helpers
12
+ helper RailsNexus::Engine.helpers
13
+ helper RailsNexus::Engine.routes.url_helpers
14
+
15
+ helper_method :rails_nexus
16
+
17
+ before_action :backup_nexus_require_enabled
18
+
19
+ private
20
+
21
+ def rails_nexus
22
+ RailsNexus::Engine.routes.url_helpers
23
+ end
24
+
25
+ def backup_nexus_require_enabled
26
+ return if BackupNexus.configuration.enabled
27
+
28
+ render plain: "Backup management not enabled", status: :forbidden
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackupNexus
4
+ class BackupController < ApplicationController
5
+ before_action :check_backup_enabled
6
+ before_action :set_config, only: %i[edit update destroy trigger]
7
+
8
+ # GET /backup_nexus/backup
9
+ def index
10
+ @configs = BackupNexus::BackupConfig.order(:name)
11
+ end
12
+
13
+ # GET /backup_nexus/backup/new
14
+ def new
15
+ @config = BackupNexus::BackupConfig.new(
16
+ adapter: "mysql",
17
+ host: "localhost",
18
+ port: 3306,
19
+ storage_path: "~/dumps",
20
+ keep_count: 30,
21
+ compress: true
22
+ )
23
+ end
24
+
25
+ # POST /backup_nexus/backup
26
+ def create
27
+ @config = BackupNexus::BackupConfig.new(config_params)
28
+
29
+ if @config.save
30
+ redirect_to backup_path, notice: "Backup config '#{@config.name}' created."
31
+ else
32
+ render :new, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ # GET /backup_nexus/backup/:id/edit
37
+ def edit; end
38
+
39
+ # PATCH /backup_nexus/backup/:id
40
+ def update
41
+ # Strip blank password fields so they don't overwrite stored values
42
+ filtered = config_params
43
+ %w[password encryption_password gpg_password].each do |attr|
44
+ filtered = filtered.except(attr) if filtered[attr].blank?
45
+ end
46
+
47
+ if @config.update(filtered)
48
+ redirect_to backup_path, notice: "Backup config '#{@config.name}' updated."
49
+ else
50
+ render :edit, status: :unprocessable_entity
51
+ end
52
+ end
53
+
54
+ # DELETE /backup_nexus/backup/:id
55
+ def destroy
56
+ name = @config.name
57
+ @config.destroy
58
+ redirect_to backup_path, notice: "Backup config '#{name}' deleted."
59
+ end
60
+
61
+ # POST /backup_nexus/backup/:id/trigger
62
+ def trigger
63
+ begin
64
+ BackupNexus::BackupJob.perform_later(@config.id)
65
+ redirect_to backup_path, notice: "Backup '#{@config.name}' started in background."
66
+ rescue
67
+ # Fallback to synchronous if Active Job is not configured
68
+ result = BackupNexus::BackupService.run(@config)
69
+ if result[:success]
70
+ redirect_to backup_path, notice: "Backup '#{@config.name}' completed successfully."
71
+ else
72
+ redirect_to backup_path, alert: "Backup failed: #{result[:error]}"
73
+ end
74
+ end
75
+ end
76
+
77
+ # GET /backup_nexus/backup/:id/history
78
+ def history
79
+ @config = BackupNexus::BackupConfig.find(params[:id])
80
+ @records = BackupNexus::Backup.where(config_name: @config.name)
81
+ .order(started_at: :desc)
82
+ .limit(50)
83
+ end
84
+
85
+ # GET /backup_nexus/backup/history
86
+ def all_history
87
+ @records = BackupNexus::Backup.order(started_at: :desc).limit(100)
88
+ render :history
89
+ end
90
+
91
+ private
92
+
93
+ def set_config
94
+ @config = BackupNexus::BackupConfig.find(params[:id])
95
+ end
96
+
97
+ def config_params
98
+ params.require(:backup_config).permit(
99
+ :name, :description, :database_name, :adapter, :host, :port,
100
+ :username, :password, :storage_path, :keep_count,
101
+ :compress, :encrypted, :encryption_password,
102
+ :rsync_enabled, :rsync_host, :rsync_port, :rsync_user, :rsync_path, :rsync_mirror, :rsync_archive, :rsync_directories, :rsync_excludes,
103
+ :notify_command, :notify_on_success, :notify_on_failure,
104
+ :schedule_cron, :enabled,
105
+ :s3_enabled, :s3_access_key, :s3_secret_key, :s3_bucket, :s3_region, :s3_prefix,
106
+ :gpg_enabled, :gpg_password,
107
+ :email_notify, :email_to,
108
+ :archive_enabled, :bzip2_compress, :split_chunks, :encrypt_base64, :mysql_additional_options,
109
+ skip_tables: [], archive_paths: [], archive_excludes: []
110
+ )
111
+ end
112
+
113
+ def check_backup_enabled
114
+ unless BackupNexus.configuration.enabled
115
+ render plain: "Backup management not enabled", status: :forbidden
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackupNexus
4
+ module ApplicationHelper
5
+ include RailsNexus::ApplicationHelper
6
+ include BackupNexus::Engine.routes.url_helpers
7
+
8
+ def backup_path(_resource = nil)
9
+ backup_nexus_route_path("/backup")
10
+ end
11
+
12
+ def new_backup_path
13
+ backup_nexus_route_path("/backup/new")
14
+ end
15
+
16
+ def backup_history_path
17
+ backup_nexus_route_path("/backup/history")
18
+ end
19
+
20
+ def edit_backup_path(resource)
21
+ backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}/edit")
22
+ end
23
+
24
+ def backup_destroy_path(resource)
25
+ backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}")
26
+ end
27
+
28
+ def backup_trigger_path(resource)
29
+ backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}/trigger")
30
+ end
31
+
32
+ def backup_config_history_path(resource)
33
+ backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}/history")
34
+ end
35
+
36
+ def backup_update_path(resource)
37
+ backup_nexus_route_path("/backup/#{backup_nexus_param(resource)}")
38
+ end
39
+
40
+ private
41
+
42
+ def backup_nexus_route_path(path)
43
+ prefix = request.respond_to?(:script_name) && request.script_name.present? ? request.script_name.chomp("/") : "/backup_nexus"
44
+ "#{prefix}#{path}"
45
+ end
46
+
47
+ def backup_nexus_param(resource)
48
+ resource.respond_to?(:to_param) ? resource.to_param : resource
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackupNexus
4
+ class BackupJob < ApplicationJob
5
+ queue_as :backup_nexus_backups
6
+
7
+ # Accept either a BackupConfig record ID or a BackupConfig record
8
+ def perform(backup_config_id_or_record)
9
+ config = if backup_config_id_or_record.is_a?(BackupNexus::BackupConfig)
10
+ backup_config_id_or_record
11
+ else
12
+ BackupNexus::BackupConfig.find(backup_config_id_or_record)
13
+ end
14
+
15
+ BackupNexus::BackupService.run(config)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackupNexus
4
+ class Backup < BaseRecord
5
+ self.table_name = "backup_nexus_backups"
6
+
7
+ validates :config_name, presence: true
8
+ validates :status, presence: true, inclusion: { in: %w[running success failed] }
9
+ validates :started_at, presence: true
10
+
11
+ scope :recent, ->(hours = 24) { where("started_at >= ?", hours.hours.ago) }
12
+ scope :successful, -> { where(status: "success") }
13
+ scope :failed, -> { where(status: "failed") }
14
+ scope :running, -> { where(status: "running") }
15
+ scope :by_config, ->(name) { where(config_name: name) }
16
+ scope :latest_first, -> { order(started_at: :desc) }
17
+
18
+ # Start tracking a backup run
19
+ def self.start!(config_name:, triggered_by: "system")
20
+ create!(
21
+ config_name: config_name,
22
+ status: "running",
23
+ triggered_by: triggered_by,
24
+ started_at: Time.current
25
+ )
26
+ end
27
+
28
+ # Mark backup as completed
29
+ def succeed!(file_path: nil, file_size: nil)
30
+ update!(
31
+ status: "success",
32
+ file_path: file_path,
33
+ file_size: file_size,
34
+ duration: (Time.current - started_at).round(2),
35
+ completed_at: Time.current
36
+ )
37
+ end
38
+
39
+ # Mark backup as failed
40
+ def fail!(error_message:)
41
+ update!(
42
+ status: "failed",
43
+ error_message: error_message,
44
+ duration: (Time.current - started_at).round(2),
45
+ completed_at: Time.current
46
+ )
47
+ end
48
+
49
+ # Get success rate for a model over the last N days
50
+ def self.success_rate(config_name:, days: 7)
51
+ records = by_config(config_name).where("started_at >= ?", days.days.ago).where.not(status: "running")
52
+ return 0 if records.empty?
53
+ (records.successful.count.to_f / records.count * 100).round(1)
54
+ end
55
+
56
+ # Get backup health summary
57
+ def self.health_summary(alert_threshold_hours: 24)
58
+ configs = BackupNexus::BackupConfig.pluck(:name)
59
+ results = configs.map do |config_name|
60
+ latest = by_config(config_name).successful.latest_first.first
61
+ age_hours = latest ? ((Time.current - latest.started_at) / 3600).round(1) : nil
62
+
63
+ {
64
+ config_name: config_name,
65
+ last_backup: latest&.started_at,
66
+ age_hours: age_hours,
67
+ status: age_hours.nil? ? "unknown" : (age_hours < alert_threshold_hours ? "healthy" : "stale"),
68
+ success_rate: success_rate(config_name: config_name),
69
+ last_file_size: latest&.file_size
70
+ }
71
+ end
72
+
73
+ {
74
+ healthy: results.all? { |r| r[:status] == "healthy" },
75
+ models: results,
76
+ alerts: results.select { |r| r[:status] == "stale" || r[:status] == "unknown" }
77
+ }
78
+ end
79
+
80
+ # Cleanup old backup records
81
+ def self.cleanup!(retention_days: 30)
82
+ where("started_at < ?", retention_days.days.ago).delete_all
83
+ end
84
+
85
+ # Format file size
86
+ def file_size_human
87
+ return "—" unless file_size
88
+ units = ["B", "KB", "MB", "GB"]
89
+ size = file_size.to_f
90
+ units.each do |unit|
91
+ return "#{size.round(1)} #{unit}" if size < 1024
92
+ size /= 1024
93
+ end
94
+ "#{size.round(1)} TB"
95
+ end
96
+
97
+ # Duration formatted
98
+ def duration_human
99
+ return "—" unless duration
100
+ if duration < 60
101
+ "#{duration.round(1)}s"
102
+ else
103
+ "#{(duration / 60).floor}m #{(duration % 60).round}s"
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackupNexus
4
+ class BackupConfig < BaseRecord
5
+ self.table_name = "backup_nexus_backup_configs"
6
+
7
+ ADAPTERS = %w[mysql postgresql sqlite mongodb redis].freeze
8
+
9
+ # ─── Boolean defaults ──────────────────────────────────────────
10
+ # Rails doesn't apply DB defaults to new in-memory records, so
11
+ # check_box helpers in form_for render unchecked. These declarations
12
+ # ensure the form reflects the intended defaults.
13
+ attribute :enabled, :boolean, default: true
14
+ attribute :compress, :boolean, default: true
15
+ attribute :encrypted, :boolean, default: false
16
+ attribute :encrypt_base64, :boolean, default: false
17
+ attribute :rsync_enabled, :boolean, default: false
18
+ attribute :rsync_mirror, :boolean, default: false
19
+ attribute :notify_on_success, :boolean, default: true
20
+ attribute :notify_on_failure, :boolean, default: true
21
+ attribute :s3_enabled, :boolean, default: false
22
+ attribute :gpg_enabled, :boolean, default: false
23
+ attribute :email_notify, :boolean, default: false
24
+ attribute :archive_enabled, :boolean, default: false
25
+ attribute :bzip2_compress, :boolean, default: false
26
+ attribute :split_chunks, :boolean, default: false
27
+
28
+ validates :name, presence: true, uniqueness: true
29
+ validates :name, format: {
30
+ with: /\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/,
31
+ message: "may contain only letters, numbers, dots, underscores, dollar signs, and hyphens"
32
+ }
33
+ validates :database_name, presence: true, if: -> { adapter != "redis" }
34
+ validates :adapter, presence: true, inclusion: { in: ADAPTERS }
35
+ validates :storage_path, presence: true
36
+ validates :keep_count, numericality: { greater_than: 0 }
37
+ validates :encryption_password, presence: true, if: :encrypted?
38
+ validates :rsync_host, presence: true, if: :rsync_enabled?
39
+ validates :rsync_user, presence: true, if: :rsync_enabled?
40
+ validates :s3_bucket, presence: true, if: :s3_enabled?
41
+ validates :s3_access_key, presence: true, if: :s3_enabled?
42
+ validates :s3_secret_key, presence: true, if: :s3_enabled?
43
+ validates :gpg_password, presence: true, if: :gpg_enabled?
44
+ validates :email_to, presence: true, if: :email_notify?
45
+ validates :port, :rsync_port,
46
+ numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 65_535 },
47
+ allow_nil: true
48
+ validate :database_identifier_is_safe
49
+ validate :remote_sync_values_are_safe
50
+
51
+ scope :enabled, -> { where(enabled: true) }
52
+ scope :disabled, -> { where(enabled: false) }
53
+
54
+ serialize :skip_tables, coder: JSON
55
+ serialize :archive_paths, coder: JSON
56
+ serialize :archive_excludes, coder: JSON
57
+
58
+ # ─── Skip Tables ───────────────────────────────────────────────
59
+ def skip_tables_list
60
+ return [] if skip_tables.blank?
61
+ skip_tables.is_a?(Array) ? skip_tables : JSON.parse(skip_tables.to_s) rescue []
62
+ end
63
+
64
+ def skip_tables_list=(value)
65
+ return if value.blank?
66
+ self.skip_tables = if value.is_a?(String)
67
+ value.split(",").map(&:strip).reject(&:blank?)
68
+ else
69
+ value
70
+ end
71
+ end
72
+
73
+ # ─── Archive Paths ─────────────────────────────────────────────
74
+ def archive_paths_list
75
+ return [] if archive_paths.blank?
76
+ archive_paths.is_a?(Array) ? archive_paths : JSON.parse(archive_paths.to_s) rescue []
77
+ end
78
+
79
+ def archive_paths_list=(value)
80
+ return if value.blank?
81
+ self.archive_paths = if value.is_a?(String)
82
+ value.split(",").map(&:strip).reject(&:blank?)
83
+ else
84
+ value
85
+ end
86
+ end
87
+
88
+ def archive_excludes_list
89
+ return [] if archive_excludes.blank?
90
+ archive_excludes.is_a?(Array) ? archive_excludes : JSON.parse(archive_excludes.to_s) rescue []
91
+ end
92
+
93
+ def archive_excludes_list=(value)
94
+ return if value.blank?
95
+ self.archive_excludes = if value.is_a?(String)
96
+ value.split(",").map(&:strip).reject(&:blank?)
97
+ else
98
+ value
99
+ end
100
+ end
101
+
102
+ # ─── Adapter checks ────────────────────────────────────────────
103
+ def mysql?; adapter == "mysql"; end
104
+ def postgresql?; adapter == "postgresql"; end
105
+ def sqlite?; adapter == "sqlite"; end
106
+ def mongodb?; adapter == "mongodb"; end
107
+ def redis?; adapter == "redis"; end
108
+
109
+ # ─── Path helpers ──────────────────────────────────────────────
110
+ def storage_path_expanded
111
+ storage_path.gsub("~", Dir.home)
112
+ end
113
+
114
+ def dump_filename
115
+ timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
116
+ ext = dump_extension
117
+ "#{name}_#{timestamp}.#{ext}"
118
+ end
119
+
120
+ def dump_filepath
121
+ File.join(storage_path_expanded, dump_filename)
122
+ end
123
+
124
+ # ─── Stats ─────────────────────────────────────────────────────
125
+ def recent_backups(limit: 10)
126
+ BackupNexus::Backup.where(config_name: name).order(started_at: :desc).limit(limit)
127
+ end
128
+
129
+ def last_failure
130
+ BackupNexus::Backup.where(config_name: name, status: "failed").order(started_at: :desc).first
131
+ end
132
+
133
+ def stats
134
+ backups = BackupNexus::Backup.where(config_name: name)
135
+ recent = backups.where("started_at >= ?", 7.days.ago)
136
+ {
137
+ total: backups.count,
138
+ successful: backups.successful.count,
139
+ failed: backups.failed.count,
140
+ recent_count: recent.count,
141
+ recent_success: recent.successful.count,
142
+ recent_failed: recent.failed.count,
143
+ last_backup: backups.successful.latest_first.first,
144
+ total_size: backups.sum(:file_size).to_i,
145
+ total_size_human: human_size(backups.sum(:file_size).to_i)
146
+ }
147
+ end
148
+
149
+ private
150
+
151
+ def database_identifier_is_safe
152
+ return if database_name.blank? || sqlite?
153
+
154
+ unless database_name.match?(/\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/) && !database_name.include?("..")
155
+ errors.add(:database_name, "contains unsupported characters")
156
+ end
157
+ end
158
+
159
+ def remote_sync_values_are_safe
160
+ return unless rsync_enabled?
161
+
162
+ unless rsync_host.to_s.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
163
+ errors.add(:rsync_host, "is not a valid hostname")
164
+ end
165
+ unless rsync_user.to_s.match?(/\A[a-zA-Z0-9_][a-zA-Z0-9_.-]*\z/)
166
+ errors.add(:rsync_user, "contains unsupported characters")
167
+ end
168
+ unless rsync_path.to_s.start_with?("/") && !rsync_path.to_s.match?(/[\0\r\n]/)
169
+ errors.add(:rsync_path, "must be an absolute remote path")
170
+ end
171
+ end
172
+
173
+ def dump_extension
174
+ parts = ["sql"]
175
+ parts << "gz" if compress? && !bzip2_compress?
176
+ parts << "bz2" if bzip2_compress?
177
+ parts << "enc" if encrypted?
178
+ parts.join(".")
179
+ end
180
+
181
+ def human_size(bytes)
182
+ return "0 B" if bytes.zero?
183
+ units = %w[B KB MB GB TB]
184
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
185
+ exp = units.size - 1 if exp >= units.size
186
+ "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
187
+ end
188
+ end
189
+ end