kamal-backup 1.0.0 → 1.1.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: d0b99d8270f625d97d1880cb3301d81aae8bcf201f77960a49fc84de7ade4667
4
- data.tar.gz: d00ea2f74dd7b1060ea49c338841e10deb4cfaab2215e1ab4e553a74f211cdae
3
+ metadata.gz: a381b9f627864fc652f6d23b5109d5a670d0a4808f075d0d129fa2292da0eac3
4
+ data.tar.gz: aedbd53cb4486c1b2cc440e7825129d4823c948e1d8fc387b9742f3179363a80
5
5
  SHA512:
6
- metadata.gz: a41f4e714f147d3216e90f96b7b66b7082f9b9914adbfef56abbb6177ddc8931375904a9d43610fba302a0fa5b70f3565411a898786259e55ef66f5fddb4d8f2
7
- data.tar.gz: 46eeba2ce40db29124c149eeb632b59ef48462bbc5fa27c6d660e3636fc703aebb4c67edac775d6f7ad17c9cd443ede06884aaaeee614398f21918f2c0b4005c
6
+ metadata.gz: ca9c294db8d9d5445ca395f80d23d7cfa2d9eaaf26ab28e628f5ee264ef268758e7c03d504eea2cb98e54c72e3ba0f3cd999ececfe3c0475bbd434327f8244b5
7
+ data.tar.gz: 05b9046e125145ce936ab33812046aff5a69cc7286bafb355c12d323f91615b948689e6db11ec365e41457addceab11aeff1aaf38b99898793d1e743a6900d44
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
 
@@ -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
@@ -524,6 +602,22 @@ module KamalBackup
524
602
  end
525
603
  end
526
604
 
605
+ def select_database(database_name)
606
+ names = databases.map { |adapter| database_config_name(adapter) }
607
+
608
+ unless database_name.to_s.strip.empty?
609
+ return databases.find { |adapter| database_config_name(adapter) == database_name } ||
610
+ raise(
611
+ ConfigurationError,
612
+ "database #{database_name.inspect} is not configured; configured names: #{names.join(', ')}"
613
+ )
614
+ end
615
+
616
+ return databases.first if databases.one?
617
+
618
+ raise ConfigurationError, "multiple databases configured (#{names.join(', ')}); pass --database NAME"
619
+ end
620
+
527
621
  def resolve_snapshot(argument, tags:)
528
622
  if argument == 'latest'
529
623
  snapshot = restic.latest_snapshot(tags: tags)
@@ -532,8 +626,67 @@ module KamalBackup
532
626
 
533
627
  snapshot['short_id'] || snapshot['id']
534
628
  else
535
- argument
629
+ resolve_explicit_snapshot(argument, tags: tags)
536
630
  end
537
631
  end
632
+
633
+ # Each backup run writes one snapshot per database and then one file
634
+ # snapshot, each with its own ID. An explicit ID names one of them, so the
635
+ # other types come from the same run.
636
+ def resolve_explicit_snapshot(argument, tags:)
637
+ anchor = find_snapshot(argument)
638
+ snapshot = backup_run_for(anchor).find { |candidate| snapshot_tagged?(candidate, tags) }
639
+
640
+ unless snapshot
641
+ raise ConfigurationError,
642
+ "the backup containing snapshot #{argument} has no snapshot for #{tags.join(', ')}"
643
+ end
644
+
645
+ snapshot['short_id'] || snapshot['id']
646
+ end
647
+
648
+ def find_snapshot(argument)
649
+ matches = backup_snapshots.select do |snapshot|
650
+ snapshot['short_id'] == argument || snapshot['id'].to_s.start_with?(argument)
651
+ end
652
+
653
+ raise ConfigurationError, "no restic snapshot found for #{argument}" if matches.empty?
654
+ raise ConfigurationError, "snapshot ID #{argument} is ambiguous; use a longer ID" if matches.size > 1
655
+
656
+ matches.first
657
+ end
658
+
659
+ # Snapshots from one host, in time order, split into backup runs. A run
660
+ # ends after its file snapshot (always written last) or when a database
661
+ # snapshot group repeats, so a run that failed partway stays on its own.
662
+ def backup_run_for(anchor)
663
+ host_snapshots = backup_snapshots.select { |snapshot| snapshot['hostname'] == anchor['hostname'] }
664
+ runs = host_snapshots.sort_by { |snapshot| Time.parse(snapshot.fetch('time')) }.each_with_object([]) do |snapshot, grouped|
665
+ current = grouped.last
666
+ if current.nil? || current.any? { |other| run_boundary?(other, snapshot) }
667
+ grouped << [snapshot]
668
+ else
669
+ current << snapshot
670
+ end
671
+ end
672
+
673
+ runs.find { |run| run.include?(anchor) }
674
+ end
675
+
676
+ def run_boundary?(earlier, later)
677
+ snapshot_tagged?(earlier, ['type:files']) || snapshot_group(earlier) == snapshot_group(later)
678
+ end
679
+
680
+ def snapshot_group(snapshot)
681
+ Array(snapshot['tags']).select { |tag| tag.start_with?('type:', 'database:', 'adapter:') }.sort
682
+ end
683
+
684
+ def snapshot_tagged?(snapshot, tags)
685
+ (tags - Array(snapshot['tags'])).empty?
686
+ end
687
+
688
+ def backup_snapshots
689
+ @backup_snapshots ||= restic.snapshots_json
690
+ end
538
691
  end
