kamal-backup 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13bcc6ed2c0eefd5883e432c6a71b760df37ee20cf4b4d1ca87252c8fd22d9f0
4
- data.tar.gz: b1fd3699bee9882492e7889054ef282c99ec18d8dbe74d224ada437b0f055794
3
+ metadata.gz: 4b93c203599481320559eb006ccf07a07c010c5bc4d5dfcae3e11b7a9a832fa3
4
+ data.tar.gz: 705fc119fcacdd54ee431e9cfb5b88220a0faae02540cba77e38c45b94044905
5
5
  SHA512:
6
- metadata.gz: 191e6b31b9688ec807ba6f85ea8fe89bbda5c383248e7c307cc4ef863fab3eccd427add8bb230540cb449d22791756b32e98e0cda0f001f662bcbb67dc0c9973
7
- data.tar.gz: e7569830ce203f34695c71f2d05338d8a32eeed3627010608714f1a4dae77301eb56d289aa7dee8b8fa497eaf4531ff4acc15114c7693d5f897b544a4e715fbb
6
+ metadata.gz: fd7b30595fe843980d39979b8b7e241fbd54c7fd814c55bccb4bfcdf6b175b5767e84910929050f4e3aa40f150aec5b01e15015b196e522e3c48566f9527020f
7
+ data.tar.gz: e81f5d21997f6f072b9556a73b7e751accbe99098adaebaec3435ca15600e8d13f204b9d343cc3ae7f6905a86efd3abea3c5dd0d64ea602bd2f2617d8bca8fef
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)
@@ -110,6 +111,7 @@ Run the first backup, check the repository, and print evidence. From an app chec
110
111
  bundle exec kamal-backup backup
111
112
  bundle exec kamal-backup list
112
113
  bundle exec kamal-backup check
114
+ bundle exec kamal-backup unlock
113
115
  bundle exec kamal-backup evidence
