kamal-backup 0.3.0.beta21 → 0.3.1

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,57 +1,25 @@
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_relative 'errors'
5
+ require_relative 'config_file'
6
+ require_relative 'databases/base'
7
+ require_relative 'rails_app'
5
8
 
6
9
  module KamalBackup
7
10
  class Config
8
11
  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"
12
+ 'RESTIC_KEEP_LAST' => '7',
13
+ 'RESTIC_KEEP_DAILY' => '7',
14
+ 'RESTIC_KEEP_WEEKLY' => '4',
15
+ 'RESTIC_KEEP_MONTHLY' => '6',
16
+ 'RESTIC_KEEP_YEARLY' => '2'
14
17
  }.freeze
15
18
 
16
19
  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"
20
+ SHARED_CONFIG_PATH = 'config/kamal-backup.yml'
21
+ LOCAL_CONFIG_PATH = 'config/kamal-backup.local.yml'
19
22
  DEFAULT_CONFIG_PATHS = [SHARED_CONFIG_PATH, LOCAL_CONFIG_PATH].freeze
20
- TOP_LEVEL_YAML_KEYS = %w[app accessory databases paths restore_from restic backup state].freeze
21
- LEGACY_YAML_KEYS = %w[
22
- app_name
23
- database_adapter
24
- database_url
25
- sqlite_database_path
26
- backup_paths
27
- local_restore_source_paths
28
- restic_repository
29
- restic_repository_file
30
- restic_password
31
- restic_password_file
32
- restic_password_command
33
- restic_init_if_missing
34
- restic_check_after_backup
35
- restic_check_read_data_subset
36
- restic_forget_after_backup
37
- restic_keep_last
38
- restic_keep_daily
39
- restic_keep_weekly
40
- restic_keep_monthly
41
- restic_keep_yearly
42
- backup_schedule_seconds
43
- backup_start_delay_seconds
44
- state_dir
45
- allow_suspicious_paths
46
- pgpassword
47
- mysql_pwd
48
- ].freeze
49
- ConfigData = Struct.new(:env, :database_definitions, :path_definitions, :restore_from_definitions, keyword_init: true) do
50
- def self.empty
51
- new(env: {}, database_definitions: nil, path_definitions: nil, restore_from_definitions: nil)
52
- end
53
- end
54
- PathDefinition = Struct.new(:path, :exclude, keyword_init: true)
55
23
 
56
24
  class DatabaseSource
