kamal-backup 0.3.0.beta21 → 0.3.1

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