kamal-backup 0.3.1 → 0.4.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: 801ab5f224e1e018805b65650da3c0946d6c785eb8b2a3e3e3256bcf97649abb
4
- data.tar.gz: 60e98bfe053103b3405d483902a167ee2d86f7a723448e06eacb2937b56fd4d3
3
+ metadata.gz: 2402ed466c957ae450d1e3196018d7099da3e01c59e7cc27e0a8dd7db0dbec5b
4
+ data.tar.gz: 43d46f5648d0a2e410ea3fee13876a65c032b42a7260adb7a9210f8e8452ae6c
5
5
  SHA512:
6
- metadata.gz: b1dbd9a03222917490629d56a4bfca69e1abbac79ed241bf480ba0da50453e67e1224dcf5f6d315ce23039b31febe2d449300dae167ddf19e627decd07c6116e
7
- data.tar.gz: c4392a6048ceea5ec81b20cedad84f15d864ecb37a83337571cc857de8c730c0a84629df335e9b6c4f273868ad86a6ddcc631c00315ff8ff965ac0230bcf1f5a
6
+ metadata.gz: 645f1b5d3a0da81237a3391efde6f22747447b93504580693ebe2dd0e7fb2d81024a00d54d427890992234687034948b25053d741e8a974ee379a385b9e6a9d3
7
+ data.tar.gz: 83577eddd32f3cd423408fea71c75769138d5b67041d555b369478129f20045eea2d51fd031b45067e6862a53dc6b4a2f7218a374e57a879d9ff606a41258c2d
data/README.md CHANGED
@@ -68,11 +68,13 @@ accessories:
68
68
  - AWS_ACCESS_KEY_ID
69
69
  - AWS_SECRET_ACCESS_KEY
70
70
  volumes:
71
- - "chatwithwork_storage:/data/storage:ro"
71
+ - "chatwithwork_storage:/data/storage"
72
72
  - "chatwithwork_backup_state:/var/lib/kamal-backup"
73
73
  ```
74
74
 
75
- For SQLite databases stored on the mounted storage volume, omit `:ro` from that volume.
75
+ The storage volume is writable so `restore production` can replace its contents safely without replacing the mount
76
+ point. Writable access is also required for SQLite databases stored on that volume. For a backup-only accessory,
77
+ you can append `:ro`; remove it and reboot the accessory before a production file restore.
76
78
  When the configured SQLite database file lives under a configured file backup path, `kamal-backup` excludes the raw database, WAL, and shared-memory files from the restic file snapshot automatically.
77
79
 
78
80
  Put the backup settings in `config/kamal-backup.yml`:
@@ -111,6 +113,7 @@ Run the first backup, check the repository, and print evidence. From an app chec
111
113
  bundle exec kamal-backup backup
112
114
  bundle exec kamal-backup list
113
115
  bundle exec kamal-backup check
116
+ bundle exec kamal-backup unlock
114
117
  bundle exec kamal-backup evidence
115
118
  ```
116
119
 
@@ -58,8 +58,8 @@ module KamalBackup
58
58
 
59
59
  build_restore_result('local', snapshot) do |result|
60
60
  databases.each { |adapter| validate_local_machine_database_target(adapter) }
61
- result[:databases] = perform_database_restores_to_current(snapshot)
62
61
  result[:files] = perform_replacement_file_restore(snapshot, production_source: false)
62
+ result[:databases] = perform_database_restores_to_current(snapshot)
63
63
  end
64
64
  end
65
65
 
@@ -67,8 +67,8 @@ module KamalBackup
67
67
  validate_production_restore
68
68
 
69
69
  build_restore_result('production', snapshot) do |result|
70
- result[:databases] = perform_database_restores_to_current(snapshot)
71
70
  result[:files] = perform_replacement_file_restore(snapshot, production_source: true)
71
+ result[:databases] = perform_database_restores_to_current(snapshot)
72
72
  end
73
73
  end
74
74
 
@@ -77,8 +77,8 @@ module KamalBackup
77
77
 
78
78
  run_drill('local', snapshot, check_command: check_command) do |result|
79
79
  databases.each { |adapter| validate_local_machine_database_target(adapter) }
80
- result[:databases] = perform_database_restores_to_current(snapshot)
81
80
  result[:files] = perform_replacement_file_restore(snapshot, production_source: false)
81
+ result[:databases] = perform_database_restores_to_current(snapshot)
82
82
  end
83
83
  end
84
84
 
@@ -87,6 +87,7 @@ module KamalBackup
87
87
  validate_production_drill(file_target, database_name, sqlite_path)
88
88
 
89
89
  run_drill('production', snapshot, check_command: check_command) do |result|
90
+ result[:files] = perform_file_restore(snapshot, target: file_target)
90
91
  result[:databases] = databases.map do |adapter|