57
25
  CONNECTION_KEYS = %w[
@@ -98,11 +66,11 @@ module KamalBackup
98
66
  end
99
67
 
100
68
  def database_name
101
- name.empty? ? "app" : name
69
+ name.empty? ? 'app' : name
102
70
  end
103
71
 
104
72
  def database_adapter
105
- @adapter || parent.send(:legacy_database_adapter)
73
+ @adapter
106
74
  end
107
75
 
108
76
  def value(key)
@@ -145,83 +113,83 @@ module KamalBackup
145
113
  end
146
114
 
147
115
  def app_name
148
- value("APP_NAME")
116
+ value('APP_NAME')
149
117
  end
150
118
 
151
119
  def required_app_name
152
- required_value("APP_NAME")
120
+ required_value('APP_NAME')
153
121
  end
154
122
 
155
123
  def accessory_name
156
- value("KAMAL_BACKUP_ACCESSORY")
124
+ value('KAMAL_BACKUP_ACCESSORY')
157
125
  end
158
126
 
159
127
  def restic_repository
160
- value("RESTIC_REPOSITORY")
128
+ value('RESTIC_REPOSITORY')
161
129
  end
162
130
 
163
131
  def restic_repository_file
164
- value("RESTIC_REPOSITORY_FILE")
132
+ value('RESTIC_REPOSITORY_FILE')
165
133
  end
166
134
 
167
135
  def restic_password
168
- value("RESTIC_PASSWORD")
136
+ value('RESTIC_PASSWORD')
169
137
  end
170
138
 
171
139
  def restic_password_file
172
- value("RESTIC_PASSWORD_FILE")
140
+ value('RESTIC_PASSWORD_FILE')
173
141
  end
174
142
 
175
143
  def restic_password_command
176
- value("RESTIC_PASSWORD_COMMAND")
144
+ value('RESTIC_PASSWORD_COMMAND')
177
145
  end
178
146
 
179
147
  def restic_init_if_missing?
180
- truthy?("RESTIC_INIT_IF_MISSING")
148
+ truthy?('RESTIC_INIT_IF_MISSING')
181
149
  end
182
150
 
183
151
  def check_after_backup?
184
- truthy?("RESTIC_CHECK_AFTER_BACKUP")
152
+ truthy?('RESTIC_CHECK_AFTER_BACKUP')
185
153
  end
186
154
 
187
155
  def forget_after_backup?
188
- !falsey?("RESTIC_FORGET_AFTER_BACKUP")
156
+ !falsey?('RESTIC_FORGET_AFTER_BACKUP')
189
157
  end
190
158
 
191
159
  def check_read_data_subset
192
- value("RESTIC_CHECK_READ_DATA_SUBSET")
160
+ value('RESTIC_CHECK_READ_DATA_SUBSET')
193
161
  end
194
162
 
195
163
  def allow_in_place_file_restore?
196
- truthy?("KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE")
164
+ truthy?('KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE')
197
165
  end
198
166
 
199
167
  def allow_suspicious_backup_paths?
200
- truthy?("KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS")
168
+ truthy?('KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS')
201
169
  end
202
170
 
203
171
  def backup_schedule_seconds
204
- integer("BACKUP_SCHEDULE_SECONDS", 86_400, minimum: 1)
172
+ integer('BACKUP_SCHEDULE_SECONDS', 86_400, minimum: 1)
205
173
  end
206
174
 
207
175
  def backup_start_delay_seconds
208
- integer("BACKUP_START_DELAY_SECONDS", 0, minimum: 0)
176
+ integer('BACKUP_START_DELAY_SECONDS', 0, minimum: 0)
209
177
  end
210
178
 
211
179
  def state_dir
212
- value("KAMAL_BACKUP_STATE_DIR") || "/var/lib/kamal-backup"
180
+ value('KAMAL_BACKUP_STATE_DIR') || '/var/lib/kamal-backup'
213
181
  end
214
182
 
215
183
  def last_check_path
216
- File.join(state_dir, "last_check.json")
184
+ File.join(state_dir, 'last_check.json')
217
185
  end
218
186
 
219
187
  def last_backup_path
220
- File.join(state_dir, "last_backup.json")
188
+ File.join(state_dir, 'last_backup.json')
221
189
  end
222
190
 
223
191
  def last_restore_drill_path
224
- File.join(state_dir, "last_restore_drill.json")
192
+ File.join(state_dir, 'last_restore_drill.json')
225
193
  end
226
194
 
227
195
  def backup_paths
@@ -239,27 +207,21 @@ module KamalBackup
239
207
  end
240
208
 
241
209
  def local_restore_source_paths
242
- if path_definitions?
243
- @restore_from_definitions || legacy_local_restore_source_paths || backup_paths
244
- else
245
- legacy_local_restore_source_paths || backup_paths
246
- end
210
+ @restore_from_definitions || legacy_local_restore_source_paths || backup_paths
247
211
  end
248
212
 
249
213
  def local_restore_path_pairs
250
214
  source_paths = local_restore_source_paths
251
215
  target_paths = backup_paths
252
216
 
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
217
+ raise ConfigurationError, 'local restore source paths must contain the same number of paths as file paths' unless source_paths.size == target_paths.size
218
+
219
+ source_paths.zip(target_paths)
258
220
  end
259
221
 
260
222
  def backup_path_label(path)
261
- label = path.to_s.sub(%r{\A/+}, "").gsub(%r{[^A-Za-z0-9_.-]+}, "-")
262
- label.empty? ? "root" : label
223
+ label = path.to_s.sub(%r{\A/+}, '').gsub(/[^A-Za-z0-9_.-]+/, '-')
224
+ label.empty? ? 'root' : label
263
225
  end
264
226
 
265
227
  def database_adapter
@@ -270,37 +232,31 @@ module KamalBackup
270
232
  end
271
233
  end
272
234
 
273
- def database_name
274
- "app"
275
- end
276
-
277
235
  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
236
+ @databases ||= if database_definitions?
237
+ @database_definitions.map do |definition|
238
+ DatabaseSource.new(
239
+ parent: self,
240
+ name: definition.fetch(:name),
241
+ adapter: definition.fetch(:adapter),
242
+ env: definition.fetch(:env),
243
+ structured: true,
244
+ missing_secrets: definition.fetch(:missing_secrets, [])
245
+ )
246
+ end
247
+ elsif legacy_database_adapter
248
+ [
249
+ DatabaseSource.new(
250
+ parent: self,
251
+ name: 'app',
252
+ adapter: legacy_database_adapter,
253
+ env: {},
254
+ structured: false
255
+ )
256
+ ]
257
+ else
258
+ []
259
+ end
304
260
  end
305
261
 
306
262
  def retention
@@ -316,7 +272,7 @@ module KamalBackup
316
272
  number = Integer(raw)
317
273
  next if number <= 0
318
274
 
319
- flag = "--#{key.sub("RESTIC_KEEP_", "keep-").downcase.tr("_", "-")}"
275
+ flag = "--#{key.sub('RESTIC_KEEP_', 'keep-').downcase.tr('_', '-')}"
320
276
  args.concat([flag, number.to_s])
321
277
  rescue ArgumentError
322
278
  raise ConfigurationError, "#{key} must be an integer"
@@ -341,27 +297,34 @@ module KamalBackup
341
297
  end
342
298
 
343
299
  def validate_database_backup(check_files: true)
344
- raise ConfigurationError, "databases must contain at least one database" if databases.empty?
300
+ raise ConfigurationError, 'databases must contain at least one database' if databases.empty?
345
301
 
346
302
  databases.each do |database|
347
303
  unless database.missing_secrets.empty?
348
- raise ConfigurationError, "database #{database.database_name} requires missing secret #{database.missing_secrets.join(", ")}"
304
+ raise ConfigurationError,
305
+ "database #{database.database_name} requires missing secret #{database.missing_secrets.join(', ')}"
349
306
  end
350
307
 
351
308
  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"
309
+ when 'postgres'
310
+ unless database.value('DATABASE_URL') || database.value('PGDATABASE')
311
+ raise ConfigurationError,
312
+ "PostgreSQL database #{database.database_name} requires url or PGDATABASE/libpq environment"
355
313
  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"
314
+ when 'mysql'
315
+ unless database.value('DATABASE_URL') || database.value('MYSQL_DATABASE') || database.value('MARIADB_DATABASE')
316
+ raise ConfigurationError,
317
+ "MySQL database #{database.database_name} requires url or MYSQL_DATABASE/MARIADB_DATABASE"
318
+ end
319
+ when 'sqlite'
320
+ path = database.required_value('SQLITE_DATABASE_PATH')
321
+ if check_files && !File.file?(path)
322
+ raise ConfigurationError,
323
+ "SQLite database #{database.database_name} does not exist: #{path}"
359
324
  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
325
  else
364
- raise ConfigurationError, "database #{database.database_name} adapter is required and must be postgres, mysql, or sqlite"
326
+ raise ConfigurationError,
327
+ "database #{database.database_name} adapter is required and must be postgres, mysql, or sqlite"
365
328
  end
366
329
  end
367
330
  end
@@ -370,49 +333,49 @@ module KamalBackup
370
333
  backup_paths.each do |path|
371
334
  expanded = File.expand_path(path)
372
335
  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"
336
+ raise ConfigurationError,
337
+ "refusing suspicious backup path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
374
338
  end
375
339
  raise ConfigurationError, "backup path does not exist: #{path}" if check_files && !File.exist?(path)
376
340
  end
377
341
  end
378
342
 
379
343
  def validate_local_database_restore_target(target)
380
- raise ConfigurationError, "local restore database target is required" if target.to_s.strip.empty?
344
+ raise ConfigurationError, 'local restore database target is required' if target.to_s.strip.empty?
381
345
 
382
- if production_named_target?(target)
383
- raise ConfigurationError, "refusing production-looking local restore target #{target}; use restore production for production restores"
384
- end
346
+ return unless production_named_target?(target)
347
+
348
+ raise ConfigurationError,
349
+ "refusing production-looking local restore target #{target}; use restore production for production restores"
385
350
  end
386
351
 
387
352
  def validate_file_restore_target(target)
388
- raise ConfigurationError, "restore target cannot be empty" if target.to_s.strip.empty?
353
+ raise ConfigurationError, 'restore target cannot be empty' if target.to_s.strip.empty?
389
354
 
390
355
  expanded_target = File.expand_path(target)
391
- raise ConfigurationError, "refusing to restore files to /" if expanded_target == "/"
356
+ raise ConfigurationError, 'refusing to restore files to /' if expanded_target == '/'
392
357
 
393
358
  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"
359
+ raise ConfigurationError,
360
+ "refusing in-place file restore to #{expanded_target}; set KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE=true to override"
395
361
  end
396
362
 
397
363
  expanded_target
398
364
  end
399
365
 
400
366
  def validate_database_restore_target(target)
401
- raise ConfigurationError, "restore database target is required" if target.to_s.strip.empty?
367
+ raise ConfigurationError, 'restore database target is required' if target.to_s.strip.empty?
402
368
 
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
369
+ return unless production_like_target?(target)
370
+
371
+ raise ConfigurationError,
372
+ "refusing production-looking restore target #{target}; choose a scratch target that does not look like production"
406
373
  end
407
374
 
408
375
  def production_like_target?(target)
409
376
  target = target.to_s
410
377
 
411
- if source_database_targets.include?(target)
412
- true
413
- else
414
- production_named_target?(target.downcase)
415
- end
378
+ source_database_targets.include?(target) || production_named_target?(target.downcase)
416
379
  end
417
380
 
418
381
  def value(key)
@@ -436,539 +399,194 @@ module KamalBackup
436
399
  end
437
400
 
438
401
  private
439
- def project_defaults(cwd:)
440
- RailsApp.new(cwd: cwd).defaults
441
- end
442
-
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
-
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
454
-
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
463
- end
464
-
465
- def validate_restic_repository(check_files:)
466
- return if restic_repository
467
-
468
- if path = restic_repository_file
469
- raise ConfigurationError, "RESTIC_REPOSITORY_FILE does not exist: #{path}" if check_files && !File.file?(path)
470
-
471
- return
472
- end
473
-
474
- raise ConfigurationError, "RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required"
475
- end
476
-
477
- def validate_restic_password(check_files:)
478
- return if restic_password || restic_password_command
479
-
480
- if path = restic_password_file
481
- raise ConfigurationError, "RESTIC_PASSWORD_FILE does not exist: #{path}" if check_files && !File.file?(path)
482
-
483
- return
484
- end
485
-
486
- raise ConfigurationError, "RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or RESTIC_PASSWORD_COMMAND is required"
487
- end
488
-
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?
492
-
493
- unless data.is_a?(Hash)
494
- raise ConfigurationError, "#{path} must contain a YAML mapping"
495
- end
496
-
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
520
- end
521
- result
522
- rescue Psych::SyntaxError => e
523
- raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
524
- end
525
-
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
530
-
531
- return if TOP_LEVEL_YAML_KEYS.include?(key)
532
-
533
- raise ConfigurationError, "#{path} contains unknown key #{key.inspect}; expected #{TOP_LEVEL_YAML_KEYS.join(", ")}"
534
- end
535
-
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
545
- end
546
-
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
- }
590
- end
591
- end
592
-
593
- def normalize_yaml_restic(raw_value, raw_env:, path:)
594
- hash = require_mapping(raw_value, "#{path} restic")
595
- 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
603
- end
604
- end
605
-
606
- env.merge!(normalize_yaml_restic_rest(hash["rest"], raw_env: raw_env, path: path)) if hash.key?("rest")
607
-
608
- {
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
616
-
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
629
-
630
- env.compact
631
- end
632
-
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) }
648
- end
649
- end
650
-
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"]
655
-
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
659
- end
660
-
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
666
- end
667
-
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
674
-
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
- []
681
- else
682
- [normalize_yaml_path(raw_value, context)].reject(&:empty?)
683
- end
684
- end
685
-
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
690
-
691
- normalize_yaml_value(raw_value)
692
- end
693
-
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
705
- end
706
-
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
715
-
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
724
- end
725
-
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?
731
-
732
- pattern
733
- end
734
- end
735
-
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
402
 
