rails_nexus 2.0.6 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +33 -6
- 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/assets/javascripts/rails_nexus/controllers.js +82 -5
- data/app/assets/stylesheets/rails_nexus/application.css +170 -60
- 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/backup_controller.rb +22 -19
- 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 +43 -43
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- 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/controllers/rails_nexus_controller.js +6 -2
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +71 -3
- data/app/javascript/rails_nexus/application.js +15 -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 +38 -2
- data/app/models/rails_nexus/logged_exception.rb +27 -13
- data/app/services/rails_nexus/backup_service.rb +342 -120
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +40 -10
- data/app/views/layouts/rails_nexus/application.html.erb +72 -14
- data/app/views/rails_nexus/backup/_form.html.erb +3 -0
- 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 +12 -44
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +21 -99
- data/app/views/rails_nexus/stats/index.html.erb +1 -1
- data/db/seeds/backup_configs.rb +5 -4
- 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 +8 -2
- 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 -0
- data/lib/rails_nexus/database_adapter.rb +53 -0
- data/lib/rails_nexus/engine.rb +0 -13
- 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 +4 -4
- data/rails_nexus.gemspec +3 -2
- metadata +9 -16
- data/config/importmap.rb +0 -15
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "json"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "tempfile"
|
|
6
8
|
|
|
7
9
|
module RailsNexus
|
|
8
10
|
class BackupService
|
|
@@ -20,28 +22,34 @@ module RailsNexus
|
|
|
20
22
|
|
|
21
23
|
def run
|
|
22
24
|
@record = RailsNexus::Backup.start!(
|
|
23
|
-
|
|
25
|
+
config_name: @config.name,
|
|
24
26
|
triggered_by: "service"
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
begin
|
|
28
|
-
|
|
30
|
+
@stage = "preparing storage"
|
|
31
|
+
validate_identifier!(@config.name, "backup name")
|
|
32
|
+
FileUtils.mkdir_p(validated_path(@config.storage_path_expanded, "storage path"))
|
|
29
33
|
|
|
30
34
|
# Step 1: Dump database
|
|
35
|
+
@stage = "database dump"
|
|
31
36
|
dump_path = dump_database
|
|
32
37
|
raise "Database dump failed" unless dump_path
|
|
33
38
|
|
|
34
39
|
# Step 2: Create archive if enabled (tar additional files/dirs)
|
|
40
|
+
@stage = "archive creation"
|
|
35
41
|
if @config.archive_enabled? && @config.archive_paths_list.any?
|
|
36
42
|
dump_path = create_archive(dump_path)
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# Step 3: Split into chunks if enabled
|
|
46
|
+
@stage = "file splitting"
|
|
40
47
|
if @config.split_chunks? && File.size(dump_path) > 50_000_000 # 50MB
|
|
41
48
|
dump_path = split_file(dump_path)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
# Step 4: Compress
|
|
52
|
+
@stage = "compression"
|
|
45
53
|
if @config.compress? && !@config.split_chunks?
|
|
46
54
|
dump_path = compress(dump_path)
|
|
47
55
|
elsif @config.bzip2_compress? && !@config.split_chunks?
|
|
@@ -49,6 +57,7 @@ module RailsNexus
|
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
# Step 5: Encrypt
|
|
60
|
+
@stage = "encryption"
|
|
52
61
|
if @config.encrypted?
|
|
53
62
|
dump_path = encrypt_openssl(dump_path)
|
|
54
63
|
elsif @config.gpg_enabled?
|
|
@@ -56,26 +65,32 @@ module RailsNexus
|
|
|
56
65
|
end
|
|
57
66
|
|
|
58
67
|
# Step 6: Sync to S3
|
|
68
|
+
@stage = "S3 sync"
|
|
59
69
|
sync_s3(dump_path) if @config.s3_enabled?
|
|
60
70
|
|
|
61
71
|
# Step 7: Sync to remote
|
|
62
|
-
|
|
72
|
+
@stage = "remote sync"
|
|
73
|
+
sync_remote(dump_path) if @config.rsync_enabled?
|
|
63
74
|
|
|
64
75
|
# Step 8: Cleanup old backups
|
|
76
|
+
@stage = "retention cleanup"
|
|
65
77
|
cleanup_old_backups
|
|
66
78
|
|
|
67
79
|
# Step 9: Record success
|
|
80
|
+
@stage = "recording success"
|
|
68
81
|
file_size = calculate_size(dump_path)
|
|
69
82
|
@record.succeed!(file_path: dump_path, file_size: file_size)
|
|
70
83
|
|
|
71
84
|
# Step 10: Notify
|
|
85
|
+
@stage = "success notification"
|
|
72
86
|
notify_success
|
|
73
87
|
|
|
74
88
|
{ success: true, record: @record, file_path: dump_path }
|
|
75
89
|
rescue StandardError => e
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 }
|
|
79
94
|
ensure
|
|
80
95
|
cleanup_temp_files
|
|
81
96
|
end
|
|
@@ -100,11 +115,14 @@ module RailsNexus
|
|
|
100
115
|
|
|
101
116
|
# ─── MySQL ─────────────────────────────────────────────────────
|
|
102
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?
|
|
103
121
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
122
|
+
password = resolve_password(@config.password)
|
|
104
123
|
cmd = ["mysqldump"]
|
|
105
|
-
cmd << "--user=#{@config.username}"
|
|
106
|
-
cmd << "--
|
|
107
|
-
cmd << "--host=#{@config.host}" if @config.host.present?
|
|
124
|
+
cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
|
|
125
|
+
cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
|
|
108
126
|
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
109
127
|
cmd << "--result-file=#{raw_path}"
|
|
110
128
|
cmd << "--single-transaction"
|
|
@@ -112,16 +130,27 @@ module RailsNexus
|
|
|
112
130
|
cmd << "--routines"
|
|
113
131
|
cmd << "--triggers"
|
|
114
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
|
|
115
140
|
|
|
116
141
|
# Skip tables
|
|
117
|
-
@config.skip_tables_list.each
|
|
142
|
+
@config.skip_tables_list.each do |table|
|
|
143
|
+
validate_identifier!(table, "table name")
|
|
144
|
+
cmd << "--ignore-table=#{@config.database_name}.#{table}"
|
|
145
|
+
end
|
|
118
146
|
|
|
119
147
|
cmd << @config.database_name
|
|
120
148
|
|
|
121
|
-
|
|
149
|
+
env = {}
|
|
150
|
+
env["MYSQL_PWD"] = password if password.present?
|
|
151
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
122
152
|
unless status.success?
|
|
123
|
-
|
|
124
|
-
return nil
|
|
153
|
+
raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
125
154
|
end
|
|
126
155
|
register_temp_file(raw_path)
|
|
127
156
|
raw_path
|
|
@@ -129,10 +158,14 @@ module RailsNexus
|
|
|
129
158
|
|
|
130
159
|
# ─── PostgreSQL ────────────────────────────────────────────────
|
|
131
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?
|
|
132
164
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
165
|
+
password = resolve_password(@config.password)
|
|
133
166
|
cmd = ["pg_dump"]
|
|
134
|
-
cmd << "--username=#{@config.username}" if @config.username.present?
|
|
135
|
-
cmd << "--host=#{@config.host}" if @config.host.present?
|
|
167
|
+
cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
|
|
168
|
+
cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
|
|
136
169
|
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
137
170
|
cmd << "--file=#{raw_path}"
|
|
138
171
|
cmd << "--format=plain"
|
|
@@ -140,36 +173,40 @@ module RailsNexus
|
|
|
140
173
|
cmd << "--no-privileges"
|
|
141
174
|
|
|
142
175
|
# Skip tables
|
|
143
|
-
@config.skip_tables_list.each
|
|
176
|
+
@config.skip_tables_list.each do |table|
|
|
177
|
+
validate_identifier!(table, "table name")
|
|
178
|
+
cmd << "--exclude-table=#{table}"
|
|
179
|
+
end
|
|
144
180
|
|
|
145
181
|
cmd << @config.database_name
|
|
146
182
|
|
|
147
183
|
env = {}
|
|
148
|
-
env["PGPASSWORD"] =
|
|
184
|
+
env["PGPASSWORD"] = password if password.present?
|
|
149
185
|
|
|
150
|
-
stdout, stderr, status =
|
|
186
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
151
187
|
unless status.success?
|
|
152
|
-
|
|
153
|
-
return nil
|
|
188
|
+
raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
154
189
|
end
|
|
155
190
|
register_temp_file(raw_path)
|
|
156
191
|
raw_path
|
|
157
192
|
end
|
|
158
193
|
|
|
194
|
+
def capture_command(*command, **options)
|
|
195
|
+
Open3.capture3(*command, **options)
|
|
196
|
+
end
|
|
197
|
+
|
|
159
198
|
# ─── SQLite ────────────────────────────────────────────────────
|
|
160
199
|
def dump_sqlite
|
|
161
200
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
162
|
-
db_path = @config.database_name
|
|
201
|
+
db_path = validated_path(@config.database_name, "SQLite database path")
|
|
163
202
|
|
|
164
203
|
unless File.exist?(db_path)
|
|
165
|
-
|
|
166
|
-
return nil
|
|
204
|
+
raise "SQLite database not found: #{db_path}"
|
|
167
205
|
end
|
|
168
206
|
|
|
169
|
-
stdout, stderr, status =
|
|
207
|
+
stdout, stderr, status = capture_command("sqlite3", db_path, ".dump")
|
|
170
208
|
unless status.success?
|
|
171
|
-
|
|
172
|
-
return nil
|
|
209
|
+
raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
173
210
|
end
|
|
174
211
|
|
|
175
212
|
File.write(raw_path, stdout)
|
|
@@ -179,35 +216,47 @@ module RailsNexus
|
|
|
179
216
|
|
|
180
217
|
# ─── MongoDB ───────────────────────────────────────────────────
|
|
181
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)
|
|
182
222
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
183
223
|
dir_path = raw_path.gsub(/\.sql$/, "")
|
|
184
224
|
FileUtils.mkdir_p(dir_path)
|
|
185
225
|
|
|
226
|
+
password = resolve_password(@config.password)
|
|
186
227
|
cmd = ["mongodump"]
|
|
187
|
-
cmd << "--host=#{@config.host || 'localhost'}"
|
|
228
|
+
cmd << "--host=#{resolve_env(@config.host) || 'localhost'}"
|
|
188
229
|
cmd << "--port=#{@config.port || 27017}"
|
|
189
230
|
cmd << "--db=#{@config.database_name}"
|
|
190
231
|
cmd << "--out=#{dir_path}"
|
|
191
|
-
cmd << "--username=#{@config.username}" if @config.username.present?
|
|
192
|
-
cmd << "--password=#{@config.password}" if @config.password.present?
|
|
232
|
+
cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
|
|
193
233
|
cmd << "--authenticationDatabase=admin" if @config.username.present?
|
|
194
234
|
|
|
195
235
|
# Skip collections
|
|
196
|
-
@config.skip_tables_list.each
|
|
236
|
+
@config.skip_tables_list.each do |collection|
|
|
237
|
+
validate_identifier!(collection, "collection name")
|
|
238
|
+
cmd << "--excludeCollection=#{collection}"
|
|
239
|
+
end
|
|
197
240
|
|
|
198
|
-
stdout, stderr, status =
|
|
241
|
+
stdout, stderr, status = if password.present?
|
|
242
|
+
with_secret_file("rails_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
|
|
199
248
|
unless status.success?
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return nil
|
|
249
|
+
FileUtils.rm_rf(dir_path)
|
|
250
|
+
raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
203
251
|
end
|
|
204
252
|
|
|
205
253
|
# Tar the dump directory
|
|
206
254
|
tar_path = "#{dir_path}.tar"
|
|
207
|
-
stdout, stderr, status =
|
|
255
|
+
stdout, stderr, status = capture_command(
|
|
256
|
+
"tar", "-cf", tar_path, "-C", File.dirname(dir_path), File.basename(dir_path)
|
|
257
|
+
)
|
|
208
258
|
unless status.success?
|
|
209
|
-
|
|
210
|
-
return nil
|
|
259
|
+
raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
211
260
|
end
|
|
212
261
|
|
|
213
262
|
FileUtils.rm_rf(dir_path)
|
|
@@ -220,16 +269,14 @@ module RailsNexus
|
|
|
220
269
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
221
270
|
raw_path = raw_path.gsub(/\.sql$/, ".rdb")
|
|
222
271
|
|
|
223
|
-
host = @config.host || "localhost"
|
|
272
|
+
host = @config.host.presence || "localhost"
|
|
224
273
|
port = @config.port || 6379
|
|
225
|
-
password = @config.password
|
|
274
|
+
password = resolve_password(@config.password)
|
|
275
|
+
validate_host!(host)
|
|
276
|
+
validate_port!(port)
|
|
226
277
|
|
|
227
278
|
# Trigger BGSAVE first
|
|
228
|
-
|
|
229
|
-
cmd += " -a #{password}" if password.present?
|
|
230
|
-
cmd += " BGSAVE"
|
|
231
|
-
|
|
232
|
-
stdout, stderr, status = Open3.capture3(cmd)
|
|
279
|
+
_, stderr, status = capture_redis(host, port, password, "BGSAVE")
|
|
233
280
|
unless status.success?
|
|
234
281
|
@errors << "redis BGSAVE failed: #{stderr}"
|
|
235
282
|
return nil
|
|
@@ -240,8 +287,7 @@ module RailsNexus
|
|
|
240
287
|
|
|
241
288
|
# Try to find the dump.rdb on the server or use redis-cli to copy
|
|
242
289
|
# If local Redis, copy the dump.rdb directly
|
|
243
|
-
|
|
244
|
-
dir_out, = Open3.capture3(redis_dir_cmd)
|
|
290
|
+
dir_out, = capture_redis(host, port, password, "CONFIG", "GET", "dir")
|
|
245
291
|
redis_dir = dir_out.split("\n").last || "/var/lib/redis"
|
|
246
292
|
|
|
247
293
|
redis_rdb = File.join(redis_dir, "dump.rdb")
|
|
@@ -257,23 +303,20 @@ module RailsNexus
|
|
|
257
303
|
end
|
|
258
304
|
|
|
259
305
|
def dump_all_keys(host, port, password, output_path)
|
|
260
|
-
|
|
261
|
-
cmd += " -a #{password}" if password.present?
|
|
262
|
-
|
|
263
|
-
keys_out, = Open3.capture3("#{cmd} KEYS '*'")
|
|
306
|
+
keys_out, = capture_redis(host, port, password, "KEYS", "*")
|
|
264
307
|
keys = keys_out.split("\n").reject { |k| k.start_with?("redis") }
|
|
265
308
|
|
|
266
309
|
File.open(output_path, "w") do |f|
|
|
267
310
|
keys.each do |key|
|
|
268
|
-
type_out, =
|
|
311
|
+
type_out, = capture_redis(host, port, password, "TYPE", key)
|
|
269
312
|
type = type_out.strip.split("\n").last
|
|
270
313
|
|
|
271
314
|
val_out, = case type
|
|
272
|
-
when "string" then
|
|
273
|
-
when "list" then
|
|
274
|
-
when "set" then
|
|
275
|
-
when "hash" then
|
|
276
|
-
else
|
|
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)
|
|
277
320
|
end
|
|
278
321
|
|
|
279
322
|
f.puts("SET #{key.inspect} #{val_out.strip.inspect}")
|
|
@@ -305,14 +348,19 @@ module RailsNexus
|
|
|
305
348
|
@temp_files << dump_path
|
|
306
349
|
end
|
|
307
350
|
|
|
308
|
-
@config.archive_paths_list.each
|
|
351
|
+
@config.archive_paths_list.each do |path|
|
|
352
|
+
cmd << validated_path(path, "archive path")
|
|
353
|
+
end
|
|
309
354
|
|
|
310
355
|
# Exclude patterns
|
|
311
356
|
if @config.archive_excludes_list.any?
|
|
312
|
-
@config.archive_excludes_list.each
|
|
357
|
+
@config.archive_excludes_list.each do |pattern|
|
|
358
|
+
validate_text_argument!(pattern, "archive exclusion")
|
|
359
|
+
cmd << "--exclude=#{pattern}"
|
|
360
|
+
end
|
|
313
361
|
end
|
|
314
362
|
|
|
315
|
-
|
|
363
|
+
_, stderr, status = capture_command(*cmd)
|
|
316
364
|
unless status.success?
|
|
317
365
|
@errors << "archive tar failed: #{stderr}"
|
|
318
366
|
return dump_path
|
|
@@ -338,7 +386,7 @@ module RailsNexus
|
|
|
338
386
|
|
|
339
387
|
def compress_bzip2(file_path)
|
|
340
388
|
bz2_path = "#{file_path}.bz2"
|
|
341
|
-
|
|
389
|
+
_, stderr, status = capture_command("bzip2", "-zk", file_path)
|
|
342
390
|
unless status.success?
|
|
343
391
|
@errors << "bzip2 failed: #{stderr}"
|
|
344
392
|
return file_path
|
|
@@ -353,13 +401,18 @@ module RailsNexus
|
|
|
353
401
|
|
|
354
402
|
def encrypt_openssl(file_path)
|
|
355
403
|
enc_path = "#{file_path}.enc"
|
|
356
|
-
password = @config.encryption_password
|
|
404
|
+
password = resolve_secret(@config.encryption_password)
|
|
357
405
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
406
|
+
# Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
|
|
407
|
+
# (AES-GCM not available in all OpenSSL builds)
|
|
408
|
+
env = { "RAILS_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:RAILS_NEXUS_OPENSSL_PASSWORD"
|
|
413
|
+
]
|
|
361
414
|
|
|
362
|
-
|
|
415
|
+
_, stderr, status = capture_command(env, *cmd)
|
|
363
416
|
unless status.success?
|
|
364
417
|
@errors << "OpenSSL encryption failed: #{stderr}"
|
|
365
418
|
return file_path
|
|
@@ -371,14 +424,18 @@ module RailsNexus
|
|
|
371
424
|
|
|
372
425
|
def encrypt_gpg(file_path)
|
|
373
426
|
enc_path = "#{file_path}.gpg"
|
|
374
|
-
password = @config.gpg_password
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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("rails_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
|
|
382
439
|
unless status.success?
|
|
383
440
|
@errors << "GPG encryption failed: #{stderr}"
|
|
384
441
|
return file_path
|
|
@@ -397,8 +454,7 @@ module RailsNexus
|
|
|
397
454
|
FileUtils.mkdir_p(chunk_dir)
|
|
398
455
|
|
|
399
456
|
# Split 50MB chunks
|
|
400
|
-
|
|
401
|
-
stdout, stderr, status = Open3.capture3(cmd)
|
|
457
|
+
_, stderr, status = capture_command("split", "-b", "50m", file_path, File.join(chunk_dir, "part_"))
|
|
402
458
|
|
|
403
459
|
unless status.success?
|
|
404
460
|
@errors << "split failed: #{stderr}"
|
|
@@ -417,18 +473,44 @@ module RailsNexus
|
|
|
417
473
|
return unless @config.rsync_enabled?
|
|
418
474
|
return unless @config.rsync_host.present?
|
|
419
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)
|
|
420
480
|
remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
|
|
421
481
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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)
|
|
432
514
|
unless status.success?
|
|
433
515
|
@errors << "RSync failed: #{stderr}"
|
|
434
516
|
end
|
|
@@ -451,16 +533,20 @@ module RailsNexus
|
|
|
451
533
|
end
|
|
452
534
|
|
|
453
535
|
def sync_s3_aws(file_path)
|
|
536
|
+
validate_s3_bucket!(@config.s3_bucket)
|
|
454
537
|
bucket = @config.s3_bucket
|
|
455
538
|
prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
|
|
456
539
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
+
]
|
|
462
548
|
|
|
463
|
-
stdout, stderr, status =
|
|
549
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
464
550
|
unless status.success?
|
|
465
551
|
@errors << "S3 upload failed: #{stderr}"
|
|
466
552
|
end
|
|
@@ -469,16 +555,20 @@ module RailsNexus
|
|
|
469
555
|
end
|
|
470
556
|
|
|
471
557
|
def sync_s3_s3cmd(file_path)
|
|
558
|
+
validate_s3_bucket!(@config.s3_bucket)
|
|
472
559
|
bucket = @config.s3_bucket
|
|
473
560
|
prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
|
|
474
561
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
+
]
|
|
480
570
|
|
|
481
|
-
stdout, stderr, status =
|
|
571
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
482
572
|
unless status.success?
|
|
483
573
|
@errors << "S3 upload (s3cmd) failed: #{stderr}"
|
|
484
574
|
end
|
|
@@ -499,22 +589,12 @@ module RailsNexus
|
|
|
499
589
|
|
|
500
590
|
while files.size > @config.keep_count
|
|
501
591
|
old_file = files.shift
|
|
502
|
-
|
|
503
|
-
FileUtils.rm_rf(old_file)
|
|
504
|
-
else
|
|
505
|
-
File.delete(old_file) if File.exist?(old_file)
|
|
506
|
-
end
|
|
592
|
+
FileUtils.rm_rf(old_file)
|
|
507
593
|
end
|
|
508
594
|
end
|
|
509
595
|
|
|
510
596
|
def cleanup_temp_files
|
|
511
|
-
@temp_files.each
|
|
512
|
-
if File.directory?(f)
|
|
513
|
-
FileUtils.rm_rf(f)
|
|
514
|
-
else
|
|
515
|
-
File.delete(f) if File.exist?(f)
|
|
516
|
-
end
|
|
517
|
-
end
|
|
597
|
+
@temp_files.each { |file| FileUtils.rm_rf(file) }
|
|
518
598
|
end
|
|
519
599
|
|
|
520
600
|
# ════════════════════════════════════════════════════════════════
|
|
@@ -536,13 +616,23 @@ module RailsNexus
|
|
|
536
616
|
def execute_notify_command(status, error = nil)
|
|
537
617
|
return if @config.notify_command.blank?
|
|
538
618
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
|
544
624
|
|
|
545
|
-
|
|
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)
|
|
546
636
|
rescue StandardError
|
|
547
637
|
# Don't fail backup because notification failed
|
|
548
638
|
end
|
|
@@ -559,12 +649,10 @@ module RailsNexus
|
|
|
559
649
|
|
|
560
650
|
# Build mail command based on available MTA
|
|
561
651
|
if command_exists?("mail")
|
|
562
|
-
|
|
563
|
-
Open3.capture3(cmd)
|
|
652
|
+
capture_command("mail", "-s", subject, @config.email_to.to_s, stdin_data: body)
|
|
564
653
|
elsif command_exists?("sendmail")
|
|
565
654
|
mail_content = "To: #{@config.email_to}\nSubject: #{subject}\n\n#{body}"
|
|
566
|
-
|
|
567
|
-
Open3.capture3(cmd)
|
|
655
|
+
capture_command("sendmail", "-t", stdin_data: mail_content)
|
|
568
656
|
else
|
|
569
657
|
@errors << "No mail command found (mail/sendmail)"
|
|
570
658
|
end
|
|
@@ -588,8 +676,142 @@ module RailsNexus
|
|
|
588
676
|
@temp_files << path unless @temp_files.include?(path)
|
|
589
677
|
end
|
|
590
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
|
+
|
|
591
707
|
def command_exists?(cmd)
|
|
592
|
-
|
|
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")
|
|
593
815
|
end
|
|
594
816
|
end
|
|
595
817
|
end
|