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