91
92
  perform_database_restore_to_scratch(
92
93
  snapshot,
@@ -95,7 +96,6 @@ module KamalBackup
95
96
  sqlite_path: sqlite_path
96
97
  )
97
98
  end
98
- result[:files] = perform_file_restore(snapshot, target: file_target)
99
99
  end
100
100
  end
101
101
 
@@ -109,6 +109,11 @@ module KamalBackup
109
109
  restic.check.stdout
110
110
  end
111
111
 
112
+ def unlock
113
+ config.validate_restic
114
+ restic.unlock.stdout
115
+ end
116
+
112
117
  def prune
113
118
  config.validate_backup(check_files: false)
114
119
  restic.prune
@@ -369,6 +374,8 @@ module KamalBackup
369
374
  end
370
375
 
371
376
  def perform_replacement_file_restore(snapshot, production_source:)
377
+ return nil if config.backup_paths.empty?
378
+
372
379
  source_paths = production_source ? config.backup_paths : config.local_restore_source_paths
373
380
  target_paths = config.backup_paths
374
381
  resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
@@ -385,20 +392,52 @@ module KamalBackup
385
392
  end
386
393
 
387
394
  def replace_target_paths(stage_dir, source_paths:, target_paths:)
388
- source_paths.zip(target_paths).each do |source_path, target_path|
395
+ path_pairs = source_paths.zip(target_paths)
396
+ validate_replacement_paths(stage_dir, path_pairs)
397
+
398
+ path_pairs.each do |source_path, target_path|
389
399
  replace_target_path(stage_dir, source_path, target_path)
390
400
  end
391
401
  end
392
402
 
403
+ def validate_replacement_paths(stage_dir, path_pairs)
404
+ path_pairs.each do |source_path, target_path|
405
+ source = staged_backup_path(stage_dir, source_path)
406
+ raise ConfigurationError, "restored file snapshot is missing #{source_path}" unless File.exist?(source)
407
+
408
+ ensure_writable_restore_target(target_path)
409
+ end
410
+ end
411
+
393
412
  def replace_target_path(stage_dir, source_path, target_path)
394
413
  source = staged_backup_path(stage_dir, source_path)
395
414
  target = File.expand_path(target_path)
396
415
 
397
416
  raise ConfigurationError, "restored file snapshot is missing #{source_path}" unless File.exist?(source)
398
417
 
399
- FileUtils.rm_rf(target)
400
- FileUtils.mkdir_p(File.dirname(target))
401
- FileUtils.mv(source, target)
418
+ if File.directory?(source) && File.directory?(target) && !File.symlink?(target)
419
+ replace_directory_contents(source, target)
420
+ else
421
+ FileUtils.remove_entry(target) if File.exist?(target) || File.symlink?(target)
422
+ FileUtils.mkdir_p(File.dirname(target))
423
+ FileUtils.mv(source, target)
424
+ end
425
+ rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
426
+ raise ConfigurationError, "cannot restore files to #{target}: target must be writable (#{e.message})"
427
+ end
428
+
429
+ def ensure_writable_restore_target(target_path)
430
+ target = File.expand_path(target_path)
431
+ return unless File.directory?(target) && !File.symlink?(target)
432
+
433
+ Dir.mktmpdir('.kamal-backup-write-test-', target) {}
434
+ rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
435
+ raise ConfigurationError, "cannot restore files to #{target}: target must be writable (#{e.message})"
436
+ end
437
+
438
+ def replace_directory_contents(source, target)
439
+ Dir.children(target).each { |entry| FileUtils.remove_entry(File.join(target, entry)) }
440
+ Dir.children(source).each { |entry| FileUtils.mv(File.join(source, entry), target) }
402
441
  end
403
442
 
404
443
  def staged_backup_path(stage_dir, path)
@@ -406,6 +445,8 @@ module KamalBackup
406
445
  end
407
446
 
408
447
  def perform_file_restore(snapshot, target:)
448
+ return nil if config.backup_paths.empty?
449
+
409
450
  resolved_snapshot = resolve_snapshot(snapshot, tags: ['type:files'])
410
451
  validated_target = config.validate_file_restore_target(target)
411
452
  restic.restore_snapshot(resolved_snapshot, validated_target)
@@ -431,7 +472,7 @@ module KamalBackup
431
472
  def validate_production_drill(file_target, database_name, sqlite_path)
432
473
  config.validate_restic
433
474
  config.validate_database_backup
434
- config.validate_file_restore_target(file_target)
475
+ config.validate_file_restore_target(file_target) if config.backup_paths.any?
435
476
 
436
477
  databases.each do |adapter|
437
478
  case adapter.adapter_name
@@ -289,7 +289,7 @@ module KamalBackup
289
289
  - AWS_ACCESS_KEY_ID
290
290
  - AWS_SECRET_ACCESS_KEY
291
291
  volumes:
292
- - "your_app_storage:/data/storage:ro"
292
+ - "your_app_storage:/data/storage"
293
293
  - "your_app_backup_state:/var/lib/kamal-backup"
294
294
  YAML
295
295
  end
@@ -191,6 +191,15 @@ module KamalBackup
191
191
  end
192
192
  end
193
193
 
194
+ desc 'unlock', 'Clear stale restic repository locks'
195
+ def unlock
196
+ if remote_command_mode?
197
+ exec_remote(%w[kamal-backup unlock])
198
+ else
199
+ print(direct_app.unlock)
200
+ end
201
+ end
202
+
194
203
  desc 'prune', 'Apply the configured restic retention policy and prune unneeded data'
195
204
  def prune
196
205
  if remote_command_mode?
@@ -174,12 +174,13 @@ module KamalBackup
174
174
  end
175
175
 
176
176
  def command_failure(spec, status, stdout, stderr, redactor)
177
+ redacted_stderr = redactor.redact_string(stderr)
177
178
  CommandError.new(
178
- "command failed (#{status}): #{spec.display(redactor)}\n#{redactor.redact_string(stderr)}",
179
+ "command failed (#{status}): #{spec.display(redactor)}\n#{redacted_stderr}#{restic_lock_hint(spec, stderr)}",
179
180
  command: spec,
180
181
  status: status,
181
182
  stdout: redactor.redact_string(stdout),
182
- stderr: redactor.redact_string(stderr)
183
+ stderr: redacted_stderr
183
184
  )
184
185
  end
185
186
 
@@ -191,6 +192,14 @@ module KamalBackup
191
192
  stderr: error.message
192
193
  )
193
194
  end
195
+
196
+ def restic_lock_hint(spec, stderr)
197
+ return '' unless spec.argv.first == 'restic'
198
+ return '' if spec.argv[1] == 'unlock'
199
+ return '' unless stderr.to_s.match?(/repository is already locked|unlock.+stale locks/i)
200
+
201
+ "\nHint: run `kamal-backup unlock` to clear stale restic locks, then retry the command."
202
+ end
194
203
  end
195
204
  end
196
205
  end
@@ -28,13 +28,12 @@ module KamalBackup
28
28
  end
29
29
 
30
30
  def backup_stream(command, filename:, tags:)
31
- restic_command = CommandSpec.new(
32
- argv: %w[restic
33
- backup] + host_args + ['--stdin', '--stdin-filename', filename] + tag_args(common_tags + tags),
34
- env: restic_env
35
- )
36
31
  log("backing up stream as #{filename}")
37
- pipe_commands(command, restic_command, producer_label: 'dump', consumer_label: 'restic backup')
32
+ run(
33
+ ['backup'] + host_args + ['--stdin-filename', filename] + tag_args(common_tags + tags) +
34
+ ['--stdin-from-command', '--'] + command.argv,
35
+ env: restic_env.merge(command.env)
36
+ )
38
37
  end
39
38
 
40
39
  def backup_file(path, filename:, tags:)
@@ -99,6 +98,11 @@ module KamalBackup
99
98
  end
100
99
  end
101
100
 
101
+ def unlock
102
+ log('clearing stale restic locks')
103
+ run(%w[unlock])
104
+ end
105
+
102
106
  def check
103
107
  args = %w[check]
104
108
  args.concat(['--read-data-subset', config.check_read_data_subset]) if config.check_read_data_subset
@@ -206,9 +210,9 @@ module KamalBackup
206
210
 
207
211
  private
208
212
 
209
- def run(args, log_output: true)
213
+ def run(args, log_output: true, env: restic_env)
210
214
  Command.capture(
211
- CommandSpec.new(argv: ['restic'] + args, env: restic_env),
215
+ CommandSpec.new(argv: ['restic'] + args, env: env),
212
216
  redactor: redactor,
213
217
  log_output: log_output
214
218
  )
@@ -24,7 +24,7 @@ module KamalBackup
24
24
  @backup_block.call
25
25
  log("backup completed at #{Time.now.utc.iso8601}")
26
26
  rescue StandardError => e
27
- warn("[kamal-backup] backup failed at #{Time.now.utc.iso8601}: #{e.class}: #{e.message}")
27
+ warn("ERROR [kamal-backup] backup failed at #{Time.now.utc.iso8601}: #{e.class}: #{e.message}")
28
28
  end
29
29
 
30
30
  sleep_interruptibly(@config.backup_schedule_seconds)
@@ -49,7 +49,7 @@ module KamalBackup
49
49
  end
50
50
 
51
51
  def log(message)
52
- $stdout.puts("[kamal-backup] #{message}")
52
+ $stdout.puts("INFO [kamal-backup] #{message}")
53
53
  end
54
54
  end
55
55
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KamalBackup
4
- VERSION = '0.3.1'
4
+ VERSION = '0.4.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
11
+ date: 2026-07-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor