rails_nexus 2.0.3 → 2.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 +4 -4
- data/app/assets/javascripts/rails_nexus/controllers.js +103 -6
- data/app/assets/stylesheets/rails_nexus/application.css +418 -42
- data/app/controllers/rails_nexus/backup_controller.rb +27 -19
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- data/app/javascript/controllers/rails_nexus_controller.js +8 -2
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +84 -3
- data/app/jobs/rails_nexus/backup_job.rb +18 -0
- data/app/models/rails_nexus/backup.rb +11 -11
- data/app/models/rails_nexus/backup_config.rb +84 -16
- data/app/services/rails_nexus/backup_service.rb +521 -115
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +113 -0
- data/app/views/layouts/rails_nexus/application.html.erb +132 -26
- data/app/views/rails_nexus/backup/_form.html.erb +186 -37
- data/app/views/rails_nexus/backup/history.html.erb +17 -2
- data/app/views/rails_nexus/backup/index.html.erb +29 -72
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +24 -55
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +24 -102
- data/db/seeds/backup_configs.rb +140 -0
- data/lib/generators/rails_nexus/templates/migration.rb +32 -6
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/tasks/rails_nexus.rake +14 -4
- metadata +4 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "json"
|
|
5
|
+
require "fileutils"
|
|
5
6
|
|
|
6
7
|
module RailsNexus
|
|
7
8
|
class BackupService
|
|
@@ -14,156 +15,336 @@ module RailsNexus
|
|
|
14
15
|
@config = config
|
|
15
16
|
@record = nil
|
|
16
17
|
@errors = []
|
|
18
|
+
@temp_files = []
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def run
|
|
20
22
|
@record = RailsNexus::Backup.start!(
|
|
21
|
-
|
|
23
|
+
config_name: @config.name,
|
|
22
24
|
triggered_by: "service"
|
|
23
25
|
)
|
|
24
26
|
|
|
25
27
|
begin
|
|
26
|
-
|
|
28
|
+
@stage = "preparing storage"
|
|
27
29
|
FileUtils.mkdir_p(@config.storage_path_expanded)
|
|
28
30
|
|
|
29
31
|
# Step 1: Dump database
|
|
32
|
+
@stage = "database dump"
|
|
30
33
|
dump_path = dump_database
|
|
31
|
-
|
|
34
|
+
raise "Database dump failed" unless dump_path
|
|
32
35
|
|
|
33
|
-
# Step 2:
|
|
34
|
-
|
|
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
|
|
35
47
|
|
|
36
|
-
# Step
|
|
37
|
-
|
|
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
|
|
38
63
|
|
|
39
|
-
# Step
|
|
40
|
-
|
|
64
|
+
# Step 6: Sync to S3
|
|
65
|
+
@stage = "S3 sync"
|
|
66
|
+
sync_s3(dump_path) if @config.s3_enabled?
|
|
41
67
|
|
|
42
|
-
# Step
|
|
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"
|
|
43
74
|
cleanup_old_backups
|
|
44
75
|
|
|
45
|
-
# Step
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
file_size: file_size
|
|
50
|
-
)
|
|
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)
|
|
51
80
|
|
|
52
|
-
# Step
|
|
81
|
+
# Step 10: Notify
|
|
82
|
+
@stage = "success notification"
|
|
53
83
|
notify_success
|
|
54
84
|
|
|
55
|
-
{ success: true, record: @record, file_path:
|
|
85
|
+
{ success: true, record: @record, file_path: dump_path }
|
|
56
86
|
rescue StandardError => e
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 }
|
|
60
91
|
ensure
|
|
61
|
-
# Clean up temp files
|
|
62
92
|
cleanup_temp_files
|
|
63
93
|
end
|
|
64
94
|
end
|
|
65
95
|
|
|
66
96
|
private
|
|
67
97
|
|
|
98
|
+
# ════════════════════════════════════════════════════════════════
|
|
99
|
+
# DATABASE DUMP
|
|
100
|
+
# ════════════════════════════════════════════════════════════════
|
|
101
|
+
|
|
68
102
|
def dump_database
|
|
69
103
|
case @config.adapter
|
|
70
|
-
when "mysql"
|
|
71
|
-
|
|
72
|
-
when "
|
|
73
|
-
|
|
74
|
-
when "
|
|
75
|
-
|
|
76
|
-
else
|
|
77
|
-
raise "Unsupported adapter: #{@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}"
|
|
78
110
|
end
|
|
79
111
|
end
|
|
80
112
|
|
|
81
|
-
# ─── MySQL
|
|
113
|
+
# ─── MySQL ─────────────────────────────────────────────────────
|
|
82
114
|
def dump_mysql
|
|
83
|
-
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
84
|
-
|
|
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
|
|
85
135
|
|
|
86
|
-
|
|
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(" "))
|
|
87
142
|
unless status.success?
|
|
88
|
-
|
|
89
|
-
return nil
|
|
143
|
+
raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
90
144
|
end
|
|
91
|
-
|
|
145
|
+
register_temp_file(raw_path)
|
|
92
146
|
raw_path
|
|
93
147
|
end
|
|
94
148
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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"
|
|
104
161
|
|
|
105
162
|
# Skip tables
|
|
106
|
-
|
|
107
|
-
skip.each { |t| parts << "--ignore-table=#{@config.database_name}.#{t}" }
|
|
163
|
+
@config.skip_tables_list.each { |t| cmd << "--exclude-table=#{t}" }
|
|
108
164
|
|
|
109
|
-
|
|
110
|
-
parts.join(" ")
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# ─── PostgreSQL Dump ─────────────────────────────────────────────
|
|
114
|
-
def dump_postgresql
|
|
115
|
-
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
116
|
-
cmd = build_postgresql_command(raw_path)
|
|
165
|
+
cmd << @config.database_name
|
|
117
166
|
|
|
118
167
|
env = {}
|
|
119
|
-
env["PGPASSWORD"] =
|
|
168
|
+
env["PGPASSWORD"] = password if password.present?
|
|
120
169
|
|
|
121
|
-
stdout, stderr, status = Open3.capture3(env, cmd)
|
|
170
|
+
stdout, stderr, status = Open3.capture3(env, cmd.join(" "))
|
|
122
171
|
unless status.success?
|
|
123
|
-
|
|
124
|
-
return nil
|
|
172
|
+
raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
125
173
|
end
|
|
126
|
-
|
|
174
|
+
register_temp_file(raw_path)
|
|
127
175
|
raw_path
|
|
128
176
|
end
|
|
129
177
|
|
|
130
|
-
def
|
|
131
|
-
|
|
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"
|
|
137
|
-
|
|
138
|
-
# Skip tables
|
|
139
|
-
skip = @config.skip_tables_list
|
|
140
|
-
skip.each { |t| parts << "--exclude-table=#{t}" }
|
|
141
|
-
|
|
142
|
-
parts << @config.database_name
|
|
143
|
-
parts.join(" ")
|
|
178
|
+
def capture_command(*command)
|
|
179
|
+
Open3.capture3(*command)
|
|
144
180
|
end
|
|
145
181
|
|
|
146
|
-
# ─── SQLite
|
|
182
|
+
# ─── SQLite ────────────────────────────────────────────────────
|
|
147
183
|
def dump_sqlite
|
|
148
|
-
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
184
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
149
185
|
db_path = @config.database_name
|
|
150
186
|
|
|
151
187
|
unless File.exist?(db_path)
|
|
152
|
-
|
|
153
|
-
return nil
|
|
188
|
+
raise "SQLite database not found: #{db_path}"
|
|
154
189
|
end
|
|
155
190
|
|
|
156
|
-
stdout, stderr, status = Open3.capture3("sqlite3 #{db_path} .dump")
|
|
191
|
+
stdout, stderr, status = Open3.capture3("sqlite3 \"#{db_path}\" .dump")
|
|
157
192
|
unless status.success?
|
|
158
|
-
|
|
159
|
-
return nil
|
|
193
|
+
raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
160
194
|
end
|
|
161
195
|
|
|
162
196
|
File.write(raw_path, stdout)
|
|
197
|
+
register_temp_file(raw_path)
|
|
163
198
|
raw_path
|
|
164
199
|
end
|
|
165
200
|
|
|
166
|
-
# ───
|
|
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
|
+
|
|
167
348
|
def compress(file_path)
|
|
168
349
|
gz_path = "#{file_path}.gz"
|
|
169
350
|
File.open(file_path, "rb") do |input|
|
|
@@ -171,44 +352,132 @@ module RailsNexus
|
|
|
171
352
|
gz.write(input.read)
|
|
172
353
|
end
|
|
173
354
|
end
|
|
174
|
-
|
|
355
|
+
@temp_files << file_path if File.exist?(gz_path)
|
|
175
356
|
gz_path
|
|
176
357
|
end
|
|
177
358
|
|
|
178
|
-
|
|
179
|
-
|
|
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)
|
|
180
375
|
enc_path = "#{file_path}.enc"
|
|
181
|
-
password = @config.
|
|
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}"
|
|
182
386
|
|
|
183
|
-
cmd = "openssl aes-256-cbc -salt -pbkdf2 -in #{file_path} -out #{enc_path} -pass pass:#{password}"
|
|
184
387
|
stdout, stderr, status = Open3.capture3(cmd)
|
|
388
|
+
unless status.success?
|
|
389
|
+
@errors << "OpenSSL encryption failed: #{stderr}"
|
|
390
|
+
return file_path
|
|
391
|
+
end
|
|
185
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)
|
|
186
410
|
unless status.success?
|
|
187
|
-
@errors << "
|
|
411
|
+
@errors << "GPG encryption failed: #{stderr}"
|
|
188
412
|
return file_path
|
|
189
413
|
end
|
|
190
414
|
|
|
191
|
-
|
|
415
|
+
@temp_files << file_path if File.exist?(enc_path)
|
|
192
416
|
enc_path
|
|
193
417
|
end
|
|
194
418
|
|
|
195
|
-
#
|
|
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
|
+
|
|
196
444
|
def sync_remote(file_path)
|
|
197
445
|
return unless @config.rsync_enabled?
|
|
198
446
|
return unless @config.rsync_host.present?
|
|
199
447
|
|
|
200
448
|
remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
|
|
201
449
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
210
470
|
|
|
211
|
-
|
|
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)
|
|
212
481
|
unless status.success?
|
|
213
482
|
@errors << "RSync failed: #{stderr}"
|
|
214
483
|
end
|
|
@@ -216,7 +485,60 @@ module RailsNexus
|
|
|
216
485
|
{ success: status.success?, output: stdout }
|
|
217
486
|
end
|
|
218
487
|
|
|
219
|
-
# ───
|
|
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
|
+
|
|
220
542
|
def cleanup_old_backups
|
|
221
543
|
storage = @config.storage_path_expanded
|
|
222
544
|
return unless Dir.exist?(storage)
|
|
@@ -224,32 +546,40 @@ module RailsNexus
|
|
|
224
546
|
pattern = File.join(storage, "#{@config.name}_*")
|
|
225
547
|
files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
|
|
226
548
|
|
|
227
|
-
# Keep only keep_count most recent
|
|
228
549
|
while files.size > @config.keep_count
|
|
229
550
|
old_file = files.shift
|
|
230
|
-
|
|
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
|
|
231
556
|
end
|
|
232
557
|
end
|
|
233
558
|
|
|
234
559
|
def cleanup_temp_files
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
241
566
|
end
|
|
242
567
|
end
|
|
243
568
|
|
|
244
|
-
#
|
|
569
|
+
# ════════════════════════════════════════════════════════════════
|
|
570
|
+
# NOTIFICATIONS
|
|
571
|
+
# ════════════════════════════════════════════════════════════════
|
|
572
|
+
|
|
245
573
|
def notify_success
|
|
246
574
|
return unless @config.notify_on_success?
|
|
247
575
|
execute_notify_command("success")
|
|
576
|
+
send_email_notification("success")
|
|
248
577
|
end
|
|
249
578
|
|
|
250
579
|
def notify_failure(error_message)
|
|
251
580
|
return unless @config.notify_on_failure?
|
|
252
581
|
execute_notify_command("failure", error_message)
|
|
582
|
+
send_email_notification("failure", error_message)
|
|
253
583
|
end
|
|
254
584
|
|
|
255
585
|
def execute_notify_command(status, error = nil)
|
|
@@ -266,11 +596,87 @@ module RailsNexus
|
|
|
266
596
|
# Don't fail backup because notification failed
|
|
267
597
|
end
|
|
268
598
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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")
|
|
274
680
|
end
|
|
275
681
|
end
|
|
276
682
|
end
|