744
- secret_name = normalize_yaml_value(hash.fetch("secret"))
745
- raw_env[secret_name]
746
- else
747
- normalize_yaml_value(raw_value)
748
- end
749
- end
750
-
751
- def missing_yaml_secrets(raw_value, raw_env:)
752
- return [] unless raw_value.is_a?(Hash)
753
-
754
- hash = stringify_keys(raw_value)
755
- return [] unless hash.key?("secret")
756
-
757
- secret_name = normalize_yaml_value(hash.fetch("secret"))
758
- raw_env[secret_name].to_s.strip.empty? ? [secret_name] : []
759
- end
760
-
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?
764
-
765
- return value if value.match?(/\A\d+\z/)
403
+ def project_defaults(cwd:)
404
+ RailsApp.new(cwd: cwd).defaults
405
+ end
766
406
 
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
407
+ def load_config_files(raw_env, cwd:, paths:)
408
+ config_paths(raw_env, cwd: cwd, paths: paths).each_with_object(ConfigData.empty) do |path, merged|
409
+ next unless File.file?(path)
771
410
 
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
411
+ data = ConfigFile.new(path, env: raw_env).data
412
+ merged.env.merge!(data.env)
413
+ merged.database_definitions = data.database_definitions if data.database_definitions
414
+ merged.path_definitions = data.path_definitions if data.path_definitions
415
+ merged.restore_from_definitions = data.restore_from_definitions if data.restore_from_definitions
781
416
  end
