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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +68 -13
- data/SECURITY.md +11 -0
- data/THIRD_PARTY_NOTICES.md +14 -0
- data/app/assets/config/rails_nexus_manifest.js +1 -1
- data/app/assets/javascripts/rails_nexus/application.js +55 -0
- data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
- data/app/controllers/rails_nexus/application_controller.rb +6 -2
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
- data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
- data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
- data/app/controllers/rails_nexus/settings_controller.rb +1 -39
- data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
- data/app/controllers/rails_nexus/stats_controller.rb +33 -159
- data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
- data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
- data/app/javascript/rails_nexus/application.js +15 -3
- data/app/models/rails_nexus/logged_exception.rb +27 -13
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +0 -11
- data/app/views/layouts/rails_nexus/application.html.erb +2 -13
- data/app/views/rails_nexus/stats/index.html.erb +1 -102
- data/config/routes.rb +0 -11
- data/lib/generators/rails_nexus/install_generator.rb +1 -1
- data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
- data/lib/generators/rails_nexus/templates/migration.rb +0 -82
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
- data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
- data/lib/rails_nexus/configuration.rb +10 -45
- data/lib/rails_nexus/database_adapter.rb +53 -0
- data/lib/rails_nexus/engine.rb +6 -17
- data/lib/rails_nexus/logger.rb +1 -12
- data/lib/rails_nexus/notifications.rb +27 -20
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/rails_nexus/webhook_client.rb +151 -0
- data/lib/rails_nexus.rb +37 -23
- data/lib/tasks/rails_nexus.rake +0 -80
- data/rails_nexus.gemspec +4 -5
- metadata +12 -41
- data/app/controllers/rails_nexus/backup_controller.rb +0 -120
- data/app/jobs/rails_nexus/backup_job.rb +0 -18
- data/app/models/rails_nexus/backup.rb +0 -107
- data/app/models/rails_nexus/backup_config.rb +0 -158
- data/app/services/rails_nexus/backup_runner.rb +0 -62
- data/app/services/rails_nexus/backup_service.rb +0 -682
- data/app/views/rails_nexus/backup/_form.html.erb +0 -334
- data/app/views/rails_nexus/backup/config_history.html.erb +0 -67
- data/app/views/rails_nexus/backup/edit.html.erb +0 -15
- data/app/views/rails_nexus/backup/history.html.erb +0 -75
- data/app/views/rails_nexus/backup/index.html.erb +0 -112
- data/app/views/rails_nexus/backup/new.html.erb +0 -15
- data/config/importmap.rb +0 -15
- data/db/seeds/backup_configs.rb +0 -140
- data/lib/generators/rails_nexus/backup_generator.rb +0 -70
- data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +0 -152
- data/lib/generators/rails_nexus/templates/backup/config.rb +0 -27
- data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +0 -24
- data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +0 -35
- data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +0 -43
- data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +0 -15
- 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
|