kamal-backup 1.1.0 → 1.1.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: a381b9f627864fc652f6d23b5109d5a670d0a4808f075d0d129fa2292da0eac3
4
- data.tar.gz: aedbd53cb4486c1b2cc440e7825129d4823c948e1d8fc387b9742f3179363a80
3
+ metadata.gz: 0ffce7c9bfda0a3f7bf1431000f0b0d9f517d532b0d0b37378879e53a320d622
4
+ data.tar.gz: f927f0c24744d0c62797831a9839aa557fee677ac04c0956a4cf5fc196702b3d
5
5
  SHA512:
6
- metadata.gz: ca9c294db8d9d5445ca395f80d23d7cfa2d9eaaf26ab28e628f5ee264ef268758e7c03d504eea2cb98e54c72e3ba0f3cd999ececfe3c0475bbd434327f8244b5
7
- data.tar.gz: 05b9046e125145ce936ab33812046aff5a69cc7286bafb355c12d323f91615b948689e6db11ec365e41457addceab11aeff1aaf38b99898793d1e743a6900d44
6
+ metadata.gz: 0b9e6b7a3c2386a7e09c199236bafaf57849a27dfe4dba95b3a0a8114bed4cfb134184e9cf4adcfd7536300add5df525742af032d8e8eb410f9355dea626b731
7
+ data.tar.gz: d1d747ccd9b1a86e13d43d812b0551eb6601780632f5f0556caa9cced544b3db75eca34ed825cb02d0922ac87b6604403faf0c3f1617e732343101f827ed02dd
@@ -36,7 +36,7 @@ module KamalBackup
36
36
  return skipped_backup_result(started_at) unless force || backup_due?(started_at)
37
37
 
38
38
  restic.ensure_repository
39
- databases.each { |database| database.backup(restic) }
39
+ databases.each { |database| backup_database(database) }
40
40
  restic.backup_paths(config.backup_paths, tags: ['type:files'])
41
41
 
42
42
  restic.prune if config.forget_after_backup?
@@ -210,6 +210,18 @@ module KamalBackup
210
210
 
211
211
  private
212
212
 
213
+ def backup_database(database)
214
+ database.backup(restic)
215
+ rescue CommandError => e
216
+ raise CommandError.new(
217
+ "database backup failed for #{database_config_name(database)}: #{e.message}",
218
+ command: e.command,
219
+ status: e.status,
220
+ stdout: e.stdout,
221
+ stderr: e.stderr
222
+ )
223
+ end
224
+
213
225
  def backup_due?(now)
214
226
  due_at = next_backup_at
215
227
  due_at.nil? || now >= due_at
@@ -82,8 +82,10 @@ module KamalBackup
82
82
 
83
83
  def command_exit(context, status)
84
84
  runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - context.fetch(:started_at)
85
- result = status.to_i.zero? ? 'successful' : 'failed'
86
- result_color = status.to_i.zero? ? :green : :red
85
+ # A command killed by a signal has no exit status, so nil is a failure.
86
+ successful = status&.zero?
87
+ result = successful ? 'successful' : 'failed'
88
+ result_color = successful ? :green : :red
87
89
 
88
90
  synchronize do
89
91
  flush_output_buffers(context)
@@ -11,6 +11,7 @@ require_relative 'private_tempfile'
11
11
  module KamalBackup
12
12
  class Restic
13
13
  RESTIC_ENV_PATTERN = /\A(?:RESTIC_|AWS_|B2_|AZURE_|GOOGLE_|RCLONE_|OS_|ST_|HP_|HTTP_|HTTPS_|NO_PROXY)/i
14
+ INCOMPLETE_SNAPSHOT_STATUS = 3
14
15
 
15
16
  attr_reader :config, :redactor
16
17
 
@@ -31,11 +32,13 @@ module KamalBackup
31
32
 
32
33
  def backup_stream(command, filename:, tags:)
33
34
  log("backing up stream as #{filename}")
34
- run(
35
- ['backup'] + host_args + ['--stdin-filename', filename] + tag_args(common_tags + tags) +
36
- ['--stdin-from-command', '--'] + command.argv,
37
- env: restic_env.merge(command.env)
38
- )
35
+ without_incomplete_snapshot do
36
+ run(
37
+ ['backup'] + host_args + ['--stdin-filename', filename] + tag_args(common_tags + tags) +
38
+ ['--stdin-from-command', '--'] + command.argv,
39
+ env: restic_env.merge(command.env)
40
+ )
41
+ end
39
42
  end
40
43
 
41
44
  def backup_file(path, filename:, tags:)
@@ -46,34 +49,36 @@ module KamalBackup
46
49
  )
47
50
  log("backing up file content as #{filename}")
48
51
 