417
+ end
782
418
 
783
- def require_array(value, context)
784
- return value if value.is_a?(Array)
785
-
786
- raise ConfigurationError, "#{context} must be a YAML sequence"
419
+ def config_paths(raw_env, cwd:, paths:)
420
+ if paths
421
+ Array(paths).map { |path| File.expand_path(path, cwd) }
422
+ elsif (explicit = raw_env['KAMAL_BACKUP_CONFIG'])
423
+ [File.expand_path(explicit, cwd)]
424
+ else
425
+ DEFAULT_CONFIG_PATHS.map { |relative| File.expand_path(relative, cwd) }
787
426
  end
427
+ end
788
428
 
789
- def require_mapping(value, context)
790
- raise ConfigurationError, "#{context} must be a YAML mapping" unless value.is_a?(Hash)
429
+ def integer(key, default, minimum:)
430
+ raw = value(key)
431
+ number = raw ? Integer(raw) : default
432
+ raise ConfigurationError, "#{key} must be >= #{minimum}" if number < minimum
791
433
 
792
- stringify_keys(value)
793
- end
434
+ number
435
+ rescue ArgumentError
436
+ raise ConfigurationError, "#{key} must be an integer"
437
+ end
794
438
 
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
439
+ def legacy_backup_paths
440
+ split_paths(value('BACKUP_PATHS'))
441
+ end
799
442
 
