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.
@@ -1,18 +1,20 @@
1
- require "fileutils"
2
- require "json"
3
- require "time"
4
- require "tmpdir"
5
- require_relative "command"
6
- require_relative "config"
7
- require_relative "databases/base"
8
- require_relative "databases/mysql"
9
- require_relative "databases/postgres"
10
- require_relative "databases/sqlite"
11
- require_relative "evidence"
12
- require_relative "redactor"
13
- require_relative "restic"
14
- require_relative "scheduler"
15
- require_relative "schema"
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require 'tmpdir'
7
+ require_relative 'command'
8
+ require_relative 'config'
9
+ require_relative 'databases/base'
10
+ require_relative 'databases/mysql'
11
+ require_relative 'databases/postgres'
12
+ require_relative 'databases/sqlite'
13
+ require_relative 'evidence'
14
+ require_relative 'redactor'
15
+ require_relative 'restic'
16
+ require_relative 'scheduler'
17
+ require_relative 'schema'
16
18
 
17
19
  module KamalBackup
18
20
  class App
@@ -20,7 +22,8 @@ module KamalBackup
20
22
 
21
23
  attr_reader :config, :redactor
22
24
 
23
- def initialize(env: ENV, config: nil, redactor: nil, restic: nil, database: nil, evidence_class: Evidence, scheduler_class: Scheduler)
25
+ def initialize(env: ENV, config: nil, redactor: nil, restic: nil, database: nil, evidence_class: Evidence,
26
+ scheduler_class: Scheduler)
24
27
  @config = config || Config.new(env: env)
25
28
  @redactor = redactor || Redactor.new(env: @config.env)
26
29
  @restic = restic
@@ -38,15 +41,11 @@ module KamalBackup
38
41
 
39
42
  restic.ensure_repository
40
43
  databases.each { |database| database.backup(restic) }
41
- restic.backup_paths(config.backup_paths, tags: ["type:files"])
44
+ restic.backup_paths(config.backup_paths, tags: ['type:files'])
42
45
 
43
- if config.forget_after_backup?
44
- restic.forget_after_success
45
- end
46
+ restic.forget_after_success if config.forget_after_backup?
46
47
 
47
- if config.check_after_backup?
48
- restic.check
49
- end
48
+ restic.check if config.check_after_backup?
50
49
 
51
50
  backup_summary(started_at: started_at, finished_at: Time.now.utc).tap do |summary|
52
51
  validate_fresh_backup_summary!(summary, started_at: started_at)
@@ -59,11 +58,11 @@ module KamalBackup
59
58
  true
60
59
  end
61
60
 
62
- def restore_to_local_machine(snapshot = "latest")
61
+ def restore_to_local_machine(snapshot = 'latest')
63
62
  validate_local_machine_restore
64
63
  require_restic!
65
64
 
66
- build_restore_result("local", snapshot) do |result|
65
+ build_restore_result('local', snapshot) do |result|
67
66
  databases.each { |adapter| validate_local_machine_database_target(adapter) }
68
67
  database_results = perform_database_restores_to_current(snapshot)
69
68
  file_results = perform_replacement_file_restores(snapshot, production_source: false)
@@ -71,22 +70,22 @@ module KamalBackup
71
70
  end
72
71
  end
73
72
 
74
- def restore_to_production(snapshot = "latest")
73
+ def restore_to_production(snapshot = 'latest')
75
74
  validate_production_restore
76
75
  require_restic!
77
76
 
78
- build_restore_result("production", snapshot) do |result|
77
+ build_restore_result('production', snapshot) do |result|
79
78
  database_results = perform_database_restores_to_current(snapshot)
80
79
  file_results = perform_replacement_file_restores(snapshot, production_source: true)
81
80
  assign_restore_results(result, database_results, file_results)
82
81
  end
83
82
  end
84
83
 
85
- def drill_on_local_machine(snapshot = "latest", check_command: nil)
84
+ def drill_on_local_machine(snapshot = 'latest', check_command: nil)
86
85
  validate_local_machine_restore
87
86
  require_restic!
88
87
 
