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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +82 -0
- data/Rakefile +10 -0
- data/app/controllers/backup_nexus/application_controller.rb +31 -0
- data/app/controllers/backup_nexus/backup_controller.rb +119 -0
- data/app/helpers/backup_nexus/application_helper.rb +51 -0
- data/app/jobs/backup_nexus/backup_job.rb +18 -0
- data/app/models/backup_nexus/backup.rb +107 -0
- data/app/models/backup_nexus/backup_config.rb +189 -0
- data/app/services/backup_nexus/backup_runner.rb +62 -0
- data/app/services/backup_nexus/backup_service.rb +817 -0
- data/app/views/backup_nexus/backup/_form.html.erb +334 -0
- data/app/views/backup_nexus/backup/config_history.html.erb +67 -0
- data/app/views/backup_nexus/backup/edit.html.erb +15 -0
- data/app/views/backup_nexus/backup/history.html.erb +75 -0
- data/app/views/backup_nexus/backup/index.html.erb +112 -0
- data/app/views/backup_nexus/backup/new.html.erb +15 -0
- data/backup_nexus.gemspec +30 -0
- data/config/routes.rb +15 -0
- data/db/seeds/backup_configs_safe.rb +61 -0
- data/lib/backup_nexus/base_record.rb +7 -0
- data/lib/backup_nexus/configuration.rb +19 -0
- data/lib/backup_nexus/engine.rb +51 -0
- data/lib/backup_nexus/version.rb +3 -0
- data/lib/backup_nexus.rb +26 -0
- data/lib/generators/backup_nexus/backup_generator.rb +70 -0
- data/lib/generators/backup_nexus/install_generator.rb +27 -0
- data/lib/generators/backup_nexus/templates/backup/backup/backup_helper.sh +152 -0
- data/lib/generators/backup_nexus/templates/backup/backup/config.rb +27 -0
- data/lib/generators/backup_nexus/templates/backup/backup/models/daily_backup.rb +24 -0
- data/lib/generators/backup_nexus/templates/backup/backup/models/full_backup.rb +35 -0
- data/lib/generators/backup_nexus/templates/backup/backup/models/sync_backup.rb +43 -0
- data/lib/generators/backup_nexus/templates/backup/backup/mysql-config/db_config.cnf +15 -0
- data/lib/generators/backup_nexus/templates/backup/backup/schedule.rb +28 -0
- data/lib/generators/backup_nexus/templates/migration.rb +105 -0
- data/lib/tasks/backup_nexus.rake +81 -0
- metadata +138 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
|
|
9
|
+
module BackupNexus
|
|
10
|
+
class BackupService
|
|
11
|
+
# Execute a backup for a given config
|
|
12
|
+
def self.run(config)
|
|
13
|
+
new(config).run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
@record = nil
|
|
19
|
+
@errors = []
|
|
20
|
+
@temp_files = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
@record = BackupNexus::Backup.start!(
|
|
25
|
+
config_name: @config.name,
|
|
26
|
+
triggered_by: "service"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
@stage = "preparing storage"
|
|
31
|
+
validate_identifier!(@config.name, "backup name")
|
|
32
|
+
FileUtils.mkdir_p(validated_path(@config.storage_path_expanded, "storage path"))
|
|
33
|
+
|
|
34
|
+
# Step 1: Dump database
|
|
35
|
+
@stage = "database dump"
|
|
36
|
+
dump_path = dump_database
|
|
37
|
+
raise "Database dump failed" unless dump_path
|
|
38
|
+
|
|
39
|
+
# Step 2: Create archive if enabled (tar additional files/dirs)
|
|
40
|
+
@stage = "archive creation"
|
|
41
|
+
if @config.archive_enabled? && @config.archive_paths_list.any?
|
|
42
|
+
dump_path = create_archive(dump_path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Step 3: Split into chunks if enabled
|
|
46
|
+
@stage = "file splitting"
|
|
47
|
+
if @config.split_chunks? && File.size(dump_path) > 50_000_000 # 50MB
|
|
48
|
+
dump_path = split_file(dump_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Step 4: Compress
|
|
52
|
+
@stage = "compression"
|
|
53
|
+
if @config.compress? && !@config.split_chunks?
|
|
54
|
+
dump_path = compress(dump_path)
|
|
55
|
+
elsif @config.bzip2_compress? && !@config.split_chunks?
|
|
56
|
+
dump_path = compress_bzip2(dump_path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Step 5: Encrypt
|
|
60
|
+
@stage = "encryption"
|
|
61
|
+
if @config.encrypted?
|
|
62
|
+
dump_path = encrypt_openssl(dump_path)
|
|
63
|
+
elsif @config.gpg_enabled?
|
|
64
|
+
dump_path = encrypt_gpg(dump_path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Step 6: Sync to S3
|
|
68
|
+
@stage = "S3 sync"
|
|
69
|
+
sync_s3(dump_path) if @config.s3_enabled?
|
|
70
|
+
|
|
71
|
+
# Step 7: Sync to remote
|
|
72
|
+
@stage = "remote sync"
|
|
73
|
+
sync_remote(dump_path) if @config.rsync_enabled?
|
|
74
|
+
|
|
75
|
+
# Step 8: Cleanup old backups
|
|
76
|
+
@stage = "retention cleanup"
|
|
77
|
+
cleanup_old_backups
|
|
78
|
+
|
|
79
|
+
# Step 9: Record success
|
|
80
|
+
@stage = "recording success"
|
|
81
|
+
file_size = calculate_size(dump_path)
|
|
82
|
+
@record.succeed!(file_path: dump_path, file_size: file_size)
|
|
83
|
+
|
|
84
|
+
# Step 10: Notify
|
|
85
|
+
@stage = "success notification"
|
|
86
|
+
notify_success
|
|
87
|
+
|
|
88
|
+
{ success: true, record: @record, file_path: dump_path }
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
full_error = failure_message(e)
|
|
91
|
+
@record&.fail!(error_message: full_error)
|
|
92
|
+
notify_failure(full_error)
|
|
93
|
+
{ success: false, error: full_error, record: @record }
|
|
94
|
+
ensure
|
|
95
|
+
cleanup_temp_files
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# ════════════════════════════════════════════════════════════════
|
|
102
|
+
# DATABASE DUMP
|
|
103
|
+
# ════════════════════════════════════════════════════════════════
|
|
104
|
+
|
|
105
|
+
def dump_database
|
|
106
|
+
case @config.adapter
|
|
107
|
+
when "mysql" then dump_mysql
|
|
108
|
+
when "postgresql" then dump_postgresql
|
|
109
|
+
when "sqlite" then dump_sqlite
|
|
110
|
+
when "mongodb" then dump_mongodb
|
|
111
|
+
when "redis" then dump_redis
|
|
112
|
+
else raise "Unsupported adapter: #{@config.adapter}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ─── MySQL ─────────────────────────────────────────────────────
|
|
117
|
+
def dump_mysql
|
|
118
|
+
validate_identifier!(@config.database_name, "database name")
|
|
119
|
+
validate_host!(@config.host) if @config.host.present?
|
|
120
|
+
validate_port!(@config.port) if @config.port.present?
|
|
121
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
122
|
+
password = resolve_password(@config.password)
|
|
123
|
+
cmd = ["mysqldump"]
|
|
124
|
+
cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
|
|
125
|
+
cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
|
|
126
|
+
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
127
|
+
cmd << "--result-file=#{raw_path}"
|
|
128
|
+
cmd << "--single-transaction"
|
|
129
|
+
cmd << "--quick"
|
|
130
|
+
cmd << "--routines"
|
|
131
|
+
cmd << "--triggers"
|
|
132
|
+
cmd << "--events"
|
|
133
|
+
# Additional MySQL options (e.g. --defaults-extra-file)
|
|
134
|
+
additional_options = if @config.respond_to?(:mysql_additional_options)
|
|
135
|
+
@config.mysql_additional_options
|
|
136
|
+
end
|
|
137
|
+
if additional_options.present?
|
|
138
|
+
cmd.concat(validated_mysql_options(additional_options))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Skip tables
|
|
142
|
+
@config.skip_tables_list.each do |table|
|
|
143
|
+
validate_identifier!(table, "table name")
|
|
144
|
+
cmd << "--ignore-table=#{@config.database_name}.#{table}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
cmd << @config.database_name
|
|
148
|
+
|
|
149
|
+
env = {}
|
|
150
|
+
env["MYSQL_PWD"] = password if password.present?
|
|
151
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
152
|
+
unless status.success?
|
|
153
|
+
raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
154
|
+
end
|
|
155
|
+
register_temp_file(raw_path)
|
|
156
|
+
raw_path
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ─── PostgreSQL ────────────────────────────────────────────────
|
|
160
|
+
def dump_postgresql
|
|
161
|
+
validate_identifier!(@config.database_name, "database name")
|
|
162
|
+
validate_host!(@config.host) if @config.host.present?
|
|
163
|
+
validate_port!(@config.port) if @config.port.present?
|
|
164
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
165
|
+
password = resolve_password(@config.password)
|
|
166
|
+
cmd = ["pg_dump"]
|
|
167
|
+
cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
|
|
168
|
+
cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
|
|
169
|
+
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
170
|
+
cmd << "--file=#{raw_path}"
|
|
171
|
+
cmd << "--format=plain"
|
|
172
|
+
cmd << "--no-owner"
|
|
173
|
+
cmd << "--no-privileges"
|
|
174
|
+
|
|
175
|
+
# Skip tables
|
|
176
|
+
@config.skip_tables_list.each do |table|
|
|
177
|
+
validate_identifier!(table, "table name")
|
|
178
|
+
cmd << "--exclude-table=#{table}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
cmd << @config.database_name
|
|
182
|
+
|
|
183
|
+
env = {}
|
|
184
|
+
env["PGPASSWORD"] = password if password.present?
|
|
185
|
+
|
|
186
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
187
|
+
unless status.success?
|
|
188
|
+
raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
189
|
+
end
|
|
190
|
+
register_temp_file(raw_path)
|
|
191
|
+
raw_path
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def capture_command(*command, **options)
|
|
195
|
+
Open3.capture3(*command, **options)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ─── SQLite ────────────────────────────────────────────────────
|
|
199
|
+
def dump_sqlite
|
|
200
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
201
|
+
db_path = validated_path(@config.database_name, "SQLite database path")
|
|
202
|
+
|
|
203
|
+
unless File.exist?(db_path)
|
|
204
|
+
raise "SQLite database not found: #{db_path}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
stdout, stderr, status = capture_command("sqlite3", db_path, ".dump")
|
|
208
|
+
unless status.success?
|
|
209
|
+
raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
File.write(raw_path, stdout)
|
|
213
|
+
register_temp_file(raw_path)
|
|
214
|
+
raw_path
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# ─── MongoDB ───────────────────────────────────────────────────
|
|
218
|
+
def dump_mongodb
|
|
219
|
+
validate_identifier!(@config.database_name, "database name")
|
|
220
|
+
validate_host!(@config.host.presence || "localhost")
|
|
221
|
+
validate_port!(@config.port || 27_017)
|
|
222
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
223
|
+
dir_path = raw_path.gsub(/\.sql$/, "")
|
|
224
|
+
FileUtils.mkdir_p(dir_path)
|
|
225
|
+
|
|
226
|
+
password = resolve_password(@config.password)
|
|
227
|
+
cmd = ["mongodump"]
|
|
228
|
+
cmd << "--host=#{resolve_env(@config.host) || 'localhost'}"
|
|
229
|
+
cmd << "--port=#{@config.port || 27017}"
|
|
230
|
+
cmd << "--db=#{@config.database_name}"
|
|
231
|
+
cmd << "--out=#{dir_path}"
|
|
232
|
+
cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
|
|
233
|
+
cmd << "--authenticationDatabase=admin" if @config.username.present?
|
|
234
|
+
|
|
235
|
+
# Skip collections
|
|
236
|
+
@config.skip_tables_list.each do |collection|
|
|
237
|
+
validate_identifier!(collection, "collection name")
|
|
238
|
+
cmd << "--excludeCollection=#{collection}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
stdout, stderr, status = if password.present?
|
|
242
|
+
with_secret_file("backup_nexus_mongo", "password: #{password.to_json}\n") do |config_path|
|
|
243
|
+
capture_command(*cmd, "--config=#{config_path}")
|
|
244
|
+
end
|
|
245
|
+
else
|
|
246
|
+
capture_command(*cmd)
|
|
247
|
+
end
|
|
248
|
+
unless status.success?
|
|
249
|
+
FileUtils.rm_rf(dir_path)
|
|
250
|
+
raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Tar the dump directory
|
|
254
|
+
tar_path = "#{dir_path}.tar"
|
|
255
|
+
stdout, stderr, status = capture_command(
|
|
256
|
+
"tar", "-cf", tar_path, "-C", File.dirname(dir_path), File.basename(dir_path)
|
|
257
|
+
)
|
|
258
|
+
unless status.success?
|
|
259
|
+
raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
FileUtils.rm_rf(dir_path)
|
|
263
|
+
register_temp_file(tar_path)
|
|
264
|
+
tar_path
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# ─── Redis ─────────────────────────────────────────────────────
|
|
268
|
+
def dump_redis
|
|
269
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
270
|
+
raw_path = raw_path.gsub(/\.sql$/, ".rdb")
|
|
271
|
+
|
|
272
|
+
host = @config.host.presence || "localhost"
|
|
273
|
+
port = @config.port || 6379
|
|
274
|
+
password = resolve_password(@config.password)
|
|
275
|
+
validate_host!(host)
|
|
276
|
+
validate_port!(port)
|
|
277
|
+
|
|
278
|
+
# Trigger BGSAVE first
|
|
279
|
+
_, stderr, status = capture_redis(host, port, password, "BGSAVE")
|
|
280
|
+
unless status.success?
|
|
281
|
+
@errors << "redis BGSAVE failed: #{stderr}"
|
|
282
|
+
return nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Wait a moment for BGSAVE to complete
|
|
286
|
+
sleep 2
|
|
287
|
+
|
|
288
|
+
# Try to find the dump.rdb on the server or use redis-cli to copy
|
|
289
|
+
# If local Redis, copy the dump.rdb directly
|
|
290
|
+
dir_out, = capture_redis(host, port, password, "CONFIG", "GET", "dir")
|
|
291
|
+
redis_dir = dir_out.split("\n").last || "/var/lib/redis"
|
|
292
|
+
|
|
293
|
+
redis_rdb = File.join(redis_dir, "dump.rdb")
|
|
294
|
+
if File.exist?(redis_rdb)
|
|
295
|
+
FileUtils.cp(redis_rdb, raw_path)
|
|
296
|
+
else
|
|
297
|
+
# Fallback: use redis-cli to dump keys
|
|
298
|
+
dump_all_keys(host, port, password, raw_path)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
register_temp_file(raw_path)
|
|
302
|
+
raw_path
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def dump_all_keys(host, port, password, output_path)
|
|
306
|
+
keys_out, = capture_redis(host, port, password, "KEYS", "*")
|
|
307
|
+
keys = keys_out.split("\n").reject { |k| k.start_with?("redis") }
|
|
308
|
+
|
|
309
|
+
File.open(output_path, "w") do |f|
|
|
310
|
+
keys.each do |key|
|
|
311
|
+
type_out, = capture_redis(host, port, password, "TYPE", key)
|
|
312
|
+
type = type_out.strip.split("\n").last
|
|
313
|
+
|
|
314
|
+
val_out, = case type
|
|
315
|
+
when "string" then capture_redis(host, port, password, "GET", key)
|
|
316
|
+
when "list" then capture_redis(host, port, password, "LRANGE", key, "0", "-1")
|
|
317
|
+
when "set" then capture_redis(host, port, password, "SMEMBERS", key)
|
|
318
|
+
when "hash" then capture_redis(host, port, password, "HGETALL", key)
|
|
319
|
+
else capture_redis(host, port, password, "DUMP", key)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
f.puts("SET #{key.inspect} #{val_out.strip.inspect}")
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# ════════════════════════════════════════════════════════════════
|
|
328
|
+
# ARCHIVES
|
|
329
|
+
# ════════════════════════════════════════════════════════════════
|
|
330
|
+
|
|
331
|
+
def create_archive(dump_path)
|
|
332
|
+
archive_path = dump_path.gsub(/\.(sql|tar)$/, ".tar")
|
|
333
|
+
|
|
334
|
+
# If the dump is already a tar (MongoDB), merge into it
|
|
335
|
+
if dump_path.end_with?(".tar")
|
|
336
|
+
archive_path = dump_path
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
cmd = ["tar"]
|
|
340
|
+
cmd << (dump_path.end_with?(".tar") ? "-rf" : "-cf")
|
|
341
|
+
cmd << archive_path
|
|
342
|
+
|
|
343
|
+
unless dump_path.end_with?(".tar")
|
|
344
|
+
cmd << "-C"
|
|
345
|
+
cmd << File.dirname(dump_path)
|
|
346
|
+
cmd << File.basename(dump_path)
|
|
347
|
+
# Remove the original SQL file after tar
|
|
348
|
+
@temp_files << dump_path
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
@config.archive_paths_list.each do |path|
|
|
352
|
+
cmd << validated_path(path, "archive path")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Exclude patterns
|
|
356
|
+
if @config.archive_excludes_list.any?
|
|
357
|
+
@config.archive_excludes_list.each do |pattern|
|
|
358
|
+
validate_text_argument!(pattern, "archive exclusion")
|
|
359
|
+
cmd << "--exclude=#{pattern}"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
_, stderr, status = capture_command(*cmd)
|
|
364
|
+
unless status.success?
|
|
365
|
+
@errors << "archive tar failed: #{stderr}"
|
|
366
|
+
return dump_path
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
archive_path
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# ════════════════════════════════════════════════════════════════
|
|
373
|
+
# COMPRESSION
|
|
374
|
+
# ════════════════════════════════════════════════════════════════
|
|
375
|
+
|
|
376
|
+
def compress(file_path)
|
|
377
|
+
gz_path = "#{file_path}.gz"
|
|
378
|
+
File.open(file_path, "rb") do |input|
|
|
379
|
+
Zlib::GzipWriter.open(gz_path) do |gz|
|
|
380
|
+
gz.write(input.read)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
@temp_files << file_path if File.exist?(gz_path)
|
|
384
|
+
gz_path
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def compress_bzip2(file_path)
|
|
388
|
+
bz2_path = "#{file_path}.bz2"
|
|
389
|
+
_, stderr, status = capture_command("bzip2", "-zk", file_path)
|
|
390
|
+
unless status.success?
|
|
391
|
+
@errors << "bzip2 failed: #{stderr}"
|
|
392
|
+
return file_path
|
|
393
|
+
end
|
|
394
|
+
@temp_files << file_path if File.exist?(bz2_path)
|
|
395
|
+
bz2_path
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# ════════════════════════════════════════════════════════════════
|
|
399
|
+
# ENCRYPTION
|
|
400
|
+
# ════════════════════════════════════════════════════════════════
|
|
401
|
+
|
|
402
|
+
def encrypt_openssl(file_path)
|
|
403
|
+
enc_path = "#{file_path}.enc"
|
|
404
|
+
password = resolve_secret(@config.encryption_password)
|
|
405
|
+
|
|
406
|
+
# Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
|
|
407
|
+
# (AES-GCM not available in all OpenSSL builds)
|
|
408
|
+
env = { "BACKUP_NEXUS_OPENSSL_PASSWORD" => password.to_s }
|
|
409
|
+
cmd = [
|
|
410
|
+
"openssl", "enc", "-aes-256-cbc", "-pbkdf2", "-iter", "600000",
|
|
411
|
+
"-salt", "-in", file_path, "-out", enc_path,
|
|
412
|
+
"-pass", "env:BACKUP_NEXUS_OPENSSL_PASSWORD"
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
_, stderr, status = capture_command(env, *cmd)
|
|
416
|
+
unless status.success?
|
|
417
|
+
@errors << "OpenSSL encryption failed: #{stderr}"
|
|
418
|
+
return file_path
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
@temp_files << file_path if File.exist?(enc_path)
|
|
422
|
+
enc_path
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def encrypt_gpg(file_path)
|
|
426
|
+
enc_path = "#{file_path}.gpg"
|
|
427
|
+
password = resolve_secret(@config.gpg_password)
|
|
428
|
+
|
|
429
|
+
# Best practice: AES256 with an iteration-counted S2K. The passphrase is
|
|
430
|
+
# provided through a mode-0600 temporary file, never process arguments.
|
|
431
|
+
_, stderr, status = with_secret_file("backup_nexus_gpg", password.to_s) do |password_path|
|
|
432
|
+
capture_command(
|
|
433
|
+
"gpg", "--batch", "--yes", "--symmetric",
|
|
434
|
+
"--cipher-algo", "AES256", "--s2k-mode", "3",
|
|
435
|
+
"--s2k-count", "65011712", "--passphrase-file", password_path,
|
|
436
|
+
"-o", enc_path, file_path
|
|
437
|
+
)
|
|
438
|
+
end
|
|
439
|
+
unless status.success?
|
|
440
|
+
@errors << "GPG encryption failed: #{stderr}"
|
|
441
|
+
return file_path
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
@temp_files << file_path if File.exist?(enc_path)
|
|
445
|
+
enc_path
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# ════════════════════════════════════════════════════════════════
|
|
449
|
+
# SPLIT INTO CHUNKS
|
|
450
|
+
# ════════════════════════════════════════════════════════════════
|
|
451
|
+
|
|
452
|
+
def split_file(file_path)
|
|
453
|
+
chunk_dir = "#{file_path}.parts"
|
|
454
|
+
FileUtils.mkdir_p(chunk_dir)
|
|
455
|
+
|
|
456
|
+
# Split 50MB chunks
|
|
457
|
+
_, stderr, status = capture_command("split", "-b", "50m", file_path, File.join(chunk_dir, "part_"))
|
|
458
|
+
|
|
459
|
+
unless status.success?
|
|
460
|
+
@errors << "split failed: #{stderr}"
|
|
461
|
+
return file_path
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
@temp_files << file_path
|
|
465
|
+
chunk_dir
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# ════════════════════════════════════════════════════════════════
|
|
469
|
+
# REMOTE SYNC
|
|
470
|
+
# ════════════════════════════════════════════════════════════════
|
|
471
|
+
|
|
472
|
+
def sync_remote(file_path)
|
|
473
|
+
return unless @config.rsync_enabled?
|
|
474
|
+
return unless @config.rsync_host.present?
|
|
475
|
+
|
|
476
|
+
validate_host!(@config.rsync_host)
|
|
477
|
+
validate_port!(@config.rsync_port.presence || 22)
|
|
478
|
+
validate_remote_component!(@config.rsync_user, "rsync user")
|
|
479
|
+
validate_remote_path!(@config.rsync_path)
|
|
480
|
+
remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
|
|
481
|
+
|
|
482
|
+
# Build rsync command
|
|
483
|
+
cmd = ["rsync"]
|
|
484
|
+
cmd << "-a"
|
|
485
|
+
cmd << "-z" # compress
|
|
486
|
+
cmd << "--progress"
|
|
487
|
+
cmd << "--archive" if @config.respond_to?(:rsync_archive?) && @config.rsync_archive?
|
|
488
|
+
cmd << "--delete" if @config.respond_to?(:rsync_mirror?) && @config.rsync_mirror?
|
|
489
|
+
cmd << "-e"
|
|
490
|
+
cmd << "ssh -p #{@config.rsync_port.presence || 22}"
|
|
491
|
+
|
|
492
|
+
# Add the dump file
|
|
493
|
+
cmd << file_path
|
|
494
|
+
|
|
495
|
+
# Add additional directories if configured
|
|
496
|
+
if @config.respond_to?(:rsync_directories) && @config.rsync_directories.present?
|
|
497
|
+
@config.rsync_directories.split(",").map(&:strip).reject(&:blank?).each do |dir|
|
|
498
|
+
expanded = dir.gsub("~", Dir.home)
|
|
499
|
+
cmd << expanded
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Add excludes
|
|
504
|
+
if @config.respond_to?(:rsync_excludes) && @config.rsync_excludes.present?
|
|
505
|
+
@config.rsync_excludes.split(",").map(&:strip).reject(&:blank?).each do |excl|
|
|
506
|
+
validate_text_argument!(excl, "rsync exclusion")
|
|
507
|
+
cmd << "--exclude=#{excl}"
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
cmd << remote
|
|
512
|
+
|
|
513
|
+
stdout, stderr, status = capture_command(*cmd)
|
|
514
|
+
unless status.success?
|
|
515
|
+
@errors << "RSync failed: #{stderr}"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
{ success: status.success?, output: stdout }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# ─── S3 Upload ─────────────────────────────────────────────────
|
|
522
|
+
def sync_s3(file_path)
|
|
523
|
+
return unless @config.s3_enabled?
|
|
524
|
+
|
|
525
|
+
# Use aws cli if available, otherwise use s3cmd
|
|
526
|
+
if command_exists?("aws")
|
|
527
|
+
sync_s3_aws(file_path)
|
|
528
|
+
elsif command_exists?("s3cmd")
|
|
529
|
+
sync_s3_s3cmd(file_path)
|
|
530
|
+
else
|
|
531
|
+
@errors << "Neither aws-cli nor s3cmd found. Cannot upload to S3."
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def sync_s3_aws(file_path)
|
|
536
|
+
validate_s3_bucket!(@config.s3_bucket)
|
|
537
|
+
bucket = @config.s3_bucket
|
|
538
|
+
prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
|
|
539
|
+
|
|
540
|
+
env = {
|
|
541
|
+
"AWS_ACCESS_KEY_ID" => @config.s3_access_key.to_s,
|
|
542
|
+
"AWS_SECRET_ACCESS_KEY" => @config.s3_secret_key.to_s
|
|
543
|
+
}
|
|
544
|
+
cmd = [
|
|
545
|
+
"aws", "s3", "cp", file_path, "s3://#{bucket}/#{prefix}",
|
|
546
|
+
"--region", @config.s3_region.presence || "us-east-1"
|
|
547
|
+
]
|
|
548
|
+
|
|
549
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
550
|
+
unless status.success?
|
|
551
|
+
@errors << "S3 upload failed: #{stderr}"
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
{ success: status.success?, output: stdout }
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def sync_s3_s3cmd(file_path)
|
|
558
|
+
validate_s3_bucket!(@config.s3_bucket)
|
|
559
|
+
bucket = @config.s3_bucket
|
|
560
|
+
prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
|
|
561
|
+
|
|
562
|
+
env = {
|
|
563
|
+
"AWS_ACCESS_KEY" => @config.s3_access_key.to_s,
|
|
564
|
+
"AWS_SECRET_KEY" => @config.s3_secret_key.to_s
|
|
565
|
+
}
|
|
566
|
+
cmd = [
|
|
567
|
+
"s3cmd", "put", file_path, "s3://#{bucket}/#{prefix}",
|
|
568
|
+
"--region=#{@config.s3_region.presence || 'us-east-1'}"
|
|
569
|
+
]
|
|
570
|
+
|
|
571
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
572
|
+
unless status.success?
|
|
573
|
+
@errors << "S3 upload (s3cmd) failed: #{stderr}"
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
{ success: status.success?, output: stdout }
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# ════════════════════════════════════════════════════════════════
|
|
580
|
+
# CLEANUP
|
|
581
|
+
# ════════════════════════════════════════════════════════════════
|
|
582
|
+
|
|
583
|
+
def cleanup_old_backups
|
|
584
|
+
storage = @config.storage_path_expanded
|
|
585
|
+
return unless Dir.exist?(storage)
|
|
586
|
+
|
|
587
|
+
pattern = File.join(storage, "#{@config.name}_*")
|
|
588
|
+
files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
|
|
589
|
+
|
|
590
|
+
while files.size > @config.keep_count
|
|
591
|
+
old_file = files.shift
|
|
592
|
+
FileUtils.rm_rf(old_file)
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def cleanup_temp_files
|
|
597
|
+
@temp_files.each { |file| FileUtils.rm_rf(file) }
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# ════════════════════════════════════════════════════════════════
|
|
601
|
+
# NOTIFICATIONS
|
|
602
|
+
# ════════════════════════════════════════════════════════════════
|
|
603
|
+
|
|
604
|
+
def notify_success
|
|
605
|
+
return unless @config.notify_on_success?
|
|
606
|
+
execute_notify_command("success")
|
|
607
|
+
send_email_notification("success")
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def notify_failure(error_message)
|
|
611
|
+
return unless @config.notify_on_failure?
|
|
612
|
+
execute_notify_command("failure", error_message)
|
|
613
|
+
send_email_notification("failure", error_message)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def execute_notify_command(status, error = nil)
|
|
617
|
+
return if @config.notify_command.blank?
|
|
618
|
+
|
|
619
|
+
command = Shellwords.split(@config.notify_command.to_s)
|
|
620
|
+
raise ArgumentError, "notify command is empty" if command.empty?
|
|
621
|
+
if %w[sh bash dash zsh ksh fish env].include?(File.basename(command.first))
|
|
622
|
+
raise ArgumentError, "shell-based notify commands are not supported"
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
replacements = {
|
|
626
|
+
"{STATUS}" => status.to_s,
|
|
627
|
+
"{MODEL}" => @config.name.to_s,
|
|
628
|
+
"{ERROR}" => error.to_s,
|
|
629
|
+
"{TIME}" => Time.current.iso8601
|
|
630
|
+
}
|
|
631
|
+
command.map! do |argument|
|
|
632
|
+
replacements.reduce(argument) { |value, (placeholder, replacement)| value.gsub(placeholder, replacement) }
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
capture_command(*command)
|
|
636
|
+
rescue StandardError
|
|
637
|
+
# Don't fail backup because notification failed
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def send_email_notification(status, error = nil)
|
|
641
|
+
return unless @config.email_notify?
|
|
642
|
+
return if @config.email_to.blank?
|
|
643
|
+
|
|
644
|
+
subject = "[BackupNexus Backup] #{status.upcase}: #{@config.name}"
|
|
645
|
+
body = "Backup #{@config.name} #{status}.\n\n"
|
|
646
|
+
body += "Error: #{error}\n\n" if error.present?
|
|
647
|
+
body += "Time: #{Time.current.iso8601}\n"
|
|
648
|
+
body += "Record ID: #{@record&.id}\n"
|
|
649
|
+
|
|
650
|
+
# Build mail command based on available MTA
|
|
651
|
+
if command_exists?("mail")
|
|
652
|
+
capture_command("mail", "-s", subject, @config.email_to.to_s, stdin_data: body)
|
|
653
|
+
elsif command_exists?("sendmail")
|
|
654
|
+
mail_content = "To: #{@config.email_to}\nSubject: #{subject}\n\n#{body}"
|
|
655
|
+
capture_command("sendmail", "-t", stdin_data: mail_content)
|
|
656
|
+
else
|
|
657
|
+
@errors << "No mail command found (mail/sendmail)"
|
|
658
|
+
end
|
|
659
|
+
rescue StandardError
|
|
660
|
+
# Don't fail backup because email notification failed
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# ════════════════════════════════════════════════════════════════
|
|
664
|
+
# HELPERS
|
|
665
|
+
# ════════════════════════════════════════════════════════════════
|
|
666
|
+
|
|
667
|
+
def calculate_size(path)
|
|
668
|
+
if File.directory?(path)
|
|
669
|
+
Dir.glob(File.join(path, "**", "*")).sum { |f| File.exist?(f) ? File.size(f) : 0 }
|
|
670
|
+
else
|
|
671
|
+
File.exist?(path) ? File.size(path) : 0
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def register_temp_file(path)
|
|
676
|
+
@temp_files << path unless @temp_files.include?(path)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# ─── Password Resolution ──────────────────────────────────────
|
|
680
|
+
# Supports:
|
|
681
|
+
# "plain_text" → used as-is
|
|
682
|
+
# "ENV[MY_VAR]" → resolved from ENV
|
|
683
|
+
# "${MY_VAR}" → resolved from ENV
|
|
684
|
+
# "credentials[:key]" → from Rails encrypted credentials
|
|
685
|
+
# "credentials[:a][:b]" → nested credentials key
|
|
686
|
+
def resolve_password(value)
|
|
687
|
+
return nil if value.blank?
|
|
688
|
+
resolve_secret(value)
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
alias_method :resolve_env, :resolve_password
|
|
692
|
+
|
|
693
|
+
def resolve_secret(value)
|
|
694
|
+
return nil if value.blank?
|
|
695
|
+
case value
|
|
696
|
+
when /^ENV\[(.+?)\]$/, /^\$\{(.+?)\}$/
|
|
697
|
+
ENV[$1]
|
|
698
|
+
when /^credentials\[/
|
|
699
|
+
# credentials[:key] or credentials[:key][:subkey]
|
|
700
|
+
keys = value.scan(/\[:([^\]]+)\]/).flatten.map(&:to_sym)
|
|
701
|
+
Rails.application.credentials.dig(*keys)
|
|
702
|
+
else
|
|
703
|
+
value
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def command_exists?(cmd)
|
|
708
|
+
executable = cmd.to_s
|
|
709
|
+
return false unless executable.match?(/\A[a-zA-Z0-9_.+-]+\z/)
|
|
710
|
+
|
|
711
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
712
|
+
path = File.join(directory, executable)
|
|
713
|
+
File.file?(path) && File.executable?(path)
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def capture_redis(host, port, password, *arguments)
|
|
718
|
+
env = {}
|
|
719
|
+
env["REDISCLI_AUTH"] = password if password.present?
|
|
720
|
+
capture_command(env, "redis-cli", "-h", host.to_s, "-p", port.to_s, *arguments.map(&:to_s))
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def with_secret_file(prefix, contents)
|
|
724
|
+
Tempfile.create([prefix, ".conf"]) do |file|
|
|
725
|
+
file.chmod(0o600)
|
|
726
|
+
file.write(contents)
|
|
727
|
+
file.flush
|
|
728
|
+
yield file.path
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def validated_mysql_options(value)
|
|
733
|
+
allowed = %r{\A(?:
|
|
734
|
+
--ssl-mode=(?:DISABLED|PREFERRED|REQUIRED|VERIFY_CA|VERIFY_IDENTITY)|
|
|
735
|
+
--ssl-(?:ca|cert|key)=\S+|
|
|
736
|
+
--protocol=(?:TCP|SOCKET)|
|
|
737
|
+
--socket=\S+|
|
|
738
|
+
--default-character-set=[a-zA-Z0-9_-]+|
|
|
739
|
+
--max-allowed-packet=\d+[KMG]?|
|
|
740
|
+
--set-gtid-purged=(?:OFF|ON|AUTO)|
|
|
741
|
+
--column-statistics=[01]|
|
|
742
|
+
--no-tablespaces|
|
|
743
|
+
--skip-lock-tables
|
|
744
|
+
)\z}x
|
|
745
|
+
|
|
746
|
+
Shellwords.split(value.to_s).tap do |options|
|
|
747
|
+
invalid = options.reject { |option| option.match?(allowed) }
|
|
748
|
+
raise ArgumentError, "unsupported MySQL option: #{invalid.first}" if invalid.any?
|
|
749
|
+
end
|
|
750
|
+
rescue ArgumentError => error
|
|
751
|
+
raise ArgumentError, "invalid MySQL additional options: #{error.message}"
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def validate_identifier!(value, label)
|
|
755
|
+
candidate = value.to_s
|
|
756
|
+
unless candidate.match?(/\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/) && !candidate.include?("..")
|
|
757
|
+
raise ArgumentError, "invalid #{label}"
|
|
758
|
+
end
|
|
759
|
+
candidate
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def validate_port!(value)
|
|
763
|
+
port = Integer(value)
|
|
764
|
+
raise ArgumentError, "invalid port" unless port.between?(1, 65_535)
|
|
765
|
+
port
|
|
766
|
+
rescue ArgumentError, TypeError
|
|
767
|
+
raise ArgumentError, "invalid port"
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def validate_host!(value)
|
|
771
|
+
host = value.to_s
|
|
772
|
+
unless host.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
|
|
773
|
+
raise ArgumentError, "invalid host"
|
|
774
|
+
end
|
|
775
|
+
host
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def validated_path(value, label)
|
|
779
|
+
validate_text_argument!(value, label)
|
|
780
|
+
File.expand_path(value.to_s)
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def validate_remote_component!(value, label)
|
|
784
|
+
candidate = value.to_s
|
|
785
|
+
raise ArgumentError, "invalid #{label}" unless candidate.match?(/\A[a-zA-Z0-9_.-]+\z/)
|
|
786
|
+
candidate
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def validate_remote_path!(value)
|
|
790
|
+
path = value.to_s
|
|
791
|
+
raise ArgumentError, "invalid rsync path" unless path.start_with?("/")
|
|
792
|
+
validate_text_argument!(path, "rsync path")
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def validate_s3_bucket!(value)
|
|
796
|
+
bucket = value.to_s
|
|
797
|
+
unless bucket.length.between?(3, 63) && bucket.match?(/\A[a-z0-9][a-z0-9.-]*[a-z0-9]\z/) && !bucket.include?("..")
|
|
798
|
+
raise ArgumentError, "invalid S3 bucket"
|
|
799
|
+
end
|
|
800
|
+
bucket
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def validate_text_argument!(value, label)
|
|
804
|
+
candidate = value.to_s
|
|
805
|
+
if candidate.empty? || candidate.include?("\0") || candidate.include?("\n") || candidate.include?("\r")
|
|
806
|
+
raise ArgumentError, "invalid #{label}"
|
|
807
|
+
end
|
|
808
|
+
candidate
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def failure_message(exception)
|
|
812
|
+
heading = "#{@stage || 'backup'} failed: #{exception.class}: #{exception.message}"
|
|
813
|
+
trace = Array(exception.backtrace).first(5).join("\n")
|
|
814
|
+
[heading, trace.presence, @errors.join("\n").presence].compact.join("\n\n")
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
end
|