kamal-backup 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13bcc6ed2c0eefd5883e432c6a71b760df37ee20cf4b4d1ca87252c8fd22d9f0
4
- data.tar.gz: b1fd3699bee9882492e7889054ef282c99ec18d8dbe74d224ada437b0f055794
3
+ metadata.gz: 801ab5f224e1e018805b65650da3c0946d6c785eb8b2a3e3e3256bcf97649abb
4
+ data.tar.gz: 60e98bfe053103b3405d483902a167ee2d86f7a723448e06eacb2937b56fd4d3
5
5
  SHA512:
6
- metadata.gz: 191e6b31b9688ec807ba6f85ea8fe89bbda5c383248e7c307cc4ef863fab3eccd427add8bb230540cb449d22791756b32e98e0cda0f001f662bcbb67dc0c9973
7
- data.tar.gz: e7569830ce203f34695c71f2d05338d8a32eeed3627010608714f1a4dae77301eb56d289aa7dee8b8fa497eaf4531ff4acc15114c7693d5f897b544a4e715fbb
6
+ metadata.gz: b1dbd9a03222917490629d56a4bfca69e1abbac79ed241bf480ba0da50453e67e1224dcf5f6d315ce23039b31febe2d449300dae167ddf19e627decd07c6116e
7
+ data.tar.gz: c4392a6048ceea5ec81b20cedad84f15d864ecb37a83337571cc857de8c730c0a84629df335e9b6c4f273868ad86a6ddcc631c00315ff8ff965ac0230bcf1f5a
data/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  [![Gem Version](https://img.shields.io/gem/v/kamal-backup.svg)](https://rubygems.org/gems/kamal-backup)
10
10
  [![CI](https://github.com/crmne/kamal-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/crmne/kamal-backup/actions/workflows/ci.yml)
11
+ [![codecov](https://codecov.io/gh/crmne/kamal-backup/branch/master/graph/badge.svg)](https://codecov.io/gh/crmne/kamal-backup)
11
12
  [![Docker Image](https://img.shields.io/badge/image-ghcr.io%2Fcrmne%2Fkamal--backup-blue)](https://github.com/crmne/kamal-backup/pkgs/container/kamal-backup)
12
13
  [![Docs](https://img.shields.io/badge/docs-kamal--backup.dev-blue)](https://kamal-backup.dev)
13
14
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
@@ -22,14 +22,11 @@ module KamalBackup
22
22
 
23
23
  attr_reader :config, :redactor
24
24
 
25
- def initialize(env: ENV, config: nil, redactor: nil, restic: nil, database: nil, evidence_class: Evidence,
26
- scheduler_class: Scheduler)
25
+ def initialize(env: ENV, config: nil, redactor: nil, restic: nil, database: nil)
27
26
  @config = config || Config.new(env: env)
28
27
  @redactor = redactor || Redactor.new(env: @config.env)
29
28
  @restic = restic
30
29
  @database = database
31
- @evidence_class = evidence_class
32
- @scheduler_class = scheduler_class
33
30
  end
34
31
 
35
32
  def backup(force: false)
@@ -37,13 +34,11 @@ module KamalBackup
37
34
  config.validate_backup
38
35
  return skipped_backup_result(started_at) unless force || backup_due?(started_at)
39
36
 
40
- require_restic!
41
-
42
37
  restic.ensure_repository
43
38
  databases.each { |database| database.backup(restic) }
44
39
  restic.backup_paths(config.backup_paths, tags: ['type:files'])
45
40
 
46
- restic.forget_after_success if config.forget_after_backup?
41
+ restic.prune if config.forget_after_backup?
47
42
 
48
43
  restic.check if config.check_after_backup?
49
44
 
@@ -60,46 +55,39 @@ module KamalBackup
60
55
 
61
56
  def restore_to_local_machine(snapshot = 'latest')
62
57
  validate_local_machine_restore
63
- require_restic!
64
58
 
65
59
  build_restore_result('local', snapshot) do |result|
66
60
  databases.each { |adapter| validate_local_machine_database_target(adapter) }
67
- database_results = perform_database_restores_to_current(snapshot)
68
- file_results = perform_replacement_file_restores(snapshot, production_source: false)
69
- 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)
70
63
  end
71
64
  end
72
65
 
73
66
  def restore_to_production(snapshot = 'latest')
74
67
  validate_production_restore
75
- require_restic!
76
68
 
77
69
  build_restore_result('production', snapshot) do |result|
78
- database_results = perform_database_restores_to_current(snapshot)
79
- file_results = perform_replacement_file_restores(snapshot, production_source: true)
80
- assign_restore_results(result, database_results, file_results)
70
+ result[:databases] = perform_database_restores_to_current(snapshot)
71
+ result[:files] = perform_replacement_file_restore(snapshot, production_source: true)
81
72
  end
82
73
  end
83
74
 
84
75
  def drill_on_local_machine(snapshot = 'latest', check_command: nil)
85
76
  validate_local_machine_restore
86
- require_restic!
87
77
 
88
78
  run_drill('local', snapshot, check_command: check_command) do |result|
89
79
  databases.each { |adapter| validate_local_machine_database_target(adapter) }
90
- database_results = perform_database_restores_to_current(snapshot)
91
- file_results = perform_replacement_file_restores(snapshot, production_source: false)
92
- 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)
93
82
  end
94
83
  end
95
84
 
96
85
  def drill_on_production(snapshot = 'latest', database_name: nil, sqlite_path: nil, file_target: '/restore/files',
97
86
  check_command: nil)
98
87
  validate_production_drill(file_target, database_name, sqlite_path)
99
- require_restic!
100
88
 
101
89
  run_drill('production', snapshot, check_command: check_command) do |result|
102
- database_results = databases.map do |adapter|
90
+ result[:databases] = databases.map do |adapter|
103
91
  perform_database_restore_to_scratch(
104
92
  snapshot,
105
93
  adapter: adapter,
@@ -107,44 +95,33 @@ 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
@@ -155,7 +132,7 @@ module KamalBackup
155
132
  end
156
133
 
157
134
  def skipped_backup_result(now)
158
- {
135
+ Schema.record(
159
136
  kind: 'backup_result',
160
137
  status: 'skipped',
161
138
  reason: 'not_due',
@@ -163,7 +140,7 @@ module KamalBackup
163
140
  next_backup_at: next_backup_at&.iso8601,
164
141
  force_command: 'kamal-backup backup --force',
165
142
  finished_at: now.iso8601
166
- }
143
+ )
167
144
  end
168
145
 
169
146
  def next_backup_at
@@ -172,7 +149,7 @@ module KamalBackup
172
149
 
173
150
  def last_backup_finished_at
174
151
  @last_backup_finished_at ||= begin
175
- value = last_backup_record['finished_at'] || last_backup_record['last_backup_at']
152
+ value = last_backup_record['finished_at']
176
153
  value ? Time.parse(value.to_s).utc : nil
177
154
  rescue ArgumentError
178
155
  nil
@@ -187,6 +164,68 @@ module KamalBackup
187
164
  end
188
165
  end
189
166
 
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
177
+
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
185
+
186
+ def backup_file_snapshot_summary
187
+ return nil if config.backup_paths.empty?
188
+
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
198
+
199
+ def validate_fresh_backup_summary!(summary, started_at:)
200
+ stale_databases = summary.fetch(:databases).reject { |entry| fresh_snapshot?(entry, started_at) }
201
+
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}"
205
+ end
206
+
207
+ files = summary[:files]
208
+ return unless files && !fresh_snapshot?(files, started_at)
209
+
210
+ raise ConfigurationError, 'backup did not create a fresh file snapshot'
211
+ end
212
+
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
219
+
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
228
+
190
229
  def build_restore_result(scope, snapshot)
191
230
  started_at = Time.now.utc
192
231
  result = Schema.record(
@@ -197,10 +236,8 @@ module KamalBackup
197
236
  started_at: started_at.iso8601,
198
237
  finished_at: nil,
199
238
  error: nil,
200
- database: nil,
201
239
  databases: [],
202
- files: nil,
203
- paths: nil
240
+ files: nil
204
241
  )
205
242
  yield(result)
206
243
  result[:finished_at] = Time.now.utc.iso8601
@@ -218,10 +255,8 @@ module KamalBackup
218
255
  started_at: started_at.iso8601,
219
256
  finished_at: nil,
220
257
  error: nil,
221
- database: nil,
222
258
  databases: [],
223
259
  files: nil,
224
- paths: nil,
225
260
  check: nil
226
261
  )
227
262
 
@@ -247,6 +282,41 @@ module KamalBackup
247
282
  result
248
283
  end
249
284
 
285
+ def drill_operator
286
+ config.value('USER') || config.value('USERNAME')
287
+ end
288
+
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
308
+
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
315
+
316
+ def perform_database_restores_to_current(snapshot)
317
+ databases.map { |adapter| perform_database_restore_to_current(snapshot, adapter: adapter) }
318
+ end
319
+
250
320
  def perform_database_restore_to_current(snapshot, adapter:)
251
321
  resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
252
322
  filename = restic.database_file(resolved_snapshot, adapter.adapter_name,
@@ -270,114 +340,37 @@ module KamalBackup
270
340
  summarize_database_restore(adapter, resolved_snapshot, filename, adapter.scratch_target_identifier(target))
271
341
  end
272
342
 
273
- def perform_database_restores_to_current(snapshot)
274
- databases.map { |adapter| perform_database_restore_to_current(snapshot, adapter: adapter) }
275
- end
276
-
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
284
- )
285
- ]
286
- end
287
-
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
294
-
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
305
-
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
313
-
314
- def backup_file_snapshot_summary
315
- return nil if config.backup_paths.empty?
316
-
317
- snapshot_summary(restic.latest_snapshot(tags: ['type:files']))
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
351
+ end
318
352
  end
319
353
 
320
- def snapshot_summary(snapshot)
354
+ def summarize_database_restore(adapter, snapshot, filename, target)
321
355
  {
322
- snapshot: snapshot && (snapshot['short_id'] || snapshot['id']),
323
- time: snapshot && snapshot['time']
356
+ snapshot: snapshot,
357
+ adapter: adapter.adapter_name,
358
+ filename: filename,
359
+ target: redactor.redact_string(target.to_s)
324
360
  }
325
361
  end
326
362
 
327
- def validate_fresh_backup_summary!(summary, started_at:)
328
- stale_databases = summary.fetch(:databases).reject { |entry| fresh_snapshot?(entry, started_at) }
329
-
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}"
333
- end
334
-
335
- files = summary[:files]
336
- return unless files && !fresh_snapshot?(files, started_at)
337
-
338
- raise ConfigurationError, 'backup did not create a fresh file snapshot'
339
- end
340
-
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
347
-
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
356
-
357
363
  def database_snapshot_tags(adapter)
358
364
  ['type:database', "database:#{database_config_name(adapter)}", "adapter:#{adapter.adapter_name}"]
359
365
  end
360
366
 
361
367
  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
366
- end
367
- end
368
-
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)
373
-
374
- {
375
- snapshot: resolved_snapshot,
376
- target: validated_target
377
- }
368
+ adapter.config.database_name
378
369
  end
379
370
 
380
- def perform_replacement_file_restore(snapshot, source_paths:, target_paths:)
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
381
374
  resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
382
375
  Dir.mktmpdir('kamal-backup-restore-') do |stage_dir|
383
376
  restic.restore_snapshot(resolved_snapshot, stage_dir)
@@ -412,26 +405,17 @@ module KamalBackup
412
405
  File.join(stage_dir, path.to_s.sub(%r{\A/+}, ''))
413
406
  end
414
407
 
415
- def summarize_database_restore(adapter, snapshot, filename, target)
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)
412
+
416
413
  {
417
- snapshot: snapshot,
418
- adapter: adapter.adapter_name,
419
- filename: filename,
420
- target: redactor.redact_string(target.to_s)
414
+ snapshot: resolved_snapshot,
415
+ target: validated_target
421
416
  }
422
417
  end
423
418
 
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
432
- end
433
- end
434
-
435
419
  def validate_local_machine_restore
436
420
  config.validate_restic
437
421
  config.validate_database_backup
@@ -463,55 +447,15 @@ module KamalBackup
463
447
  config.validate_local_database_restore_target(adapter.current_target_identifier)
464
448
  end
465
449
 
466
- def require_restic!
467
- return unless using_builtin_restic?
468
- return if Command.available?('restic')
469
-
470
- raise ConfigurationError,
471
- 'restic is required on PATH for commands that run on this machine. Install restic locally and try again.'
472
- end
473
-
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
493
-
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
500
-
501
- def drill_operator
502
- config.value('USER') || config.value('USERNAME')
503
- end
504
-
505
450
  def restic
506
- @restic ||= Restic.new(config, redactor: redactor)
507
- end
508
-
509
- def using_builtin_restic?
510
- @restic.nil? || @restic.is_a?(Restic)
511
- end
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.'
455
+ end
512
456
 
513
- def database
514
- databases.first
457
+ Restic.new(config, redactor: redactor)
458
+ end
515
459
  end
516
460
 
517
461
  def databases
@@ -529,7 +473,6 @@ module KamalBackup
529
473
  raise ConfigurationError, "no restic snapshot found for #{tags.join(', ')}" unless snapshot
530
474
 
531
475
  snapshot['short_id'] || snapshot['id']
532
-
533
476
  else
534
477
  argument
535
478
  end