kamal-backup 0.3.0 → 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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/lib/kamal_backup/app.rb +145 -202
- data/lib/kamal_backup/cli/helpers.rb +298 -0
- data/lib/kamal_backup/cli.rb +4 -294
- data/lib/kamal_backup/command.rb +0 -185
- data/lib/kamal_backup/command_output.rb +189 -0
- data/lib/kamal_backup/config.rb +77 -481
- data/lib/kamal_backup/config_file.rb +376 -0
- data/lib/kamal_backup/databases/base.rb +11 -0
- data/lib/kamal_backup/databases/postgres.rb +3 -3
- data/lib/kamal_backup/evidence.rb +0 -3
- data/lib/kamal_backup/kamal_bridge.rb +39 -27
- data/lib/kamal_backup/rails_app.rb +6 -17
- data/lib/kamal_backup/restic.rb +41 -45
- data/lib/kamal_backup/version.rb +1 -1
- data/lib/kamal_backup/yaml_access.rb +13 -0
- data/lib/kamal_backup.rb +3 -0
- metadata +48 -2
data/lib/kamal_backup/config.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'uri'
|
|
4
|
-
require 'yaml'
|
|
5
4
|
require_relative 'errors'
|
|
5
|
+
require_relative 'config_file'
|
|
6
|
+
require_relative 'databases/base'
|
|
6
7
|
require_relative 'rails_app'
|
|
7
8
|
|
|
8
9
|
module KamalBackup
|
|
@@ -19,42 +20,6 @@ module KamalBackup
|
|
|
19
20
|
SHARED_CONFIG_PATH = 'config/kamal-backup.yml'
|
|
20
21
|
LOCAL_CONFIG_PATH = 'config/kamal-backup.local.yml'
|
|
21
22
|
DEFAULT_CONFIG_PATHS = [SHARED_CONFIG_PATH, LOCAL_CONFIG_PATH].freeze
|
|
22
|
-
TOP_LEVEL_YAML_KEYS = %w[app accessory databases paths restore_from restic backup state].freeze
|
|
23
|
-
LEGACY_YAML_KEYS = %w[
|
|
24
|
-
app_name
|
|
25
|
-
database_adapter
|
|
26
|
-
database_url
|
|
27
|
-
sqlite_database_path
|
|
28
|
-
backup_paths
|
|
29
|
-
local_restore_source_paths
|
|
30
|
-
restic_repository
|
|
31
|
-
restic_repository_file
|
|
32
|
-
restic_password
|
|
33
|
-
restic_password_file
|
|
34
|
-
restic_password_command
|
|
35
|
-
restic_init_if_missing
|
|
36
|
-
restic_check_after_backup
|
|
37
|
-
restic_check_read_data_subset
|
|
38
|
-
restic_forget_after_backup
|
|
39
|
-
restic_keep_last
|
|
40
|
-
restic_keep_daily
|
|
41
|
-
restic_keep_weekly
|
|
42
|
-
restic_keep_monthly
|
|
43
|
-
restic_keep_yearly
|
|
44
|
-
backup_schedule_seconds
|
|
45
|
-
backup_start_delay_seconds
|
|
46
|
-
state_dir
|
|
47
|
-
allow_suspicious_paths
|
|
48
|
-
pgpassword
|
|
49
|
-
mysql_pwd
|
|
50
|
-
].freeze
|
|
51
|
-
ConfigData = Struct.new(:env, :database_definitions, :path_definitions, :restore_from_definitions,
|
|
52
|
-
keyword_init: true) do
|
|
53
|
-
def self.empty
|
|
54
|
-
new(env: {}, database_definitions: nil, path_definitions: nil, restore_from_definitions: nil)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
PathDefinition = Struct.new(:path, :exclude, keyword_init: true)
|
|
58
23
|
|
|
59
24
|
class DatabaseSource
|
|
60
25
|
CONNECTION_KEYS = %w[
|
|
@@ -105,7 +70,7 @@ module KamalBackup
|
|
|
105
70
|
end
|
|
106
71
|
|
|
107
72
|
def database_adapter
|
|
108
|
-
@adapter
|
|
73
|
+
@adapter
|
|
109
74
|
end
|
|
110
75
|
|
|
111
76
|
def value(key)
|
|
@@ -242,11 +207,7 @@ module KamalBackup
|
|
|
242
207
|
end
|
|
243
208
|
|
|
244
209
|
def local_restore_source_paths
|
|
245
|
-
|
|
246
|
-
@restore_from_definitions || legacy_local_restore_source_paths || backup_paths
|
|
247
|
-
else
|
|
248
|
-
legacy_local_restore_source_paths || backup_paths
|
|
249
|
-
end
|
|
210
|
+
@restore_from_definitions || legacy_local_restore_source_paths || backup_paths
|
|
250
211
|
end
|
|
251
212
|
|
|
252
213
|
def local_restore_path_pairs
|
|
@@ -271,10 +232,6 @@ module KamalBackup
|
|
|
271
232
|
end
|
|
272
233
|
end
|
|
273
234
|
|
|
274
|
-
def database_name
|
|
275
|
-
'app'
|
|
276
|
-
end
|
|
277
|
-
|
|
278
235
|
def databases
|
|
279
236
|
@databases ||= if database_definitions?
|
|
280
237
|
@database_definitions.map do |definition|
|
|
@@ -291,7 +248,7 @@ module KamalBackup
|
|
|
291
248
|
[
|
|
292
249
|
DatabaseSource.new(
|
|
293
250
|
parent: self,
|
|
294
|
-
name:
|
|
251
|
+
name: 'app',
|
|
295
252
|
adapter: legacy_database_adapter,
|
|
296
253
|
env: {},
|
|
297
254
|
structured: false
|
|
@@ -451,7 +408,7 @@ module KamalBackup
|
|
|
451
408
|
config_paths(raw_env, cwd: cwd, paths: paths).each_with_object(ConfigData.empty) do |path, merged|
|
|
452
409
|
next unless File.file?(path)
|
|
453
410
|
|
|
454
|
-
data =
|
|
411
|
+
data = ConfigFile.new(path, env: raw_env).data
|
|
455
412
|
merged.env.merge!(data.env)
|
|
456
413
|
merged.database_definitions = data.database_definitions if data.database_definitions
|
|
457
414
|
merged.path_definitions = data.path_definitions if data.path_definitions
|
|
@@ -469,378 +426,108 @@ module KamalBackup
|
|
|
469
426
|
end
|
|
470
427
|
end
|
|
471
428
|
|
|
472
|
-
def
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if
|
|
476
|
-
raise ConfigurationError, "RESTIC_REPOSITORY_FILE does not exist: #{path}" if check_files && !File.file?(path)
|
|
477
|
-
|
|
478
|
-
return
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
raise ConfigurationError, 'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required'
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
def validate_restic_password(check_files:)
|
|
485
|
-
return if restic_password || restic_password_command
|
|
486
|
-
|
|
487
|
-
if (path = restic_password_file)
|
|
488
|
-
raise ConfigurationError, "RESTIC_PASSWORD_FILE does not exist: #{path}" if check_files && !File.file?(path)
|
|
489
|
-
|
|
490
|
-
return
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
raise ConfigurationError, 'RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or RESTIC_PASSWORD_COMMAND is required'
|
|
494
|
-
end
|
|
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
|
|
495
433
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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))
|
|
524
|
-
end
|
|
525
|
-
end
|
|
526
|
-
result
|
|
527
|
-
rescue Psych::SyntaxError => e
|
|
528
|
-
raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
|
|
434
|
+
number
|
|
435
|
+
rescue ArgumentError
|
|
436
|
+
raise ConfigurationError, "#{key} must be an integer"
|
|
529
437
|
end
|
|
530
438
|
|
|
531
|
-
def
|
|
532
|
-
|
|
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
|
|
536
|
-
|
|
537
|
-
return if TOP_LEVEL_YAML_KEYS.include?(key)
|
|
538
|
-
|
|
539
|
-
raise ConfigurationError,
|
|
540
|
-
"#{path} contains unknown key #{key.inspect}; expected #{TOP_LEVEL_YAML_KEYS.join(', ')}"
|
|
439
|
+
def legacy_backup_paths
|
|
440
|
+
split_paths(value('BACKUP_PATHS'))
|
|
541
441
|
end
|
|
542
442
|
|
|
543
|
-
def
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
raw_value.map(&:to_s).join("\n")
|
|
547
|
-
when NilClass
|
|
548
|
-
nil
|
|
549
|
-
else
|
|
550
|
-
raw_value.to_s
|
|
443
|
+
def legacy_local_restore_source_paths
|
|
444
|
+
if (raw = value('LOCAL_RESTORE_SOURCE_PATHS'))
|
|
445
|
+
split_paths(raw)
|
|
551
446
|
end
|
|
552
447
|
end
|
|
553
448
|
|
|
554
|
-
def
|
|
555
|
-
|
|
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"
|
|
563
|
-
end
|
|
564
|
-
|
|
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))
|
|
596
|
-
end
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
{
|
|
600
|
-
name: name,
|
|
601
|
-
adapter: adapter,
|
|
602
|
-
env: env.compact,
|
|
603
|
-
missing_secrets: missing_secrets
|
|
604
|
-
}
|
|
605
|
-
end
|
|
449
|
+
def split_paths(raw)
|
|
450
|
+
raw.to_s.split(/[\n:]+/).map(&:strip).reject(&:empty?)
|
|
606
451
|
end
|
|
607
452
|
|
|
608
|
-
def
|
|
609
|
-
|
|
610
|
-
env = {}
|
|
611
|
-
|
|
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')
|
|
618
|
-
|
|
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
|
|
622
|
-
end
|
|
623
|
-
end
|
|
624
|
-
|
|
625
|
-
env.merge!(normalize_yaml_restic_rest(hash['rest'], raw_env: raw_env, path: path)) if hash.key?('rest')
|
|
626
|
-
|
|
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
|
|
635
|
-
|
|
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
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
env.compact
|
|
453
|
+
def path_definitions?
|
|
454
|
+
!@path_definitions.nil?
|
|
650
455
|
end
|
|
651
456
|
|
|
652
|
-
def
|
|
653
|
-
|
|
654
|
-
|
|
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']) }
|
|
662
|
-
else
|
|
663
|
-
raise ConfigurationError, "#{path} restic.password must use secret, file, or command"
|
|
664
|
-
end
|
|
457
|
+
def backup_path_definitions
|
|
458
|
+
if path_definitions?
|
|
459
|
+
@path_definitions
|
|
665
460
|
else
|
|
666
|
-
{
|
|
461
|
+
legacy_backup_paths.map { |path| PathDefinition.new(path: path, exclude: []) }
|
|
667
462
|
end
|
|
668
463
|
end
|
|
669
464
|
|
|
670
|
-
def
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
username = hash.key?('username') ? hash['username'] : hash['user']
|
|
674
|
-
|
|
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")
|
|
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)
|
|
683
468
|
end
|
|
684
|
-
env.compact
|
|
685
469
|
end
|
|
686
470
|
|
|
687
|
-
def
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
normalize_duration(hash['schedule'], "#{path} backup.schedule")
|
|
693
|
-
end
|
|
694
|
-
env.compact
|
|
695
|
-
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) }
|
|
696
476
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
env = {}
|
|
700
|
-
env['KAMAL_BACKUP_STATE_DIR'] = normalize_yaml_value(hash['path']) if hash.key?('path')
|
|
701
|
-
env.compact
|
|
477
|
+
[sqlite_database_path, "#{sqlite_database_path}-wal", "#{sqlite_database_path}-shm"]
|
|
478
|
+
end.uniq
|
|
702
479
|
end
|
|
703
480
|
|
|
704
|
-
def
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
when NilClass
|
|
709
|
-
[]
|
|
710
|
-
else
|
|
711
|
-
[normalize_yaml_path(raw_value, context)].reject(&:empty?)
|
|
712
|
-
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}/")
|
|
713
485
|
end
|
|
714
486
|
|
|
715
|
-
def
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
normalize_yaml_value(raw_value)
|
|
487
|
+
def database_definitions?
|
|
488
|
+
!@database_definitions.nil?
|
|
719
489
|
end
|
|
720
490
|
|
|
721
|
-
def
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
when NilClass
|
|
729
|
-
[]
|
|
730
|
-
else
|
|
731
|
-
[normalize_yaml_backup_path(raw_value, "#{context}[1]")].reject { |definition| definition.path.to_s.empty? }
|
|
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'
|
|
732
498
|
end
|
|
733
499
|
end
|
|
734
500
|
|
|
735
|
-
def
|
|
736
|
-
|
|
737
|
-
|
|
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"
|
|
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: [])
|
|
501
|
+
def adapter_from_database_url
|
|
502
|
+
if (url = value('DATABASE_URL'))
|
|
503
|
+
Databases.normalize_adapter(URI.parse(url).scheme)
|
|
752
504
|
end
|
|
505
|
+
rescue URI::InvalidURIError
|
|
506
|
+
nil
|
|
753
507
|
end
|
|
754
508
|
|
|
755
|
-
def
|
|
756
|
-
|
|
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?
|
|
760
|
-
|
|
761
|
-
pattern
|
|
762
|
-
end
|
|
763
|
-
end
|
|
509
|
+
def validate_restic_repository(check_files:)
|
|
510
|
+
return if restic_repository
|
|
764
511
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
when Hash
|
|
768
|
-
hash = stringify_keys(raw_value)
|
|
769
|
-
raise ConfigurationError, "#{context} must be a scalar value or { secret: NAME }" unless hash.keys == ['secret']
|
|
512
|
+
if (path = restic_repository_file)
|
|
513
|
+
raise ConfigurationError, "RESTIC_REPOSITORY_FILE does not exist: #{path}" if check_files && !File.file?(path)
|
|
770
514
|
|
|
771
|
-
|
|
772
|
-
raw_env[secret_name]
|
|
773
|
-
else
|
|
774
|
-
normalize_yaml_value(raw_value)
|
|
515
|
+
return
|
|
775
516
|
end
|
|
776
|
-
end
|
|
777
|
-
|
|
778
|
-
def missing_yaml_secrets(raw_value, raw_env:)
|
|
779
|
-
return [] unless raw_value.is_a?(Hash)
|
|
780
|
-
|
|
781
|
-
hash = stringify_keys(raw_value)
|
|
782
|
-
return [] unless hash.key?('secret')
|
|
783
|
-
|
|
784
|
-
secret_name = normalize_yaml_value(hash.fetch('secret'))
|
|
785
|
-
raw_env[secret_name].to_s.strip.empty? ? [secret_name] : []
|
|
786
|
-
end
|
|
787
|
-
|
|
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?
|
|
791
|
-
|
|
792
|
-
return value if value.match?(/\A\d+\z/)
|
|
793
|
-
|
|
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
|
|
796
|
-
|
|
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
|
|
807
|
-
|
|
808
|
-
def require_array(value, context)
|
|
809
|
-
return value if value.is_a?(Array)
|
|
810
|
-
|
|
811
|
-
raise ConfigurationError, "#{context} must be a YAML sequence"
|
|
812
|
-
end
|
|
813
|
-
|
|
814
|
-
def require_mapping(value, context)
|
|
815
|
-
raise ConfigurationError, "#{context} must be a YAML mapping" unless value.is_a?(Hash)
|
|
816
|
-
|
|
817
|
-
stringify_keys(value)
|
|
818
|
-
end
|
|
819
517
|
|
|
820
|
-
|
|
821
|
-
raise ConfigurationError, "#{context} must be a string" if value.is_a?(Hash) || value.is_a?(Array)
|
|
822
|
-
|
|
823
|
-
normalize_yaml_value(value)
|
|
518
|
+
raise ConfigurationError, 'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required'
|
|
824
519
|
end
|
|
825
520
|
|
|
826
|
-
def
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
value = optional_yaml_string(hash[key], "#{context}.#{key}")
|
|
830
|
-
raise ConfigurationError, "#{context} #{key} is required" if value.to_s.strip.empty?
|
|
831
|
-
|
|
832
|
-
value
|
|
833
|
-
end
|
|
521
|
+
def validate_restic_password(check_files:)
|
|
522
|
+
return if restic_password || restic_password_command
|
|
834
523
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
raise ConfigurationError, "#{context} #{key} is required" if value.to_s.empty?
|
|
524
|
+
if (path = restic_password_file)
|
|
525
|
+
raise ConfigurationError, "RESTIC_PASSWORD_FILE does not exist: #{path}" if check_files && !File.file?(path)
|
|
838
526
|
|
|
839
|
-
|
|
840
|
-
|
|
527
|
+
return
|
|
528
|
+
end
|
|
841
529
|
|
|
842
|
-
|
|
843
|
-
hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
530
|
+
raise ConfigurationError, 'RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or RESTIC_PASSWORD_COMMAND is required'
|
|
844
531
|
end
|
|
845
532
|
|
|
846
533
|
def validate_local_machine_environment
|
|
@@ -854,6 +541,16 @@ module KamalBackup
|
|
|
854
541
|
end
|
|
855
542
|
end
|
|
856
543
|
|
|
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)
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def production_environment?(value)
|
|
551
|
+
%w[production prod live].include?(value.to_s.downcase)
|
|
552
|
+
end
|
|
553
|
+
|
|
857
554
|
def validate_local_machine_paths
|
|
858
555
|
path_pairs = local_restore_path_pairs
|
|
859
556
|
|
|
@@ -867,83 +564,6 @@ module KamalBackup
|
|
|
867
564
|
end
|
|
868
565
|
end
|
|
869
566
|
|
|
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'
|
|
888
|
-
end
|
|
889
|
-
end
|
|
890
|
-
|
|
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: []) }
|
|
904
|
-
end
|
|
905
|
-
end
|
|
906
|
-
|
|
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)
|
|
910
|
-
end
|
|
911
|
-
end
|
|
912
|
-
|
|
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'
|
|
936
|
-
end
|
|
937
|
-
end
|
|
938
|
-
|
|
939
|
-
def adapter_from_database_url
|
|
940
|
-
if (url = value('DATABASE_URL'))
|
|
941
|
-
normalize_adapter(URI.parse(url).scheme)
|
|
942
|
-
end
|
|
943
|
-
rescue URI::InvalidURIError
|
|
944
|
-
nil
|
|
945
|
-
end
|
|
946
|
-
|
|
947
567
|
def in_place_file_restore?(expanded_target)
|
|
948
568
|
backup_paths.any? do |path|
|
|
949
569
|
expanded_path = File.expand_path(path)
|
|
@@ -963,30 +583,6 @@ module KamalBackup
|
|
|
963
583
|
end.compact
|
|
964
584
|
end
|
|
965
585
|
|
|
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)
|
|
973
|
-
end
|
|
974
|
-
end
|
|
975
|
-
|
|
976
|
-
def split_paths(raw)
|
|
977
|
-
raw.to_s.split(/[\n:]+/).map(&:strip).reject(&:empty?)
|
|
978
|
-
end
|
|
979
|
-
|
|
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)
|
|
983
|
-
end
|
|
984
|
-
end
|
|
985
|
-
|
|
986
|
-
def production_environment?(value)
|
|
987
|
-
%w[production prod live].include?(value.to_s.downcase)
|
|
988
|
-
end
|
|
989
|
-
|
|
990
586
|
def production_named_target?(target)
|
|
991
587
|
target.include?('production') ||
|
|
992
588
|
target.match?(%r{(^|[/_.:-])prod([/_.:-]|$)}) ||
|