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 +4 -4
- data/README.md +5 -2
- data/lib/kamal_backup/app.rb +50 -9
- data/lib/kamal_backup/cli/helpers.rb +1 -1
- data/lib/kamal_backup/cli.rb +9 -0
- data/lib/kamal_backup/command.rb +11 -2
- data/lib/kamal_backup/restic.rb +12 -8
- data/lib/kamal_backup/scheduler.rb +2 -2
- data/lib/kamal_backup/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2402ed466c957ae450d1e3196018d7099da3e01c59e7cc27e0a8dd7db0dbec5b
|
|
4
|
+
data.tar.gz: 43d46f5648d0a2e410ea3fee13876a65c032b42a7260adb7a9210f8e8452ae6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
71
|
+
- "chatwithwork_storage:/data/storage"
|
|
72
72
|
- "chatwithwork_backup_state:/var/lib/kamal-backup"
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
|
|
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
|
|
data/lib/kamal_backup/app.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
data/lib/kamal_backup/cli.rb
CHANGED
|
@@ -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?
|
data/lib/kamal_backup/command.rb
CHANGED
|
@@ -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#{
|
|
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:
|
|
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
|
data/lib/kamal_backup/restic.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
data/lib/kamal_backup/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-07-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|