800
- normalize_yaml_value(value)
443
+ def legacy_local_restore_source_paths
444
+ if (raw = value('LOCAL_RESTORE_SOURCE_PATHS'))
445
+ split_paths(raw)
801
446
  end
447
+ end
802
448
 
803
- def required_yaml_string(hash, key, context)
804
- raise ConfigurationError, "#{context} #{key} is required" unless hash.key?(key)
449
+ def split_paths(raw)
450
+ raw.to_s.split(/[\n:]+/).map(&:strip).reject(&:empty?)
451
+ end
805
452
 
806
- value = optional_yaml_string(hash[key], "#{context}.#{key}")
807
- raise ConfigurationError, "#{context} #{key} is required" if value.to_s.strip.empty?
453
+ def path_definitions?
454
+ !@path_definitions.nil?
455
+ end
808
456
 
809
- value
457
+ def backup_path_definitions
458
+ if path_definitions?
459
+ @path_definitions
460
+ else
461
+ legacy_backup_paths.map { |path| PathDefinition.new(path: path, exclude: []) }
810
462
  end
463
+ end
811
464
 
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?
815
-
816
- value
465
+ def configured_backup_path_excludes(paths)
466
+ backup_path_definitions.each_with_object([]) do |definition, excludes|
467
+ excludes.concat(definition.exclude) if paths.include?(definition.path)
817
468
  end
