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.
@@ -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,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, 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
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? ? "app" : name
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("APP_NAME")
151
+ value('APP_NAME')
148
152
  end
149
153
 
150
154
  def required_app_name
151
- required_value("APP_NAME")
155
+ required_value('APP_NAME')
152
156
  end
153
157
 
154
158
  def accessory_name
155
- value("KAMAL_BACKUP_ACCESSORY")
159
+ value('KAMAL_BACKUP_ACCESSORY')
156
160
  end
157
161
 
158
162
  def restic_repository
159
- value("RESTIC_REPOSITORY")
163
+ value('RESTIC_REPOSITORY')
160
164
  end
161
165
 
162
166
  def restic_repository_file
163
- value("RESTIC_REPOSITORY_FILE")
167
+ value('RESTIC_REPOSITORY_FILE')
164
168
  end
165
169
 
166
170
  def restic_password
167
- value("RESTIC_PASSWORD")
171
+ value('RESTIC_PASSWORD')
168
172
  end
169
173
 
170
174
  def restic_password_file
171
- value("RESTIC_PASSWORD_FILE")
175
+ value('RESTIC_PASSWORD_FILE')
172
176
  end
173
177
 
174
178
  def restic_password_command
175
- value("RESTIC_PASSWORD_COMMAND")
179
+ value('RESTIC_PASSWORD_COMMAND')
176
180
  end
177
181
 
178
182
  def restic_init_if_missing?
179
- truthy?("RESTIC_INIT_IF_MISSING")
183
+ truthy?('RESTIC_INIT_IF_MISSING')
180
184
  end
181
185
 
182
186
  def check_after_backup?
183
- truthy?("RESTIC_CHECK_AFTER_BACKUP")
187
+ truthy?('RESTIC_CHECK_AFTER_BACKUP')
184
188
  end
185
189
 
186
190
  def forget_after_backup?
187
- !falsey?("RESTIC_FORGET_AFTER_BACKUP")
191
+ !falsey?('RESTIC_FORGET_AFTER_BACKUP')
188
192
  end
189
193
 
190
194
  def check_read_data_subset
191
- value("RESTIC_CHECK_READ_DATA_SUBSET")
195
+ value('RESTIC_CHECK_READ_DATA_SUBSET')
192
196
  end
193
197
 
194
198
  def allow_in_place_file_restore?
195
- truthy?("KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE")
199
+ truthy?('KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE')
196
200
  end
197
201
 
198
202
  def allow_suspicious_backup_paths?
199
- truthy?("KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS")
203
+ truthy?('KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS')
200
204
  end
201
205
 
202
206
  def backup_schedule_seconds
203
- integer("BACKUP_SCHEDULE_SECONDS", 86_400, minimum: 1)
207
+ integer('BACKUP_SCHEDULE_SECONDS', 86_400, minimum: 1)
204
208
  end
205
209
 
206
210
  def backup_start_delay_seconds
207
- integer("BACKUP_START_DELAY_SECONDS", 0, minimum: 0)
211
+ integer('BACKUP_START_DELAY_SECONDS', 0, minimum: 0)
208
212
  end
209
213
 
210
214
  def state_dir
211
- value("KAMAL_BACKUP_STATE_DIR") || "/var/lib/kamal-backup"
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, "last_check.json")
219
+ File.join(state_dir, 'last_check.json')
216
220
  end
217
221
 
218
222
  def last_backup_path
219
- File.join(state_dir, "last_backup.json")
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, "last_restore_drill.json")
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
- if source_paths.size == target_paths.size
247
- source_paths.zip(target_paths)
248
- else
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/+}, "").gsub(%r{[^A-Za-z0-9_.-]+}, "-")
255
- label.empty? ? "root" : label
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
- "app"
275
+ 'app'
268
276
  end
269
277
 
270
278
  def databases
271
- @databases ||= begin
272
- if database_definitions?
273
- @database_definitions.map do |definition|
274
- DatabaseSource.new(
275
- parent: self,
276
- name: definition.fetch(:name),
277
- adapter: definition.fetch(:adapter),
278
- env: definition.fetch(:env),
279
- structured: true,
280
- missing_secrets: definition.fetch(:missing_secrets, [])
281
- )
282
- end
283
- elsif legacy_database_adapter
284
- [
285
- DatabaseSource.new(
286
- parent: self,
287
- name: database_name,
288
- adapter: legacy_database_adapter,
289
- env: {},
290
- structured: false
291
- )
292
- ]
293
- else
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("RESTIC_KEEP_", "keep-").downcase.tr("_", "-")}"
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, "databases must contain at least one database" if databases.empty?
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, "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(', ')}"
342
349
  end
343
350
 
344
351
  case database.database_adapter
345
- when "postgres"
346
- unless database.value("DATABASE_URL") || database.value("PGDATABASE")
347
- 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"
348
361
  end
349
- when "mysql"
350
- unless database.value("DATABASE_URL") || database.value("MYSQL_DATABASE") || database.value("MARIADB_DATABASE")
351
- 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}"
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, "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"
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, "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"
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, "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?
374
388
 
375
- if production_named_target?(target)
376
- raise ConfigurationError, "refusing production-looking local restore target #{target}; use restore production for production restores"
377
- 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"
378
393
  end
379
394
 
380
395
  def validate_file_restore_target(target)
381
- 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?
382
397
 
383
398
  expanded_target = File.expand_path(target)
384
- raise ConfigurationError, "refusing to restore files to /" if expanded_target == "/"
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, "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"
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, "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?
395
411
 
396
- if production_like_target?(target)
397
- raise ConfigurationError, "refusing production-looking restore target #{target}; choose a scratch target that does not look like production"
398
- 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"
399
416
  end
400
417
 
401
418
  def production_like_target?(target)
402
419
  target = target.to_s
403
420
 
404
- if source_database_targets.include?(target)
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
- def load_config_files(raw_env, cwd:, paths:)
437
- config_paths(raw_env, cwd: cwd, paths: paths).each_with_object(ConfigData.empty) do |path, merged|
438
- next unless File.file?(path)
446
+ def project_defaults(cwd:)
447
+ RailsApp.new(cwd: cwd).defaults
448
+ end
439
449
 
440
- data = normalize_config_file(path, raw_env: raw_env)
441
- merged.env.merge!(data.env)
442
- merged.database_definitions = data.database_definitions if data.database_definitions
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
- def config_paths(raw_env, cwd:, paths:)
449
- if paths
450
- Array(paths).map { |path| File.expand_path(path, cwd) }
451
- elsif explicit = raw_env["KAMAL_BACKUP_CONFIG"]
452
- [File.expand_path(explicit, cwd)]
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
- def validate_restic_repository(check_files:)
459
- 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
460
471
 
461
- if path = restic_repository_file
462
- 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
463
474
 
464
- return
465
- end
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
- raise ConfigurationError, "RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required"
478
+ return
468
479
  end
469
480
 
470
- def validate_restic_password(check_files:)
471
- return if restic_password || restic_password_command
481
+ raise ConfigurationError, 'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required'
482
+ end
472
483
 
473
- if path = restic_password_file
474
- 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
475
486
 
476
- return
477
- end
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
- raise ConfigurationError, "RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or RESTIC_PASSWORD_COMMAND is required"
490
+ return
480
491
  end
481
492
 
482
- def normalize_config_file(path, raw_env:)
483
- data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
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
- unless data.is_a?(Hash)
487
- raise ConfigurationError, "#{path} must contain a YAML mapping"
488
- 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?
489
499
 
490
- result = ConfigData.empty
491
- data.each do |raw_key, raw_value|
492
- key = raw_key.to_s
493
- validate_top_level_yaml_key!(path, key)
494
-
495
- case key
496
- when "app"
497
- result.env["APP_NAME"] = normalize_yaml_value(raw_value)
498
- when "accessory"
499
- result.env["KAMAL_BACKUP_ACCESSORY"] = normalize_yaml_value(raw_value)
500
- when "databases"
501
- result.database_definitions = normalize_yaml_databases(raw_value, raw_env: raw_env, path: path)
502
- when "paths"
503
- result.path_definitions = normalize_yaml_paths(raw_value, "#{path} paths")
504
- when "restore_from"
505
- result.restore_from_definitions = normalize_yaml_paths(raw_value, "#{path} restore_from")
506
- when "restic"
507
- result.env.merge!(normalize_yaml_restic(raw_value, raw_env: raw_env, path: path))
508
- when "backup"
509
- result.env.merge!(normalize_yaml_backup(raw_value, path: path))
510
- when "state"
511
- result.env.merge!(normalize_yaml_state(raw_value, path: path))
512
- 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))
513
524
  end
514
- result
515
- rescue Psych::SyntaxError => e
516
- raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
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
- def validate_top_level_yaml_key!(path, key)
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
- return if TOP_LEVEL_YAML_KEYS.include?(key)
539
+ raise ConfigurationError,
540
+ "#{path} contains unknown key #{key.inspect}; expected #{TOP_LEVEL_YAML_KEYS.join(', ')}"
541
+ end
525
542
 
526
- raise ConfigurationError, "#{path} contains unknown key #{key.inspect}; expected #{TOP_LEVEL_YAML_KEYS.join(", ")}"
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
- def normalize_yaml_value(raw_value)
530
- case raw_value
531
- when Array
532
- raw_value.map(&:to_s).join("\n")
533
- when NilClass
534
- nil
535
- else
536
- raw_value.to_s
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
- def normalize_yaml_databases(raw_value, raw_env:, path:)
541
- entries = require_array(raw_value, "#{path} databases")
542
- entries.map.with_index(1) do |entry, index|
543
- hash = require_mapping(entry, "#{path} databases[#{index}]")
544
- name = required_yaml_scalar(hash, "name", "#{path} databases[#{index}]")
545
- adapter = normalize_adapter(required_yaml_scalar(hash, "adapter", "#{path} databases[#{index}]"))
546
- raise ConfigurationError, "#{path} databases[#{index}] adapter must be postgres, mysql, or sqlite" unless adapter
547
-
548
- env = {}
549
- missing_secrets = []
550
- case adapter
551
- when "postgres"
552
- if hash.key?("url")
553
- env["DATABASE_URL"] = resolve_yaml_value(hash["url"], raw_env: raw_env, context: "#{path} databases[#{index}].url")
554
- missing_secrets.concat(missing_yaml_secrets(hash["url"], raw_env: raw_env))
555
- end
556
- if hash.key?("password")
557
- env["PGPASSWORD"] = resolve_yaml_value(hash["password"], raw_env: raw_env, context: "#{path} databases[#{index}].password")
558
- missing_secrets.concat(missing_yaml_secrets(hash["password"], raw_env: raw_env))
559
- end
560
- when "mysql"
561
- if hash.key?("url")
562
- env["DATABASE_URL"] = resolve_yaml_value(hash["url"], raw_env: raw_env, context: "#{path} databases[#{index}].url")
563
- missing_secrets.concat(missing_yaml_secrets(hash["url"], raw_env: raw_env))
564
- end
565
- if hash.key?("password")
566
- env["MYSQL_PWD"] = resolve_yaml_value(hash["password"], raw_env: raw_env, context: "#{path} databases[#{index}].password")
567
- missing_secrets.concat(missing_yaml_secrets(hash["password"], raw_env: raw_env))
568
- end
569
- when "sqlite"
570
- sqlite_path = hash.key?("path") ? hash["path"] : hash["database"]
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
- def normalize_yaml_restic(raw_value, raw_env:, path:)
587
- hash = require_mapping(raw_value, "#{path} restic")
588
- env = {}
608
+ def normalize_yaml_restic(raw_value, raw_env:, path:)
609
+ hash = require_mapping(raw_value, "#{path} restic")
610
+ env = {}
589
611
 
590
- env["RESTIC_REPOSITORY"] = resolve_yaml_value(hash["repository"], raw_env: raw_env, context: "#{path} restic.repository") if hash.key?("repository")
591
- env["RESTIC_REPOSITORY_FILE"] = normalize_yaml_value(hash["repository_file"]) if hash.key?("repository_file")
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
- if hash.key?("password")
594
- normalize_yaml_restic_password(hash["password"], raw_env: raw_env, path: path).each do |key, value|
595
- env[key] = value
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
- env.merge!(normalize_yaml_restic_rest(hash["rest"], raw_env: raw_env, path: path)) if hash.key?("rest")
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
- "init_if_missing" => "RESTIC_INIT_IF_MISSING",
603
- "check_after_backup" => "RESTIC_CHECK_AFTER_BACKUP",
604
- "check_read_data_subset" => "RESTIC_CHECK_READ_DATA_SUBSET",
605
- "forget_after_backup" => "RESTIC_FORGET_AFTER_BACKUP"
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(hash[source]) if hash.key?(source)
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
- def normalize_yaml_restic_password(raw_value, raw_env:, path:)
627
- case raw_value
628
- when Hash
629
- hash = stringify_keys(raw_value)
630
- if hash.key?("secret")
631
- { "RESTIC_PASSWORD" => resolve_yaml_value(hash, raw_env: raw_env, context: "#{path} restic.password") }
632
- elsif hash.key?("file")
633
- { "RESTIC_PASSWORD_FILE" => normalize_yaml_value(hash["file"]) }
634
- elsif hash.key?("command")
635
- { "RESTIC_PASSWORD_COMMAND" => normalize_yaml_value(hash["command"]) }
636
- else
637
- raise ConfigurationError, "#{path} restic.password must use secret, file, or command"
638
- end
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
- { "RESTIC_PASSWORD" => normalize_yaml_value(raw_value) }
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
- def normalize_yaml_restic_rest(raw_value, raw_env:, path:)
645
- hash = require_mapping(raw_value, "#{path} restic.rest")
646
- env = {}
647
- username = hash.key?("username") ? hash["username"] : hash["user"]
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
- env["RESTIC_REST_USERNAME"] = resolve_yaml_value(username, raw_env: raw_env, context: "#{path} restic.rest.username") if username
650
- env["RESTIC_REST_PASSWORD"] = resolve_yaml_value(hash["password"], raw_env: raw_env, context: "#{path} restic.rest.password") if hash.key?("password")
651
- env.compact
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
- def normalize_yaml_backup(raw_value, path:)
655
- hash = require_mapping(raw_value, "#{path} backup")
656
- env = {}
657
- env["BACKUP_SCHEDULE_SECONDS"] = normalize_duration(hash["schedule"], "#{path} backup.schedule") if hash.key?("schedule")
658
- env.compact
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
- def normalize_yaml_state(raw_value, path:)
662
- hash = require_mapping(raw_value, "#{path} state")
663
- env = {}
664
- env["KAMAL_BACKUP_STATE_DIR"] = normalize_yaml_value(hash["path"]) if hash.key?("path")
665
- env.compact
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
- def normalize_yaml_paths(raw_value, context)
669
- case raw_value
670
- when Array
671
- raw_value.map { |path| normalize_yaml_path(path, context) }.reject(&:empty?)
672
- when NilClass
673
- []
674
- else
675
- [normalize_yaml_path(raw_value, context)].reject(&: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)
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
- def normalize_yaml_path(raw_value, context)
680
- if raw_value.is_a?(Hash) || raw_value.is_a?(Array)
681
- raise ConfigurationError, "#{context} entries must be path strings"
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
- normalize_yaml_value(raw_value)
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
- def resolve_yaml_value(raw_value, raw_env:, context:)
688
- case raw_value
689
- when Hash
690
- hash = stringify_keys(raw_value)
691
- unless hash.keys == ["secret"]
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
- secret_name = normalize_yaml_value(hash.fetch("secret"))
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
- def missing_yaml_secrets(raw_value, raw_env:)
703
- return [] unless raw_value.is_a?(Hash)
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
- return [] unless hash.key?("secret")
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("secret"))
709
- 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)
710
775
  end
776
+ end
711
777
 
712
- def normalize_duration(raw_value, context)
713
- value = normalize_yaml_value(raw_value)
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
- return value if value.match?(/\A\d+\z/)
781
+ hash = stringify_keys(raw_value)
782
+ return [] unless hash.key?('secret')
717
783
 