89
- run_drill("local", snapshot, check_command: check_command) do |result|
88
+ run_drill('local', snapshot, check_command: check_command) do |result|
90
89
  databases.each { |adapter| validate_local_machine_database_target(adapter) }
91
90
  database_results = perform_database_restores_to_current(snapshot)
92
91
  file_results = perform_replacement_file_restores(snapshot, production_source: false)
@@ -94,11 +93,12 @@ module KamalBackup
94
93
  end
95
94
  end
96
95
 
97
- def drill_on_production(snapshot = "latest", database_name: nil, sqlite_path: nil, file_target: "/restore/files", check_command: nil)
96
+ def drill_on_production(snapshot = 'latest', database_name: nil, sqlite_path: nil, file_target: '/restore/files',
97
+ check_command: nil)
98
98
  validate_production_drill(file_target, database_name, sqlite_path)
99
99
  require_restic!
100
100
 
101
- run_drill("production", snapshot, check_command: check_command) do |result|
101
+ run_drill('production', snapshot, check_command: check_command) do |result|
102
102
  database_results = databases.map do |adapter|
103
103
  perform_database_restore_to_scratch(
104
104
  snapshot,
@@ -113,7 +113,7 @@ module KamalBackup
113
113
  end
114
114
 
115
115
  def drill_failed?(result)
116
- result.fetch(:status) != "ok"
116
+ result.fetch(:status) != 'ok'
117
117
  rescue KeyError
118
118
  true
119
119
  end
@@ -148,397 +148,391 @@ module KamalBackup
148
148
  end
149
149
 
150
150
  private
151
- def backup_due?(now)
152
- due_at = next_backup_at
153
- due_at.nil? || now >= due_at
154
- end
155
151
 
156
- def skipped_backup_result(now)
157
- {
158
- kind: "backup_result",
159
- status: "skipped",
160
- reason: "not_due",
161
- last_backup_at: last_backup_finished_at&.iso8601,
162
- next_backup_at: next_backup_at&.iso8601,
163
- force_command: "kamal-backup backup --force",
164
- finished_at: now.iso8601
165
- }
166
- end
152
+ def backup_due?(now)
153
+ due_at = next_backup_at
154
+ due_at.nil? || now >= due_at
155
+ end
167
156
 
168
- def next_backup_at
169
- last_backup_finished_at + config.backup_schedule_seconds if last_backup_finished_at
170
- end
157
+ def skipped_backup_result(now)
158
+ {
159
+ kind: 'backup_result',
160
+ status: 'skipped',
161
+ reason: 'not_due',
162
+ last_backup_at: last_backup_finished_at&.iso8601,
163
+ next_backup_at: next_backup_at&.iso8601,
164
+ force_command: 'kamal-backup backup --force',
165
+ finished_at: now.iso8601
166
+ }
167
+ end
171
168
 
172
- def last_backup_finished_at
173
- @last_backup_finished_at ||= begin
174
- value = last_backup_record["finished_at"] || last_backup_record["last_backup_at"]
175
- value ? Time.parse(value.to_s).utc : nil
176
- rescue ArgumentError
177
- nil
178
- end
179
- end
169
+ def next_backup_at
170
+ last_backup_finished_at + config.backup_schedule_seconds if last_backup_finished_at
171
+ end
180
172
 
181
- def last_backup_record
182
- @last_backup_record ||= begin
183
- JSON.parse(File.read(config.last_backup_path))
184
- rescue JSON::ParserError, SystemCallError
185
- {}
186
- end
173
+ def last_backup_finished_at
174
+ @last_backup_finished_at ||= begin
175
+ value = last_backup_record['finished_at'] || last_backup_record['last_backup_at']
176
+ value ? Time.parse(value.to_s).utc : nil
177
+ rescue ArgumentError
178
+ nil
187
179
  end
180
+ end
188
181
 
189
- def build_restore_result(scope, snapshot)
190
- started_at = Time.now.utc
191
- result = Schema.record(
192
- kind: "restore_result",
193
- status: "ok",
194
- scope: scope,
195
- requested_snapshot: snapshot,
196
- started_at: started_at.iso8601,
197
- finished_at: nil,
198
- error: nil,
199
- database: nil,
200
- databases: [],
201
- files: nil,
202
- paths: nil
203
- )
204
- yield(result)
205
- result[:finished_at] = Time.now.utc.iso8601
206
- result
182
+ def last_backup_record
183
+ @last_backup_record ||= begin
184
+ JSON.parse(File.read(config.last_backup_path))
185
+ rescue JSON::ParserError, SystemCallError
186
+ {}
207
187
  end
188
+ end
208
189
 
209
- def run_drill(scope, snapshot, check_command:)
210
- started_at = Time.now.utc
211
- result = Schema.record(
212
- kind: "drill_result",
213
- status: "ok",
214
- scope: scope,
215
- operator: drill_operator,
216
- requested_snapshot: snapshot,
217
- started_at: started_at.iso8601,
218
- finished_at: nil,
219
- error: nil,
220
- database: nil,
221
- databases: [],
222
- files: nil,
223
- paths: nil,
224
- check: nil
225
- )
190
+ def build_restore_result(scope, snapshot)
191
+ started_at = Time.now.utc
192
+ result = Schema.record(
193
+ kind: 'restore_result',
194
+ status: 'ok',
195
+ scope: scope,
196
+ requested_snapshot: snapshot,
197
+ started_at: started_at.iso8601,
198
+ finished_at: nil,
199
+ error: nil,
200
+ database: nil,
201
+ databases: [],
202
+ files: nil,
203
+ paths: nil
204
+ )
205
+ yield(result)
206
+ result[:finished_at] = Time.now.utc.iso8601
207
+ result
208
+ end
226
209
 
227
- begin
228
- yield(result)
210
+ def run_drill(scope, snapshot, check_command:)
211
+ started_at = Time.now.utc
212
+ result = Schema.record(
213
+ kind: 'drill_result',
214
+ status: 'ok',
215
+ scope: scope,
216
+ operator: drill_operator,
217
+ requested_snapshot: snapshot,
218
+ started_at: started_at.iso8601,
219
+ finished_at: nil,
220
+ error: nil,
221
+ database: nil,
222
+ databases: [],
223
+ files: nil,
224
+ paths: nil,
225
+ check: nil
226
+ )
227
+
228
+ begin
229
+ yield(result)
229
230
 
230
- if check_command
231
- result[:check] = run_drill_check(check_command)
231
+ if check_command
232
+ result[:check] = run_drill_check(check_command)
232
233
 
233
- if result[:check][:status] == "failed"
234
- result[:status] = "failed"
235
- result[:error] = result[:check][:error]
236
- end
234
+ if result[:check][:status] == 'failed'
235
+ result[:status] = 'failed'
236
+ result[:error] = result[:check][:error]
237
237
  end
238
- rescue StandardError => e
239
- result[:status] = "failed"
240
- result[:error] = redactor.redact_string(e.message)
241
- ensure
242
- result[:finished_at] = Time.now.utc.iso8601
243
- write_last_restore_drill(result)
244
238
  end
245
-
246
- result
239
+ rescue StandardError => e
240
+ result[:status] = 'failed'
241
+ result[:error] = redactor.redact_string(e.message)
242
+ ensure
243
+ result[:finished_at] = Time.now.utc.iso8601
244
+ write_last_restore_drill(result)
247
245
  end
248
246
 
249
- def perform_database_restore_to_current(snapshot, adapter:)
250
- resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
251
- filename = restic.database_file(resolved_snapshot, adapter.adapter_name, database_name: database_config_name(adapter))
247
+ result
248
+ end
252
249
 
253
- if filename
254
- adapter.restore_to_current(restic, resolved_snapshot, filename)
255
- summarize_database_restore(adapter, resolved_snapshot, filename, adapter.current_target_identifier)
256
- else
257
- raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}"
258
- end
259
- end
250
+ def perform_database_restore_to_current(snapshot, adapter:)
251
+ resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
252
+ filename = restic.database_file(resolved_snapshot, adapter.adapter_name,
253
+ database_name: database_config_name(adapter))
260
254
 