114
116
  ```
115
117
 
@@ -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,38 @@ 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
 
112
+ def unlock
113
+ config.validate_restic
114
+ restic.unlock.stdout
115
+ end
116
+
133
117
  def prune
134
118
  config.validate_backup(check_files: false)
135
- require_restic!
136
119
  restic.prune
137
120
  end
138
121
 
139
122
  def evidence
140
123
  config.validate_restic
141
- require_restic!
142
- @evidence_class.new(config, restic: restic, redactor: redactor).to_json
124
+ Evidence.new(config, restic: restic, redactor: redactor).to_json
143
125
  end
144
126
 
145
127
  def schedule
146
128
  config.validate_backup
147
- @scheduler_class.new(config) { backup(force: true) }.run
129
+ Scheduler.new(config) { backup(force: true) }.run
148
130
  end
149
131
 
150
132
  private
@@ -155,7 +137,7 @@ module KamalBackup
155
137
  end
156
138
 
157
139
  def skipped_backup_result(now)
158
- {
140
+ Schema.record(
159
141
  kind: 'backup_result',
160
142
  status: 'skipped',
161
143
  reason: 'not_due',
@@ -163,7 +145,7 @@ module KamalBackup
163
145
  next_backup_at: next_backup_at&.iso8601,
164
146
  force_command: 'kamal-backup backup --force',
165
147
  finished_at: now.iso8601
166
- }
148
+ )
167
149
  end
168
150
 
169
151
  def next_backup_at
@@ -172,7 +154,7 @@ module KamalBackup
172
154
 
173
155
  def last_backup_finished_at
174
156
  @last_backup_finished_at ||= begin
175
- value = last_backup_record['finished_at'] || last_backup_record['last_backup_at']
157
+ value = last_backup_record['finished_at']
176
158
  value ? Time.parse(value.to_s).utc : nil
177
159
  rescue ArgumentError
178
160
  nil
@@ -187,6 +169,68 @@ module KamalBackup
187
169
  end
188
170
  end
189
171
 
172
+ def backup_summary(started_at:, finished_at:)
173
+ Schema.record(
174
+ kind: 'backup_result',
175
+ status: 'ok',
176
+ started_at: started_at.iso8601,
177
+ finished_at: finished_at.iso8601,
178
+ databases: databases.map { |adapter| backup_snapshot_summary(adapter) },
179
+ files: backup_file_snapshot_summary
180
+ )
181
+ end
182
+
183
+ def backup_snapshot_summary(adapter)
184
+ snapshot = restic.latest_snapshot(tags: database_snapshot_tags(adapter))
185
+ snapshot_summary(snapshot).merge(
186
+ database: database_config_name(adapter),
187
+ adapter: adapter.adapter_name
188
+ )
189
+ end
190
+
191
+ def backup_file_snapshot_summary
192
+ return nil if config.backup_paths.empty?
193
+
194
+ snapshot_summary(restic.latest_snapshot(tags: ['type:files']))
195
+ end
196
+
197
+ def snapshot_summary(snapshot)
198
+ {
199
+ snapshot: snapshot && (snapshot['short_id'] || snapshot['id']),
200
+ time: snapshot && snapshot['time']
201
+ }
202
+ end
203
+
204
+ def validate_fresh_backup_summary!(summary, started_at:)
205
+ stale_databases = summary.fetch(:databases).reject { |entry| fresh_snapshot?(entry, started_at) }
206
+
207
+ unless stale_databases.empty?
208
+ names = stale_databases.map { |entry| entry.fetch(:database) }.join(', ')
209
+ raise ConfigurationError, "backup did not create a fresh database snapshot for #{names}"
210
+ end
211
+
212
+ files = summary[:files]
213
+ return unless files && !fresh_snapshot?(files, started_at)
214
+
215
+ raise ConfigurationError, 'backup did not create a fresh file snapshot'
216
+ end
217
+
218
+ def fresh_snapshot?(entry, started_at)
219
+ snapshot_time = Time.parse(entry[:time].to_s)
220
+ snapshot_time >= started_at - FRESH_BACKUP_GRACE_SECONDS
221
+ rescue ArgumentError
222
+ false
223
+ end
224
+
225
+ def write_last_backup(result)
226
+ FileUtils.mkdir_p(config.state_dir)
227
+ File.write(config.last_backup_path, JSON.pretty_generate(result))
228
+ @last_backup_record = result.transform_keys(&:to_s)
229
+ @last_backup_finished_at = Time.parse(result.fetch(:finished_at)).utc
230
+ rescue SystemCallError, ArgumentError
231
+ nil
232
+ end
233
+
190
234
  def build_restore_result(scope, snapshot)
191
235
  started_at = Time.now.utc
192
236
  result = Schema.record(
@@ -197,10 +241,8 @@ module KamalBackup
197
241
  started_at: started_at.iso8601,
198
242
  finished_at: nil,
199
243
  error: nil,
200
- database: nil,
201
244
  databases: [],
202
- files: nil,
203
- paths: nil
245
+ files: nil
204
246
  )
205
247
  yield(result)
206
248
  result[:finished_at] = Time.now.utc.iso8601
@@ -218,10 +260,8 @@ module KamalBackup
218
260
  started_at: started_at.iso8601,
219
261
  finished_at: nil,
220
262
  error: nil,
221
- database: nil,
222
263
  databases: [],
223
264
  files: nil,
224
- paths: nil,
225
265
  check: nil
226
266
  )
227
267
 
@@ -247,6 +287,41 @@ module KamalBackup
247
287
  result
248
288
  end
249
289
 
290
+ def drill_operator
291
+ config.value('USER') || config.value('USERNAME')
292
+ end
293
+
294
+ def run_drill_check(command)
295
+ result = Command.capture(
296
+ CommandSpec.new(argv: ['sh', '-lc', command]),
297
+ redactor: redactor
298
+ )
299
+ output = result.stdout.empty? ? result.stderr : result.stdout
300
+
301
+ {
302
+ status: 'ok',
303
+ command: redactor.redact_string(command),
304
+ output: redactor.redact_string(output.strip)
305
+ }
306
+ rescue CommandError => e
307
+ {
308
+ status: 'failed',
309
+ command: redactor.redact_string(command),
310
+ error: redactor.redact_string(e.message)
311
+ }
312
+ end
313
+
314
+ def write_last_restore_drill(payload)
315
+ FileUtils.mkdir_p(config.state_dir)
316
+ File.write(config.last_restore_drill_path, JSON.pretty_generate(payload))
317
+ rescue SystemCallError
318
+ nil
319
+ end
320
+
321
+ def perform_database_restores_to_current(snapshot)
322
+ databases.map { |adapter| perform_database_restore_to_current(snapshot, adapter: adapter) }
323
+ end
324
+
250
325
  def perform_database_restore_to_current(snapshot, adapter:)
251
326
  resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
252
327
  filename = restic.database_file(resolved_snapshot, adapter.adapter_name,
@@ -270,114 +345,37 @@ module KamalBackup
270
345
  summarize_database_restore(adapter, resolved_snapshot, filename, adapter.scratch_target_identifier(target))
271
346
  end
272
347
 
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']))
348
+ def scratch_database_target(adapter, database_name, sqlite_path)
349
+ case adapter.adapter_name
350
+ when 'sqlite'
351
+ target = sqlite_path || raise(ConfigurationError, 'scratch SQLite path is required')
352
+ databases.size > 1 ? File.join(target, "#{database_config_name(adapter)}.sqlite3") : target
353
+ else
354
+ target = database_name || raise(ConfigurationError, 'scratch database name is required')
355
+ databases.size > 1 ? "#{target}_#{database_config_name(adapter)}" : target
356
+ end
318
357
  end
319
358
 
320
- def snapshot_summary(snapshot)
359
+ def summarize_database_restore(adapter, snapshot, filename, target)
321
360
  {
322
- snapshot: snapshot && (snapshot['short_id'] || snapshot['id']),
323
- time: snapshot && snapshot['time']
361
+ snapshot: snapshot,
362
+ adapter: adapter.adapter_name,
363
+ filename: filename,
364
+ target: redactor.redact_string(target.to_s)
324
365
  }
325
366
  end
326
367
 
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
368
  def database_snapshot_tags(adapter)
358
369
  ['type:database', "database:#{database_config_name(adapter)}", "adapter:#{adapter.adapter_name}"]
359
370
  end
360
371
 
361
372
  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
373
+ adapter.config.database_name
367
374
  end
368
375
 
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
- }
378
- end
379
-
380
- def perform_replacement_file_restore(snapshot, source_paths:, target_paths:)
376
+ def perform_replacement_file_restore(snapshot, production_source:)
377
+ source_paths = production_source ? config.backup_paths : config.local_restore_source_paths
378
+ target_paths = config.backup_paths
381
379
  resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
382
380
  Dir.mktmpdir('kamal-backup-restore-') do |stage_dir|
383
381
  restic.restore_snapshot(resolved_snapshot, stage_dir)
@@ -412,26 +410,17 @@ module KamalBackup
412
410
  File.join(stage_dir, path.to_s.sub(%r{\A/+}, ''))
413
411
  end
414
412
 
415
- def summarize_database_restore(adapter, snapshot, filename, target)
413
+ def perform_file_restore(snapshot, target:)
414
+ resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
415
+ validated_target = config.validate_file_restore_target(target)
416
+ restic.restore_snapshot(resolved_snapshot, validated_target)
417
+
416
418
  {
417
- snapshot: snapshot,
418
- adapter: adapter.adapter_name,
419
- filename: filename,
420
- target: redactor.redact_string(target.to_s)
419
+ snapshot: resolved_snapshot,
420
+ target: validated_target
421
421
  }
422
422
  end
423
423
 
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
424
  def validate_local_machine_restore
436
425
  config.validate_restic
437
426
  config.validate_database_backup
@@ -463,55 +452,15 @@ module KamalBackup
463
452
  config.validate_local_database_restore_target(adapter.current_target_identifier)
464
453
  end
465
454
 
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
455
  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
456
+ @restic ||= begin
457
+ unless Command.available?('restic')
458
+ raise ConfigurationError,
459
+ 'restic is required on PATH for commands that run on this machine. Install restic locally and try again.'
460
+ end
512
461
 
513
- def database
514
- databases.first
462
+ Restic.new(config, redactor: redactor)
463
+ end
515
464
  end
516
465
 
517
466
  def databases
@@ -529,7 +478,6 @@ module KamalBackup
529
478
  raise ConfigurationError, "no restic snapshot found for #{tags.join(', ')}" unless snapshot
530
479
 
531
480
  snapshot['short_id'] || snapshot['id']
532
-
533
481
  else
534
482
  argument
535
483
  end