718
- match = value.match(/\A(\d+)\s*([smhdw])\z/i)
719
- unless match
720
- raise ConfigurationError, "#{context} must be seconds or a duration like 30m, 6h, or 1d"
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
- amount = match[1].to_i
724
- multiplier = {
725
- "s" => 1,
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
- def require_array(value, context)
735
- return value if value.is_a?(Array)
792
+ return value if value.match?(/\A\d+\z/)
736
793
 
737
- raise ConfigurationError, "#{context} must be a YAML sequence"
738
- 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
739
796
 
740
- def require_mapping(value, context)
741
- 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
742
807
 
743
- stringify_keys(value)
744
- end
808
+ def require_array(value, context)
809
+ return value if value.is_a?(Array)
745
810
 
746
- def required_yaml_scalar(hash, key, context)
747
- value = normalize_yaml_value(hash[key])
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
- value
751
- end
814
+ def require_mapping(value, context)
815
+ raise ConfigurationError, "#{context} must be a YAML mapping" unless value.is_a?(Hash)
752
816
 
753
- def stringify_keys(hash)
754
- hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
755
- end
817
+ stringify_keys(value)
818
+ end
756
819
 
757
- def validate_local_machine_environment
758
- if environment = local_restore_environment
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
- if production_environment?(value)
762
- raise ConfigurationError, "restore local refuses to run with #{key}=#{value}; unset #{key} or use restore production"
763
- end
764
- end
765
- end
823
+ normalize_yaml_value(value)
824
+ end
766
825
 
767
- def validate_local_machine_paths
768
- path_pairs = local_restore_path_pairs
826
+ def required_yaml_string(hash, key, context)
827
+ raise ConfigurationError, "#{context} #{key} is required" unless hash.key?(key)
769
828
 
770
- path_pairs.each do |_source_path, target_path|
771
- expanded = File.expand_path(target_path)
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
- def integer(key, default, minimum:)
779
- raw = value(key)
780
- number = raw ? Integer(raw) : default
781
- raise ConfigurationError, "#{key} must be >= #{minimum}" if number < minimum
832
+ value
833
+ end
782
834
 
783
- number
784
- rescue ArgumentError
785
- raise ConfigurationError, "#{key} must be an integer"
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
- def normalize_adapter(value)
789
- case value.to_s.downcase
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
- def database_definitions?
802
- !@database_definitions.nil?
803
- end
842
+ def stringify_keys(hash)
843
+ hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
844
+ end
804
845
 
805
- def path_definitions?
806
- !@path_definitions.nil?
807
- end
846
+ def validate_local_machine_environment
847
+ if (environment = local_restore_environment)
848
+ key, value = environment
808
849
 
809
- def legacy_database_adapter
810
- if explicit = value("DATABASE_ADAPTER")
811
- normalize_adapter(explicit)
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
- def adapter_from_database_url
820
- if url = value("DATABASE_URL")
821
- normalize_adapter(URI.parse(url).scheme)
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
- def in_place_file_restore?(expanded_target)
828
- backup_paths.any? do |path|
829
- expanded_path = File.expand_path(path)
830
- expanded_target == expanded_path || expanded_path.start_with?(expanded_target + "/") || expanded_target.start_with?(expanded_path + "/")
831
- end
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
- def source_database_targets
835
- databases.flat_map do |database|
836
- [
837
- database.value("DATABASE_URL"),
838
- database.value("SQLITE_DATABASE_PATH"),
839
- database.value("PGDATABASE"),
840
- database.value("MYSQL_DATABASE"),
841
- database.value("MARIADB_DATABASE")
842
- ]
843
- end.compact
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
- def legacy_backup_paths
847
- split_paths(value("BACKUP_PATHS"))
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
- def legacy_local_restore_source_paths
851
- if raw = value("LOCAL_RESTORE_SOURCE_PATHS")
852
- split_paths(raw)
853
- end
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
- def split_paths(raw)
857
- raw.to_s.split(/[\n:]+/).map(&:strip).reject(&:empty?)
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
- def local_restore_environment
861
- %w[RAILS_ENV RACK_ENV APP_ENV KAMAL_ENVIRONMENT].each do |key|
862
- if value(key)
863
- return [key, value(key)]
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
- def production_environment?(value)
869
- %w[production prod live].include?(value.to_s.downcase)
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
- def production_named_target?(target)
873
- target.include?("production") ||
874
- target.match?(%r{(^|[/_.:-])prod([/_.:-]|$)}) ||
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