469
+ end
818
470
 
819
- def stringify_keys(hash)
820
- hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
821
- end
471
+ def sqlite_backup_path_excludes(paths)
472
+ databases.select { |database| database.database_adapter == 'sqlite' }.flat_map do |database|
473
+ sqlite_database_path = database.value('SQLITE_DATABASE_PATH')
474
+ next [] if sqlite_database_path.to_s.empty?
475
+ next [] unless paths.any? { |path| path_contains?(path, sqlite_database_path) }
822
476
 
823
- def validate_local_machine_environment
824
- if environment = local_restore_environment
825
- key, value = environment
477
+ [sqlite_database_path, "#{sqlite_database_path}-wal", "#{sqlite_database_path}-shm"]
478
+ end.uniq
479
+ end
826
480
 
827
- if production_environment?(value)
828
- raise ConfigurationError, "restore local refuses to run with #{key}=#{value}; unset #{key} or use restore production"
829
- end
830
- end
831
- end
481
+ def path_contains?(parent, child)
482
+ expanded_parent = File.expand_path(parent)
483
+ expanded_child = File.expand_path(child)
484
+ expanded_child == expanded_parent || expanded_child.start_with?("#{expanded_parent}/")
485
+ end
832
486
 
833
- def validate_local_machine_paths
834
- path_pairs = local_restore_path_pairs
487
+ def database_definitions?
488
+ !@database_definitions.nil?
489
+ end
835
490
 
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
841
- end
491
+ def legacy_database_adapter
492
+ if (explicit = value('DATABASE_ADAPTER'))
493
+ Databases.normalize_adapter(explicit)
494
+ elsif (adapter = adapter_from_database_url)
495
+ adapter
496
+ elsif value('SQLITE_DATABASE_PATH')
497
+ 'sqlite'
842
498
  end
499
+ end
843
500
 
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
848
-
849
- number
850
- rescue ArgumentError
851
- raise ConfigurationError, "#{key} must be an integer"
501
+ def adapter_from_database_url
502
+ if (url = value('DATABASE_URL'))
503
+ Databases.normalize_adapter(URI.parse(url).scheme)
852
504
  end
505
+ rescue URI::InvalidURIError
506
+ nil
507
+ end
853
508
 
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
865
- end
509
+ def validate_restic_repository(check_files:)
510
+ return if restic_repository
866
511
 
867
- def database_definitions?
868
- !@database_definitions.nil?
869
- end
512
+ if (path = restic_repository_file)
513
+ raise ConfigurationError, "RESTIC_REPOSITORY_FILE does not exist: #{path}" if check_files && !File.file?(path)
870
514
 
871
- def path_definitions?
872
- !@path_definitions.nil?
515
+ return
873
516
  end