539
692
  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?
@@ -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,6 +6,7 @@ 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
@@ -13,9 +14,10 @@ module KamalBackup
13
14
 
14
15
  attr_reader :config, :redactor
15
16
 
16
- def initialize(config, redactor:)
17
+ def initialize(config, redactor:, runner: nil)
17
18
  @config = config
18
19
  @redactor = redactor
20
+ @runner = runner
19
21
  end
20
22
 
21
23
  def ensure_repository
@@ -173,34 +175,57 @@ module KamalBackup
173
175
  pipe_commands(restic_command, command, producer_label: 'restic dump', consumer_label: command.argv.first)
174
176
  end
175
177
 
176
- def write_dump_to_path(snapshot, filename, target_path)
178
+ def write_dump_to_path(snapshot, filename, target_path, overwrite: false)
177
179
  command = CommandSpec.new(argv: ['restic', 'dump', snapshot, filename], env: restic_env)
178
180
  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"
181
+ raise ConfigurationError, "output path must be a file, not a directory: #{target_path}" if File.directory?(target_path)
181
182
 
183
+ parent = File.dirname(target_path)
184
+ raise ConfigurationError, "output path directory does not exist: #{parent}" unless File.directory?(parent)
185
+
186
+ temp = nil
182
187
  output = Command.output
183
188
  context = output&.command_start(command, redactor: redactor)
189
+ temp = PrivateTempfile.open(target_path)
184
190
  Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
185
191
  stdin.close
186
192
  stderr_reader = Thread.new do
187
193
  Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor)
188
194
  end
189
- File.open(temp_path, 'wb') { |file| IO.copy_stream(stdout, file) }
195
+ IO.copy_stream(stdout, temp)
190
196
  err = stderr_reader.value
191
197
  status = wait_thread.value
192
198
  output&.command_exit(context, status.exitstatus)
193
199
  raise_command_error(command, status, '', err) unless status.success?
194
200
  end
195
- File.rename(temp_path, target_path)
201
+ PrivateTempfile.publish(temp, target_path, overwrite: overwrite)
196
202
  target_path
197
203
  rescue Errno::ENOENT => e
198
- FileUtils.rm_f(temp_path) if temp_path
199
204
  raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127,
200
205
  stderr: e.message)
201
- rescue StandardError
202
- FileUtils.rm_f(temp_path) if temp_path
203
- raise
206
+ ensure
207
+ PrivateTempfile.discard(temp)
208
+ end
209
+
210
+ def pipe_dump_to_io(snapshot, filename, io)
211
+ command = CommandSpec.new(argv: ['restic', 'dump', snapshot, filename], env: restic_env)
212
+ output = Command.output
213
+ context = output&.command_start(command, redactor: redactor)
214
+ Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
215
+ stdin.close
216
+ stderr_reader = Thread.new do
217
+ Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor)
218
+ end
219
+ IO.copy_stream(stdout, io)
220
+ err = stderr_reader.value
221
+ status = wait_thread.value
222
+ output&.command_exit(context, status.exitstatus)
223
+ raise_command_error(command, status, '', err) unless status.success?
224
+ end
225
+ true
226
+ rescue Errno::ENOENT => e
227
+ raise CommandError.new("command not found: #{command.argv.first}", command: command, status: 127,
228
+ stderr: e.message)
204
229
  end
205
230
 
206
231
  def restore_snapshot(snapshot, target)
@@ -208,9 +233,17 @@ module KamalBackup
208
233
  run(['restore', snapshot, '--target', target])
209
234
  end
210
235
 
236
+ def self.environment_for(config)
237
+ config.env.each_with_object({}) do |(key, value), env|
238
+ env[key] = value if key.to_s.match?(RESTIC_ENV_PATTERN)
239
+ end
240
+ end
241
+
211
242
  private
212
243
 
213
244
  def run(args, log_output: true, env: restic_env)
245
+ return @runner.call(args, log_output: log_output, env: env) if @runner
246
+
214
247
  Command.capture(
215
248
  CommandSpec.new(argv: ['restic'] + args, env: env),
216
249
  redactor: redactor,
@@ -284,9 +317,7 @@ module KamalBackup
284
317
  end
285
318
 
286
319
  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
320
+ self.class.environment_for(config)
290
321
  end
291
322
 
292
323
  def pipe_commands(producer, consumer, producer_label:, consumer_label:)
@@ -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.0'
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,14 @@
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-27 00:00:00.000000000 Z
11
+ date: 2026-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -134,6 +134,7 @@ files:
134
134
  - lib/kamal_backup/errors.rb
135
135
  - lib/kamal_backup/evidence.rb
136
136
  - lib/kamal_backup/kamal_bridge.rb
137
+ - lib/kamal_backup/private_tempfile.rb
137
138
  - lib/kamal_backup/rails_app.rb
138
139
  - lib/kamal_backup/redactor.rb
139
140
  - lib/kamal_backup/restic.rb