49
- File.open(path, 'rb') do |file|
50
- output = Command.output
51
- context = output&.command_start(command, redactor: redactor)
52
- Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
53
- stdout_reader = Thread.new do
54
- Command.collect_stream(stdout, command_output: output, context: context, stream: :stdout,
55
- redactor: redactor)
56
- end
57
- stderr_reader = Thread.new do
58
- Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr,
59
- redactor: redactor)
52
+ without_incomplete_snapshot do
53
+ File.open(path, 'rb') do |file|
54
+ output = Command.output
55
+ context = output&.command_start(command, redactor: redactor)
56
+ Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
57
+ stdout_reader = Thread.new do
58
+ Command.collect_stream(stdout, command_output: output, context: context, stream: :stdout,
59
+ redactor: redactor)
60
+ end
61
+ stderr_reader = Thread.new do
62
+ Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr,
63
+ redactor: redactor)
64
+ end
65
+ copy_error = nil
66
+ begin
67
+ IO.copy_stream(file, stdin)
68
+ rescue Errno::EPIPE => e
69
+ copy_error = e
70
+ ensure
71
+ stdin.close unless stdin.closed?
72
+ end
73
+ out = stdout_reader.value
74
+ err = stderr_reader.value
75
+ status = wait_thread.value
76
+ output&.command_exit(context, status.exitstatus)
77
+ raise_command_error(command, status, out, err) unless status.success?
78
+ raise_stream_error(command, copy_error, out, err) if copy_error
79
+
80
+ CommandResult.new(stdout: out, stderr: err, status: status.exitstatus)
60
81
  end
61
- copy_error = nil
62
- begin
63
- IO.copy_stream(file, stdin)
64
- rescue Errno::EPIPE => e
65
- copy_error = e
66
- ensure
67
- stdin.close unless stdin.closed?
68
- end
69
- out = stdout_reader.value
70
- err = stderr_reader.value
71
- status = wait_thread.value
72
- output&.command_exit(context, status.exitstatus)
73
- raise_command_error(command, status, out, err) unless status.success?
74
- raise_stream_error(command, copy_error, out, err) if copy_error
75
-
76
- CommandResult.new(stdout: out, stderr: err, status: status.exitstatus)
77
82
  end
78
83
  end
79
84
  rescue Errno::ENOENT => e
@@ -381,6 +386,33 @@ module KamalBackup
381
386
  stderr: e.message)
382
387
  end
383
388
 
389
+ # restic exits 3 when it could not read a source but saved a snapshot anyway.
390
+ # A dump that exits 0 without writing anything ends up here: the snapshot has
391
+ # no dump in it, yet it would become the latest one and use a retention slot.
392
+ def without_incomplete_snapshot
393
+ yield
394
+ rescue CommandError => e
395
+ raise unless e.status == INCOMPLETE_SNAPSHOT_STATUS
396
+
397
+ raise CommandError.new(
398
+ "#{e.message}\n#{discard_incomplete_snapshot(e.stdout)}",
399
+ command: e.command,
400
+ status: e.status,
401
+ stdout: e.stdout,
402
+ stderr: e.stderr
403
+ )
404
+ end
405
+
406
+ def discard_incomplete_snapshot(stdout)
407
+ snapshot = stdout[/snapshot (\h+) saved/, 1]
408
+ return 'restic may have saved an incomplete snapshot; check `kamal-backup list`.' unless snapshot
409
+
410
+ run(['forget', snapshot])
411
+ "Removed incomplete snapshot #{snapshot}."
412
+ rescue CommandError => e
413
+ "Could not remove incomplete snapshot #{snapshot}; run `restic forget #{snapshot}`: #{e.message}"
414
+ end
415
+
384
416
  def raise_command_error(command, status, stdout, stderr)
385
417
  raise CommandError.new(
386
418
  "command failed (#{status.exitstatus}): #{command.display(redactor)}\n#{redactor.redact_string(stderr)}",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KamalBackup
4
- VERSION = '1.1.0'
4
+ VERSION = '1.1.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-10-04 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: thor
@@ -110,7 +109,6 @@ dependencies:
110
109
  version: '0'
111
110
  description: Back up PostgreSQL, MySQL, SQLite, and file-backed Active Storage files
112
111
  into restic on a schedule, then run restore drills and produce review evidence.
113
- email:
114
112
  executables:
115
113
  - kamal-backup
116
114
  extensions: []
@@ -149,7 +147,6 @@ metadata:
149
147
  source_code_uri: https://github.com/crmne/kamal-backup
150
148
  changelog_uri: https://github.com/crmne/kamal-backup/releases
151
149
  rubygems_mfa_required: 'true'
152
- post_install_message:
153
150
  rdoc_options: []
154
151
  require_paths:
155
152
  - lib
@@ -164,8 +161,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
164
161
  - !ruby/object:Gem::Version
165
162
  version: '0'
166
163
  requirements: []
167
- rubygems_version: 3.5.22
168
- signing_key:
164
+ rubygems_version: 4.0.20
169
165
  specification_version: 4
170
166
  summary: Scheduled backups, restore drills, and evidence for Rails apps deployed with
171
167
  Kamal