rails_nexus 2.0.2 → 2.0.6
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 +21 -1
- data/app/assets/stylesheets/rails_nexus/application.css +295 -29
- data/app/controllers/rails_nexus/backup_controller.rb +6 -2
- data/app/javascript/controllers/rails_nexus_controller.js +2 -0
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +13 -0
- data/app/models/rails_nexus/backup_config.rb +77 -14
- data/app/services/rails_nexus/backup_service.rb +416 -97
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +83 -0
- data/app/views/layouts/rails_nexus/application.html.erb +70 -22
- data/app/views/rails_nexus/backup/_form.html.erb +183 -37
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +16 -15
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +3 -3
- data/db/seeds/backup_configs.rb +139 -0
- data/lib/generators/rails_nexus/templates/migration.rb +59 -0
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/tasks/rails_nexus.rake +10 -0
- metadata +3 -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,6 +15,7 @@ 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
|
|
@@ -23,129 +25,140 @@ module RailsNexus
|
|
|
23
25
|
)
|
|
24
26
|
|
|
25
27
|
begin
|
|
26
|
-
# Ensure storage directory exists
|
|
27
28
|
FileUtils.mkdir_p(@config.storage_path_expanded)
|
|
28
29
|
|
|
29
30
|
# Step 1: Dump database
|
|
30
31
|
dump_path = dump_database
|
|
31
|
-
|
|
32
|
+
raise "Database dump failed" unless dump_path
|
|
32
33
|
|
|
33
|
-
# Step 2:
|
|
34
|
-
|
|
34
|
+
# Step 2: Create archive if enabled (tar additional files/dirs)
|
|
35
|
+
if @config.archive_enabled? && @config.archive_paths_list.any?
|
|
36
|
+
dump_path = create_archive(dump_path)
|
|
37
|
+
end
|
|
35
38
|
|
|
36
|
-
# Step 3:
|
|
37
|
-
|
|
39
|
+
# Step 3: Split into chunks if enabled
|
|
40
|
+
if @config.split_chunks? && File.size(dump_path) > 50_000_000 # 50MB
|
|
41
|
+
dump_path = split_file(dump_path)
|
|
42
|
+
end
|
|
38
43
|
|
|
39
|
-
# Step 4:
|
|
40
|
-
|
|
44
|
+
# Step 4: Compress
|
|
45
|
+
if @config.compress? && !@config.split_chunks?
|
|
46
|
+
dump_path = compress(dump_path)
|
|
47
|
+
elsif @config.bzip2_compress? && !@config.split_chunks?
|
|
48
|
+
dump_path = compress_bzip2(dump_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Step 5: Encrypt
|
|
52
|
+
if @config.encrypted?
|
|
53
|
+
dump_path = encrypt_openssl(dump_path)
|
|
54
|
+
elsif @config.gpg_enabled?
|
|
55
|
+
dump_path = encrypt_gpg(dump_path)
|
|
56
|
+
end
|
|
41
57
|
|
|
42
|
-
# Step
|
|
58
|
+
# Step 6: Sync to S3
|
|
59
|
+
sync_s3(dump_path) if @config.s3_enabled?
|
|
60
|
+
|
|
61
|
+
# Step 7: Sync to remote
|
|
62
|
+
rsync_result = sync_remote(dump_path) if @config.rsync_enabled?
|
|
63
|
+
|
|
64
|
+
# Step 8: Cleanup old backups
|
|
43
65
|
cleanup_old_backups
|
|
44
66
|
|
|
45
|
-
# Step
|
|
46
|
-
file_size =
|
|
47
|
-
@record.succeed!(
|
|
48
|
-
file_path: final_path,
|
|
49
|
-
file_size: file_size
|
|
50
|
-
)
|
|
67
|
+
# Step 9: Record success
|
|
68
|
+
file_size = calculate_size(dump_path)
|
|
69
|
+
@record.succeed!(file_path: dump_path, file_size: file_size)
|
|
51
70
|
|
|
52
|
-
# Step
|
|
71
|
+
# Step 10: Notify
|
|
53
72
|
notify_success
|
|
54
73
|
|
|
55
|
-
{ success: true, record: @record, file_path:
|
|
74
|
+
{ success: true, record: @record, file_path: dump_path }
|
|
56
75
|
rescue StandardError => e
|
|
57
|
-
@record
|
|
76
|
+
@record&.fail!(error_message: e.message)
|
|
58
77
|
notify_failure(e.message)
|
|
59
78
|
{ success: false, error: e.message, record: @record }
|
|
60
79
|
ensure
|
|
61
|
-
# Clean up temp files
|
|
62
80
|
cleanup_temp_files
|
|
63
81
|
end
|
|
64
82
|
end
|
|
65
83
|
|
|
66
84
|
private
|
|
67
85
|
|
|
86
|
+
# ════════════════════════════════════════════════════════════════
|
|
87
|
+
# DATABASE DUMP
|
|
88
|
+
# ════════════════════════════════════════════════════════════════
|
|
89
|
+
|
|
68
90
|
def dump_database
|
|
69
91
|
case @config.adapter
|
|
70
|
-
when "mysql"
|
|
71
|
-
|
|
72
|
-
when "
|
|
73
|
-
|
|
74
|
-
when "
|
|
75
|
-
|
|
76
|
-
else
|
|
77
|
-
raise "Unsupported adapter: #{@config.adapter}"
|
|
92
|
+
when "mysql" then dump_mysql
|
|
93
|
+
when "postgresql" then dump_postgresql
|
|
94
|
+
when "sqlite" then dump_sqlite
|
|
95
|
+
when "mongodb" then dump_mongodb
|
|
96
|
+
when "redis" then dump_redis
|
|
97
|
+
else raise "Unsupported adapter: #{@config.adapter}"
|
|
78
98
|
end
|
|
79
99
|
end
|
|
80
100
|
|
|
81
|
-
# ─── MySQL
|
|
101
|
+
# ─── MySQL ─────────────────────────────────────────────────────
|
|
82
102
|
def dump_mysql
|
|
83
|
-
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
84
|
-
cmd =
|
|
103
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
104
|
+
cmd = ["mysqldump"]
|
|
105
|
+
cmd << "--user=#{@config.username}" if @config.username.present?
|
|
106
|
+
cmd << "--password=#{@config.password}" if @config.password.present?
|
|
107
|
+
cmd << "--host=#{@config.host}" if @config.host.present?
|
|
108
|
+
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
109
|
+
cmd << "--result-file=#{raw_path}"
|
|
110
|
+
cmd << "--single-transaction"
|
|
111
|
+
cmd << "--quick"
|
|
112
|
+
cmd << "--routines"
|
|
113
|
+
cmd << "--triggers"
|
|
114
|
+
cmd << "--events"
|
|
85
115
|
|
|
86
|
-
|
|
116
|
+
# Skip tables
|
|
117
|
+
@config.skip_tables_list.each { |t| cmd << "--ignore-table=#{@config.database_name}.#{t}" }
|
|
118
|
+
|
|
119
|
+
cmd << @config.database_name
|
|
120
|
+
|
|
121
|
+
stdout, stderr, status = Open3.capture3(cmd.join(" "))
|
|
87
122
|
unless status.success?
|
|
88
123
|
@errors << "mysqldump failed: #{stderr}"
|
|
89
124
|
return nil
|
|
90
125
|
end
|
|
91
|
-
|
|
126
|
+
register_temp_file(raw_path)
|
|
92
127
|
raw_path
|
|
93
128
|
end
|
|
94
129
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
130
|
+
# ─── PostgreSQL ────────────────────────────────────────────────
|
|
131
|
+
def dump_postgresql
|
|
132
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
133
|
+
cmd = ["pg_dump"]
|
|
134
|
+
cmd << "--username=#{@config.username}" if @config.username.present?
|
|
135
|
+
cmd << "--host=#{@config.host}" if @config.host.present?
|
|
136
|
+
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
137
|
+
cmd << "--file=#{raw_path}"
|
|
138
|
+
cmd << "--format=plain"
|
|
139
|
+
cmd << "--no-owner"
|
|
140
|
+
cmd << "--no-privileges"
|
|
104
141
|
|
|
105
142
|
# Skip tables
|
|
106
|
-
|
|
107
|
-
skip.each { |t| parts << "--ignore-table=#{@config.database_name}.#{t}" }
|
|
108
|
-
|
|
109
|
-
parts << @config.database_name
|
|
110
|
-
parts.join(" ")
|
|
111
|
-
end
|
|
143
|
+
@config.skip_tables_list.each { |t| cmd << "--exclude-table=#{t}" }
|
|
112
144
|
|
|
113
|
-
|
|
114
|
-
def dump_postgresql
|
|
115
|
-
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
116
|
-
cmd = build_postgresql_command(raw_path)
|
|
145
|
+
cmd << @config.database_name
|
|
117
146
|
|
|
118
147
|
env = {}
|
|
119
148
|
env["PGPASSWORD"] = @config.password if @config.password.present?
|
|
120
149
|
|
|
121
|
-
stdout, stderr, status = Open3.capture3(env, cmd)
|
|
150
|
+
stdout, stderr, status = Open3.capture3(env, cmd.join(" "))
|
|
122
151
|
unless status.success?
|
|
123
152
|
@errors << "pg_dump failed: #{stderr}"
|
|
124
153
|
return nil
|
|
125
154
|
end
|
|
126
|
-
|
|
155
|
+
register_temp_file(raw_path)
|
|
127
156
|
raw_path
|
|
128
157
|
end
|
|
129
158
|
|
|
130
|
-
|
|
131
|
-
parts = ["pg_dump"]
|
|
132
|
-
parts << "--username=#{@config.username}" if @config.username.present?
|
|
133
|
-
parts << "--host=#{@config.host}" if @config.host.present?
|
|
134
|
-
parts << "--port=#{@config.port}" if @config.port.present?
|
|
135
|
-
parts << "--file=#{output_path}"
|
|
136
|
-
parts << "--format=plain"
|
|
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(" ")
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# ─── SQLite Dump ─────────────────────────────────────────────────
|
|
159
|
+
# ─── SQLite ────────────────────────────────────────────────────
|
|
147
160
|
def dump_sqlite
|
|
148
|
-
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
161
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
149
162
|
db_path = @config.database_name
|
|
150
163
|
|
|
151
164
|
unless File.exist?(db_path)
|
|
@@ -153,17 +166,165 @@ module RailsNexus
|
|
|
153
166
|
return nil
|
|
154
167
|
end
|
|
155
168
|
|
|
156
|
-
stdout, stderr, status = Open3.capture3("sqlite3 #{db_path} .dump")
|
|
169
|
+
stdout, stderr, status = Open3.capture3("sqlite3 \"#{db_path}\" .dump")
|
|
157
170
|
unless status.success?
|
|
158
171
|
@errors << "sqlite3 dump failed: #{stderr}"
|
|
159
172
|
return nil
|
|
160
173
|
end
|
|
161
174
|
|
|
162
175
|
File.write(raw_path, stdout)
|
|
176
|
+
register_temp_file(raw_path)
|
|
177
|
+
raw_path
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# ─── MongoDB ───────────────────────────────────────────────────
|
|
181
|
+
def dump_mongodb
|
|
182
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
183
|
+
dir_path = raw_path.gsub(/\.sql$/, "")
|
|
184
|
+
FileUtils.mkdir_p(dir_path)
|
|
185
|
+
|
|
186
|
+
cmd = ["mongodump"]
|
|
187
|
+
cmd << "--host=#{@config.host || 'localhost'}"
|
|
188
|
+
cmd << "--port=#{@config.port || 27017}"
|
|
189
|
+
cmd << "--db=#{@config.database_name}"
|
|
190
|
+
cmd << "--out=#{dir_path}"
|
|
191
|
+
cmd << "--username=#{@config.username}" if @config.username.present?
|
|
192
|
+
cmd << "--password=#{@config.password}" if @config.password.present?
|
|
193
|
+
cmd << "--authenticationDatabase=admin" if @config.username.present?
|
|
194
|
+
|
|
195
|
+
# Skip collections
|
|
196
|
+
@config.skip_tables_list.each { |t| cmd << "--excludeCollection=#{t}" }
|
|
197
|
+
|
|
198
|
+
stdout, stderr, status = Open3.capture3(cmd.join(" "))
|
|
199
|
+
unless status.success?
|
|
200
|
+
@errors << "mongodump failed: #{stderr}"
|
|
201
|
+
FileUtils.rm_rf(dir_path) if Dir.exist?(dir_path)
|
|
202
|
+
return nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Tar the dump directory
|
|
206
|
+
tar_path = "#{dir_path}.tar"
|
|
207
|
+
stdout, stderr, status = Open3.capture3("tar -cf \"#{tar_path}\" -C \"#{File.dirname(dir_path)}\" \"#{File.basename(dir_path)}\"")
|
|
208
|
+
unless status.success?
|
|
209
|
+
@errors << "tar failed: #{stderr}"
|
|
210
|
+
return nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
FileUtils.rm_rf(dir_path)
|
|
214
|
+
register_temp_file(tar_path)
|
|
215
|
+
tar_path
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# ─── Redis ─────────────────────────────────────────────────────
|
|
219
|
+
def dump_redis
|
|
220
|
+
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
221
|
+
raw_path = raw_path.gsub(/\.sql$/, ".rdb")
|
|
222
|
+
|
|
223
|
+
host = @config.host || "localhost"
|
|
224
|
+
port = @config.port || 6379
|
|
225
|
+
password = @config.password
|
|
226
|
+
|
|
227
|
+
# Trigger BGSAVE first
|
|
228
|
+
cmd = "redis-cli -h #{host} -p #{port}"
|
|
229
|
+
cmd += " -a #{password}" if password.present?
|
|
230
|
+
cmd += " BGSAVE"
|
|
231
|
+
|
|
232
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
233
|
+
unless status.success?
|
|
234
|
+
@errors << "redis BGSAVE failed: #{stderr}"
|
|
235
|
+
return nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Wait a moment for BGSAVE to complete
|
|
239
|
+
sleep 2
|
|
240
|
+
|
|
241
|
+
# Try to find the dump.rdb on the server or use redis-cli to copy
|
|
242
|
+
# If local Redis, copy the dump.rdb directly
|
|
243
|
+
redis_dir_cmd = "#{cmd.gsub(' BGSAVE', '')} CONFIG GET dir"
|
|
244
|
+
dir_out, = Open3.capture3(redis_dir_cmd)
|
|
245
|
+
redis_dir = dir_out.split("\n").last || "/var/lib/redis"
|
|
246
|
+
|
|
247
|
+
redis_rdb = File.join(redis_dir, "dump.rdb")
|
|
248
|
+
if File.exist?(redis_rdb)
|
|
249
|
+
FileUtils.cp(redis_rdb, raw_path)
|
|
250
|
+
else
|
|
251
|
+
# Fallback: use redis-cli to dump keys
|
|
252
|
+
dump_all_keys(host, port, password, raw_path)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
register_temp_file(raw_path)
|
|
163
256
|
raw_path
|
|
164
257
|
end
|
|
165
258
|
|
|
166
|
-
|
|
259
|
+
def dump_all_keys(host, port, password, output_path)
|
|
260
|
+
cmd = "redis-cli -h #{host} -p #{port}"
|
|
261
|
+
cmd += " -a #{password}" if password.present?
|
|
262
|
+
|
|
263
|
+
keys_out, = Open3.capture3("#{cmd} KEYS '*'")
|
|
264
|
+
keys = keys_out.split("\n").reject { |k| k.start_with?("redis") }
|
|
265
|
+
|
|
266
|
+
File.open(output_path, "w") do |f|
|
|
267
|
+
keys.each do |key|
|
|
268
|
+
type_out, = Open3.capture3("#{cmd} TYPE \"#{key}\"")
|
|
269
|
+
type = type_out.strip.split("\n").last
|
|
270
|
+
|
|
271
|
+
val_out, = case type
|
|
272
|
+
when "string" then Open3.capture3("#{cmd} GET \"#{key}\"")
|
|
273
|
+
when "list" then Open3.capture3("#{cmd} LRANGE \"#{key}\" 0 -1")
|
|
274
|
+
when "set" then Open3.capture3("#{cmd} SMEMBERS \"#{key}\"")
|
|
275
|
+
when "hash" then Open3.capture3("#{cmd} HGETALL \"#{key}\"")
|
|
276
|
+
else Open3.capture3("#{cmd} DUMP \"#{key}\"")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
f.puts("SET #{key.inspect} #{val_out.strip.inspect}")
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# ════════════════════════════════════════════════════════════════
|
|
285
|
+
# ARCHIVES
|
|
286
|
+
# ════════════════════════════════════════════════════════════════
|
|
287
|
+
|
|
288
|
+
def create_archive(dump_path)
|
|
289
|
+
archive_path = dump_path.gsub(/\.(sql|tar)$/, ".tar")
|
|
290
|
+
|
|
291
|
+
# If the dump is already a tar (MongoDB), merge into it
|
|
292
|
+
if dump_path.end_with?(".tar")
|
|
293
|
+
archive_path = dump_path
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
cmd = ["tar"]
|
|
297
|
+
cmd << (dump_path.end_with?(".tar") ? "-rf" : "-cf")
|
|
298
|
+
cmd << archive_path
|
|
299
|
+
|
|
300
|
+
unless dump_path.end_with?(".tar")
|
|
301
|
+
cmd << "-C"
|
|
302
|
+
cmd << File.dirname(dump_path)
|
|
303
|
+
cmd << File.basename(dump_path)
|
|
304
|
+
# Remove the original SQL file after tar
|
|
305
|
+
@temp_files << dump_path
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
@config.archive_paths_list.each { |p| cmd << p }
|
|
309
|
+
|
|
310
|
+
# Exclude patterns
|
|
311
|
+
if @config.archive_excludes_list.any?
|
|
312
|
+
@config.archive_excludes_list.each { |e| cmd << "--exclude=#{e}" }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
stdout, stderr, status = Open3.capture3(cmd.join(" "))
|
|
316
|
+
unless status.success?
|
|
317
|
+
@errors << "archive tar failed: #{stderr}"
|
|
318
|
+
return dump_path
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
archive_path
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# ════════════════════════════════════════════════════════════════
|
|
325
|
+
# COMPRESSION
|
|
326
|
+
# ════════════════════════════════════════════════════════════════
|
|
327
|
+
|
|
167
328
|
def compress(file_path)
|
|
168
329
|
gz_path = "#{file_path}.gz"
|
|
169
330
|
File.open(file_path, "rb") do |input|
|
|
@@ -171,28 +332,87 @@ module RailsNexus
|
|
|
171
332
|
gz.write(input.read)
|
|
172
333
|
end
|
|
173
334
|
end
|
|
174
|
-
|
|
335
|
+
@temp_files << file_path if File.exist?(gz_path)
|
|
175
336
|
gz_path
|
|
176
337
|
end
|
|
177
338
|
|
|
178
|
-
|
|
179
|
-
|
|
339
|
+
def compress_bzip2(file_path)
|
|
340
|
+
bz2_path = "#{file_path}.bz2"
|
|
341
|
+
stdout, stderr, status = Open3.capture3("bzip2 -zk \"#{file_path}\"")
|
|
342
|
+
unless status.success?
|
|
343
|
+
@errors << "bzip2 failed: #{stderr}"
|
|
344
|
+
return file_path
|
|
345
|
+
end
|
|
346
|
+
@temp_files << file_path if File.exist?(bz2_path)
|
|
347
|
+
bz2_path
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# ════════════════════════════════════════════════════════════════
|
|
351
|
+
# ENCRYPTION
|
|
352
|
+
# ════════════════════════════════════════════════════════════════
|
|
353
|
+
|
|
354
|
+
def encrypt_openssl(file_path)
|
|
180
355
|
enc_path = "#{file_path}.enc"
|
|
181
|
-
password = @config.
|
|
356
|
+
password = @config.encryption_password
|
|
357
|
+
|
|
358
|
+
cmd = "openssl aes-256-cbc -salt -pbkdf2 -iter 100000 " \
|
|
359
|
+
"-in \"#{file_path}\" -out \"#{enc_path}\" " \
|
|
360
|
+
"-pass pass:#{password}"
|
|
182
361
|
|
|
183
|
-
cmd = "openssl aes-256-cbc -salt -pbkdf2 -in #{file_path} -out #{enc_path} -pass pass:#{password}"
|
|
184
362
|
stdout, stderr, status = Open3.capture3(cmd)
|
|
363
|
+
unless status.success?
|
|
364
|
+
@errors << "OpenSSL encryption failed: #{stderr}"
|
|
365
|
+
return file_path
|
|
366
|
+
end
|
|
185
367
|
|
|
368
|
+
@temp_files << file_path if File.exist?(enc_path)
|
|
369
|
+
enc_path
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def encrypt_gpg(file_path)
|
|
373
|
+
enc_path = "#{file_path}.gpg"
|
|
374
|
+
password = @config.gpg_password
|
|
375
|
+
|
|
376
|
+
cmd = "echo #{password} | gpg --batch --yes --symmetric " \
|
|
377
|
+
"--cipher-algo AES256 " \
|
|
378
|
+
"--passphrase-fd 0 " \
|
|
379
|
+
"-o \"#{enc_path}\" \"#{file_path}\""
|
|
380
|
+
|
|
381
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
186
382
|
unless status.success?
|
|
187
|
-
@errors << "
|
|
383
|
+
@errors << "GPG encryption failed: #{stderr}"
|
|
188
384
|
return file_path
|
|
189
385
|
end
|
|
190
386
|
|
|
191
|
-
|
|
387
|
+
@temp_files << file_path if File.exist?(enc_path)
|
|
192
388
|
enc_path
|
|
193
389
|
end
|
|
194
390
|
|
|
195
|
-
#
|
|
391
|
+
# ════════════════════════════════════════════════════════════════
|
|
392
|
+
# SPLIT INTO CHUNKS
|
|
393
|
+
# ════════════════════════════════════════════════════════════════
|
|
394
|
+
|
|
395
|
+
def split_file(file_path)
|
|
396
|
+
chunk_dir = "#{file_path}.parts"
|
|
397
|
+
FileUtils.mkdir_p(chunk_dir)
|
|
398
|
+
|
|
399
|
+
# Split 50MB chunks
|
|
400
|
+
cmd = "split -b 50m \"#{file_path}\" \"#{chunk_dir}/part_\""
|
|
401
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
402
|
+
|
|
403
|
+
unless status.success?
|
|
404
|
+
@errors << "split failed: #{stderr}"
|
|
405
|
+
return file_path
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
@temp_files << file_path
|
|
409
|
+
chunk_dir
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# ════════════════════════════════════════════════════════════════
|
|
413
|
+
# REMOTE SYNC
|
|
414
|
+
# ════════════════════════════════════════════════════════════════
|
|
415
|
+
|
|
196
416
|
def sync_remote(file_path)
|
|
197
417
|
return unless @config.rsync_enabled?
|
|
198
418
|
return unless @config.rsync_host.present?
|
|
@@ -204,7 +424,7 @@ module RailsNexus
|
|
|
204
424
|
"-avz",
|
|
205
425
|
"--progress",
|
|
206
426
|
"-e", "ssh -p #{@config.rsync_port}",
|
|
207
|
-
file_path,
|
|
427
|
+
"\"#{file_path}\"",
|
|
208
428
|
remote
|
|
209
429
|
].join(" ")
|
|
210
430
|
|
|
@@ -216,7 +436,60 @@ module RailsNexus
|
|
|
216
436
|
{ success: status.success?, output: stdout }
|
|
217
437
|
end
|
|
218
438
|
|
|
219
|
-
# ───
|
|
439
|
+
# ─── S3 Upload ─────────────────────────────────────────────────
|
|
440
|
+
def sync_s3(file_path)
|
|
441
|
+
return unless @config.s3_enabled?
|
|
442
|
+
|
|
443
|
+
# Use aws cli if available, otherwise use s3cmd
|
|
444
|
+
if command_exists?("aws")
|
|
445
|
+
sync_s3_aws(file_path)
|
|
446
|
+
elsif command_exists?("s3cmd")
|
|
447
|
+
sync_s3_s3cmd(file_path)
|
|
448
|
+
else
|
|
449
|
+
@errors << "Neither aws-cli nor s3cmd found. Cannot upload to S3."
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def sync_s3_aws(file_path)
|
|
454
|
+
bucket = @config.s3_bucket
|
|
455
|
+
prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
|
|
456
|
+
|
|
457
|
+
cmd = "AWS_ACCESS_KEY_ID=#{@config.s3_access_key} " \
|
|
458
|
+
"AWS_SECRET_ACCESS_KEY=#{@config.s3_secret_key} " \
|
|
459
|
+
"aws s3 cp \"#{file_path}\" " \
|
|
460
|
+
"s3://#{bucket}/#{prefix}" \
|
|
461
|
+
"--region #{@config.s3_region || 'us-east-1'}"
|
|
462
|
+
|
|
463
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
464
|
+
unless status.success?
|
|
465
|
+
@errors << "S3 upload failed: #{stderr}"
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
{ success: status.success?, output: stdout }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def sync_s3_s3cmd(file_path)
|
|
472
|
+
bucket = @config.s3_bucket
|
|
473
|
+
prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
|
|
474
|
+
|
|
475
|
+
cmd = "s3cmd put \"#{file_path}\" " \
|
|
476
|
+
"s3://#{bucket}/#{prefix} " \
|
|
477
|
+
"--access_key=#{@config.s3_access_key} " \
|
|
478
|
+
"--secret_key=#{@config.s3_secret_key} " \
|
|
479
|
+
"--region=#{@config.s3_region || 'us-east-1'}"
|
|
480
|
+
|
|
481
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
482
|
+
unless status.success?
|
|
483
|
+
@errors << "S3 upload (s3cmd) failed: #{stderr}"
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
{ success: status.success?, output: stdout }
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# ════════════════════════════════════════════════════════════════
|
|
490
|
+
# CLEANUP
|
|
491
|
+
# ════════════════════════════════════════════════════════════════
|
|
492
|
+
|
|
220
493
|
def cleanup_old_backups
|
|
221
494
|
storage = @config.storage_path_expanded
|
|
222
495
|
return unless Dir.exist?(storage)
|
|
@@ -224,32 +497,40 @@ module RailsNexus
|
|
|
224
497
|
pattern = File.join(storage, "#{@config.name}_*")
|
|
225
498
|
files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
|
|
226
499
|
|
|
227
|
-
# Keep only keep_count most recent
|
|
228
500
|
while files.size > @config.keep_count
|
|
229
501
|
old_file = files.shift
|
|
230
|
-
|
|
502
|
+
if File.directory?(old_file)
|
|
503
|
+
FileUtils.rm_rf(old_file)
|
|
504
|
+
else
|
|
505
|
+
File.delete(old_file) if File.exist?(old_file)
|
|
506
|
+
end
|
|
231
507
|
end
|
|
232
508
|
end
|
|
233
509
|
|
|
234
510
|
def cleanup_temp_files
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
511
|
+
@temp_files.each do |f|
|
|
512
|
+
if File.directory?(f)
|
|
513
|
+
FileUtils.rm_rf(f)
|
|
514
|
+
else
|
|
515
|
+
File.delete(f) if File.exist?(f)
|
|
516
|
+
end
|
|
241
517
|
end
|
|
242
518
|
end
|
|
243
519
|
|
|
244
|
-
#
|
|
520
|
+
# ════════════════════════════════════════════════════════════════
|
|
521
|
+
# NOTIFICATIONS
|
|
522
|
+
# ════════════════════════════════════════════════════════════════
|
|
523
|
+
|
|
245
524
|
def notify_success
|
|
246
525
|
return unless @config.notify_on_success?
|
|
247
526
|
execute_notify_command("success")
|
|
527
|
+
send_email_notification("success")
|
|
248
528
|
end
|
|
249
529
|
|
|
250
530
|
def notify_failure(error_message)
|
|
251
531
|
return unless @config.notify_on_failure?
|
|
252
532
|
execute_notify_command("failure", error_message)
|
|
533
|
+
send_email_notification("failure", error_message)
|
|
253
534
|
end
|
|
254
535
|
|
|
255
536
|
def execute_notify_command(status, error = nil)
|
|
@@ -266,11 +547,49 @@ module RailsNexus
|
|
|
266
547
|
# Don't fail backup because notification failed
|
|
267
548
|
end
|
|
268
549
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
550
|
+
def send_email_notification(status, error = nil)
|
|
551
|
+
return unless @config.email_notify?
|
|
552
|
+
return if @config.email_to.blank?
|
|
553
|
+
|
|
554
|
+
subject = "[RailsNexus Backup] #{status.upcase}: #{@config.name}"
|
|
555
|
+
body = "Backup #{@config.name} #{status}.\n\n"
|
|
556
|
+
body += "Error: #{error}\n\n" if error.present?
|
|
557
|
+
body += "Time: #{Time.current.iso8601}\n"
|
|
558
|
+
body += "Record ID: #{@record&.id}\n"
|
|
559
|
+
|
|
560
|
+
# Build mail command based on available MTA
|
|
561
|
+
if command_exists?("mail")
|
|
562
|
+
cmd = "echo #{body.shellescape} | mail -s #{subject.shellescape} #{@config.email_to}"
|
|
563
|
+
Open3.capture3(cmd)
|
|
564
|
+
elsif command_exists?("sendmail")
|
|
565
|
+
mail_content = "To: #{@config.email_to}\nSubject: #{subject}\n\n#{body}"
|
|
566
|
+
cmd = "echo #{mail_content.shellescape} | sendmail -t"
|
|
567
|
+
Open3.capture3(cmd)
|
|
568
|
+
else
|
|
569
|
+
@errors << "No mail command found (mail/sendmail)"
|
|
570
|
+
end
|
|
571
|
+
rescue StandardError
|
|
572
|
+
# Don't fail backup because email notification failed
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# ════════════════════════════════════════════════════════════════
|
|
576
|
+
# HELPERS
|
|
577
|
+
# ════════════════════════════════════════════════════════════════
|
|
578
|
+
|
|
579
|
+
def calculate_size(path)
|
|
580
|
+
if File.directory?(path)
|
|
581
|
+
Dir.glob(File.join(path, "**", "*")).sum { |f| File.exist?(f) ? File.size(f) : 0 }
|
|
582
|
+
else
|
|
583
|
+
File.exist?(path) ? File.size(path) : 0
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def register_temp_file(path)
|
|
588
|
+
@temp_files << path unless @temp_files.include?(path)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def command_exists?(cmd)
|
|
592
|
+
system("which #{cmd} > /dev/null 2>&1")
|
|
274
593
|
end
|
|
275
594
|
end
|
|
276
595
|
end
|