kamal-backup 1.0.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: d0b99d8270f625d97d1880cb3301d81aae8bcf201f77960a49fc84de7ade4667
4
- data.tar.gz: d00ea2f74dd7b1060ea49c338841e10deb4cfaab2215e1ab4e553a74f211cdae
3
+ metadata.gz: 0ffce7c9bfda0a3f7bf1431000f0b0d9f517d532b0d0b37378879e53a320d622
4
+ data.tar.gz: f927f0c24744d0c62797831a9839aa557fee677ac04c0956a4cf5fc196702b3d
5
5
  SHA512:
6
- metadata.gz: a41f4e714f147d3216e90f96b7b66b7082f9b9914adbfef56abbb6177ddc8931375904a9d43610fba302a0fa5b70f3565411a898786259e55ef66f5fddb4d8f2
7
- data.tar.gz: 46eeba2ce40db29124c149eeb632b59ef48462bbc5fa27c6d660e3636fc703aebb4c67edac775d6f7ad17c9cd443ede06884aaaeee614398f21918f2c0b4005c
6
+ metadata.gz: 0b9e6b7a3c2386a7e09c199236bafaf57849a27dfe4dba95b3a0a8114bed4cfb134184e9cf4adcfd7536300add5df525742af032d8e8eb410f9355dea626b731
7
+ data.tar.gz: d1d747ccd9b1a86e13d43d812b0551eb6601780632f5f0556caa9cced544b3db75eca34ed825cb02d0922ac87b6604403faf0c3f1617e732343101f827ed02dd
data/README.md CHANGED
@@ -114,6 +114,7 @@ Run the first backup, check the repository, and print evidence. From an app chec
114
114
  ```sh
115
115
  bundle exec kamal-backup backup
116
116
  bundle exec kamal-backup list
117
+ bundle exec kamal-backup dump latest -o tmp/app.pgdump
117
118
  bundle exec kamal-backup check
118
119
  bundle exec kamal-backup unlock
119
120
  bundle exec kamal-backup evidence
@@ -144,13 +145,13 @@ Start here:
144
145
 
145
146
  ## Releasing
146
147
 
147
- Run the release helper from a clean `master` checkout:
148
+ Commit hand-written release notes as `packaging/release-notes/vX.Y.Z.md`, then run the release helper from a clean `master` checkout:
148
149
 
149
150
  ```sh
150
151
  bin/release 1.0.1
151
152
  ```
152
153
 
153
- It updates `lib/kamal_backup/version.rb`, syncs `Gemfile.lock`, commits the release, and pushes `master`. CI runs the test suite and docs build, publishes the RubyGem and Docker image tags, then creates the version tag, GitHub release, and docs deployment from the release commit.
154
+ It updates `lib/kamal_backup/version.rb`, syncs `Gemfile.lock`, commits the release, and pushes `master`. CI runs the test suite and docs build, publishes the RubyGem and Docker image tags, then creates the version tag, GitHub release, and docs deployment from the release commit. The GitHub release uses the committed notes, and CI stops before publishing anything when they are missing.
154
155
 
155
156
  Add `--no-push` to prepare the release commit locally without publishing.
156
157
 
@@ -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?
@@ -100,6 +100,84 @@ module KamalBackup
100
100
  end
101
101
  end
102
102
 
103
+ def locate_database_dump(snapshot: 'latest', database_name: nil, validate_credentials: true)
104
+ if validate_credentials
105
+ config.validate_restic
106
+ else
107
+ config.required_app_name
108
+ unless config.restic_repository || config.restic_repository_file
109
+ raise ConfigurationError,
110
+ 'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required to dump from the backup accessory'
111
+ end
112
+ end
113
+
114
+ # Dump reads a stored snapshot. It needs the selected database's name and
115
+ # adapter, not live connection secrets or the current SQLite file.
116
+ raise ConfigurationError, 'databases must contain at least one database' if databases.empty?
117
+
118
+ adapter = select_database(database_name)
119
+ resolved_snapshot = resolve_snapshot(snapshot, tags: database_snapshot_tags(adapter))
120
+ database = database_config_name(adapter)
121
+ filename = restic.database_file(
122
+ resolved_snapshot,
123
+ adapter.adapter_name,
124
+ database_name: database
125
+ )
126
+ raise ConfigurationError, "could not find database backup file in snapshot #{resolved_snapshot}" unless filename
127
+
128
+ {
129
+ snapshot: resolved_snapshot,
130
+ database: database,
131
+ adapter: adapter.adapter_name,
132
+ filename: filename,
133
+ dump_extension: adapter.dump_extension
134
+ }
135
+ end
136
+
137
+ def dump_database(snapshot: 'latest', database_name: nil, output_path: nil, io: nil, overwrite: false)
138
+ located = locate_database_dump(snapshot: snapshot, database_name: database_name)
139
+
140
+ if output_path
141
+ expanded = require_dump_output_path!(output_path)
142
+ warn_dump_extension!(expanded, located.fetch(:dump_extension))
143
+ restic.write_dump_to_path(
144
+ located.fetch(:snapshot),
145
+ located.fetch(:filename),
146
+ expanded,
147
+ overwrite: overwrite
148
+ )
149
+ located.merge(output: expanded)
150
+ elsif io
151
+ restic.pipe_dump_to_io(located.fetch(:snapshot), located.fetch(:filename), io)
152
+ located.merge(output: 'io')
153
+ else
154
+ raise ConfigurationError, 'output path is required; pass -o PATH'
155
+ end
156
+ end
157
+
158
+ def require_dump_output_path!(output_path)
159
+ path = output_path.to_s.strip
160
+ raise ConfigurationError, 'output path is required; pass -o PATH' if path.empty?
161
+ raise ConfigurationError, 'output path must be a file, not a directory' if path.end_with?('/', '\\')
162
+
163
+ expanded = File.expand_path(path)
164
+ raise ConfigurationError, "output path must be a file, not a directory: #{expanded}" if File.directory?(expanded)
165
+
166
+ parent = File.dirname(expanded)
167
+ raise ConfigurationError, "output path directory does not exist: #{parent}" unless File.directory?(parent)
168
+
169
+ expanded
170
+ end
171
+
172
+ def warn_dump_extension!(output_path, expected_extension)
173
+ expected = ".#{expected_extension}"
174
+ actual = File.extname(output_path.to_s)
175
+ return if actual.casecmp?(expected)
176
+
177
+ actual_label = actual.empty? ? 'no extension' : actual.inspect
178
+ warn("warning: output path has #{actual_label}; expected #{expected.inspect} for this database dump")
179
+ end
180
+
103
181
  def snapshots
104
182
  config.validate_restic
105
183
  restic.snapshots.stdout
@@ -132,6 +210,18 @@ module KamalBackup
132
210
 
133
211
  private
134
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
+
135
225
  def backup_due?(now)
136
226
  due_at = next_backup_at
137
227
  due_at.nil? || now >= due_at
@@ -524,6 +614,22 @@ module KamalBackup
524
614
  end
525
615
  end
526
616
 
617
+ def select_database(database_name)
618
+ names = databases.map { |adapter| database_config_name(adapter) }
619
+
620
+ unless database_name.to_s.strip.empty?
621
+ return databases.find { |adapter| database_config_name(adapter) == database_name } ||
622
+ raise(
623
+ ConfigurationError,
624
+ "database #{database_name.inspect} is not configured; configured names: #{names.join(', ')}"
625
+ )
626
+ end
627
+
628
+ return databases.first if databases.one?
629
+
630
+ raise ConfigurationError, "multiple databases configured (#{names.join(', ')}); pass --database NAME"
631
+ end
632
+
527
633
  def resolve_snapshot(argument, tags:)
528
634
  if argument == 'latest'
529
635
  snapshot = restic.latest_snapshot(tags: tags)
@@ -532,8 +638,67 @@ module KamalBackup
532
638
 
533
639
  snapshot['short_id'] || snapshot['id']
534
640
  else
535
- argument
641
+ resolve_explicit_snapshot(argument, tags: tags)
642
+ end
643
+ end
644
+
645
+ # Each backup run writes one snapshot per database and then one file
646
+ # snapshot, each with its own ID. An explicit ID names one of them, so the
647
+ # other types come from the same run.
648
+ def resolve_explicit_snapshot(argument, tags:)
649
+ anchor = find_snapshot(argument)
650
+ snapshot = backup_run_for(anchor).find { |candidate| snapshot_tagged?(candidate, tags) }
651
+
652
+ unless snapshot
653
+ raise ConfigurationError,
654
+ "the backup containing snapshot #{argument} has no snapshot for #{tags.join(', ')}"
655
+ end
656
+
657
+ snapshot['short_id'] || snapshot['id']
658
+ end
659
+
660
+ def find_snapshot(argument)
661
+ matches = backup_snapshots.select do |snapshot|
662
+ snapshot['short_id'] == argument || snapshot['id'].to_s.start_with?(argument)
536
663
  end
664
+
665
+ raise ConfigurationError, "no restic snapshot found for #{argument}" if matches.empty?
666
+ raise ConfigurationError, "snapshot ID #{argument} is ambiguous; use a longer ID" if matches.size > 1
667
+
668
+ matches.first
669
+ end
670
+
671
+ # Snapshots from one host, in time order, split into backup runs. A run
672
+ # ends after its file snapshot (always written last) or when a database
673
+ # snapshot group repeats, so a run that failed partway stays on its own.
674
+ def backup_run_for(anchor)
675
+ host_snapshots = backup_snapshots.select { |snapshot| snapshot['hostname'] == anchor['hostname'] }
676
+ runs = host_snapshots.sort_by { |snapshot| Time.parse(snapshot.fetch('time')) }.each_with_object([]) do |snapshot, grouped|
677
+ current = grouped.last
678
+ if current.nil? || current.any? { |other| run_boundary?(other, snapshot) }
679
+ grouped << [snapshot]
680
+ else
681
+ current << snapshot
682
+ end
683
+ end
684
+
685
+ runs.find { |run| run.include?(anchor) }
686
+ end
687
+
688
+ def run_boundary?(earlier, later)
689
+ snapshot_tagged?(earlier, ['type:files']) || snapshot_group(earlier) == snapshot_group(later)
690
+ end
691
+
692
+ def snapshot_group(snapshot)
693
+ Array(snapshot['tags']).select { |tag| tag.start_with?('type:', 'database:', 'adapter:') }.sort
694
+ end
695
+
696
+ def snapshot_tagged?(snapshot, tags)
697
+ (tags - Array(snapshot['tags'])).empty?
698
+ end
699
+
700
+ def backup_snapshots
701
+ @backup_snapshots ||= restic.snapshots_json
537
702
  end
538
703
  end
539
704
  end
@@ -5,6 +5,7 @@ require 'json'
5
5
  require 'shellwords'
6
6
  require 'thor'
7
7
  require_relative '../app'
8
+ require_relative '../private_tempfile'
8
9
  require_relative '../command_output'
9
10
  require_relative '../config'
10
11
  require_relative '../kamal_bridge'
@@ -111,6 +112,103 @@ module KamalBackup
111
112
  result
112
113
  end
113
114
 
115
+ def require_dump_output_path!(output_path)
116
+ path = output_path.to_s.strip
117
+ raise ConfigurationError, 'output path is required; pass -o PATH' if path.empty?
118
+ raise ConfigurationError, 'output path must be a file, not a directory' if path.end_with?('/', '\\')
119
+
120
+ expanded = File.expand_path(path)
121
+ raise ConfigurationError, "output path must be a file, not a directory: #{expanded}" if File.directory?(expanded)
122
+
123
+ parent = File.dirname(expanded)
124
+ raise ConfigurationError, "output path directory does not exist: #{parent}" unless File.directory?(parent)
125
+
126
+ expanded
127
+ end
128
+
129
+ def warn_dump_extension!(output_path, expected_extension)
130
+ expected = ".#{expected_extension}"
131
+ actual = File.extname(output_path.to_s)
132
+ return if actual.casecmp?(expected)
133
+
134
+ actual_label = actual.empty? ? 'no extension' : actual.inspect
135
+ warn("warning: output path has #{actual_label}; expected #{expected.inspect} for this database dump")
136
+ end
137
+
138
+ def dump_overwrite?(output_path)
139
+ return true if options[:yes]
140
+ return false unless File.exist?(output_path)
141
+
142
+ confirm!("Overwrite #{output_path}? This will replace the existing file.")
143
+ true
144
+ end
145
+
146
+ def dump_remote(snapshot, output_path:, overwrite:)
147
+ ensure_remote_version_match!
148
+ config = remote_dump_config
149
+ location = remote_restic_location(config)
150
+ located = remote_dump_app(config, location).locate_database_dump(
151
+ snapshot: snapshot,
152
+ database_name: options[:database],
153
+ validate_credentials: false
154
+ )
155
+ filename = located.fetch(:filename)
156
+ expanded = require_dump_output_path!(output_path)
157
+ warn_dump_extension!(expanded, located.fetch(:dump_extension))
158
+ temp = nil
159
+
160
+ begin
161
+ temp = PrivateTempfile.open(expanded)
162
+ bridge.stream_restic_dump(
163
+ accessory_name: accessory_name,
164
+ snapshot: located.fetch(:snapshot),
165
+ filename: filename,
166
+ io: temp,
167
+ **location
168
+ )
169
+ PrivateTempfile.publish(temp, expanded, overwrite: overwrite)
170
+ warn("wrote #{filename.sub(%r{\A/+}, '')} from snapshot #{located.fetch(:snapshot)} to #{expanded}")
171
+ ensure
172
+ PrivateTempfile.discard(temp)
173
+ end
174
+ end
175
+
176
+ def remote_dump_config
177
+ Config.new(
178
+ env: bridge.accessory_environment(accessory_name: accessory_name),
179
+ config_paths: [Config::SHARED_CONFIG_PATH],
180
+ load_project_defaults: false
181
+ )
182
+ end
183
+
184
+ def remote_restic_location(config)
185
+ if (repository = config.restic_repository)
186
+ { repository: repository }
187
+ elsif (repository_file = config.restic_repository_file)
188
+ { repository_file: repository_file }
189
+ else
190
+ raise ConfigurationError,
191
+ 'RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required to dump from the backup accessory'
192
+ end
193
+ end
194
+
195
+ def remote_dump_app(config, location)
196
+ runner = lambda do |args, **|
197
+ stdout = bridge.capture_restic_command(
198
+ accessory_name: accessory_name,
199
+ argv: args,
200
+ **location
201
+ )
202
+ CommandResult.new(stdout: stdout, stderr: '', status: 0)
203
+ end
204
+
205
+ App.new(
206
+ config: config,
207
+ redactor: redactor,
208
+ restic: Restic.new(config, redactor: redactor, runner: runner)
209
+ )
210
+ end
211
+
114
212
  def ensure_remote_version_match!
115
213
  return if remote_version == VERSION
116
214
 
@@ -130,6 +130,7 @@ module KamalBackup
130
130
 
131
131
  package_name 'kamal-backup'
132
132
  map %w[-v --version] => :version
133
+ stop_on_unknown_option! :run_restic
133
134
  class_option :config_file, aliases: '-c', type: :string, desc: 'Path to Kamal deploy config file'
134
135
  class_option :destination, aliases: '-d', type: :string, desc: 'Kamal destination to use'
135
136
  remove_command :tree
@@ -182,6 +183,39 @@ module KamalBackup
182
183
  end
183
184
  end
184
185
 
186
+ method_option :output, aliases: '-o', type: :string,
187
+ desc: 'Write the dump to this path (required; parent directory must exist)'
188
+ method_option :database, type: :string,
189
+ desc: 'Name from config/kamal-backup.yml when multiple databases are configured'
190
+ method_option :yes, aliases: '-y', type: :boolean, default: false,
191
+ desc: 'Replace the output file if it exists, including one created while the dump downloads'
192
+ desc 'dump [SNAPSHOT]', 'Download a database dump to a file (not file or Active Storage backups)'
193
+ def dump(snapshot = 'latest')
194
+ output_path = require_dump_output_path!(options[:output])
195
+ overwrite = dump_overwrite?(output_path)
196
+
197
+ if remote_command_mode?
198
+ dump_remote(snapshot, output_path: output_path, overwrite: overwrite)
199
+ else
200
+ result = direct_app.dump_database(
201
+ snapshot: snapshot,
202
+ database_name: options[:database],
203
+ output_path: output_path,
204
+ overwrite: overwrite
205
+ )
206
+ warn("wrote #{result[:filename]} from snapshot #{result[:snapshot]} to #{result[:output]}")
207
+ end
208
+ end
209
+
210
+ desc 'run-restic ARGS...', 'Run restic with the loaded backup configuration', hide: true
211
+ def run_restic(*args)
212
+ raise ConfigurationError, 'restic arguments are required' if args.empty?
213
+
214
+ config = Config.new(env: command_env)
215
+ config.validate_restic
216
+ exec(Restic.environment_for(config), 'restic', *args)
217
+ end
218
+
185
219
  desc 'check', 'Run restic check and record the latest result'
186
220
  def check
187
221
  if remote_command_mode?
@@ -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)
@@ -71,7 +71,7 @@ module KamalBackup
71
71
  def restore_database(restic, snapshot, filename, target:)
72
72
  Tempfile.create(['kamal-backup-restore-', '.sqlite3']) do |tempfile|
73
73
  tempfile.close
74
- restic.write_dump_to_path(snapshot, filename, tempfile.path)
74
+ restic.write_dump_to_path(snapshot, filename, tempfile.path, overwrite: true)
75
75
  validate_database_file(tempfile.path)
76
76
  FileUtils.mkdir_p(File.dirname(File.expand_path(target)))
77
77
  Command.capture(
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'open3'
3
4
  require 'shellwords'
5
+ require 'tempfile'
4
6
  require 'yaml'
5
7
  require_relative 'command'
6
8
  require_relative 'yaml_access'
@@ -11,6 +13,9 @@ module KamalBackup
11
13
 
12
14
  DEFAULT_CONFIG_FILE = 'config/deploy.yml'
13
15
  VERSION_LINE_PATTERN = /\A\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*\z/
16
+ # Query keys such as auth are credentials even when the URL pattern does not
17
+ # name them. A diagnostic that prints only the value still has to be redacted.
18
+ CREDENTIAL_KEY_PATTERN = /(?:pass|password|secret|token|key|credential|authorization)|(?:\A|_)(?:auth|pwd)(?:\z|_)/i
14
19
 
15
20
  class FilteringIO
16
21
  def initialize(io, &reject)
@@ -84,6 +89,38 @@ module KamalBackup
84
89
  end
85
90
  end
86
91
 
92
+ # Run restic on the live accessory over SSH + docker exec without Kamal's
93
+ # log wrapper. Kamal accessory exec mixes INFO lines into stdout.
94
+ def capture_restic_command(accessory_name:, argv:, repository: nil, repository_file: nil)
95
+ stdout = +''
96
+ run_restic_on_accessory(
97
+ accessory_name: accessory_name,
98
+ repository: repository,
99
+ repository_file: repository_file,
100
+ argv: argv
101
+ ) do |stream|
102
+ stdout << stream.read
103
+ end
104
+ stdout
105
+ end
106
+
107
+ # Stream a restic dump over SSH + docker exec without Kamal's log wrapper.
108
+ # Kamal accessory exec mixes INFO lines into stdout, which corrupts binary dumps.
109
+ def stream_restic_dump(accessory_name:, snapshot:, filename:, io:, repository: nil, repository_file: nil)
110
+ run_restic_on_accessory(
111
+ accessory_name: accessory_name,
112
+ repository: repository,
113
+ repository_file: repository_file,
114
+ argv: ['dump', snapshot.to_s, filename.to_s],
115
+ snapshot: snapshot,
116
+ filename: filename
117
+ ) do |stdout|
118
+ IO.copy_stream(stdout, io)
119
+ end
120
+
121
+ true
122
+ end
123
+
87
124
  def remote_version(accessory_name:)
88
125
  result = execute_on_accessory(accessory_name: accessory_name, command: %w[kamal-backup version])
89
126
  version = parse_version_line(result.stdout)
@@ -95,12 +132,388 @@ module KamalBackup
95
132
 
96
133
  private
97
134
 
98
- def config
99
- @config ||= begin
100
- result = capture_kamal(kamal_config_argv)
101
- load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
102
- YAML.public_send(load_method, result.stdout)
135
+ def run_restic_on_accessory(accessory_name:, argv:, snapshot: nil, filename: nil, repository: nil,
136
+ repository_file: nil)
137
+ config
138
+ target = live_accessory_target(accessory_name)
139
+ unless target
140
+ raise ConfigurationError,
141
+ "could not find a live backup accessory #{accessory_name.inspect} to dump from"
142
+ end
143
+
144
+ docker_argv = accessory_restic_docker_argv(
145
+ target.fetch(:service_name),
146
+ argv,
147
+ repository: repository,
148
+ repository_file: repository_file
149
+ )
150
+ remote = docker_argv.shelljoin
151
+
152
+ with_ssh_identity_files do |identity_files, config_file|
153
+ spec = CommandSpec.new(
154
+ argv: ssh_argv(target.fetch(:host), remote, identity_files: identity_files, config_file: config_file)
155
+ )
156
+ Open3.popen3(*spec.argv) do |stdin, stdout, stderr, wait_thread|
157
+ stdin.close
158
+ err_reader = Thread.new { stderr.read }
159
+ yield stdout
160
+ err = err_reader.value
161
+ status = wait_thread.value
162
+ unless status.success?
163
+ raise_restic_accessory_error(
164
+ spec,
165
+ status.exitstatus,
166
+ err,
167
+ snapshot: snapshot,
168
+ filename: filename,
169
+ docker_argv: docker_argv,
170
+ redactor: diagnostic_redactor(repository)
171
+ )
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ # The accessory command loads config/kamal-backup.yml itself. Repository URLs,
178
+ # repository files, password files, and password commands stay in that process
179
+ # and are not repeated on the SSH command line.
180
+ def accessory_restic_docker_argv(service_name, argv, repository:, repository_file:)
181
+ command = ['docker', 'exec', service_name, 'kamal-backup', 'run-restic', '--', *Array(argv).map(&:to_s)]
182
+ forbidden = [repository, repository_file].compact.map(&:to_s).reject(&:empty?)
183
+ leaked = forbidden.intersect?(command) || command.any? { |arg| arg.include?('RESTIC_REPOSITORY') }
184
+ raise ConfigurationError, 'refusing to place repository settings on the accessory command' if leaked
185
+
186
+ command
187
+ end
188
+
189
+ def raise_restic_accessory_error(spec, status, stderr, snapshot:, filename:, docker_argv: nil, redactor: nil)
190
+ reporter = redactor || @redactor
191
+ redacted = reporter.redact_string(stderr.to_s)
192
+ dump_context = snapshot && filename
193
+ message =
194
+ if dump_context && stderr.to_s.match?(/no matching ID found|failed to find snapshot|no snapshot found/i)
195
+ "backup not found for snapshot #{snapshot.inspect}"
196
+ elsif dump_context && missing_requested_dump_file?(stderr, filename)
197
+ "backup file #{filename.inspect} not found in snapshot #{snapshot.inspect}"
198
+ else
199
+ "command failed (#{status}): #{redacted_restic_command(spec, docker_argv, reporter)}\n#{redacted}"
200
+ end
201
+
202
+ raise CommandError.new(
203
+ message,
204
+ command: spec,
205
+ status: status,
206
+ stderr: redacted
207
+ )
208
+ end
209
+
210
+ # A missing password file or repository file also says "no such file". Only
211
+ # the requested dump path is a missing backup.
212
+ def missing_requested_dump_file?(stderr, filename)
213
+ text = stderr.to_s
214
+ return false unless text.match?(/not found|does not exist|no such file/i)
215
+
216
+ [filename.to_s, filename.to_s.sub(%r{\A/+}, '')].uniq.reject(&:empty?).any? do |path|
217
+ text.include?(path)
218
+ end
219
+ end
220
+
221
+ # Shell-escaping hides query credentials from a later redactor pass.
222
+ # Redact each argument first, then escape that copy.
223
+ def redacted_restic_command(spec, docker_argv, redactor)
224
+ return spec.display(redactor) unless docker_argv
225
+
226
+ prefix = spec.argv[0..-2].map { |arg| redactor.redact_string(arg) }.shelljoin
227
+ remote = docker_argv.map { |arg| redactor.redact_string(arg) }.shelljoin
228
+ "#{prefix} #{remote}"
229
+ end
230
+
231
+ def diagnostic_redactor(repository)
232
+ @redactor.with_additional_secrets(configured_credential_values(repository))
233
+ end
234
+
235
+ def configured_credential_values(repository)
236
+ [repository, ssh_proxy_command(ssh_options[:proxy])].flat_map { |source| credential_values_in(source) }
237
+ end
238
+
239
+ def credential_values_in(source)
240
+ text = source.to_s
241
+ return [] if text.strip.empty?
242
+
243
+ url_credential_values(text) + option_credential_values(text)
244
+ end
245
+
246
+ def url_credential_values(text)
247
+ values = []
248
+ text.scan(%r{://[^/\s@:]+:([^/\s@]+)@}) { values << Regexp.last_match(1) }
249
+ text.scan(/[?&]([A-Za-z0-9][\w.-]*)=([^&#\s]+)/) do |key, value|
250
+ values << value if key.match?(CREDENTIAL_KEY_PATTERN)
251
+ end
252
+ values
253
+ end
254
+
255
+ # Shellwords removes quotes, so --token 'secret' and --token "secret" register
256
+ # the secret itself. A nested sh -c command is parsed again. A broken quote
257
+ # must not raise while an error is being reported.
258
+ def option_credential_values(text, depth: 0)
259
+ return [] if depth > 4 || text.strip.empty?
260
+
261
+ words = split_proxy_command(text)
262
+ values = []
263
+ words.each_with_index do |word, index|
264
+ values.concat(option_values_at(words, word, index))
265
+ values.concat(url_credential_values(word)) if word.match?(%r{://|[?&][\w.-]+=})
266
+ next if word == text || !word.match?(/--/) || !word.match?(/[\s'"]/)
267
+
268
+ values.concat(option_credential_values(word, depth: depth + 1))
103
269
  end
270
+ values
271
+ end
272
+
273
+ def option_values_at(words, word, index)
274
+ if (assignment = word.match(/\A--([A-Za-z0-9][\w-]*)=(.*)\z/m))
275
+ return [] unless assignment[1].match?(CREDENTIAL_KEY_PATTERN)
276
+
277
+ value = assignment[2]
278
+ return value.empty? ? [] : [value]
279
+ end
280
+
281
+ flag = word.match(/\A--([A-Za-z0-9][\w-]*)\z/)
282
+ return [] unless flag && flag[1].match?(CREDENTIAL_KEY_PATTERN)
283
+
284
+ value = words[index + 1]
285
+ return [] if value.nil? || value.empty? || value.start_with?('-')
286
+
287
+ [value]
288
+ end
289
+
290
+ def split_proxy_command(text)
291
+ Shellwords.split(text)
292
+ rescue ArgumentError
293
+ lenient_shell_split(text)
294
+ end
295
+
296
+ def lenient_shell_split(text)
297
+ words = []
298
+ word = +''
299
+ quoted = nil
300
+ started = false
301
+ index = 0
302
+ while index < text.length
303
+ char = text[index]
304
+ if quoted
305
+ if char == quoted
306
+ quoted = nil
307
+ elsif quoted == '"' && char == '\\' && index + 1 < text.length
308
+ index += 1
309
+ word << text[index]
310
+ else
311
+ word << char
312
+ end
313
+ started = true
314
+ elsif char == '\\' && index + 1 < text.length
315
+ index += 1
316
+ word << text[index]
317
+ started = true
318
+ elsif ["'", '"'].include?(char)
319
+ quoted = char
320
+ started = true
321
+ elsif char.match?(/\s/)
322
+ if started
323
+ words << word
324
+ word = +''
325
+ started = false
326
+ end
327
+ else
328
+ word << char
329
+ started = true
330
+ end
331
+ index += 1
332
+ end
333
+ words << word if started
334
+ words
335
+ end
336
+
337
+ # Match Kamal's SSH defaults: user root, port 22, plus ssh.user, port, proxy,
338
+ # keys, and config from the rendered deploy config. -T overrides RequestTTY
339
+ # force so a config file cannot translate dump bytes or merge stderr into stdout.
340
+ def ssh_argv(host, remote_command, identity_files:, config_file:)
341
+ options = ssh_options
342
+ argv = ['ssh', '-T', '-p', options.fetch(:port), '-l', options.fetch(:user)]
343
+ argv.concat(ssh_proxy_args(options[:proxy]))
344
+ Array(options[:keys]).each { |key| argv.concat(['-i', key]) }
345
+ identity_files.each { |path| argv.concat(['-i', path]) }
346
+ argv.concat(['-o', 'IdentitiesOnly=yes']) if options[:keys_only]
347
+ argv.concat(['-F', '/dev/null']) if options[:config] == false && config_file.nil?
348
+ argv.concat(['-F', config_file]) if config_file
349
+ case options[:forward_agent]
350
+ when true
351
+ argv.concat(['-o', 'ForwardAgent=yes'])
352
+ when false
353
+ argv.concat(['-o', 'ForwardAgent=no'])
354
+ end
355
+ argv << host
356
+ argv << remote_command
357
+ argv
358
+ end
359
+
360
+ def ssh_options
361
+ raw = fetch(config, :ssh_options) || {}
362
+ {
363
+ user: (ssh_config_value(raw, :user) || 'root').to_s,
364
+ port: (ssh_config_value(raw, :port) || 22).to_s,
365
+ proxy: ssh_config_value(raw, :proxy),
366
+ keys: Array(ssh_config_value(raw, :keys)).map { |key| File.expand_path(key.to_s) },
367
+ keys_only: ssh_config_value(raw, :keys_only),
368
+ config: ssh_config_value(raw, :config),
369
+ forward_agent: ssh_config_value(raw, :forward_agent),
370
+ key_data: Array(ssh_config_value(raw, :key_data)).map(&:to_s).reject(&:empty?)
371
+ }
372
+ end
373
+
374
+ def ssh_config_value(raw, key)
375
+ [key, key.to_s, key.to_sym].each do |candidate|
376
+ return raw[candidate] if raw.key?(candidate)
377
+ end
378
+ nil
379
+ end
380
+
381
+ def ssh_proxy_args(proxy)
382
+ return [] if proxy.nil? || proxy == false
383
+ return [] unless (jump = ssh_jump_target(proxy))
384
+
385
+ jump = "root@#{jump}" unless jump.include?('@') || jump.include?(',')
386
+ ['-J', jump]
387
+ end
388
+
389
+ def ssh_jump_target(proxy)
390
+ raw = if proxy.is_a?(String)
391
+ proxy
392
+ elsif proxy.respond_to?(:jump_proxies)
393
+ proxy.jump_proxies
394
+ elsif proxy.is_a?(Hash)
395
+ fetch(proxy, :jump_proxies)
396
+ end
397
+ value = raw.to_s.strip
398
+ return if value.empty? || value.include?(' ')
399
+
400
+ value
401
+ end
402
+
403
+ def ssh_proxy_command(proxy)
404
+ raw = if proxy.is_a?(String)
405
+ proxy
406
+ elsif proxy.respond_to?(:command_line_template) && !proxy.respond_to?(:jump_proxies)
407
+ proxy.command_line_template
408
+ elsif proxy.is_a?(Hash)
409
+ fetch(proxy, :command_line_template) || fetch(proxy, :command)
410
+ end
411
+ value = raw.to_s.strip
412
+ return if value.empty?
413
+
414
+ value
415
+ end
416
+
417
+ def with_ssh_identity_files
418
+ identity_files = []
419
+ config_file = ssh_config_file
420
+ ssh_options.fetch(:key_data).each do |data|
421
+ file = Tempfile.new(['kamal-backup-ssh-', '.key'])
422
+ file.chmod(0o600)
423
+ file.write(data)
424
+ file.close
425
+ identity_files << file
426
+ end
427
+ yield identity_files.map(&:path), config_file&.path
428
+ ensure
429
+ (Array(identity_files) + [config_file]).compact.each do |file|
430
+ file.close! if file.respond_to?(:close!)
431
+ rescue StandardError
432
+ nil
433
+ end
434
+ end
435
+
436
+ def ssh_config_file
437
+ lines = []
438
+ # First value wins. Keep ProxyCommand ahead of Include so a credential in
439
+ # the deploy config is not replaced by a later file, and so it never
440
+ # appears in the ssh process arguments.
441
+ if (command = ssh_proxy_directive)
442
+ raise ConfigurationError, 'SSH proxy command cannot contain a newline' if command.match?(/[\r\n]/)
443
+
444
+ lines << "ProxyCommand #{command}"
445
+ end
446
+ lines.concat(ssh_config_include_lines)
447
+ return if lines.empty?
448
+
449
+ file = Tempfile.new(['kamal-backup-ssh-config-', '.conf'])
450
+ file.chmod(0o600)
451
+ file.write("#{lines.join("\n")}\n")
452
+ file.close
453
+ file
454
+ end
455
+
456
+ def ssh_proxy_directive
457
+ proxy = ssh_options[:proxy]
458
+ return if proxy.nil? || proxy == false
459
+ return if ssh_jump_target(proxy)
460
+
461
+ ssh_proxy_command(proxy)
462
+ end
463
+
464
+ def ssh_config_include_lines
465
+ paths = explicit_ssh_config_paths
466
+ # -F hides the files OpenSSH would read on its own. Put them back when the
467
+ # deploy config left ssh.config on and the only reason for -F is the proxy.
468
+ paths = default_ssh_config_paths if paths.empty? && ssh_options[:config] != false && ssh_proxy_directive
469
+
470
+ paths.map { |path| File.expand_path(path) }.reject(&:empty?).map do |path|
471
+ %(Include "#{path.gsub(/["\\]/) { |char| "\\#{char}" }}")
472
+ end
473
+ end
474
+
475
+ def explicit_ssh_config_paths
476
+ case ssh_options[:config]
477
+ when String
478
+ [ssh_options[:config]]
479
+ when Array
480
+ ssh_options[:config].map(&:to_s)
481
+ else
482
+ []
483
+ end
484
+ end
485
+
486
+ def default_ssh_config_paths
487
+ home = ENV.fetch('HOME', '').to_s
488
+ candidates = []
489
+ candidates << File.join(home, '.ssh', 'config') unless home.empty?
490
+ candidates << '/etc/ssh/ssh_config'
491
+ candidates << '/etc/ssh_config'
492
+ candidates.select { |path| File.file?(path) }
493
+ end
494
+
495
+ def config
496
+ @config ||= load_kamal_config(capture_kamal(kamal_config_argv).stdout)
497
+ end
498
+
499
+ # kamal config renders ssh.proxy and ssh.proxy_command as Net::SSH::Proxy
500
+ # objects. kamal-backup does not load net-ssh, so read any object whose
501
+ # class is not loaded as a Hash of its instance variables.
502
+ def load_kamal_config(yaml)
503
+ document = Psych.parse_stream(yaml).children.first
504
+ return false unless document
505
+
506
+ document.each { |node| node.tag = nil if unloaded_ruby_object?(node) }
507
+ document.to_ruby
508
+ end
509
+
510
+ def unloaded_ruby_object?(node)
511
+ return false unless node.is_a?(Psych::Nodes::Mapping) && node.tag&.start_with?('!ruby/object:')
512
+
513
+ Object.const_get(node.tag.delete_prefix('!ruby/object:'))
514
+ false
515
+ rescue NameError
516
+ true
104
517
  end
105
518
 
106
519
  def accessories
@@ -296,8 +709,6 @@ module KamalBackup
296
709
  end
297
710
 
298
711
  def live_accessory_target(accessory_name)
299
- return unless defined?(@config)
300
-
301
712
  accessory_config = accessory(accessory_name)
302
713
  host = single_accessory_host(accessory_config)
303
714
  service_name = fetch(accessory_config, :service) || default_accessory_service_name(accessory_name)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+ require_relative 'errors'
6
+
7
+ module KamalBackup
8
+ # Same-directory dump output that is created exclusively and kept owner-only.
9
+ # A predictable path opened with truncation follows a pre-created symlink and
10
+ # inherits the process umask.
11
+ class PrivateTempfile
12
+ MODE = 0o600
13
+ UNSUPPORTED_LINK_ERRORS = [Errno::EPERM, Errno::ENOTSUP, Errno::EOPNOTSUPP, Errno::EXDEV].uniq.freeze
14
+
15
+ def self.open(target_path)
16
+ directory = File.dirname(target_path)
17
+ file = Tempfile.create(
18
+ ["#{File.basename(target_path)}.kamal-backup-", '.tmp'],
19
+ directory,
20
+ mode: File::BINARY
21
+ )
22
+ file.chmod(MODE)
23
+ file
24
+ end
25
+
26
+ # File.rename always replaces the destination. Without overwrite authorization,
27
+ # File.link fails with EEXIST if that name already exists, including a file
28
+ # created after the earlier confirmation check.
29
+ def self.publish(file, target_path, overwrite:)
30
+ file.flush
31
+ file.close
32
+ if overwrite
33
+ File.rename(file.path, target_path)
34
+ else
35
+ link_exclusively(file.path, target_path)
36
+ end
37
+ end
38
+
39
+ def self.discard(file)
40
+ return unless file
41
+
42
+ file.close unless file.closed?
43
+ FileUtils.rm_f(file.path) if file.path
44
+ end
45
+
46
+ def self.link_exclusively(source, target_path)
47
+ File.link(source, target_path)
48
+ File.unlink(source)
49
+ rescue Errno::EEXIST
50
+ raise ConfigurationError, "output file already exists: #{target_path}; pass --yes to overwrite"
51
+ rescue *UNSUPPORTED_LINK_ERRORS
52
+ copy_exclusively(source, target_path)
53
+ end
54
+
55
+ # Exclusive create still refuses a name that appeared during the download.
56
+ # A failed or interrupted copy removes that new file so a partial dump is not
57
+ # left behind. copied is set only after the file closes, so a close error is
58
+ # cleaned up too. Interrupt is not a StandardError, so this uses ensure.
59
+ def self.copy_exclusively(source, target_path)
60
+ created = false
61
+ copied = false
62
+ File.open(target_path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, MODE) do |out|
63
+ created = true
64
+ out.chmod(MODE)
65
+ File.open(source, File::RDONLY | File::BINARY) { |input| IO.copy_stream(input, out) }
66
+ end
67
+ copied = true
68
+ File.unlink(source)
69
+ rescue Errno::EEXIST
70
+ raise ConfigurationError, "output file already exists: #{target_path}; pass --yes to overwrite"
71
+ ensure
72
+ remove_partial_copy(target_path) if created && !copied
73
+ end
74
+
75
+ def self.remove_partial_copy(path)
76
+ File.unlink(path)
77
+ rescue Errno::ENOENT
78
+ nil
79
+ end
80
+ private_class_method :link_exclusively, :copy_exclusively, :remove_partial_copy
81
+ end
82
+ end
@@ -32,6 +32,10 @@ module KamalBackup
32
32
  redacted
33
33
  end
34
34
 
35
+ def with_additional_secrets(values)
36
+ self.class.new(secret_values: @secret_values + Array(values), env: @env)
37
+ end
38
+
35
39
  private
36
40
 
37
41
  def known_secret_values
@@ -40,7 +44,8 @@ module KamalBackup
40
44
  values << value.to_s if key.to_s.match?(SECRET_KEY_PATTERN)
41
45
  end
42
46
 
43
- (@secret_values + env_secrets).compact.uniq.reject { |value| value.empty? || value.length < 4 }
47
+ secrets = (@secret_values + env_secrets).compact.uniq
48
+ secrets.reject { |value| value.empty? || value.length < 4 }.sort_by { |value| -value.length }
44
49
  end
45
50
  end
46
51
 
@@ -6,16 +6,19 @@ require 'json'
6
6
  require 'open3'
7
7
  require 'time'
8
8
  require_relative 'command'
9
+ require_relative 'private_tempfile'
9
10
 
10
11
  module KamalBackup
11
12
  class Restic
12
13
  RESTIC_ENV_PATTERN = /\A(?:RESTIC_|AWS_|B2_|AZURE_|GOOGLE_|RCLONE_|OS_|ST_|HP_|HTTP_|HTTPS_|NO_PROXY)/i
14
+ INCOMPLETE_SNAPSHOT_STATUS = 3
13
15
 
14
16
  attr_reader :config, :redactor
15
17
 
16
- def initialize(config, redactor:)
18
+ def initialize(config, redactor:, runner: nil)
17
19
  @config = config
18
20
  @redactor = redactor
21
+ @runner = runner
19
22
  end
20
23
 
21
24
  def ensure_repository
@@ -29,11 +32,13 @@ module KamalBackup
29
32
 
30
33
  def backup_stream(command, filename:, tags:)
31
34
  log("backing up stream as #{filename}")
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
- )
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
37
42
  end
38
43
 
39
44
  def backup_file(path, filename:, tags:)
@@ -44,34 +49,36 @@ module KamalBackup
44
49
  )
45
50
  log("backing up file content as #{filename}")
46
51
 
47
- File.open(path, 'rb') do |file|
48
- output = Command.output
49
- context = output&.command_start(command, redactor: redactor)
50
- Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
51
- stdout_reader = Thread.new do
52
- Command.collect_stream(stdout, command_output: output, context: context, stream: :stdout,
53
- redactor: redactor)
54
- end
55
- stderr_reader = Thread.new do
56
- Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr,
57
- redactor: redactor)
58
- end
59
- copy_error = nil
60
- begin
61
- IO.copy_stream(file, stdin)
62
- rescue Errno::EPIPE => e
63
- copy_error = e
64
- ensure
65
- stdin.close unless stdin.closed?
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)
66
81
  end
67
- out = stdout_reader.value
68
- err = stderr_reader.value
69
- status = wait_thread.value
70
- output&.command_exit(context, status.exitstatus)
71
- raise_command_error(command, status, out, err) unless status.success?
72
- raise_stream_error(command, copy_error, out, err) if copy_error
73
-
74
- CommandResult.new(stdout: out, stderr: err, status: status.exitstatus)
75
82
  end
76
83
  end
77
84
  rescue Errno::ENOENT => e
@@ -173,34 +180,57 @@ module KamalBackup
173
180
  pipe_commands(restic_command, command, producer_label: 'restic dump', consumer_label: command.argv.first)
174
181
  end
175
182
 
176
- def write_dump_to_path(snapshot, filename, target_path)
183
+ def write_dump_to_path(snapshot, filename, target_path, overwrite: false)
177
184
  command = CommandSpec.new(argv: ['restic', 'dump', snapshot, filename], env: restic_env)
178
185
  target_path = File.expand_path(target_path)
179
- FileUtils.mkdir_p(File.dirname(target_path))
180
- temp_path = "#{target_path}.kamal-backup-#{$PROCESS_ID}.tmp"
186
+ raise ConfigurationError, "output path must be a file, not a directory: #{target_path}" if File.directory?(target_path)
187
+
188
+ parent = File.dirname(target_path)
189
+ raise ConfigurationError, "output path directory does not exist: #{parent}" unless File.directory?(parent)
181
190
 
191
+ temp = nil
182
192
  output = Command.output
183
193
  context = output&.command_start(command, redactor: redactor)
194
+ temp = PrivateTempfile.open(target_path)
184
195
  Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
185
196
  stdin.close
186
197
  stderr_reader = Thread.new do
187
198
  Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor)
188
199
  end
189
- File.open(temp_path, 'wb') { |file| IO.copy_stream(stdout, file) }
200
+ IO.copy_stream(stdout, temp)
190
201
  err = stderr_reader.value
191
202
  status = wait_thread.value
192
203
  output&.command_exit(context, status.exitstatus)
193
204
  raise_command_error(command, status, '', err) unless status.success?
194
205
  end
195
- File.rename(temp_path, target_path)
206
+ PrivateTempfile.publish(temp, target_path, overwrite: overwrite)
196
207
  target_path
197
208
  rescue Errno::ENOENT => e
198
- FileUtils.rm_f(temp_path) if temp_path
199
209
  raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127,
200
210
  stderr: e.message)
201
- rescue StandardError
202
- FileUtils.rm_f(temp_path) if temp_path
203
- raise
211
+ ensure
212
+ PrivateTempfile.discard(temp)
213
+ end
214
+
215
+ def pipe_dump_to_io(snapshot, filename, io)
216
+ command = CommandSpec.new(argv: ['restic', 'dump', snapshot, filename], env: restic_env)
217
+ output = Command.output
218
+ context = output&.command_start(command, redactor: redactor)
219
+ Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
220
+ stdin.close
221
+ stderr_reader = Thread.new do
222
+ Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor)
223
+ end
224
+ IO.copy_stream(stdout, io)
225
+ err = stderr_reader.value
226
+ status = wait_thread.value
227
+ output&.command_exit(context, status.exitstatus)
228
+ raise_command_error(command, status, '', err) unless status.success?
229
+ end
230
+ true
231
+ rescue Errno::ENOENT => e
232
+ raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127,
233
+ stderr: e.message)
204
234
  end
205
235
 
206
236
  def restore_snapshot(snapshot, target)
@@ -208,9 +238,17 @@ module KamalBackup
208
238
  run(['restore', snapshot, '--target', target])
209
239
  end
210
240
 
241
+ def self.environment_for(config)
242
+ config.env.each_with_object({}) do |(key, value), env|
243
+ env[key] = value if key.to_s.match?(RESTIC_ENV_PATTERN)
244
+ end
245
+ end
246
+
211
247
  private
212
248
 
213
249
  def run(args, log_output: true, env: restic_env)
250
+ return @runner.call(args, log_output: log_output, env: env) if @runner
251
+
214
252
  Command.capture(
215
253
  CommandSpec.new(argv: ['restic'] + args, env: env),
216
254
  redactor: redactor,
@@ -284,9 +322,7 @@ module KamalBackup
284
322
  end
285
323
 
286
324
  def restic_env
287
- config.env.each_with_object({}) do |(key, value), env|
288
- env[key] = value if key.to_s.match?(RESTIC_ENV_PATTERN)
289
- end
325
+ self.class.environment_for(config)
290
326
  end
291
327
 
292
328
  def pipe_commands(producer, consumer, producer_label:, consumer_label:)
@@ -350,6 +386,33 @@ module KamalBackup
350
386
  stderr: e.message)
351
387
  end
352
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
+
353
416
  def raise_command_error(command, status, stdout, stderr)
354
417
  raise CommandError.new(
355
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.0.0'
4
+ VERSION = '1.1.1'
5
5
  end
data/lib/kamal_backup.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'kamal_backup/errors'
6
6
  require_relative 'kamal_backup/yaml_access'
7
7
  require_relative 'kamal_backup/command'
8
8
  require_relative 'kamal_backup/command_output'
9
+ require_relative 'kamal_backup/private_tempfile'
9
10
  require_relative 'kamal_backup/redactor'
10
11
  require_relative 'kamal_backup/config_file'
11
12
  require_relative 'kamal_backup/config'
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.0.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-08-27 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: []
@@ -134,6 +132,7 @@ files:
134
132
  - lib/kamal_backup/errors.rb
135
133
  - lib/kamal_backup/evidence.rb
136
134
  - lib/kamal_backup/kamal_bridge.rb
135
+ - lib/kamal_backup/private_tempfile.rb
137
136
  - lib/kamal_backup/rails_app.rb
138
137
  - lib/kamal_backup/redactor.rb
139
138
  - lib/kamal_backup/restic.rb
@@ -148,7 +147,6 @@ metadata:
148
147
  source_code_uri: https://github.com/crmne/kamal-backup
149
148
  changelog_uri: https://github.com/crmne/kamal-backup/releases
150
149
  rubygems_mfa_required: 'true'
151
- post_install_message:
152
150
  rdoc_options: []
153
151
  require_paths:
154
152
  - lib
@@ -163,8 +161,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
163
161
  - !ruby/object:Gem::Version
164
162
  version: '0'
165
163
  requirements: []
166
- rubygems_version: 3.5.22
167
- signing_key:
164
+ rubygems_version: 4.0.20
168
165
  specification_version: 4
169
166
  summary: Scheduled backups, restore drills, and evidence for Rails apps deployed with
170
167
  Kamal