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