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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/exe/kamal-backup +7 -6
- data/lib/kamal_backup/app.rb +330 -393
- data/lib/kamal_backup/cli/helpers.rb +298 -0
- data/lib/kamal_backup/cli.rb +73 -367
- data/lib/kamal_backup/command.rb +77 -258
- data/lib/kamal_backup/command_output.rb +189 -0
- data/lib/kamal_backup/config.rb +242 -624
- data/lib/kamal_backup/config_file.rb +376 -0
- data/lib/kamal_backup/databases/base.rb +28 -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 +61 -63
- data/lib/kamal_backup/kamal_bridge.rb +270 -254
- data/lib/kamal_backup/rails_app.rb +94 -104
- data/lib/kamal_backup/redactor.rb +18 -13
- data/lib/kamal_backup/restic.rb +207 -183
- 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/yaml_access.rb +13 -0
- data/lib/kamal_backup.rb +22 -17
- metadata +76 -2
data/lib/kamal_backup/config.rb
CHANGED
|
@@ -1,57 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
require_relative
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 =
|
|
18
|
-
LOCAL_CONFIG_PATH =
|
|
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? ?
|
|
69
|
+
name.empty? ? 'app' : name
|
|
102
70
|
end
|
|
103
71
|
|
|
104
72
|
def database_adapter
|
|
105
|
-
@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(
|
|
116
|
+
value('APP_NAME')
|
|
149
117
|
end
|
|
150
118
|
|
|
151
119
|
def required_app_name
|
|
152
|
-
required_value(
|
|
120
|
+
required_value('APP_NAME')
|
|
153
121
|
end
|
|
154
122
|
|
|
155
123
|
def accessory_name
|
|
156
|
-
value(
|
|
124
|
+
value('KAMAL_BACKUP_ACCESSORY')
|
|
157
125
|
end
|
|
158
126
|
|
|
159
127
|
def restic_repository
|
|
160
|
-
value(
|
|
128
|
+
value('RESTIC_REPOSITORY')
|
|
161
129
|
end
|
|
162
130
|
|
|
163
131
|
def restic_repository_file
|
|
164
|
-
value(
|
|
132
|
+
value('RESTIC_REPOSITORY_FILE')
|
|
165
133
|
end
|
|
166
134
|
|
|
167
135
|
def restic_password
|
|
168
|
-
value(
|
|
136
|
+
value('RESTIC_PASSWORD')
|
|
169
137
|
end
|
|
170
138
|
|
|
171
139
|
def restic_password_file
|
|
172
|
-
value(
|
|
140
|
+
value('RESTIC_PASSWORD_FILE')
|
|
173
141
|
end
|
|
174
142
|
|
|
175
143
|
def restic_password_command
|
|
176
|
-
value(
|
|
144
|
+
value('RESTIC_PASSWORD_COMMAND')
|
|
177
145
|
end
|
|
178
146
|
|
|
179
147
|
def restic_init_if_missing?
|
|
180
|
-
truthy?(
|
|
148
|
+
truthy?('RESTIC_INIT_IF_MISSING')
|
|
181
149
|
end
|
|
182
150
|
|
|
183
151
|
def check_after_backup?
|
|
184
|
-
truthy?(
|
|
152
|
+
truthy?('RESTIC_CHECK_AFTER_BACKUP')
|
|
185
153
|
end
|
|
186
154
|
|
|
187
155
|
def forget_after_backup?
|
|
188
|
-
!falsey?(
|
|
156
|
+
!falsey?('RESTIC_FORGET_AFTER_BACKUP')
|
|
189
157
|
end
|
|
190
158
|
|
|
191
159
|
def check_read_data_subset
|
|
192
|
-
value(
|
|
160
|
+
value('RESTIC_CHECK_READ_DATA_SUBSET')
|
|
193
161
|
end
|
|
194
162
|
|
|
195
163
|
def allow_in_place_file_restore?
|
|
196
|
-
truthy?(
|
|
164
|
+
truthy?('KAMAL_BACKUP_ALLOW_IN_PLACE_FILE_RESTORE')
|
|
197
165
|
end
|
|
198
166
|
|
|
199
167
|
def allow_suspicious_backup_paths?
|
|
200
|
-
truthy?(
|
|
168
|
+
truthy?('KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS')
|
|
201
169
|
end
|
|
202
170
|
|
|
203
171
|
def backup_schedule_seconds
|
|
204
|
-
integer(
|
|
172
|
+
integer('BACKUP_SCHEDULE_SECONDS', 86_400, minimum: 1)
|
|
205
173
|
end
|
|
206
174
|
|
|
207
175
|
def backup_start_delay_seconds
|
|
208
|
-
integer(
|
|
176
|
+
integer('BACKUP_START_DELAY_SECONDS', 0, minimum: 0)
|
|
209
177
|
end
|
|
210
178
|
|
|
211
179
|
def state_dir
|
|
212
|
-
value(
|
|
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,
|
|
184
|
+
File.join(state_dir, 'last_check.json')
|
|
217
185
|
end
|
|
218
186
|
|
|
219
187
|
def last_backup_path
|
|
220
|
-
File.join(state_dir,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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/+},
|
|
262
|
-
label.empty? ?
|
|
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 ||=
|
|
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
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
|
353
|
-
unless database.value(
|
|
354
|
-
raise ConfigurationError,
|
|
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
|
|
357
|
-
unless database.value(
|
|
358
|
-
raise ConfigurationError,
|
|
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,
|
|
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,
|
|
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,
|
|
344
|
+
raise ConfigurationError, 'local restore database target is required' if target.to_s.strip.empty?
|
|
381
345
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
367
|
+
raise ConfigurationError, 'restore database target is required' if target.to_s.strip.empty?
|
|
402
368
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
790
|
-
|
|
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
|
-
|
|
793
|
-
|
|
434
|
+
number
|
|
435
|
+
rescue ArgumentError
|
|
436
|
+
raise ConfigurationError, "#{key} must be an integer"
|
|
437
|
+
end
|
|
794
438
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
end
|
|
439
|
+
def legacy_backup_paths
|
|
440
|
+
split_paths(value('BACKUP_PATHS'))
|
|
441
|
+
end
|
|
799
442
|
|
|
800
|
-
|
|
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
|
-
|
|
804
|
-
|
|
449
|
+
def split_paths(raw)
|
|
450
|
+
raw.to_s.split(/[\n:]+/).map(&:strip).reject(&:empty?)
|
|
451
|
+
end
|
|
805
452
|
|
|
806
|
-
|
|
807
|
-
|
|
453
|
+
def path_definitions?
|
|
454
|
+
!@path_definitions.nil?
|
|
455
|
+
end
|
|
808
456
|
|
|
809
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
477
|
+
[sqlite_database_path, "#{sqlite_database_path}-wal", "#{sqlite_database_path}-shm"]
|
|
478
|
+
end.uniq
|
|
479
|
+
end
|
|
826
480
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
834
|
-
|
|
487
|
+
def database_definitions?
|
|
488
|
+
!@database_definitions.nil?
|
|
489
|
+
end
|
|
835
490
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
855
|
-
|
|
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
|
-
|
|
868
|
-
|
|
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
|
-
|
|
872
|
-
!@path_definitions.nil?
|
|
515
|
+
return
|
|
873
516
|
end
|
|
874
517
|
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
890
|
-
|
|
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
|
-
|
|
896
|
-
end.uniq
|
|
527
|
+
return
|
|
897
528
|
end
|
|
898
529
|
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
end
|
|
554
|
+
def validate_local_machine_paths
|
|
555
|
+
path_pairs = local_restore_path_pairs
|
|
945
556
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|