874
517
 
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
881
- end
518
+ raise ConfigurationError, 'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required'
519
+ end
882
520
 
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
887
- end
521
+ def validate_restic_password(check_files:)
522
+ return if restic_password || restic_password_command
888
523
 
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) }
524
+ if (path = restic_password_file)
525
+ raise ConfigurationError, "RESTIC_PASSWORD_FILE does not exist: #{path}" if check_files && !File.file?(path)
894
526
 
895
- [sqlite_database_path, "#{sqlite_database_path}-wal", "#{sqlite_database_path}-shm"]
896
- end.uniq
527
+ return
897
528
  end
898
529
 
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
530
+ raise ConfigurationError, 'RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or RESTIC_PASSWORD_COMMAND is required'
531
+ end
904
532
 
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
913
- end
533
+ def validate_local_machine_environment
534
+ if (environment = local_restore_environment)
535
+ key, value = environment
914
536
 
915
- def adapter_from_database_url
916
- if url = value("DATABASE_URL")
917
- normalize_adapter(URI.parse(url).scheme)
537
+ if production_environment?(value)
538
+ raise ConfigurationError,
539
+ "restore local refuses to run with #{key}=#{value}; unset #{key} or use restore production"
918
540
  end
919
- rescue URI::InvalidURIError
920
- nil
921
541
  end
542
+ end
922
543
 
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
544
+ def local_restore_environment
545
+ %w[RAILS_ENV RACK_ENV APP_ENV KAMAL_ENVIRONMENT].each do |key|
546
+ return [key, value(key)] if value(key)
928
547
  end
548
+ end
929
549
 
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
550
+ def production_environment?(value)
551
+ %w[production prod live].include?(value.to_s.downcase)
552
+ end
941
553
 
942
- def legacy_backup_paths
943
- split_paths(value("BACKUP_PATHS"))
944
- end
554
+ def validate_local_machine_paths
555
+ path_pairs = local_restore_path_pairs
945
556
 
946
- def legacy_local_restore_source_paths
947
- if raw = value("LOCAL_RESTORE_SOURCE_PATHS")
948
- split_paths(raw)
557
+ path_pairs.each do |path_pair|
558
+ target_path = path_pair.last
559
+ expanded = File.expand_path(target_path)
560
+ if SUSPICIOUS_BACKUP_PATHS.include?(expanded) && !allow_suspicious_backup_paths?
561
+ raise ConfigurationError,
562
+ "refusing suspicious local restore path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
949
563
  end
950
564
  end
565
+ end
951
566
 
952
- def split_paths(raw)
953
- raw.to_s.split(/[\n:]+/).map(&:strip).reject(&:empty?)
954
- end
955
-
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
567
+ def in_place_file_restore?(expanded_target)
568
+ backup_paths.any? do |path|
569
+ expanded_path = File.expand_path(path)
570
+ expanded_target == expanded_path || expanded_path.start_with?("#{expanded_target}/") || expanded_target.start_with?("#{expanded_path}/")
962
571
  end
572
+ end
963
573
 
964
- def production_environment?(value)
965
- %w[production prod live].include?(value.to_s.downcase)
966
- end
574
+ def source_database_targets
575
+ databases.flat_map do |database|
576
+ [
577
+ database.value('DATABASE_URL'),
578
+ database.value('SQLITE_DATABASE_PATH'),
579
+ database.value('PGDATABASE'),
580
+ database.value('MYSQL_DATABASE'),
581
+ database.value('MARIADB_DATABASE')
582
+ ]
583
+ end.compact
584
+ end
967
585
 
968
- def production_named_target?(target)
969
- target.include?("production") ||
970
- target.match?(%r{(^|[/_.:-])prod([/_.:-]|$)}) ||
971
- target.match?(%r{(^|[/_.:-])live([/_.:-]|$)})
972
- end
586
+ def production_named_target?(target)
587
+ target.include?('production') ||
588
+ target.match?(%r{(^|[/_.:-])prod([/_.:-]|$)}) ||
589
+ target.match?(%r{(^|[/_.:-])live([/_.:-]|$)})
590
+ end
973
591
  end
974
592
  end