261
- def perform_database_restore_to_scratch(snapshot, adapter:, database_name:, sqlite_path:)
262
- target = scratch_database_target(adapter, database_name, sqlite_path)
263
- resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
264
- filename = restic.database_file(resolved_snapshot, adapter.adapter_name, database_name: database_config_name(adapter))
255
+ raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}" unless filename
265
256
 
266
- if filename
267
- adapter.restore_to_scratch(restic, resolved_snapshot, filename, target: target)
268
- summarize_database_restore(adapter, resolved_snapshot, filename, adapter.scratch_target_identifier(target))
269
- else
270
- raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}"
271
- end
272
- end
257
+ adapter.restore_to_current(restic, resolved_snapshot, filename)
258
+ summarize_database_restore(adapter, resolved_snapshot, filename, adapter.current_target_identifier)
259
+ end
273
260
 
274
- def perform_database_restores_to_current(snapshot)
275
- databases.map { |adapter| perform_database_restore_to_current(snapshot, adapter: adapter) }
276
- end
261
+ def perform_database_restore_to_scratch(snapshot, adapter:, database_name:, sqlite_path:)
262
+ target = scratch_database_target(adapter, database_name, sqlite_path)
263
+ resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
264
+ filename = restic.database_file(resolved_snapshot, adapter.adapter_name,
265
+ database_name: database_config_name(adapter))
277
266
 
278
- def perform_replacement_file_restores(snapshot, production_source:)
279
- source_paths = production_source ? config.backup_paths : config.local_restore_source_paths
280
- [
281
- perform_replacement_file_restore(
282
- snapshot,
283
- source_paths: source_paths,
284
- target_paths: config.backup_paths
285
- )
286
- ]
287
- end
267
+ raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}" unless filename
288
268
 
289
- def assign_restore_results(result, database_results, file_results)
290
- result[:databases] = database_results
291
- result[:database] = database_results.first
292
- result[:paths] = file_results.first
293
- result[:files] = file_results.size == 1 ? file_results.first : file_results
294
- end
269
+ adapter.restore_to_scratch(restic, resolved_snapshot, filename, target: target)
270
+ summarize_database_restore(adapter, resolved_snapshot, filename, adapter.scratch_target_identifier(target))
271
+ end
295
272
 
296
- def backup_summary(started_at:, finished_at:)
297
- {
298
- kind: "backup_result",
299
- status: "ok",
300
- started_at: started_at.iso8601,
301
- finished_at: finished_at.iso8601,
302
- databases: databases.map { |adapter| backup_snapshot_summary(adapter) },
303
- files: backup_file_snapshot_summary
304
- }
305
- end
273
+ def perform_database_restores_to_current(snapshot)
274
+ databases.map { |adapter| perform_database_restore_to_current(snapshot, adapter: adapter) }
275
+ end
306
276
 
307
- def backup_snapshot_summary(adapter)
308
- snapshot = restic.latest_snapshot(tags: database_snapshot_tags(adapter))
309
- snapshot_summary(snapshot).merge(
310
- database: database_config_name(adapter),
311
- adapter: adapter.adapter_name
277
+ def perform_replacement_file_restores(snapshot, production_source:)
278
+ source_paths = production_source ? config.backup_paths : config.local_restore_source_paths
279
+ [
280
+ perform_replacement_file_restore(
281
+ snapshot,
282
+ source_paths: source_paths,
283
+ target_paths: config.backup_paths
312
284
  )
313
- end
285
+ ]
286
+ end
314
287
 
315
- def backup_file_snapshot_summary
316
- return nil if config.backup_paths.empty?
288
+ def assign_restore_results(result, database_results, file_results)
289
+ result[:databases] = database_results
290
+ result[:database] = database_results.first
291
+ result[:paths] = file_results.first
292
+ result[:files] = file_results.size == 1 ? file_results.first : file_results
293
+ end
317
294
 
318
- snapshot_summary(restic.latest_snapshot(tags: ["type:files"]))
319
- end
295
+ def backup_summary(started_at:, finished_at:)
296
+ {
297
+ kind: 'backup_result',
298
+ status: 'ok',
299
+ started_at: started_at.iso8601,
300
+ finished_at: finished_at.iso8601,
301
+ databases: databases.map { |adapter| backup_snapshot_summary(adapter) },
302
+ files: backup_file_snapshot_summary
303
+ }
304
+ end
320
305
 
321
- def snapshot_summary(snapshot)
322
- {
323
- snapshot: snapshot && (snapshot["short_id"] || snapshot["id"]),
324
- time: snapshot && snapshot["time"]
325
- }
326
- end
306
+ def backup_snapshot_summary(adapter)
307
+ snapshot = restic.latest_snapshot(tags: database_snapshot_tags(adapter))
308
+ snapshot_summary(snapshot).merge(
309
+ database: database_config_name(adapter),
310
+ adapter: adapter.adapter_name
311
+ )
312
+ end
327
313
 
328
- def validate_fresh_backup_summary!(summary, started_at:)
329
- stale_databases = summary.fetch(:databases).reject { |entry| fresh_snapshot?(entry, started_at) }
314
+ def backup_file_snapshot_summary
315
+ return nil if config.backup_paths.empty?
330
316
 
331
- unless stale_databases.empty?
332
- names = stale_databases.map { |entry| entry.fetch(:database) }.join(", ")
333
- raise ConfigurationError, "backup did not create a fresh database snapshot for #{names}"
334
- end
317
+ snapshot_summary(restic.latest_snapshot(tags: ['type:files']))
318
+ end
335
319
 
336
- files = summary[:files]
337
- if files && !fresh_snapshot?(files, started_at)
338
- raise ConfigurationError, "backup did not create a fresh file snapshot"
339
- end
340
- end
320
+ def snapshot_summary(snapshot)
321
+ {
322
+ snapshot: snapshot && (snapshot['short_id'] || snapshot['id']),
323
+ time: snapshot && snapshot['time']
324
+ }
325
+ end
341
326
 
342
- def fresh_snapshot?(entry, started_at)
343
- snapshot_time = Time.parse(entry[:time].to_s)
344
- snapshot_time >= started_at - FRESH_BACKUP_GRACE_SECONDS
345
- rescue ArgumentError
346
- false
347
- end
327
+ def validate_fresh_backup_summary!(summary, started_at:)
328
+ stale_databases = summary.fetch(:databases).reject { |entry| fresh_snapshot?(entry, started_at) }
348
329
 
349
- def write_last_backup(result)
350
- FileUtils.mkdir_p(config.state_dir)
351
- File.write(config.last_backup_path, JSON.pretty_generate(result))
352
- @last_backup_record = result.transform_keys(&:to_s)
353
- @last_backup_finished_at = Time.parse(result.fetch(:finished_at)).utc
354
- rescue SystemCallError, ArgumentError
355
- nil
330
+ unless stale_databases.empty?
331
+ names = stale_databases.map { |entry| entry.fetch(:database) }.join(', ')
332
+ raise ConfigurationError, "backup did not create a fresh database snapshot for #{names}"
356
333
  end
357
334
 
358
- def database_snapshot_tags(adapter)
359
- ["type:database", "database:#{database_config_name(adapter)}", "adapter:#{adapter.adapter_name}"]
360
- end
335
+ files = summary[:files]
336
+ return unless files && !fresh_snapshot?(files, started_at)
361
337
 
362
- def database_config_name(adapter)
363
- if adapter.respond_to?(:config) && adapter.config.respond_to?(:database_name)
364
- adapter.config.database_name
365
- else
366
- config.database_name
367
- end
368
- end
338
+ raise ConfigurationError, 'backup did not create a fresh file snapshot'
339
+ end
369
340
 
370
- def perform_file_restore(snapshot, target:)
371
- resolved_snapshot = resolve_snapshot(snapshot, tags: ["type:files"])
372
- validated_target = config.validate_file_restore_target(target)
373
- restic.restore_snapshot(resolved_snapshot, validated_target)
341
+ def fresh_snapshot?(entry, started_at)
342
+ snapshot_time = Time.parse(entry[:time].to_s)
343
+ snapshot_time >= started_at - FRESH_BACKUP_GRACE_SECONDS
344
+ rescue ArgumentError
345
+ false
346
+ end
374
347
 
375
- {
376
- snapshot: resolved_snapshot,
377
- target: validated_target
378
- }
379
- end
348
+ def write_last_backup(result)
349
+ FileUtils.mkdir_p(config.state_dir)
350
+ File.write(config.last_backup_path, JSON.pretty_generate(result))
351
+ @last_backup_record = result.transform_keys(&:to_s)
352
+ @last_backup_finished_at = Time.parse(result.fetch(:finished_at)).utc
353
+ rescue SystemCallError, ArgumentError
354
+ nil
355
+ end
380
356
 
381
- def perform_replacement_file_restore(snapshot, source_paths:, target_paths:)
382
- resolved_snapshot = resolve_snapshot(snapshot, tags: ["type:files"])
383
- Dir.mktmpdir("kamal-backup-restore-") do |stage_dir|
384
- restic.restore_snapshot(resolved_snapshot, stage_dir)
385
- replace_target_paths(stage_dir, source_paths: source_paths, target_paths: target_paths)
386
- end
357
+ def database_snapshot_tags(adapter)
358
+ ['type:database', "database:#{database_config_name(adapter)}", "adapter:#{adapter.adapter_name}"]
359
+ end
387
360
 
388
- {
389
- snapshot: resolved_snapshot,
390
- source_paths: source_paths,
391
- target_paths: target_paths.map { |path| File.expand_path(path) }
392
- }
361
+ def database_config_name(adapter)
362
+ if adapter.respond_to?(:config) && adapter.config.respond_to?(:database_name)
363
+ adapter.config.database_name
364
+ else
365
+ config.database_name
393
366
  end
367
+ end
394
368
 
395
- def replace_target_paths(stage_dir, source_paths:, target_paths:)
396
- source_paths.zip(target_paths).each do |source_path, target_path|
397
- replace_target_path(stage_dir, source_path, target_path)
398
- end
399
- end
369
+ def perform_file_restore(snapshot, target:)
370
+ resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
371
+ validated_target = config.validate_file_restore_target(target)
372
+ restic.restore_snapshot(resolved_snapshot, validated_target)
400
373
 
401
- def replace_target_path(stage_dir, source_path, target_path)
402
- source = staged_backup_path(stage_dir, source_path)
403
- target = File.expand_path(target_path)
374
+ {
375
+ snapshot: resolved_snapshot,
376
+ target: validated_target
377
+ }
378
+ end
404
379
 
405
- if File.exist?(source)
406
- FileUtils.rm_rf(target)
407
- FileUtils.mkdir_p(File.dirname(target))
408
- FileUtils.mv(source, target)
409
- else
410
- raise ConfigurationError, "restored file snapshot is missing #{source_path}"
411
- end
380
+ def perform_replacement_file_restore(snapshot, source_paths:, target_paths:)
381
+ resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
382
+ Dir.mktmpdir('kamal-backup-restore-') do |stage_dir|
383
+ restic.restore_snapshot(resolved_snapshot, stage_dir)
384
+ replace_target_paths(stage_dir, source_paths: source_paths, target_paths: target_paths)
412
385
  end
413
386
 
414
- def staged_backup_path(stage_dir, path)
415
- File.join(stage_dir, path.to_s.sub(%r{\A/+}, ""))
387
+ {
388
+ snapshot: resolved_snapshot,
389
+ source_paths: source_paths,
390
+ target_paths: target_paths.map { |path| File.expand_path(path) }
391
+ }
392
+ end
393
+
394
+ def replace_target_paths(stage_dir, source_paths:, target_paths:)
395
+ source_paths.zip(target_paths).each do |source_path, target_path|
396
+ replace_target_path(stage_dir, source_path, target_path)
416
397
  end
398
+ end
399
+
400
+ def replace_target_path(stage_dir, source_path, target_path)
401
+ source = staged_backup_path(stage_dir, source_path)
402
+ target = File.expand_path(target_path)
403
+
404
+ raise ConfigurationError, "restored file snapshot is missing #{source_path}" unless File.exist?(source)
405
+
406
+ FileUtils.rm_rf(target)
407
+ FileUtils.mkdir_p(File.dirname(target))
408
+ FileUtils.mv(source, target)
409
+ end
410
+
411
+ def staged_backup_path(stage_dir, path)
412
+ File.join(stage_dir, path.to_s.sub(%r{\A/+}, ''))
413
+ end
414
+
415
+ def summarize_database_restore(adapter, snapshot, filename, target)
416
+ {
417
+ snapshot: snapshot,
418
+ adapter: adapter.adapter_name,
419
+ filename: filename,
420
+ target: redactor.redact_string(target.to_s)
421
+ }
422
+ end
417
423
 
418
- def summarize_database_restore(adapter, snapshot, filename, target)
419
- {
420
- snapshot: snapshot,
421
- adapter: adapter.adapter_name,
422
- filename: filename,
423
- target: redactor.redact_string(target.to_s)
424
- }
424
+ def scratch_database_target(adapter, database_name, sqlite_path)
425
+ case adapter.adapter_name
426
+ when 'sqlite'
427
+ target = sqlite_path || raise(ConfigurationError, 'scratch SQLite path is required')
428
+ databases.size > 1 ? File.join(target, "#{database_config_name(adapter)}.sqlite3") : target
429
+ else
430
+ target = database_name || raise(ConfigurationError, 'scratch database name is required')
431
+ databases.size > 1 ? "#{target}_#{database_config_name(adapter)}" : target
425
432
  end
433
+ end
434
+
435
+ def validate_local_machine_restore
436
+ config.validate_restic
437
+ config.validate_database_backup
438
+ config.validate_local_machine_restore
439
+ end
440
+
441
+ def validate_production_restore
442
+ config.validate_restic
443
+ config.validate_database_backup
444
+ config.validate_backup_paths
445
+ end
426
446
 
427
- def scratch_database_target(adapter, database_name, sqlite_path)
447
+ def validate_production_drill(file_target, database_name, sqlite_path)
448
+ config.validate_restic
449
+ config.validate_database_backup
450
+ config.validate_file_restore_target(file_target)
451
+
452
+ databases.each do |adapter|
428
453
  case adapter.adapter_name
429
- when "sqlite"
430
- target = sqlite_path || raise(ConfigurationError, "scratch SQLite path is required")
431
- databases.size > 1 ? File.join(target, "#{database_config_name(adapter)}.sqlite3") : target
454
+ when 'sqlite'
455
+ raise ConfigurationError, 'scratch SQLite path is required' if sqlite_path.to_s.strip.empty?
432
456
  else
433
- target = database_name || raise(ConfigurationError, "scratch database name is required")
434
- databases.size > 1 ? "#{target}_#{database_config_name(adapter)}" : target
457
+ raise ConfigurationError, 'scratch database name is required' if database_name.to_s.strip.empty?
435
458
  end
436
459
  end
460
+ end
437
461
 
438
- def validate_local_machine_restore
439
- config.validate_restic
440
- config.validate_database_backup
441
- config.validate_local_machine_restore
442
- end
443
-
444
- def validate_production_restore
445
- config.validate_restic
446
- config.validate_database_backup
447
- config.validate_backup_paths
448
- end
462
+ def validate_local_machine_database_target(adapter)
463
+ config.validate_local_database_restore_target(adapter.current_target_identifier)
464
+ end
449
465
 
450
- def validate_production_drill(file_target, database_name, sqlite_path)
451
- config.validate_restic
452
- config.validate_database_backup
453
- config.validate_file_restore_target(file_target)
454
-
455
- databases.each do |adapter|
456
- case adapter.adapter_name
457
- when "sqlite"
458
- raise ConfigurationError, "scratch SQLite path is required" if sqlite_path.to_s.strip.empty?
459
- else
460
- raise ConfigurationError, "scratch database name is required" if database_name.to_s.strip.empty?
461
- end
462
- end
463
- end
466
+ def require_restic!
467
+ return unless using_builtin_restic?
468
+ return if Command.available?('restic')
464
469
 
465
- def validate_local_machine_database_target(adapter)
466
- config.validate_local_database_restore_target(adapter.current_target_identifier)
467
- end
470
+ raise ConfigurationError,
471
+ 'restic is required on PATH for commands that run on this machine. Install restic locally and try again.'
472
+ end
468
473
 
469
- def require_restic!
470
- return unless using_builtin_restic?
471
- return if Command.available?("restic")
474
+ def run_drill_check(command)
475
+ result = Command.capture(
476
+ CommandSpec.new(argv: ['sh', '-lc', command]),
477
+ redactor: redactor
478
+ )
479
+ output = result.stdout.empty? ? result.stderr : result.stdout
480
+
481
+ {
482
+ status: 'ok',
483
+ command: redactor.redact_string(command),
484
+ output: redactor.redact_string(output.strip)
485
+ }
486
+ rescue CommandError => e
487
+ {
488
+ status: 'failed',
489
+ command: redactor.redact_string(command),
490
+ error: redactor.redact_string(e.message)
491
+ }
492
+ end
472
493
 
473
- raise ConfigurationError,
474
- "restic is required on PATH for commands that run on this machine. Install restic locally and try again."
475
- end
494
+ def write_last_restore_drill(payload)
495
+ FileUtils.mkdir_p(config.state_dir)
496
+ File.write(config.last_restore_drill_path, JSON.pretty_generate(payload))
497
+ rescue SystemCallError
498
+ nil
499
+ end
476
500
 
477
- def run_drill_check(command)
478
- result = Command.capture(
479
- CommandSpec.new(argv: ["sh", "-lc", command]),
480
- redactor: redactor
481
- )
482
- output = result.stdout.empty? ? result.stderr : result.stdout
483
-
484
- {
485
- status: "ok",
486
- command: redactor.redact_string(command),
487
- output: redactor.redact_string(output.strip)
488
- }
489
- rescue CommandError => e
490
- {
491
- status: "failed",
492
- command: redactor.redact_string(command),
493
- error: redactor.redact_string(e.message)
494
- }
495
- end
501
+ def drill_operator
502
+ config.value('USER') || config.value('USERNAME')
503
+ end
496
504
 
497
- def write_last_restore_drill(payload)
498
- FileUtils.mkdir_p(config.state_dir)
499
- File.write(config.last_restore_drill_path, JSON.pretty_generate(payload))
500
- rescue SystemCallError
501
- nil
502
- end
505
+ def restic
506
+ @restic ||= Restic.new(config, redactor: redactor)
507
+ end
503
508
 
504
- def drill_operator
505
- config.value("USER") || config.value("USERNAME")
506
- end
509
+ def using_builtin_restic?
510
+ @restic.nil? || @restic.is_a?(Restic)
511
+ end
507
512
 
508
- def restic
509
- @restic ||= Restic.new(config, redactor: redactor)
510
- end
513
+ def database
514
+ databases.first
515
+ end
511
516
 
512
- def using_builtin_restic?
513
- @restic.nil? || @restic.is_a?(Restic)
514
- end
517
+ def databases
518
+ @databases ||= if @database
519
+ Array(@database)
520
+ else
521
+ config.databases.map { |database_config| Databases::Base.build(database_config, redactor: redactor) }
522
+ end
523
+ end
515
524
 
516
- def database
517
- databases.first
518
- end
525
+ def resolve_snapshot(argument, tags:)
526
+ if argument == 'latest'
527
+ snapshot = restic.latest_snapshot(tags: tags)
519
528
 
520
- def databases
521
- @databases ||= begin
522
- if @database
523
- Array(@database)
524
- else
525
- config.databases.map { |database_config| Databases::Base.build(database_config, redactor: redactor) }
526
- end
527
- end
528
- end
529
+ raise ConfigurationError, "no restic snapshot found for #{tags.join(', ')}" unless snapshot
529
530
 
530
- def resolve_snapshot(argument, tags:)
531
- if argument == "latest"
532
- snapshot = restic.latest_snapshot(tags: tags)
531
+ snapshot['short_id'] || snapshot['id']
533
532
 
534
- if snapshot
535
- snapshot["short_id"] || snapshot["id"]
536
- else
537
- raise ConfigurationError, "no restic snapshot found for #{tags.join(", ")}"
538
- end
539
- else
540
- argument
541
- end
533
+ else
534
+ argument
542
535
  end
536
+ end
543
537
  end
544
538
  end