rails_nexus 2.0.0 → 2.0.2

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/rails_nexus/backup_controller.rb +74 -53
  3. data/app/models/rails_nexus/backup_config.rb +90 -0
  4. data/app/services/rails_nexus/backup_runner.rb +62 -0
  5. data/app/services/rails_nexus/backup_service.rb +215 -214
  6. data/app/views/layouts/rails_nexus/application.html.erb +8 -0
  7. data/app/views/rails_nexus/backup/_form.html.erb +185 -0
  8. data/app/views/rails_nexus/backup/config_history.html.erb +67 -0
  9. data/app/views/rails_nexus/backup/edit.html.erb +15 -0
  10. data/app/views/rails_nexus/backup/history.html.erb +60 -0
  11. data/app/views/rails_nexus/backup/index.html.erb +91 -147
  12. data/app/views/rails_nexus/backup/new.html.erb +15 -0
  13. data/config/routes.rb +9 -6
  14. data/lib/generators/rails_nexus/install_generator.rb +1 -13
  15. data/lib/generators/rails_nexus/templates/migration.rb +119 -11
  16. data/lib/generators/rails_nexus/templates/rails_nexus.rb +1 -1
  17. data/lib/rails_nexus/version.rb +1 -1
  18. data/lib/tasks/rails_nexus.rake +71 -0
  19. metadata +8 -16
  20. data/app/models/rails_nexus/database_stat.rb +0 -128
  21. data/app/models/rails_nexus/event.rb +0 -101
  22. data/app/models/rails_nexus/metric.rb +0 -107
  23. data/app/models/rails_nexus/nginx_metric.rb +0 -112
  24. data/app/models/rails_nexus/server_metric.rb +0 -168
  25. data/app/views/rails_nexus/backup/files.html.erb +0 -53
  26. data/app/views/rails_nexus/backup/settings.html.erb +0 -158
  27. data/config/initializers/rails_nexus.rb +0 -18
  28. data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +0 -22
  29. data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +0 -30
  30. data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +0 -22
  31. data/lib/generators/rails_nexus/templates/migration_new_schema.rb +0 -120
  32. data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +0 -11
  33. data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +0 -21
  34. data/lib/generators/rails_nexus/templates/migration_workflow.rb +0 -29
@@ -1,275 +1,276 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
3
+ require "open3"
4
+ require "json"
4
5
 
5
6
  module RailsNexus
6
7
  class BackupService
7
- attr_reader :config_path, :models_path, :dump_path
8
-
9
- CONFIG_FILE = "rails_nexus_backup.yml"
10
-
11
- def initialize
12
- saved = self.class.load_config
13
- @config_path = saved[:config_path] || RailsNexus.configuration.backup_config_path || default_config_path
14
- @models_path = saved[:models_path] || RailsNexus.configuration.backup_models_path || default_models_path
15
- @dump_path = saved[:dump_path] || RailsNexus.configuration.backup_dump_path || default_dump_path
8
+ # Execute a backup for a given config
9
+ def self.run(config)
10
+ new(config).run
16
11
  end
17
12
 
18
- # Load saved config from YAML file
19
- def self.load_config
20
- path = config_file_path
21
- return default_config unless File.exist?(path)
13
+ def initialize(config)
14
+ @config = config
15
+ @record = nil
16
+ @errors = []
17
+ end
22
18
 
23
- data = YAML.safe_load_file(path, permitted_classes: [Symbol, Date, Time]) || {}
24
- default_config.merge(data.transform_keys(&:to_sym))
25
- rescue StandardError
26
- default_config
19
+ def run
20
+ @record = RailsNexus::Backup.start!(
21
+ model_name: @config.name,
22
+ triggered_by: "service"
23
+ )
24
+
25
+ begin
26
+ # Ensure storage directory exists
27
+ FileUtils.mkdir_p(@config.storage_path_expanded)
28
+
29
+ # Step 1: Dump database
30
+ dump_path = dump_database
31
+ return fail_record("Database dump failed") unless dump_path
32
+
33
+ # Step 2: Compress if configured
34
+ final_path = @config.compress? ? compress(dump_path) : dump_path
35
+
36
+ # Step 3: Encrypt if configured
37
+ final_path = encrypt(final_path) if @config.encrypt?
38
+
39
+ # Step 4: Sync to remote if configured
40
+ rsync_result = sync_remote(final_path) if @config.rsync_enabled?
41
+
42
+ # Step 5: Cleanup old backups
43
+ cleanup_old_backups
44
+
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
+ )
51
+
52
+ # Step 7: Notify
53
+ notify_success
54
+
55
+ { success: true, record: @record, file_path: final_path }
56
+ 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 }
60
+ ensure
61
+ # Clean up temp files
62
+ cleanup_temp_files
63
+ end
27
64
  end
28
65
 
29
- # Save config to YAML file
30
- def self.save_config(params)
31
- config = load_config
66
+ private
32
67
 
33
- # Merge new params
34
- params.each do |key, value|
35
- config[key.to_sym] = value
68
+ def dump_database
69
+ 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}"
36
78
  end
79
+ end
37
80
 
38
- # Ensure directory exists
39
- dir = File.dirname(config_file_path)
40
- FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
81
+ # ─── MySQL Dump ──────────────────────────────────────────────────
82
+ def dump_mysql
83
+ raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
84
+ cmd = build_mysql_command(raw_path)
41
85
 
42
- # Write YAML
43
- File.write(config_file_path, config.to_yaml)
86
+ stdout, stderr, status = Open3.capture3(cmd)
87
+ unless status.success?
88
+ @errors << "mysqldump failed: #{stderr}"
89
+ return nil
90
+ end
44
91
 
45
- { success: true }
46
- rescue StandardError => e
47
- { success: false, error: e.message }
92
+ raw_path
48
93
  end
49
94
 
50
- # Delete saved config
51
- def self.reset_config
52
- File.delete(config_file_path) if File.exist?(config_file_path)
53
- { success: true }
54
- rescue StandardError => e
55
- { success: false, error: e.message }
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?
104
+
105
+ # 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(" ")
56
111
  end
57
112
 
58
- # Check if backup gem is configured
59
- def configured?
60
- File.exist?(@config_path) && Dir.exist?(@models_path)
61
- end
113
+ # ─── PostgreSQL Dump ─────────────────────────────────────────────
114
+ def dump_postgresql
115
+ raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
116
+ cmd = build_postgresql_command(raw_path)
62
117
 
63
- # Get all backup models
64
- def models
65
- return [] unless configured?
118
+ env = {}
119
+ env["PGPASSWORD"] = @config.password if @config.password.present?
66
120
 
67
- Dir.glob(File.join(@models_path, "*.rb")).map do |path|
68
- parse_model_file(path)
69
- end.compact
70
- end
121
+ stdout, stderr, status = Open3.capture3(env, cmd)
122
+ unless status.success?
123
+ @errors << "pg_dump failed: #{stderr}"
124
+ return nil
125
+ end
71
126
 
72
- # Get backup files
73
- def backup_files
74
- return [] unless @dump_path && Dir.exist?(@dump_path)
75
-
76
- Dir.glob(File.join(@dump_path, "*")).select { |f| File.file?(f) }.map do |path|
77
- {
78
- name: File.basename(path),
79
- path: path,
80
- size: File.size(path),
81
- size_human: human_size(File.size(path)),
82
- created_at: File.mtime(path),
83
- age_hours: ((Time.current - File.mtime(path)) / 3600).round(1),
84
- encrypted: path.end_with?(".enc"),
85
- compressed: path.end_with?(".gz") || path.end_with?(".tar.gz")
86
- }
87
- end.sort_by { |f| -f[:created_at].to_i }
127
+ raw_path
88
128
  end
89
129
 
90
- # Get backup summary stats
91
- def summary
92
- files = backup_files
93
- {
94
- total_files: files.size,
95
- total_size: files.sum { |f| f[:size] },
96
- total_size_human: human_size(files.sum { |f| f[:size] }),
97
- last_backup: files.first&.dig(:created_at),
98
- last_backup_age_hours: files.first&.dig(:age_hours),
99
- oldest_backup: files.last&.dig(:created_at),
100
- models_count: models.size,
101
- healthy: last_backup_healthy?,
102
- alerts: generate_alerts
103
- }
104
- end
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"
105
137
 
106
- # Get cron schedule from whenever config
107
- def cron_schedule
108
- schedule_file = File.join(File.dirname(@config_path), "config", "schedule.rb")
109
- return nil unless File.exist?(schedule_file)
110
-
111
- content = File.read(schedule_file)
112
- {
113
- file: schedule_file,
114
- entries: parse_schedule(content),
115
- raw: content
116
- }
117
- end
138
+ # Skip tables
139
+ skip = @config.skip_tables_list
140
+ skip.each { |t| parts << "--exclude-table=#{t}" }
118
141
 
119
- # Run a backup model
120
- def trigger_backup(model_name)
121
- return { success: false, error: "Backup not configured" } unless configured?
122
- return { success: false, error: "Invalid model: #{model_name}" } unless valid_model?(model_name)
142
+ parts << @config.database_name
143
+ parts.join(" ")
144
+ end
123
145
 
124
- runner = find_runner_script
125
- return { success: false, error: "Runner script not found" } unless runner
146
+ # ─── SQLite Dump ─────────────────────────────────────────────────
147
+ def dump_sqlite
148
+ raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
149
+ db_path = @config.database_name
126
150
 
127
- log_file = File.join(File.dirname(@config_path), "log", "cron.log")
128
- error_log = File.join(File.dirname(@config_path), "log", "cron-error.log")
151
+ unless File.exist?(db_path)
152
+ @errors << "SQLite database not found: #{db_path}"
153
+ return nil
154
+ end
129
155
 
130
- command = "bash #{runner} #{model_name} >> #{log_file} 2>> #{error_log}"
131
- pid = Process.spawn(command)
132
- Process.detach(pid)
156
+ stdout, stderr, status = Open3.capture3("sqlite3 #{db_path} .dump")
157
+ unless status.success?
158
+ @errors << "sqlite3 dump failed: #{stderr}"
159
+ return nil
160
+ end
133
161
 
134
- { success: true, pid: pid, model: model_name }
162
+ File.write(raw_path, stdout)
163
+ raw_path
135
164
  end
136
165
 
137
- # Get backup health status
138
- def health_status
139
- return { status: "not_configured", message: "Backup not configured" } unless configured?
140
-
141
- summary_data = summary
142
- threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
143
-
144
- if summary_data[:last_backup].nil?
145
- { status: "warning", message: "No backups found", details: summary_data }
146
- elsif summary_data[:last_backup_age_hours] > threshold * 2
147
- { status: "critical", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago (>#{threshold * 2}h)", details: summary_data }
148
- elsif summary_data[:last_backup_age_hours] > threshold
149
- { status: "warning", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago (>#{threshold}h)", details: summary_data }
150
- else
151
- { status: "healthy", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago", details: summary_data }
166
+ # ─── Compression ─────────────────────────────────────────────────
167
+ def compress(file_path)
168
+ gz_path = "#{file_path}.gz"
169
+ File.open(file_path, "rb") do |input|
170
+ Zlib::GzipWriter.open(gz_path) do |gz|
171
+ gz.write(input.read)
172
+ end
152
173
  end
174
+ File.delete(file_path) if File.exist?(gz_path)
175
+ gz_path
153
176
  end
154
177
 
155
- # Get saved config
156
- def saved_config
157
- @saved_config ||= self.class.load_config
158
- end
178
+ # ─── Encryption ──────────────────────────────────────────────────
179
+ def encrypt(file_path)
180
+ enc_path = "#{file_path}.enc"
181
+ password = @config.encrypt_password
159
182
 
160
- private
183
+ cmd = "openssl aes-256-cbc -salt -pbkdf2 -in #{file_path} -out #{enc_path} -pass pass:#{password}"
184
+ stdout, stderr, status = Open3.capture3(cmd)
161
185
 
162
- def self.config_file_path
163
- Rails.root.join("config", CONFIG_FILE)
164
- end
186
+ unless status.success?
187
+ @errors << "Encryption failed: #{stderr}"
188
+ return file_path
189
+ end
165
190
 
166
- def self.default_config
167
- {
168
- enabled: true,
169
- config_path: nil,
170
- models_path: nil,
171
- dump_path: nil,
172
- notify_command: nil,
173
- alert_threshold_hours: 24,
174
- rsync_host: nil,
175
- rsync_port: 22,
176
- rsync_user: nil,
177
- rsync_path: nil,
178
- encrypt_password: nil,
179
- auto_cleanup_days: 30
180
- }
191
+ File.delete(file_path) if File.exist?(enc_path)
192
+ enc_path
181
193
  end
182
194
 
183
- def default_config_path
184
- Rails.root.join("config", "backup", "config.rb").to_s
185
- end
195
+ # ─── RSync Remote Sync ──────────────────────────────────────────
196
+ def sync_remote(file_path)
197
+ return unless @config.rsync_enabled?
198
+ return unless @config.rsync_host.present?
199
+
200
+ remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
201
+
202
+ cmd = [
203
+ "rsync",
204
+ "-avz",
205
+ "--progress",
206
+ "-e", "ssh -p #{@config.rsync_port}",
207
+ file_path,
208
+ remote
209
+ ].join(" ")
210
+
211
+ stdout, stderr, status = Open3.capture3(cmd)
212
+ unless status.success?
213
+ @errors << "RSync failed: #{stderr}"
214
+ end
186
215
 
187
- def default_models_path
188
- Rails.root.join("config", "backup", "models").to_s
216
+ { success: status.success?, output: stdout }
189
217
  end
190
218
 
191
- def default_dump_path
192
- Rails.root.join("storage", "rails_nexus", "backups").to_s
193
- end
219
+ # ─── Cleanup ─────────────────────────────────────────────────────
220
+ def cleanup_old_backups
221
+ storage = @config.storage_path_expanded
222
+ return unless Dir.exist?(storage)
194
223
 
195
- def parse_model_file(path)
196
- content = File.read(path)
197
- name_match = content.match(/Model\.new\(:(\w+)/)
198
- desc_match = content.match(/'([^']+)'/)
199
-
200
- return nil unless name_match
201
-
202
- {
203
- name: name_match[1],
204
- description: desc_match ? desc_match[1] : name_match[1],
205
- file: path,
206
- has_mysql: content.include?("database MySQL"),
207
- has_local: content.include?("store_with Local"),
208
- has_rsync: content.include?("sync_with RSync"),
209
- has_encryption: content.include?("encrypt_with OpenSSL"),
210
- has_compression: content.include?("compress_with Gzip"),
211
- skip_tables: content.scan(/skip_tables\s*=\s*\[([^\]]+)\]/).flatten.first,
212
- keep_count: content.scan(/keep\s*=\s*(\d+)/).flatten.first&.to_i
213
- }
214
- end
224
+ pattern = File.join(storage, "#{@config.name}_*")
225
+ files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
215
226
 
216
- def parse_schedule(content)
217
- entries = []
218
- content.scan(/every\s+([^,]+)(?:,\s*at:\s*"([^"]+)")?\s+do\s+command\s+"([^"]+)"/) do |match|
219
- entries << {
220
- frequency: match[0].strip,
221
- time: match[1],
222
- command: match[2]
223
- }
227
+ # Keep only keep_count most recent
228
+ while files.size > @config.keep_count
229
+ old_file = files.shift
230
+ File.delete(old_file) if File.exist?(old_file)
224
231
  end
225
- entries
226
232
  end
227
233
 
228
- def find_runner_script
229
- runner = File.join(File.dirname(@config_path), "bin", "run-backup")
230
- runner if File.exist?(runner)
231
- end
234
+ def cleanup_temp_files
235
+ # Clean up any intermediate files (uncompressed, unencrypted)
236
+ storage = @config.storage_path_expanded
237
+ return unless Dir.exist?(storage)
232
238
 
233
- def valid_model?(name)
234
- models.any? { |m| m[:name] == name }
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")
241
+ end
235
242
  end
236
243
 
237
- def last_backup_healthy?
238
- files = backup_files
239
- return false if files.empty?
244
+ # ─── Notifications ───────────────────────────────────────────────
245
+ def notify_success
246
+ return unless @config.notify_on_success?
247
+ execute_notify_command("success")
248
+ end
240
249
 
241
- threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
242
- files.first[:age_hours] <= threshold
250
+ def notify_failure(error_message)
251
+ return unless @config.notify_on_failure?
252
+ execute_notify_command("failure", error_message)
243
253
  end
244
254
 
245
- def generate_alerts
246
- alerts = []
247
- files = backup_files
248
- threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
255
+ def execute_notify_command(status, error = nil)
256
+ return if @config.notify_command.blank?
249
257
 
250
- if files.empty?
251
- alerts << { level: "warning", message: "No backup files found" }
252
- elsif files.first[:age_hours] > threshold
253
- alerts << { level: "critical", message: "No backup in #{files.first[:age_hours]}h (threshold: #{threshold}h)" }
254
- end
258
+ cmd = @config.notify_command
259
+ .gsub("{STATUS}", status.to_s)
260
+ .gsub("{MODEL}", @config.name)
261
+ .gsub("{ERROR}", error.to_s)
262
+ .gsub("{TIME}", Time.current.iso8601)
255
263
 
256
- # Check for old backups consuming space
257
- old_files = files.select { |f| f[:age_hours] > 720 } # 30 days
258
- if old_files.size > 10
259
- alerts << { level: "info", message: "#{old_files.size} backups older than 30 days (#{human_size(old_files.sum { |f| f[:size] })})" }
260
- end
261
-
262
- alerts
264
+ Open3.capture3(cmd)
265
+ rescue StandardError
266
+ # Don't fail backup because notification failed
263
267
  end
264
268
 
265
- def human_size(bytes)
266
- return "0 B" if bytes == 0
267
-
268
- units = %w[B KB MB GB TB]
269
- exp = (Math.log(bytes) / Math.log(1024)).to_i
270
- exp = units.size - 1 if exp >= units.size
271
-
272
- "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
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 }
273
274
  end
274
275
  end
275
276
  end
@@ -82,6 +82,14 @@
82
82
  <%# ─── Tools section ──────────────────────────────────────── %>
83
83
  <div class="rn-sidebar-section-divider"></div>
84
84
 
85
+ <% if main_app.respond_to?(:root_path) %>
86
+ <%= link_to main_app.root_path,
87
+ class: "rn-nav-item rn-nav-item--muted" do %>
88
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
89
+ Admin Panel
90
+ <% end %>
91
+ <% end %>
92
+
85
93
  <%= link_to feed_logged_exceptions_path(format: :rss),
86
94
  class: "rn-nav-item rn-nav-item--muted" do %>
87
95
  <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 00-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /></svg>