kamal-backup 0.3.0.beta20 → 0.3.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/README.md +1 -0
- data/exe/kamal-backup +7 -6
- data/lib/kamal_backup/app.rb +350 -356
- data/lib/kamal_backup/cli.rb +107 -111
- data/lib/kamal_backup/command.rb +165 -161
- data/lib/kamal_backup/config.rb +555 -437
- data/lib/kamal_backup/databases/base.rb +17 -14
- data/lib/kamal_backup/databases/mysql.rb +68 -67
- data/lib/kamal_backup/databases/postgres.rb +59 -58
- data/lib/kamal_backup/databases/sqlite.rb +21 -20
- data/lib/kamal_backup/errors.rb +3 -1
- data/lib/kamal_backup/evidence.rb +62 -61
- data/lib/kamal_backup/kamal_bridge.rb +254 -250
- data/lib/kamal_backup/rails_app.rb +102 -101
- data/lib/kamal_backup/redactor.rb +18 -13
- data/lib/kamal_backup/restic.rb +196 -163
- data/lib/kamal_backup/scheduler.rb +17 -14
- data/lib/kamal_backup/schema.rb +2 -0
- data/lib/kamal_backup/version.rb +3 -1
- data/lib/kamal_backup.rb +19 -17
- metadata +30 -2
data/lib/kamal_backup/config.rb
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'rails_app'
|
|
5
7
|
|
|
6
8
|
module KamalBackup
|
|
7
9
|
class Config
|
|
8
10
|
DEFAULT_RETENTION = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
'RESTIC_KEEP_LAST' => '7',
|
|
12
|
+
'RESTIC_KEEP_DAILY' => '7',
|
|
13
|
+
'RESTIC_KEEP_WEEKLY' => '4',
|
|
14
|
+
'RESTIC_KEEP_MONTHLY' => '6',
|
|
15
|
+
'RESTIC_KEEP_YEARLY' => '2'
|
|
14
16
|
}.freeze
|
|
15
17
|
|
|
16
18
|
SUSPICIOUS_BACKUP_PATHS = %w[/ /var /etc /root /usr /bin /sbin /boot /dev /proc /sys /run].freeze
|
|
17
|
-
SHARED_CONFIG_PATH =
|
|
18
|
-
LOCAL_CONFIG_PATH =
|
|
19
|
+
SHARED_CONFIG_PATH = 'config/kamal-backup.yml'
|
|
20
|
+
LOCAL_CONFIG_PATH = 'config/kamal-backup.local.yml'
|
|
19
21
|
DEFAULT_CONFIG_PATHS = [SHARED_CONFIG_PATH, LOCAL_CONFIG_PATH].freeze
|
|
20
22
|
TOP_LEVEL_YAML_KEYS = %w[app accessory databases paths restore_from restic backup state].freeze
|
|
21
23
|
LEGACY_YAML_KEYS = %w[
|
|
@@ -46,11 +48,13 @@ module KamalBackup
|
|
|
46
48
|
pgpassword
|
|
47
49
|
mysql_pwd
|
|
48
50
|
].freeze
|
|
49
|
-
ConfigData = Struct.new(:env, :database_definitions, :path_definitions, :restore_from_definitions,
|
|
51
|
+
ConfigData = Struct.new(:env, :database_definitions, :path_definitions, :restore_from_definitions,
|
|
52
|
+
keyword_init: true) do
|
|
50
53
|
def self.empty
|
|
51
54
|
new(env: {}, database_definitions: nil, path_definitions: nil, restore_from_definitions: nil)
|
|
52
55
|
end
|
|
53
56
|
end
|
|
57
|
+
PathDefinition = Struct.new(:path, :exclude, keyword_init: true)
|
|
54
58
|
|
|
55
59
|
class DatabaseSource
|
|
56
60
|
CONNECTION_KEYS = %w[
|
|
@@ -97,7 +101,7 @@ module KamalBackup
|
|
|
97
101
|
end
|
|
98
102
|
|
|
99
103
|
def database_name
|
|
100
|
-
name.empty? ?
|
|
104
|
+
name.empty? ? 'app' : name
|
|
101
105
|
end
|
|
102
106
|
|
|
103
107
|
def database_adapter
|
|
@@ -144,93 +148,99 @@ module KamalBackup
|
|
|
144
148
|
end
|
|
145
149
|
|
|
146
150
|
def app_name
|
|
147
|
-
value(
|
|
151
|
+
value('APP_NAME')
|
|
148
152
|
end
|
|
149
153
|
|
|
150
154
|
def required_app_name
|
|
151
|
-
required_value(
|
|
155
|
+
required_value('APP_NAME')
|
|
152
156
|
end
|
|
153
157
|
|
|
154
158
|
def accessory_name
|
|
155
|
-
value(
|
|
159
|
+
value('KAMAL_BACKUP_ACCESSORY')
|
|
156
160
|
end
|
|
157
161
|
|
|
158
162
|
def restic_repository
|
|
159
|
-
value(
|
|
163
|
+
value('RESTIC_REPOSITORY')
|
|
160
164
|
end
|
|
161
165
|
|
|
162
166
|
def restic_repository_file
|
|
163
|
-
value(
|
|
167
|
+
value('RESTIC_REPOSITORY_FILE')
|
|
164
168
|
end
|
|
165
169
|
|
|
166
170
|
def restic_password
|
|
167
|
-
value(
|
|
171
|
+
value('RESTIC_PASSWORD')
|
|
168
172
|
end
|
|
169
173
|
|
|
170
174
|
def restic_password_file
|
|
171
|
-
value(
|
|
175
|
+
value('RESTIC_PASSWORD_FILE')
|
|
172
176
|
end
|
|
173
177
|
|
|
174
178
|
def restic_password_command
|
|
175
|
-
value(
|
|
179
|
+
value('RESTIC_PASSWORD_COMMAND')
|
|
176
180
|
end
|
|
177
181
|
|
|
178
182
|
def restic_init_if_missing?
|
|
179
|
-
truthy?(
|
|
183
|
+
truthy?('RESTIC_INIT_IF_MISSING')
|
|
180
184
|
end
|
|
181
185
|
|
|
182
186
|
def check_after_backup?
|
|
183
|
-
truthy?(
|
|
187
|
+
truthy?('RESTIC_CHECK_AFTER_BACKUP')
|
|
184
188
|
end
|
|
185
189
|
|
|
186
190
|
def forget_after_backup?
|
|
187
|
-
!falsey?(
|
|
191
|
+
!falsey?('RESTIC_FORGET_AFTER_BACKUP')
|
|
188
192
|
end
|
|
189
193
|
|
|
190
194
|
def check_read_data_subset
|
|
191
|
-
value(
|
|
195
|
+
value('RESTIC_CHECK_READ_DATA_SUBSET')
|
|
192
196
|
end
|
|
193
197
|
|
|
194
198
|
def allow_in_place_file_restore?
|
|
195
|
-
truthy?(
|
|
199
|
+
truthy?('KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE')
|
|
196
200
|
end
|
|
197
201
|
|
|
198
202
|
def allow_suspicious_backup_paths?
|
|
199
|
-
truthy?(
|
|
203
|
+
truthy?('KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS')
|
|
200
204
|
end
|
|
201
205
|
|
|
202
206
|
def backup_schedule_seconds
|
|
203
|
-
integer(
|
|
207
|
+
integer('BACKUP_SCHEDULE_SECONDS', 86_400, minimum: 1)
|
|
204
208
|
end
|
|
205
209
|
|
|
206
210
|
def backup_start_delay_seconds
|
|
207
|
-
integer(
|
|
211
|
+
integer('BACKUP_START_DELAY_SECONDS', 0, minimum: 0)
|
|
208
212
|
end
|
|
209
213
|
|
|
210
214
|
def state_dir
|
|
211
|
-
value(
|
|
215
|
+
value('KAMAL_BACKUP_STATE_DIR') || '/var/lib/kamal-backup'
|
|
212
216
|
end
|
|
213
217
|
|
|
214
218
|
def last_check_path
|
|
215
|
-
File.join(state_dir,
|
|
219
|
+
File.join(state_dir, 'last_check.json')
|
|
216
220
|
end
|
|
217
221
|
|
|
218
222
|
def last_backup_path
|
|
219
|
-
File.join(state_dir,
|
|
223
|
+
File.join(state_dir, 'last_backup.json')
|
|
220
224
|
end
|
|
221
225
|
|
|
222
226
|
def last_restore_drill_path
|
|
223
|
-
File.join(state_dir,
|
|
227
|
+
File.join(state_dir, 'last_restore_drill.json')
|
|
224
228
|
end
|
|
225
229
|
|
|
226
230
|
def backup_paths
|
|
227
231
|
if path_definitions?
|
|
228
|
-
@path_definitions
|
|
232
|
+
@path_definitions.map(&:path)
|
|
229
233
|
else
|
|
230
234
|
legacy_backup_paths
|
|
231
235
|
end
|
|
232
236
|
end
|
|
233
237
|
|
|
238
|
+
def backup_path_excludes(paths = backup_paths)
|
|
239
|
+
paths = Array(paths).compact.map(&:to_s).reject(&:empty?)
|
|
240
|
+
|
|
241
|
+
configured_backup_path_excludes(paths) + sqlite_backup_path_excludes(paths)
|
|
242
|
+
end
|
|
243
|
+
|
|
234
244
|
def local_restore_source_paths
|
|
235
245
|
if path_definitions?
|
|
236
246
|
@restore_from_definitions || legacy_local_restore_source_paths || backup_paths
|
|
@@ -243,16 +253,14 @@ module KamalBackup
|
|
|
243
253
|
source_paths = local_restore_source_paths
|
|
244
254
|
target_paths = backup_paths
|
|
245
255
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
raise ConfigurationError, "local restore source paths must contain the same number of paths as file paths"
|
|
250
|
-
end
|
|
256
|
+
raise ConfigurationError, 'local restore source paths must contain the same number of paths as file paths' unless source_paths.size == target_paths.size
|
|
257
|
+
|
|
258
|
+
source_paths.zip(target_paths)
|
|
251
259
|
end
|
|
252
260
|
|
|
253
261
|
def backup_path_label(path)
|
|
254
|
-
label = path.to_s.sub(%r{\A/+},
|
|
255
|
-
label.empty? ?
|
|
262
|
+
label = path.to_s.sub(%r{\A/+}, '').gsub(/[^A-Za-z0-9_.-]+/, '-')
|
|
263
|
+
label.empty? ? 'root' : label
|
|
256
264
|
end
|
|
257
265
|
|
|
258
266
|
def database_adapter
|
|
@@ -264,36 +272,34 @@ module KamalBackup
|
|
|
264
272
|
end
|
|
265
273
|
|
|
266
274
|
def database_name
|
|
267
|
-
|
|
275
|
+
'app'
|
|
268
276
|
end
|
|
269
277
|
|
|
270
278
|
def databases
|
|
271
|
-
@databases ||=
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
end
|
|
296
|
-
end
|
|
279
|
+
@databases ||= if database_definitions?
|
|
280
|
+
@database_definitions.map do |definition|
|
|
281
|
+
DatabaseSource.new(
|
|
282
|
+
parent: self,
|
|
283
|
+
name: definition.fetch(:name),
|
|
284
|
+
adapter: definition.fetch(:adapter),
|
|
285
|
+
env: definition.fetch(:env),
|
|
286
|
+
structured: true,
|
|
287
|
+
missing_secrets: definition.fetch(:missing_secrets, [])
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
elsif legacy_database_adapter
|
|
291
|
+
[
|
|
292
|
+
DatabaseSource.new(
|
|
293
|
+
parent: self,
|
|
294
|
+
name: database_name,
|
|
295
|
+
adapter: legacy_database_adapter,
|
|
296
|
+
env: {},
|
|
297
|
+
structured: false
|
|
298
|
+
)
|
|
299
|
+
]
|
|
300
|
+
else
|
|
301
|
+
[]
|
|
302
|
+
end
|
|
297
303
|
end
|
|
298
304
|
|
|
299
305
|
def retention
|
|
@@ -309,7 +315,7 @@ module KamalBackup
|
|
|
309
315
|
number = Integer(raw)
|
|
310
316
|
next if number <= 0
|
|
311
317
|
|
|
312
|
-
flag = "--#{key.sub(
|
|
318
|
+
flag = "--#{key.sub('RESTIC_KEEP_', 'keep-').downcase.tr('_', '-')}"
|
|
313
319
|
args.concat([flag, number.to_s])
|
|
314
320
|
rescue ArgumentError
|
|
315
321
|
raise ConfigurationError, "#{key} must be an integer"
|
|
@@ -334,27 +340,34 @@ module KamalBackup
|
|
|
334
340
|
end
|
|
335
341
|
|
|
336
342
|
def validate_database_backup(check_files: true)
|
|
337
|
-
raise ConfigurationError,
|
|
343
|
+
raise ConfigurationError, 'databases must contain at least one database' if databases.empty?
|
|
338
344
|
|
|
339
345
|
databases.each do |database|
|
|
340
346
|
unless database.missing_secrets.empty?
|
|
341
|
-
raise ConfigurationError,
|
|
347
|
+
raise ConfigurationError,
|
|
348
|
+
"database #{database.database_name} requires missing secret #{database.missing_secrets.join(', ')}"
|
|
342
349
|
end
|
|
343
350
|
|
|
344
351
|
case database.database_adapter
|
|
345
|
-
when
|
|
346
|
-
unless database.value(
|
|
347
|
-
raise ConfigurationError,
|
|
352
|
+
when 'postgres'
|
|
353
|
+
unless database.value('DATABASE_URL') || database.value('PGDATABASE')
|
|
354
|
+
raise ConfigurationError,
|
|
355
|
+
"PostgreSQL database #{database.database_name} requires url or PGDATABASE/libpq environment"
|
|
356
|
+
end
|
|
357
|
+
when 'mysql'
|
|
358
|
+
unless database.value('DATABASE_URL') || database.value('MYSQL_DATABASE') || database.value('MARIADB_DATABASE')
|
|
359
|
+
raise ConfigurationError,
|
|
360
|
+
"MySQL database #{database.database_name} requires url or MYSQL_DATABASE/MARIADB_DATABASE"
|
|
348
361
|
end
|
|
349
|
-
when
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
when 'sqlite'
|
|
363
|
+
path = database.required_value('SQLITE_DATABASE_PATH')
|
|
364
|
+
if check_files && !File.file?(path)
|
|
365
|
+
raise ConfigurationError,
|
|
366
|
+
"SQLite database #{database.database_name} does not exist: #{path}"
|
|
352
367
|
end
|
|
353
|
-
when "sqlite"
|
|
354
|
-
path = database.required_value("SQLITE_DATABASE_PATH")
|
|
355
|
-
raise ConfigurationError, "SQLite database #{database.database_name} does not exist: #{path}" if check_files && !File.file?(path)
|
|
356
368
|
else
|
|
357
|
-
raise ConfigurationError,
|
|
369
|
+
raise ConfigurationError,
|
|
370
|
+
"database #{database.database_name} adapter is required and must be postgres, mysql, or sqlite"
|
|
358
371
|
end
|
|
359
372
|
end
|
|
360
373
|
end
|
|
@@ -363,49 +376,49 @@ module KamalBackup
|
|
|
363
376
|
backup_paths.each do |path|
|
|
364
377
|
expanded = File.expand_path(path)
|
|
365
378
|
if SUSPICIOUS_BACKUP_PATHS.include?(expanded) && !allow_suspicious_backup_paths?
|
|
366
|
-
raise ConfigurationError,
|
|
379
|
+
raise ConfigurationError,
|
|
380
|
+
"refusing suspicious backup path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
|
|
367
381
|
end
|
|
368
382
|
raise ConfigurationError, "backup path does not exist: #{path}" if check_files && !File.exist?(path)
|
|
369
383
|
end
|
|
370
384
|
end
|
|
371
385
|
|
|
372
386
|
def validate_local_database_restore_target(target)
|
|
373
|
-
raise ConfigurationError,
|
|
387
|
+
raise ConfigurationError, 'local restore database target is required' if target.to_s.strip.empty?
|
|
374
388
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
389
|
+
return unless production_named_target?(target)
|
|
390
|
+
|
|
391
|
+
raise ConfigurationError,
|
|
392
|
+
"refusing production-looking local restore target #{target}; use restore production for production restores"
|
|
378
393
|
end
|
|
379
394
|
|
|
380
395
|
def validate_file_restore_target(target)
|
|
381
|
-
raise ConfigurationError,
|
|
396
|
+
raise ConfigurationError, 'restore target cannot be empty' if target.to_s.strip.empty?
|
|
382
397
|
|
|
383
398
|
expanded_target = File.expand_path(target)
|
|
384
|
-
raise ConfigurationError,
|
|
399
|
+
raise ConfigurationError, 'refusing to restore files to /' if expanded_target == '/'
|
|
385
400
|
|
|
386
401
|
if in_place_file_restore?(expanded_target) && !allow_in_place_file_restore?
|
|
387
|
-
raise ConfigurationError,
|
|
402
|
+
raise ConfigurationError,
|
|
403
|
+
"refusing in-place file restore to #{expanded_target}; set KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE=true to override"
|
|
388
404
|
end
|
|
389
405
|
|
|
390
406
|
expanded_target
|
|
391
407
|
end
|
|
392
408
|
|
|
393
409
|
def validate_database_restore_target(target)
|
|
394
|
-
raise ConfigurationError,
|
|
410
|
+
raise ConfigurationError, 'restore database target is required' if target.to_s.strip.empty?
|
|
395
411
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
412
|
+
return unless production_like_target?(target)
|
|
413
|
+
|
|
414
|
+
raise ConfigurationError,
|
|
415
|
+
"refusing production-looking restore target #{target}; choose a scratch target that does not look like production"
|
|
399
416
|
end
|
|
400
417
|
|
|
401
418
|
def production_like_target?(target)
|
|
402
419
|
target = target.to_s
|
|
403
420
|
|
|
404
|
-
|
|
405
|
-
true
|
|
406
|
-
else
|
|
407
|
-
production_named_target?(target.downcase)
|
|
408
|
-
end
|
|
421
|
+
source_database_targets.include?(target) || production_named_target?(target.downcase)
|
|
409
422
|
end
|
|
410
423
|
|
|
411
424
|
def value(key)
|
|
@@ -429,450 +442,555 @@ module KamalBackup
|
|
|
429
442
|
end
|
|
430
443
|
|
|
431
444
|
private
|
|
432
|
-
def project_defaults(cwd:)
|
|
433
|
-
RailsApp.new(cwd: cwd).defaults
|
|
434
|
-
end
|
|
435
445
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
446
|
+
def project_defaults(cwd:)
|
|
447
|
+
RailsApp.new(cwd: cwd).defaults
|
|
448
|
+
end
|
|
439
449
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
merged.path_definitions = data.path_definitions if data.path_definitions
|
|
444
|
-
merged.restore_from_definitions = data.restore_from_definitions if data.restore_from_definitions
|
|
445
|
-
end
|
|
446
|
-
end
|
|
450
|
+
def load_config_files(raw_env, cwd:, paths:)
|
|
451
|
+
config_paths(raw_env, cwd: cwd, paths: paths).each_with_object(ConfigData.empty) do |path, merged|
|
|
452
|
+
next unless File.file?(path)
|
|
447
453
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
else
|
|
454
|
-
DEFAULT_CONFIG_PATHS.map { |relative| File.expand_path(relative, cwd) }
|
|
455
|
-
end
|
|
454
|
+
data = normalize_config_file(path, raw_env: raw_env)
|
|
455
|
+
merged.env.merge!(data.env)
|
|
456
|
+
merged.database_definitions = data.database_definitions if data.database_definitions
|
|
457
|
+
merged.path_definitions = data.path_definitions if data.path_definitions
|
|
458
|
+
merged.restore_from_definitions = data.restore_from_definitions if data.restore_from_definitions
|
|
456
459
|
end
|
|
460
|
+
end
|
|
457
461
|
|
|
458
|
-
|
|
459
|
-
|
|
462
|
+
def config_paths(raw_env, cwd:, paths:)
|
|
463
|
+
if paths
|
|
464
|
+
Array(paths).map { |path| File.expand_path(path, cwd) }
|
|
465
|
+
elsif (explicit = raw_env['KAMAL_BACKUP_CONFIG'])
|
|
466
|
+
[File.expand_path(explicit, cwd)]
|
|
467
|
+
else
|
|
468
|
+
DEFAULT_CONFIG_PATHS.map { |relative| File.expand_path(relative, cwd) }
|
|
469
|
+
end
|
|
470
|
+
end
|
|
460
471
|
|
|
461
|
-
|
|
462
|
-
|
|
472
|
+
def validate_restic_repository(check_files:)
|
|
473
|
+
return if restic_repository
|
|
463
474
|
|
|
464
|
-
|
|
465
|
-
|
|
475
|
+
if (path = restic_repository_file)
|
|
476
|
+
raise ConfigurationError, "RESTIC_REPOSITORY_FILE does not exist: #{path}" if check_files && !File.file?(path)
|
|
466
477
|
|
|
467
|
-
|
|
478
|
+
return
|
|
468
479
|
end
|
|
469
480
|
|
|
470
|
-
|
|
471
|
-
|
|
481
|
+
raise ConfigurationError, 'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required'
|
|
482
|
+
end
|
|
472
483
|
|
|
473
|
-
|
|
474
|
-
|
|
484
|
+
def validate_restic_password(check_files:)
|
|
485
|
+
return if restic_password || restic_password_command
|
|
475
486
|
|
|
476
|
-
|
|
477
|
-
|
|
487
|
+
if (path = restic_password_file)
|
|
488
|
+
raise ConfigurationError, "RESTIC_PASSWORD_FILE does not exist: #{path}" if check_files && !File.file?(path)
|
|
478
489
|
|
|
479
|
-
|
|
490
|
+
return
|
|
480
491
|
end
|
|
481
492
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
return ConfigData.empty if data.nil?
|
|
493
|
+
raise ConfigurationError, 'RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or RESTIC_PASSWORD_COMMAND is required'
|
|
494
|
+
end
|
|
485
495
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
496
|
+
def normalize_config_file(path, raw_env:)
|
|
497
|
+
data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
498
|
+
return ConfigData.empty if data.nil?
|
|
489
499
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
500
|
+
raise ConfigurationError, "#{path} must contain a YAML mapping" unless data.is_a?(Hash)
|
|
501
|
+
|
|
502
|
+
result = ConfigData.empty
|
|
503
|
+
data.each do |raw_key, raw_value|
|
|
504
|
+
key = raw_key.to_s
|
|
505
|
+
validate_top_level_yaml_key!(path, key)
|
|
506
|
+
|
|
507
|
+
case key
|
|
508
|
+
when 'app'
|
|
509
|
+
result.env['APP_NAME'] = normalize_yaml_value(raw_value)
|
|
510
|
+
when 'accessory'
|
|
511
|
+
result.env['KAMAL_BACKUP_ACCESSORY'] = normalize_yaml_value(raw_value)
|
|
512
|
+
when 'databases'
|
|
513
|
+
result.database_definitions = normalize_yaml_databases(raw_value, raw_env: raw_env, path: path)
|
|
514
|
+
when 'paths'
|
|
515
|
+
result.path_definitions = normalize_yaml_backup_paths(raw_value, "#{path} paths")
|
|
516
|
+
when 'restore_from'
|
|
517
|
+
result.restore_from_definitions = normalize_yaml_paths(raw_value, "#{path} restore_from")
|
|
518
|
+
when 'restic'
|
|
519
|
+
result.env.merge!(normalize_yaml_restic(raw_value, raw_env: raw_env, path: path))
|
|
520
|
+
when 'backup'
|
|
521
|
+
result.env.merge!(normalize_yaml_backup(raw_value, path: path))
|
|
522
|
+
when 'state'
|
|
523
|
+
result.env.merge!(normalize_yaml_state(raw_value, path: path))
|
|
513
524
|
end
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
525
|
+
end
|
|
526
|
+
result
|
|
527
|
+
rescue Psych::SyntaxError => e
|
|
528
|
+
raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def validate_top_level_yaml_key!(path, key)
|
|
532
|
+
if LEGACY_YAML_KEYS.include?(key)
|
|
533
|
+
raise ConfigurationError,
|
|
534
|
+
"#{path} uses legacy key #{key}; use databases, paths, restic, and backup instead. See the upgrading guide for the 0.3 config migration."
|
|
517
535
|
end
|
|
518
536
|
|
|
519
|
-
|
|
520
|
-
if LEGACY_YAML_KEYS.include?(key)
|
|
521
|
-
raise ConfigurationError, "#{path} uses legacy key #{key}; use databases, paths, restic, and backup instead. See the upgrading guide for the 0.3 config migration."
|
|
522
|
-
end
|
|
537
|
+
return if TOP_LEVEL_YAML_KEYS.include?(key)
|
|
523
538
|
|
|
524
|
-
|
|
539
|
+
raise ConfigurationError,
|
|
540
|
+
"#{path} contains unknown key #{key.inspect}; expected #{TOP_LEVEL_YAML_KEYS.join(', ')}"
|
|
541
|
+
end
|
|
525
542
|
|
|
526
|
-
|
|
543
|
+
def normalize_yaml_value(raw_value)
|
|
544
|
+
case raw_value
|
|
545
|
+
when Array
|
|
546
|
+
raw_value.map(&:to_s).join("\n")
|
|
547
|
+
when NilClass
|
|
548
|
+
nil
|
|
549
|
+
else
|
|
550
|
+
raw_value.to_s
|
|
527
551
|
end
|
|
552
|
+
end
|
|
528
553
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
554
|
+
def normalize_yaml_databases(raw_value, raw_env:, path:)
|
|
555
|
+
entries = require_array(raw_value, "#{path} databases")
|
|
556
|
+
entries.map.with_index(1) do |entry, index|
|
|
557
|
+
hash = require_mapping(entry, "#{path} databases[#{index}]")
|
|
558
|
+
name = required_yaml_scalar(hash, 'name', "#{path} databases[#{index}]")
|
|
559
|
+
adapter = normalize_adapter(required_yaml_scalar(hash, 'adapter', "#{path} databases[#{index}]"))
|
|
560
|
+
unless adapter
|
|
561
|
+
raise ConfigurationError,
|
|
562
|
+
"#{path} databases[#{index}] adapter must be postgres, mysql, or sqlite"
|
|
537
563
|
end
|
|
538
|
-
end
|
|
539
564
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if sqlite_path
|
|
572
|
-
env["SQLITE_DATABASE_PATH"] = resolve_yaml_value(sqlite_path, raw_env: raw_env, context: "#{path} databases[#{index}].path")
|
|
573
|
-
missing_secrets.concat(missing_yaml_secrets(sqlite_path, raw_env: raw_env))
|
|
574
|
-
end
|
|
565
|
+
env = {}
|
|
566
|
+
missing_secrets = []
|
|
567
|
+
case adapter
|
|
568
|
+
when 'postgres'
|
|
569
|
+
if hash.key?('url')
|
|
570
|
+
env['DATABASE_URL'] =
|
|
571
|
+
resolve_yaml_value(hash['url'], raw_env: raw_env, context: "#{path} databases[#{index}].url")
|
|
572
|
+
missing_secrets.concat(missing_yaml_secrets(hash['url'], raw_env: raw_env))
|
|
573
|
+
end
|
|
574
|
+
if hash.key?('password')
|
|
575
|
+
env['PGPASSWORD'] =
|
|
576
|
+
resolve_yaml_value(hash['password'], raw_env: raw_env, context: "#{path} databases[#{index}].password")
|
|
577
|
+
missing_secrets.concat(missing_yaml_secrets(hash['password'], raw_env: raw_env))
|
|
578
|
+
end
|
|
579
|
+
when 'mysql'
|
|
580
|
+
if hash.key?('url')
|
|
581
|
+
env['DATABASE_URL'] =
|
|
582
|
+
resolve_yaml_value(hash['url'], raw_env: raw_env, context: "#{path} databases[#{index}].url")
|
|
583
|
+
missing_secrets.concat(missing_yaml_secrets(hash['url'], raw_env: raw_env))
|
|
584
|
+
end
|
|
585
|
+
if hash.key?('password')
|
|
586
|
+
env['MYSQL_PWD'] =
|
|
587
|
+
resolve_yaml_value(hash['password'], raw_env: raw_env, context: "#{path} databases[#{index}].password")
|
|
588
|
+
missing_secrets.concat(missing_yaml_secrets(hash['password'], raw_env: raw_env))
|
|
589
|
+
end
|
|
590
|
+
when 'sqlite'
|
|
591
|
+
sqlite_path = hash.key?('path') ? hash['path'] : hash['database']
|
|
592
|
+
if sqlite_path
|
|
593
|
+
env['SQLITE_DATABASE_PATH'] =
|
|
594
|
+
resolve_yaml_value(sqlite_path, raw_env: raw_env, context: "#{path} databases[#{index}].path")
|
|
595
|
+
missing_secrets.concat(missing_yaml_secrets(sqlite_path, raw_env: raw_env))
|
|
575
596
|
end
|
|
576
|
-
|
|
577
|
-
{
|
|
578
|
-
name: name,
|
|
579
|
-
adapter: adapter,
|
|
580
|
-
env: env.compact,
|
|
581
|
-
missing_secrets: missing_secrets
|
|
582
|
-
}
|
|
583
597
|
end
|
|
598
|
+
|
|
599
|
+
{
|
|
600
|
+
name: name,
|
|
601
|
+
adapter: adapter,
|
|
602
|
+
env: env.compact,
|
|
603
|
+
missing_secrets: missing_secrets
|
|
604
|
+
}
|
|
584
605
|
end
|
|
606
|
+
end
|
|
585
607
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
608
|
+
def normalize_yaml_restic(raw_value, raw_env:, path:)
|
|
609
|
+
hash = require_mapping(raw_value, "#{path} restic")
|
|
610
|
+
env = {}
|
|
589
611
|
|
|
590
|
-
|
|
591
|
-
env[
|
|
612
|
+
if hash.key?('repository')
|
|
613
|
+
env['RESTIC_REPOSITORY'] =
|
|
614
|
+
resolve_yaml_value(hash['repository'], raw_env: raw_env,
|
|
615
|
+
context: "#{path} restic.repository")
|
|
616
|
+
end
|
|
617
|
+
env['RESTIC_REPOSITORY_FILE'] = normalize_yaml_value(hash['repository_file']) if hash.key?('repository_file')
|
|
592
618
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
end
|
|
619
|
+
if hash.key?('password')
|
|
620
|
+
normalize_yaml_restic_password(hash['password'], raw_env: raw_env, path: path).each do |key, value|
|
|
621
|
+
env[key] = value
|
|
597
622
|
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
env.merge!(normalize_yaml_restic_rest(hash['rest'], raw_env: raw_env, path: path)) if hash.key?('rest')
|
|
598
626
|
|
|
599
|
-
|
|
627
|
+
{
|
|
628
|
+
'init_if_missing' => 'RESTIC_INIT_IF_MISSING',
|
|
629
|
+
'check_after_backup' => 'RESTIC_CHECK_AFTER_BACKUP',
|
|
630
|
+
'check_read_data_subset' => 'RESTIC_CHECK_READ_DATA_SUBSET',
|
|
631
|
+
'forget_after_backup' => 'RESTIC_FORGET_AFTER_BACKUP'
|
|
632
|
+
}.each do |source, target|
|
|
633
|
+
env[target] = normalize_yaml_value(hash[source]) if hash.key?(source)
|
|
634
|
+
end
|
|
600
635
|
|
|
636
|
+
if hash.key?('retention')
|
|
637
|
+
retention = require_mapping(hash['retention'], "#{path} restic.retention")
|
|
601
638
|
{
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
639
|
+
'keep_last' => 'RESTIC_KEEP_LAST',
|
|
640
|
+
'keep_daily' => 'RESTIC_KEEP_DAILY',
|
|
641
|
+
'keep_weekly' => 'RESTIC_KEEP_WEEKLY',
|
|
642
|
+
'keep_monthly' => 'RESTIC_KEEP_MONTHLY',
|
|
643
|
+
'keep_yearly' => 'RESTIC_KEEP_YEARLY'
|
|
606
644
|
}.each do |source, target|
|
|
607
|
-
env[target] = normalize_yaml_value(
|
|
608
|
-
end
|
|
609
|
-
|
|
610
|
-
if hash.key?("retention")
|
|
611
|
-
retention = require_mapping(hash["retention"], "#{path} restic.retention")
|
|
612
|
-
{
|
|
613
|
-
"keep_last" => "RESTIC_KEEP_LAST",
|
|
614
|
-
"keep_daily" => "RESTIC_KEEP_DAILY",
|
|
615
|
-
"keep_weekly" => "RESTIC_KEEP_WEEKLY",
|
|
616
|
-
"keep_monthly" => "RESTIC_KEEP_MONTHLY",
|
|
617
|
-
"keep_yearly" => "RESTIC_KEEP_YEARLY"
|
|
618
|
-
}.each do |source, target|
|
|
619
|
-
env[target] = normalize_yaml_value(retention[source]) if retention.key?(source)
|
|
620
|
-
end
|
|
645
|
+
env[target] = normalize_yaml_value(retention[source]) if retention.key?(source)
|
|
621
646
|
end
|
|
622
|
-
|
|
623
|
-
env.compact
|
|
624
647
|
end
|
|
625
648
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
649
|
+
env.compact
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def normalize_yaml_restic_password(raw_value, raw_env:, path:)
|
|
653
|
+
case raw_value
|
|
654
|
+
when Hash
|
|
655
|
+
hash = stringify_keys(raw_value)
|
|
656
|
+
if hash.key?('secret')
|
|
657
|
+
{ 'RESTIC_PASSWORD' => resolve_yaml_value(hash, raw_env: raw_env, context: "#{path} restic.password") }
|
|
658
|
+
elsif hash.key?('file')
|
|
659
|
+
{ 'RESTIC_PASSWORD_FILE' => normalize_yaml_value(hash['file']) }
|
|
660
|
+
elsif hash.key?('command')
|
|
661
|
+
{ 'RESTIC_PASSWORD_COMMAND' => normalize_yaml_value(hash['command']) }
|
|
639
662
|
else
|
|
640
|
-
|
|
663
|
+
raise ConfigurationError, "#{path} restic.password must use secret, file, or command"
|
|
641
664
|
end
|
|
665
|
+
else
|
|
666
|
+
{ 'RESTIC_PASSWORD' => normalize_yaml_value(raw_value) }
|
|
642
667
|
end
|
|
668
|
+
end
|
|
643
669
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
670
|
+
def normalize_yaml_restic_rest(raw_value, raw_env:, path:)
|
|
671
|
+
hash = require_mapping(raw_value, "#{path} restic.rest")
|
|
672
|
+
env = {}
|
|
673
|
+
username = hash.key?('username') ? hash['username'] : hash['user']
|
|
648
674
|
|
|
649
|
-
|
|
650
|
-
env[
|
|
651
|
-
|
|
675
|
+
if username
|
|
676
|
+
env['RESTIC_REST_USERNAME'] =
|
|
677
|
+
resolve_yaml_value(username, raw_env: raw_env, context: "#{path} restic.rest.username")
|
|
678
|
+
end
|
|
679
|
+
if hash.key?('password')
|
|
680
|
+
env['RESTIC_REST_PASSWORD'] =
|
|
681
|
+
resolve_yaml_value(hash['password'], raw_env: raw_env,
|
|
682
|
+
context: "#{path} restic.rest.password")
|
|
652
683
|
end
|
|
684
|
+
env.compact
|
|
685
|
+
end
|
|
653
686
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
env
|
|
687
|
+
def normalize_yaml_backup(raw_value, path:)
|
|
688
|
+
hash = require_mapping(raw_value, "#{path} backup")
|
|
689
|
+
env = {}
|
|
690
|
+
if hash.key?('schedule')
|
|
691
|
+
env['BACKUP_SCHEDULE_SECONDS'] =
|
|
692
|
+
normalize_duration(hash['schedule'], "#{path} backup.schedule")
|
|
659
693
|
end
|
|
694
|
+
env.compact
|
|
695
|
+
end
|
|
660
696
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
697
|
+
def normalize_yaml_state(raw_value, path:)
|
|
698
|
+
hash = require_mapping(raw_value, "#{path} state")
|
|
699
|
+
env = {}
|
|
700
|
+
env['KAMAL_BACKUP_STATE_DIR'] = normalize_yaml_value(hash['path']) if hash.key?('path')
|
|
701
|
+
env.compact
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def normalize_yaml_paths(raw_value, context)
|
|
705
|
+
case raw_value
|
|
706
|
+
when Array
|
|
707
|
+
raw_value.map { |path| normalize_yaml_path(path, context) }.reject(&:empty?)
|
|
708
|
+
when NilClass
|
|
709
|
+
[]
|
|
710
|
+
else
|
|
711
|
+
[normalize_yaml_path(raw_value, context)].reject(&:empty?)
|
|
666
712
|
end
|
|
713
|
+
end
|
|
667
714
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
715
|
+
def normalize_yaml_path(raw_value, context)
|
|
716
|
+
raise ConfigurationError, "#{context} entries must be path strings" if raw_value.is_a?(Hash) || raw_value.is_a?(Array)
|
|
717
|
+
|
|
718
|
+
normalize_yaml_value(raw_value)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def normalize_yaml_backup_paths(raw_value, context)
|
|
722
|
+
case raw_value
|
|
723
|
+
when Array
|
|
724
|
+
definitions = raw_value.map.with_index(1) do |entry, index|
|
|
725
|
+
normalize_yaml_backup_path(entry, "#{context}[#{index}]")
|
|
676
726
|
end
|
|
727
|
+
definitions.reject { |definition| definition.path.to_s.empty? }
|
|
728
|
+
when NilClass
|
|
729
|
+
[]
|
|
730
|
+
else
|
|
731
|
+
[normalize_yaml_backup_path(raw_value, "#{context}[1]")].reject { |definition| definition.path.to_s.empty? }
|
|
677
732
|
end
|
|
733
|
+
end
|
|
678
734
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
735
|
+
def normalize_yaml_backup_path(raw_value, context)
|
|
736
|
+
case raw_value
|
|
737
|
+
when Hash
|
|
738
|
+
hash = require_mapping(raw_value, context)
|
|
739
|
+
unknown_keys = hash.keys - %w[path exclude]
|
|
740
|
+
unless unknown_keys.empty?
|
|
741
|
+
raise ConfigurationError,
|
|
742
|
+
"#{context} contains unknown key #{unknown_keys.first.inspect}; expected path and exclude"
|
|
682
743
|
end
|
|
683
744
|
|
|
684
|
-
|
|
745
|
+
path = required_yaml_string(hash, 'path', context)
|
|
746
|
+
exclude = hash.key?('exclude') ? normalize_yaml_excludes(hash['exclude'], "#{context}.exclude") : []
|
|
747
|
+
PathDefinition.new(path: path, exclude: exclude)
|
|
748
|
+
when Array
|
|
749
|
+
raise ConfigurationError, "#{context} must be a path string or a mapping with path and optional exclude"
|
|
750
|
+
else
|
|
751
|
+
PathDefinition.new(path: normalize_yaml_value(raw_value), exclude: [])
|
|
685
752
|
end
|
|
753
|
+
end
|
|
686
754
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
raise ConfigurationError, "#{context} must be a scalar value or { secret: NAME }"
|
|
693
|
-
end
|
|
755
|
+
def normalize_yaml_excludes(raw_value, context)
|
|
756
|
+
entries = require_array(raw_value, context)
|
|
757
|
+
entries.map.with_index(1) do |entry, index|
|
|
758
|
+
pattern = optional_yaml_string(entry, "#{context}[#{index}]")
|
|
759
|
+
raise ConfigurationError, "#{context}[#{index}] must not be empty" if pattern.to_s.strip.empty?
|
|
694
760
|
|
|
695
|
-
|
|
696
|
-
raw_env[secret_name]
|
|
697
|
-
else
|
|
698
|
-
normalize_yaml_value(raw_value)
|
|
699
|
-
end
|
|
761
|
+
pattern
|
|
700
762
|
end
|
|
763
|
+
end
|
|
701
764
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
765
|
+
def resolve_yaml_value(raw_value, raw_env:, context:)
|
|
766
|
+
case raw_value
|
|
767
|
+
when Hash
|
|
705
768
|
hash = stringify_keys(raw_value)
|
|
706
|
-
|
|
769
|
+
raise ConfigurationError, "#{context} must be a scalar value or { secret: NAME }" unless hash.keys == ['secret']
|
|
707
770
|
|
|
708
|
-
secret_name = normalize_yaml_value(hash.fetch(
|
|
709
|
-
raw_env[secret_name]
|
|
771
|
+
secret_name = normalize_yaml_value(hash.fetch('secret'))
|
|
772
|
+
raw_env[secret_name]
|
|
773
|
+
else
|
|
774
|
+
normalize_yaml_value(raw_value)
|
|
710
775
|
end
|
|
776
|
+
end
|
|
711
777
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
raise ConfigurationError, "#{context} is required" if value.to_s.empty?
|
|
778
|
+
def missing_yaml_secrets(raw_value, raw_env:)
|
|
779
|
+
return [] unless raw_value.is_a?(Hash)
|
|
715
780
|
|
|
716
|
-
|
|
781
|
+
hash = stringify_keys(raw_value)
|
|
782
|
+
return [] unless hash.key?('secret')
|
|
717
783
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
end
|
|
784
|
+
secret_name = normalize_yaml_value(hash.fetch('secret'))
|
|
785
|
+
raw_env[secret_name].to_s.strip.empty? ? [secret_name] : []
|
|
786
|
+
end
|
|
722
787
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
"m" => 60,
|
|
727
|
-
"h" => 3600,
|
|
728
|
-
"d" => 86_400,
|
|
729
|
-
"w" => 604_800
|
|
730
|
-
}.fetch(match[2].downcase)
|
|
731
|
-
(amount * multiplier).to_s
|
|
732
|
-
end
|
|
788
|
+
def normalize_duration(raw_value, context)
|
|
789
|
+
value = normalize_yaml_value(raw_value)
|
|
790
|
+
raise ConfigurationError, "#{context} is required" if value.to_s.empty?
|
|
733
791
|
|
|
734
|
-
|
|
735
|
-
return value if value.is_a?(Array)
|
|
792
|
+
return value if value.match?(/\A\d+\z/)
|
|
736
793
|
|
|
737
|
-
|
|
738
|
-
|
|
794
|
+
match = value.match(/\A(\d+)\s*([smhdw])\z/i)
|
|
795
|
+
raise ConfigurationError, "#{context} must be seconds or a duration like 30m, 6h, or 1d" unless match
|
|
739
796
|
|
|
740
|
-
|
|
741
|
-
|
|
797
|
+
amount = match[1].to_i
|
|
798
|
+
multiplier = {
|
|
799
|
+
's' => 1,
|
|
800
|
+
'm' => 60,
|
|
801
|
+
'h' => 3600,
|
|
802
|
+
'd' => 86_400,
|
|
803
|
+
'w' => 604_800
|
|
804
|
+
}.fetch(match[2].downcase)
|
|
805
|
+
(amount * multiplier).to_s
|
|
806
|
+
end
|
|
742
807
|
|
|
743
|
-
|
|
744
|
-
|
|
808
|
+
def require_array(value, context)
|
|
809
|
+
return value if value.is_a?(Array)
|
|
745
810
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
raise ConfigurationError, "#{context} #{key} is required" if value.to_s.empty?
|
|
811
|
+
raise ConfigurationError, "#{context} must be a YAML sequence"
|
|
812
|
+
end
|
|
749
813
|
|
|
750
|
-
|
|
751
|
-
|
|
814
|
+
def require_mapping(value, context)
|
|
815
|
+
raise ConfigurationError, "#{context} must be a YAML mapping" unless value.is_a?(Hash)
|
|
752
816
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
end
|
|
817
|
+
stringify_keys(value)
|
|
818
|
+
end
|
|
756
819
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
key, value = environment
|
|
820
|
+
def optional_yaml_string(value, context)
|
|
821
|
+
raise ConfigurationError, "#{context} must be a string" if value.is_a?(Hash) || value.is_a?(Array)
|
|
760
822
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
end
|
|
764
|
-
end
|
|
765
|
-
end
|
|
823
|
+
normalize_yaml_value(value)
|
|
824
|
+
end
|
|
766
825
|
|
|
767
|
-
|
|
768
|
-
|
|
826
|
+
def required_yaml_string(hash, key, context)
|
|
827
|
+
raise ConfigurationError, "#{context} #{key} is required" unless hash.key?(key)
|
|
769
828
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if SUSPICIOUS_BACKUP_PATHS.include?(expanded) && !allow_suspicious_backup_paths?
|
|
773
|
-
raise ConfigurationError, "refusing suspicious local restore path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
|
|
774
|
-
end
|
|
775
|
-
end
|
|
776
|
-
end
|
|
829
|
+
value = optional_yaml_string(hash[key], "#{context}.#{key}")
|
|
830
|
+
raise ConfigurationError, "#{context} #{key} is required" if value.to_s.strip.empty?
|
|
777
831
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
number = raw ? Integer(raw) : default
|
|
781
|
-
raise ConfigurationError, "#{key} must be >= #{minimum}" if number < minimum
|
|
832
|
+
value
|
|
833
|
+
end
|
|
782
834
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
end
|
|
835
|
+
def required_yaml_scalar(hash, key, context)
|
|
836
|
+
value = normalize_yaml_value(hash[key])
|
|
837
|
+
raise ConfigurationError, "#{context} #{key} is required" if value.to_s.empty?
|
|
787
838
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
when "postgres", "postgresql"
|
|
791
|
-
"postgres"
|
|
792
|
-
when "mysql", "mysql2", "mariadb"
|
|
793
|
-
"mysql"
|
|
794
|
-
when "sqlite", "sqlite3"
|
|
795
|
-
"sqlite"
|
|
796
|
-
else
|
|
797
|
-
nil
|
|
798
|
-
end
|
|
799
|
-
end
|
|
839
|
+
value
|
|
840
|
+
end
|
|
800
841
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
842
|
+
def stringify_keys(hash)
|
|
843
|
+
hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
844
|
+
end
|
|
804
845
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
846
|
+
def validate_local_machine_environment
|
|
847
|
+
if (environment = local_restore_environment)
|
|
848
|
+
key, value = environment
|
|
808
849
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
elsif adapter = adapter_from_database_url
|
|
813
|
-
adapter
|
|
814
|
-
elsif value("SQLITE_DATABASE_PATH")
|
|
815
|
-
"sqlite"
|
|
850
|
+
if production_environment?(value)
|
|
851
|
+
raise ConfigurationError,
|
|
852
|
+
"restore local refuses to run with #{key}=#{value}; unset #{key} or use restore production"
|
|
816
853
|
end
|
|
817
854
|
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def validate_local_machine_paths
|
|
858
|
+
path_pairs = local_restore_path_pairs
|
|
818
859
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
860
|
+
path_pairs.each do |path_pair|
|
|
861
|
+
target_path = path_pair.last
|
|
862
|
+
expanded = File.expand_path(target_path)
|
|
863
|
+
if SUSPICIOUS_BACKUP_PATHS.include?(expanded) && !allow_suspicious_backup_paths?
|
|
864
|
+
raise ConfigurationError,
|
|
865
|
+
"refusing suspicious local restore path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
|
|
822
866
|
end
|
|
823
|
-
rescue URI::InvalidURIError
|
|
824
|
-
nil
|
|
825
867
|
end
|
|
868
|
+
end
|
|
826
869
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
870
|
+
def integer(key, default, minimum:)
|
|
871
|
+
raw = value(key)
|
|
872
|
+
number = raw ? Integer(raw) : default
|
|
873
|
+
raise ConfigurationError, "#{key} must be >= #{minimum}" if number < minimum
|
|
874
|
+
|
|
875
|
+
number
|
|
876
|
+
rescue ArgumentError
|
|
877
|
+
raise ConfigurationError, "#{key} must be an integer"
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def normalize_adapter(value)
|
|
881
|
+
case value.to_s.downcase
|
|
882
|
+
when 'postgres', 'postgresql'
|
|
883
|
+
'postgres'
|
|
884
|
+
when 'mysql', 'mysql2', 'mariadb'
|
|
885
|
+
'mysql'
|
|
886
|
+
when 'sqlite', 'sqlite3'
|
|
887
|
+
'sqlite'
|
|
832
888
|
end
|
|
889
|
+
end
|
|
833
890
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
891
|
+
def database_definitions?
|
|
892
|
+
!@database_definitions.nil?
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def path_definitions?
|
|
896
|
+
!@path_definitions.nil?
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def backup_path_definitions
|
|
900
|
+
if path_definitions?
|
|
901
|
+
@path_definitions
|
|
902
|
+
else
|
|
903
|
+
legacy_backup_paths.map { |path| PathDefinition.new(path: path, exclude: []) }
|
|
844
904
|
end
|
|
905
|
+
end
|
|
845
906
|
|
|
846
|
-
|
|
847
|
-
|
|
907
|
+
def configured_backup_path_excludes(paths)
|
|
908
|
+
backup_path_definitions.each_with_object([]) do |definition, excludes|
|
|
909
|
+
excludes.concat(definition.exclude) if paths.include?(definition.path)
|
|
848
910
|
end
|
|
911
|
+
end
|
|
849
912
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
913
|
+
def sqlite_backup_path_excludes(paths)
|
|
914
|
+
databases.select { |database| database.database_adapter == 'sqlite' }.flat_map do |database|
|
|
915
|
+
sqlite_database_path = database.value('SQLITE_DATABASE_PATH')
|
|
916
|
+
next [] if sqlite_database_path.to_s.empty?
|
|
917
|
+
next [] unless paths.any? { |path| path_contains?(path, sqlite_database_path) }
|
|
918
|
+
|
|
919
|
+
[sqlite_database_path, "#{sqlite_database_path}-wal", "#{sqlite_database_path}-shm"]
|
|
920
|
+
end.uniq
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def path_contains?(parent, child)
|
|
924
|
+
expanded_parent = File.expand_path(parent)
|
|
925
|
+
expanded_child = File.expand_path(child)
|
|
926
|
+
expanded_child == expanded_parent || expanded_child.start_with?("#{expanded_parent}/")
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def legacy_database_adapter
|
|
930
|
+
if (explicit = value('DATABASE_ADAPTER'))
|
|
931
|
+
normalize_adapter(explicit)
|
|
932
|
+
elsif (adapter = adapter_from_database_url)
|
|
933
|
+
adapter
|
|
934
|
+
elsif value('SQLITE_DATABASE_PATH')
|
|
935
|
+
'sqlite'
|
|
854
936
|
end
|
|
937
|
+
end
|
|
855
938
|
|
|
856
|
-
|
|
857
|
-
|
|
939
|
+
def adapter_from_database_url
|
|
940
|
+
if (url = value('DATABASE_URL'))
|
|
941
|
+
normalize_adapter(URI.parse(url).scheme)
|
|
858
942
|
end
|
|
943
|
+
rescue URI::InvalidURIError
|
|
944
|
+
nil
|
|
945
|
+
end
|
|
859
946
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
end
|
|
865
|
-
end
|
|
947
|
+
def in_place_file_restore?(expanded_target)
|
|
948
|
+
backup_paths.any? do |path|
|
|
949
|
+
expanded_path = File.expand_path(path)
|
|
950
|
+
expanded_target == expanded_path || expanded_path.start_with?("#{expanded_target}/") || expanded_target.start_with?("#{expanded_path}/")
|
|
866
951
|
end
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def source_database_targets
|
|
955
|
+
databases.flat_map do |database|
|
|
956
|
+
[
|
|
957
|
+
database.value('DATABASE_URL'),
|
|
958
|
+
database.value('SQLITE_DATABASE_PATH'),
|
|
959
|
+
database.value('PGDATABASE'),
|
|
960
|
+
database.value('MYSQL_DATABASE'),
|
|
961
|
+
database.value('MARIADB_DATABASE')
|
|
962
|
+
]
|
|
963
|
+
end.compact
|
|
964
|
+
end
|
|
867
965
|
|
|
868
|
-
|
|
869
|
-
|
|
966
|
+
def legacy_backup_paths
|
|
967
|
+
split_paths(value('BACKUP_PATHS'))
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def legacy_local_restore_source_paths
|
|
971
|
+
if (raw = value('LOCAL_RESTORE_SOURCE_PATHS'))
|
|
972
|
+
split_paths(raw)
|
|
870
973
|
end
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def split_paths(raw)
|
|
977
|
+
raw.to_s.split(/[\n:]+/).map(&:strip).reject(&:empty?)
|
|
978
|
+
end
|
|
871
979
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
target.match?(%r{(^|[/_.:-])live([/_.:-]|$)})
|
|
980
|
+
def local_restore_environment
|
|
981
|
+
%w[RAILS_ENV RACK_ENV APP_ENV KAMAL_ENVIRONMENT].each do |key|
|
|
982
|
+
return [key, value(key)] if value(key)
|
|
876
983
|
end
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
def production_environment?(value)
|
|
987
|
+
%w[production prod live].include?(value.to_s.downcase)
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
def production_named_target?(target)
|
|
991
|
+
target.include?('production') ||
|
|
992
|
+
target.match?(%r{(^|[/_.:-])prod([/_.:-]|$)}) ||
|
|
993
|
+
target.match?(%r{(^|[/_.:-])live([/_.:-]|$)})
|
|
994
|
+
end
|
|
877
995
|
end
|
|
878
996
|
end
|