kamal-backup 0.3.0.beta4 → 0.3.0.beta6

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: 78778951b0506ad5ef03f4e644f19a76ab1f7a882512f2a65cf23e1e53557669
4
- data.tar.gz: 24c346091e522d02a9233bdffec2b86e7acc3e704485498039d6506fc9780f77
3
+ metadata.gz: 68d3201dc26c940938479dc76d1c0e17b206676ae69610d736a69879a78de21c
4
+ data.tar.gz: dd58ee38da83d4652515b108869e5fe441a25d80697b7db3ea1c606240a44766
5
5
  SHA512:
6
- metadata.gz: a7642dd936a98360f3fb2230a7886878342ba4bb5c68df5ee250bb11ad6d917cfafcc72f86a9d5759edba35d928c298d4639eabab0b252ed54bf2f1e7fad67af
7
- data.tar.gz: 24bd2e79792381abf43a3f9f846eb2665596d1b67e4a47cd687717009c119ab51a830447ee52dc37ce40ae10da19bc08ce0d75ce2dccf1d1cf3e289c910ecdc3
6
+ metadata.gz: 37ca5f6463fe6eb76c2f6994a783e2180f3eecff24e834550743c1f9130e6ad3019ce1e296dc44ac523a36c065f1e05ac00aaaec7c7224254daea0c2d683d7a0
7
+ data.tar.gz: '0985cd5e2eb8a52db5dcc4265271eddde1513c8c8db80d0aec21ee3ca1be0bd42e2adcd60ffeb942aa7bb2d926cbad69f5d89051485ce64145f3f5c821abe3bb'
data/README.md CHANGED
@@ -141,7 +141,7 @@ Run the release helper from a clean `master` checkout:
141
141
  bin/release 0.2.9
142
142
  ```
143
143
 
144
- It updates `lib/kamal_backup/version.rb`, runs the test suite and docs build, commits `Release 0.2.9`, and pushes `master`. CI publishes the RubyGem and Docker image tags first, then creates `v0.2.9`, the GitHub release, and the docs deployment from the release commit.
144
+ It updates `lib/kamal_backup/version.rb`, syncs `Gemfile.lock`, commits `Release 0.2.9`, and pushes `master`. CI runs the test suite and docs build, publishes the RubyGem and Docker image tags, then creates `v0.2.9`, the GitHub release, and the docs deployment from the release commit.
145
145
 
146
146
  Use `bin/release 0.2.9 --no-push` to prepare the commit locally without publishing.
147
147
 
@@ -31,10 +31,9 @@ module KamalBackup
31
31
  config.validate_backup
32
32
  require_restic!
33
33
 
34
- timestamp = current_timestamp
35
34
  restic.ensure_repository
36
- databases.each { |database| database.backup(restic, timestamp) }
37
- restic.backup_paths(config.backup_paths, tags: ["type:files", "run:#{timestamp}"])
35
+ databases.each { |database| database.backup(restic) }
36
+ restic.backup_paths(config.backup_paths, tags: ["type:files"])
38
37
 
39
38
  if config.forget_after_backup?
40
39
  restic.forget_after_success
@@ -135,10 +134,6 @@ module KamalBackup
135
134
  end
136
135
 
137
136
  private
138
- def current_timestamp
139
- Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
140
- end
141
-
142
137
  def build_restore_result(scope, snapshot)
143
138
  started_at = Time.now.utc
144
139
  result = Schema.record(
@@ -20,11 +20,17 @@ module KamalBackup
20
20
  end
21
21
 
22
22
  def direct_app
23
- @direct_app ||= App.new(config: Config.new(env: command_env), redactor: redactor)
23
+ @direct_app ||= App.new(
24
+ config: Config.new(env: command_env),
25
+ redactor: redactor
26
+ )
24
27
  end
25
28
 
26
- def local_app
27
- @local_app ||= App.new(config: local_command_config, redactor: redactor)
29
+ def local_restore_app
30
+ @local_restore_app ||= App.new(
31
+ config: local_command_config,
32
+ redactor: redactor
33
+ )
28
34
  end
29
35
 
30
36
  def local_preferences
@@ -65,7 +71,9 @@ module KamalBackup
65
71
  redactor: redactor,
66
72
  config_file: options[:config_file],
67
73
  destination: options[:destination],
68
- env: command_env
74
+ env: command_env,
75
+ stdout: $stdout,
76
+ stderr: $stderr
69
77
  )
70
78
  end
71
79
 
@@ -94,9 +102,11 @@ module KamalBackup
94
102
 
95
103
  result = bridge.execute_on_accessory(
96
104
  accessory_name: accessory_name,
97
- command: Shellwords.join(argv)
105
+ command: Shellwords.join(argv),
106
+ stream: true
98
107
  )
99
- print(result.stdout)
108
+ print(result.stdout) unless result.streamed
109
+ $stderr.print(result.stderr) if !result.streamed && !result.stderr.empty?
100
110
  result
101
111
  end
102
112
 
@@ -276,7 +286,7 @@ module KamalBackup
276
286
  desc "local [SNAPSHOT]", "Restore the backup into the local database and Active Storage path"
277
287
  def local(snapshot = "latest")
278
288
  confirm!("Restore #{snapshot} into the local database and Active Storage path? This will overwrite local data.")
279
- puts(JSON.pretty_generate(local_app.restore_to_local_machine(snapshot)))
289
+ puts(JSON.pretty_generate(local_restore_app.restore_to_local_machine(snapshot)))
280
290
  end
281
291
 
282
292
  method_option :"confirm-production-restore", type: :boolean, default: false, desc: "Confirm production restore without interactive prompts"
@@ -301,9 +311,9 @@ module KamalBackup
301
311
  desc "local [SNAPSHOT]", "Run a restore drill on the local machine"
302
312
  def local(snapshot = "latest")
303
313
  confirm!("Run a local restore drill for #{snapshot}? This will overwrite local data.")
304
- result = local_app.drill_on_local_machine(snapshot, check_command: options[:check])
314
+ result = local_restore_app.drill_on_local_machine(snapshot, check_command: options[:check])
305
315
  puts(JSON.pretty_generate(result))
306
- exit(1) if local_app.drill_failed?(result)
316
+ exit(1) if local_restore_app.drill_failed?(result)
307
317
  end
308
318
 
309
319
  method_option :database, type: :string, desc: "Scratch database name for PostgreSQL or MySQL"
@@ -389,7 +399,9 @@ module KamalBackup
389
399
 
390
400
  def self.start(argv = ARGV, env: ENV)
391
401
  self.command_env = env
392
- super(normalize_global_options(argv))
402
+ Command.with_output(CommandOutput.new(io: $stderr)) do
403
+ super(normalize_global_options(argv))
404
+ end
393
405
  rescue Error => e
394
406
  warn("kamal-backup: #{Redactor.new(env: env).redact_string(e.message)}")
395
407
  exit(1)
@@ -1,4 +1,6 @@
1
1
  require "open3"
2
+ require "securerandom"
3
+ require "shellwords"
2
4
  require_relative "errors"
3
5
 
4
6
  module KamalBackup
@@ -18,14 +20,109 @@ module KamalBackup
18
20
 
19
21
  def display(redactor)
20
22
  env_prefix = env.keys.sort.map { |key| "#{key}=#{redactor.redact_value(key, env[key])}" }
21
- redactor.redact_string((env_prefix + argv).join(" "))
23
+ redactor.redact_string((env_prefix + [argv.shelljoin]).join(" "))
22
24
  end
23
25
  end
24
26
 
25
- CommandResult = Struct.new(:stdout, :stderr, :status, keyword_init: true)
27
+ CommandResult = Struct.new(:stdout, :stderr, :status, :streamed, keyword_init: true)
28
+
29
+ class CommandOutput
30
+ def initialize(io: $stdout)
31
+ @io = io
32
+ @mutex = Mutex.new
33
+ @buffers = {}
34
+ end
35
+
36
+ def info(message, redactor:)
37
+ write_line(" INFO #{redactor.redact_string(message)}")
38
+ end
39
+
40
+ def command_start(spec, redactor:)
41
+ id = SecureRandom.hex(4)
42
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ display = spec.display(redactor)
44
+
45
+ write_line(" INFO [#{id}] Running #{display} locally")
46
+ write_line(" DEBUG [#{id}] Command: #{display}")
47
+
48
+ { id: id, started_at: started_at, redactor: redactor }
49
+ end
50
+
51
+ def command_output(context, _stream, data, redactor:)
52
+ raw = data.to_s
53
+ return if raw.empty?
54
+
55
+ synchronize do
56
+ key = [context.fetch(:id), _stream]
57
+ @buffers[key] = "#{@buffers[key]}#{raw}"
58
+ flush_complete_output_lines(context, key, redactor: redactor)
59
+ end
60
+ end
61
+
62
+ def command_exit(context, status)
63
+ runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - context.fetch(:started_at)
64
+ result = status.to_i.zero? ? "successful" : "failed"
65
+
66
+ synchronize do
67
+ flush_output_buffers(context)
68
+ @io.puts(" INFO [#{context.fetch(:id)}] Finished in #{format("%.3f seconds", runtime)} with exit status #{status} (#{result}).")
69
+ end
70
+ end
71
+
72
+ private
73
+ def write_line(message)
74
+ synchronize { @io.puts(message) }
75
+ end
76
+
77
+ def synchronize(&block)
78
+ @mutex.synchronize(&block)
79
+ end
80
+
81
+ def flush_complete_output_lines(context, key, redactor:)
82
+ buffer = @buffers.fetch(key)
83
+ output = +""
84
+
85
+ while (index = buffer.index("\n"))
86
+ output << buffer.slice!(0..index)
87
+ end
88
+
89
+ @buffers[key] = buffer
90
+ write_output(context, output, redactor: redactor) unless output.empty?
91
+ end
92
+
93
+ def flush_output_buffers(context)
94
+ id = context.fetch(:id)
95
+ keys = @buffers.keys.select { |key_id, _stream| key_id == id }
96
+
97
+ keys.each do |key|
98
+ output = @buffers.delete(key)
99
+ next if output.to_s.empty?
100
+
101
+ write_output(context, output, redactor: context.fetch(:redactor))
102
+ @io.puts unless output.end_with?("\n")
103
+ end
104
+ end
105
+
106
+ def write_output(context, output, redactor:)
107
+ redactor.redact_string(output).each_line do |line|
108
+ @io.print(" DEBUG [#{context.fetch(:id)}] \t#{line}")
109
+ end
110
+ @io.flush if @io.respond_to?(:flush)
111
+ end
112
+ end
26
113
 
27
114
  class Command
28
115
  class << self
116
+ attr_accessor :output
117
+
118
+ def with_output(output)
119
+ previous_output = self.output
120
+ self.output = output
121
+ yield
122
+ ensure
123
+ self.output = previous_output
124
+ end
125
+
29
126
  def available?(name)
30
127
  ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
31
128
  path = File.join(dir, name)
@@ -33,9 +130,23 @@ module KamalBackup
33
130
  end
34
131
  end
35
132
 
36
- def capture(spec, input: nil, redactor:)
37
- stdout, stderr, status = Open3.capture3(spec.env, *spec.argv, stdin_data: input)
38
- result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus)
133
+ def capture(spec, input: nil, redactor:, log: true, log_output: true, tee_stdout: nil, tee_stderr: nil)
134
+ output = log ? self.output : nil
135
+ return capture_quietly(spec, input: input, redactor: redactor) unless output || tee_stdout || tee_stderr
136
+
137
+ context = output&.command_start(spec, redactor: redactor)
138
+ stdout, stderr, status = popen_capture(
139
+ spec,
140
+ input: input,
141
+ redactor: redactor,
142
+ output: output,
143
+ context: context,
144
+ log_output: log_output,
145
+ tee_stdout: tee_stdout,
146
+ tee_stderr: tee_stderr
147
+ )
148
+ output&.command_exit(context, status.exitstatus)
149
+ result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus, streamed: !!(tee_stdout || tee_stderr))
39
150
 
40
151
  if status.success?
41
152
  result
@@ -46,7 +157,72 @@ module KamalBackup
46
157
  raise command_not_found(spec, e)
47
158
  end
48
159
 
160
+ def collect_stream(io, command_output: self.output, context: nil, stream: :stdout, log_output: true, tee_io: nil, redactor: nil)
161
+ captured_output = +""
162
+ tee_buffer = +""
163
+
164
+ loop do
165
+ chunk = io.readpartial(16 * 1024)
166
+ captured_output << chunk
167
+ command_output&.command_output(context, stream, chunk, redactor: redactor) if log_output && context
168
+ tee_buffer = tee_stream(tee_io, redactor, tee_buffer, chunk) if tee_io
169
+ rescue EOFError
170
+ flush_tee_stream(tee_io, redactor, tee_buffer) if tee_io
171
+ break
172
+ end
173
+
174
+ captured_output
175
+ end
176
+
49
177
  private
178
+ def tee_stream(io, redactor, buffer, chunk)
179
+ buffer << chunk
180
+
181
+ while (index = buffer.index("\n"))
182
+ io.print(redactor.redact_string(buffer.slice!(0..index)))
183
+ end
184
+
185
+ io.flush if io.respond_to?(:flush)
186
+ buffer
187
+ end
188
+
189
+ def flush_tee_stream(io, redactor, buffer)
190
+ return if buffer.empty?
191
+
192
+ io.print(redactor.redact_string(buffer))
193
+ io.flush if io.respond_to?(:flush)
194
+ end
195
+
196
+ def capture_quietly(spec, input:, redactor:)
197
+ stdout, stderr, status = Open3.capture3(spec.env, *spec.argv, stdin_data: input)
198
+ result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus, streamed: false)
199
+
200
+ if status.success?
201
+ result
202
+ else
203
+ raise command_failure(spec, status.exitstatus, stdout, stderr, redactor)
204
+ end
205
+ end
206
+
207
+ def popen_capture(spec, input:, redactor:, output:, context:, log_output:, tee_stdout:, tee_stderr:)
208
+ Open3.popen3(spec.env, *spec.argv) do |stdin, stdout, stderr, wait_thread|
209
+ stdin_writer = Thread.new do
210
+ stdin.write(input) if input
211
+ ensure
212
+ stdin.close unless stdin.closed?
213
+ end
214
+ stdout_reader = Thread.new do
215
+ collect_stream(stdout, command_output: output, context: context, stream: :stdout, log_output: log_output, tee_io: tee_stdout, redactor: redactor)
216
+ end
217
+ stderr_reader = Thread.new do
218
+ collect_stream(stderr, command_output: output, context: context, stream: :stderr, log_output: log_output, tee_io: tee_stderr, redactor: redactor)
219
+ end
220
+
221
+ stdin_writer.join
222
+ [stdout_reader.value, stderr_reader.value, wait_thread.value]
223
+ end
224
+ end
225
+
50
226
  def command_failure(spec, status, stdout, stderr, redactor)
51
227
  CommandError.new(
52
228
  "command failed (#{status}): #{spec.display(redactor)}\n#{redactor.redact_string(stderr)}",
@@ -24,11 +24,11 @@ module KamalBackup
24
24
  @redactor = redactor
25
25
  end
26
26
 
27
- def backup(restic, timestamp)
27
+ def backup(restic)
28
28
  restic.backup_stream(
29
29
  dump_command,
30
- filename: database_filename(timestamp),
31
- tags: backup_tags(timestamp)
30
+ filename: database_filename,
31
+ tags: backup_tags
32
32
  )
33
33
  end
34
34
 
@@ -41,14 +41,14 @@ module KamalBackup
41
41
  restic.pipe_dump_to_command(snapshot, filename, scratch_restore_command(target))
42
42
  end
43
43
 
44
- def database_filename(timestamp)
44
+ def database_filename
45
45
  app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
46
46
  database = config.database_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
47
- "databases-#{app}-#{database}-#{adapter_name}-#{timestamp}.#{dump_extension}"
47
+ "databases/#{app}/#{database}/#{adapter_name}.#{dump_extension}"
48
48
  end
49
49
 
50
- def backup_tags(timestamp)
51
- ["type:database", "database:#{config.database_name}", "adapter:#{adapter_name}", "run:#{timestamp}"]
50
+ def backup_tags
51
+ ["type:database", "database:#{config.database_name}", "adapter:#{adapter_name}"]
52
52
  end
53
53
 
54
54
  def adapter_name
@@ -13,7 +13,7 @@ module KamalBackup
13
13
  "sqlite3"
14
14
  end
15
15
 
16
- def backup(restic, timestamp)
16
+ def backup(restic)
17
17
  source = sqlite_source
18
18
  Tempfile.create(["kamal-backup-", ".sqlite3"]) do |tempfile|
19
19
  tempfile.close
@@ -23,8 +23,8 @@ module KamalBackup
23
23
  )
24
24
  restic.backup_file(
25
25
  tempfile.path,
26
- filename: database_filename(timestamp),
27
- tags: backup_tags(timestamp)
26
+ filename: database_filename,
27
+ tags: backup_tags
28
28
  )
29
29
  end
30
30
  end
@@ -7,12 +7,14 @@ module KamalBackup
7
7
  DEFAULT_CONFIG_FILE = "config/deploy.yml"
8
8
  VERSION_LINE_PATTERN = /\A\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*\z/
9
9
 
10
- def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd)
10
+ def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd, stdout: $stdout, stderr: $stderr)
11
11
  @redactor = redactor
12
12
  @config_file = config_file
13
13
  @destination = destination
14
14
  @env = env
15
15
  @cwd = cwd
16
+ @stdout = stdout
17
+ @stderr = stderr
16
18
  end
17
19
 
18
20
  def accessory_name(preferred: nil)
@@ -50,8 +52,8 @@ module KamalBackup
50
52
  accessory_secret_placeholders(accessory_name).merge(accessory_clear_env(accessory_name))
51
53
  end
52
54
 
53
- def execute_on_accessory(accessory_name:, command:)
54
- capture_kamal(kamal_exec_argv(accessory_name, command))
55
+ def execute_on_accessory(accessory_name:, command:, stream: false)
56
+ capture_kamal(kamal_exec_argv(accessory_name, command), stream: stream)
55
57
  end
56
58
 
57
59
  def remote_version(accessory_name:)
@@ -212,13 +214,19 @@ module KamalBackup
212
214
  argv
213
215
  end
214
216
 
215
- def capture_kamal(argv)
217
+ def capture_kamal(argv, stream: false)
216
218
  spec = CommandSpec.new(argv: argv)
219
+ options = {
220
+ redactor: @redactor,
221
+ log_output: false,
222
+ tee_stdout: stream ? @stdout : nil,
223
+ tee_stderr: stream ? @stderr : nil
224
+ }
217
225
 
218
226
  if defined?(Bundler)
219
- Bundler.with_unbundled_env { Command.capture(spec, redactor: @redactor) }
227
+ Bundler.with_unbundled_env { Command.capture(spec, **options) }
220
228
  else
221
- Command.capture(spec, redactor: @redactor)
229
+ Command.capture(spec, **options)
222
230
  end
223
231
  end
224
232
 
@@ -16,7 +16,7 @@ module KamalBackup
16
16
  end
17
17
 
18
18
  def ensure_repository
19
- run(%w[snapshots --json])
19
+ run(%w[snapshots --json], log_output: false)
20
20
  rescue CommandError => e
21
21
  if config.restic_init_if_missing?
22
22
  log("restic repository not ready, running restic init")
@@ -28,7 +28,7 @@ module KamalBackup
28
28
 
29
29
  def backup_stream(command, filename:, tags:)
30
30
  restic_command = CommandSpec.new(
31
- argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
31
+ argv: ["restic", "backup"] + host_args + ["--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
32
32
  env: restic_env
33
33
  )
34
34
  log("backing up stream as #{filename}")
@@ -37,20 +37,23 @@ module KamalBackup
37
37
 
38
38
  def backup_file(path, filename:, tags:)
39
39
  command = CommandSpec.new(
40
- argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
40
+ argv: ["restic", "backup"] + host_args + ["--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
41
41
  env: restic_env
42
42
  )
43
43
  log("backing up file content as #{filename}")
44
44
 
45
45
  File.open(path, "rb") do |file|
46
+ output = Command.output
47
+ context = output&.command_start(command, redactor: redactor)
46
48
  Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
47
- stdout_reader = Thread.new { stdout.read }
48
- stderr_reader = Thread.new { stderr.read }
49
+ stdout_reader = Thread.new { Command.collect_stream(stdout, command_output: output, context: context, stream: :stdout, redactor: redactor) }
50
+ stderr_reader = Thread.new { Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor) }
49
51
  IO.copy_stream(file, stdin)
50
52
  stdin.close
51
53
  out = stdout_reader.value
52
54
  err = stderr_reader.value
53
55
  status = wait_thread.value
56
+ output&.command_exit(context, status.exitstatus)
54
57
  raise_command_error(command, status, out, err) unless status.success?
55
58
 
56
59
  CommandResult.new(stdout: out, stderr: err, status: status.exitstatus)
@@ -66,7 +69,7 @@ module KamalBackup
66
69
  if paths.any?
67
70
  path_tags = paths.map { |path| "path:#{config.backup_path_label(path)}" }
68
71
  log("backing up #{paths.size} file path(s): #{paths.join(", ")}")
69
- run(["backup"] + paths + tag_args(common_tags + tags + path_tags))
72
+ run(["backup"] + host_args + paths + tag_args(common_tags + tags + path_tags))
70
73
  end
71
74
  end
72
75
 
@@ -99,7 +102,7 @@ module KamalBackup
99
102
  end
100
103
 
101
104
  def snapshots_json(tags: common_tags)
102
- output = run(["snapshots", "--json"] + filter_tag_args(tags)).stdout
105
+ output = run(["snapshots", "--json"] + filter_tag_args(tags), log_output: false).stdout
103
106
  snapshots = JSON.parse(output)
104
107
  required_tags = tags.compact
105
108
  snapshots.select do |snapshot|
@@ -116,7 +119,7 @@ module KamalBackup
116
119
  end
117
120
 
118
121
  def ls_json(snapshot)
119
- output = run(["ls", "--json", snapshot]).stdout
122
+ output = run(["ls", "--json", snapshot], log_output: false).stdout
120
123
  output.lines.filter_map do |line|
121
124
  JSON.parse(line)
122
125
  rescue JSON::ParserError
@@ -128,13 +131,15 @@ module KamalBackup
128
131
  legacy_prefix = "databases/#{config.app_name}/#{adapter}/"
129
132
  app = config.app_name.gsub(/[^A-Za-z0-9_.-]+/, "-")
130
133
  database = database_name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "-")
134
+ stable_prefix = database.empty? ? nil : "databases/#{app}/#{database}/#{adapter}."
131
135
  flat_prefix = "databases-#{app}-#{adapter}-"
132
136
  named_flat_prefix = database.empty? ? nil : "databases-#{app}-#{database}-#{adapter}-"
133
137
  ls_json(snapshot).find do |entry|
134
138
  next false unless entry["type"] == "file"
135
139
 
136
140
  normalized = entry["path"].to_s.sub(%r{\A/+}, "")
137
- normalized.start_with?(legacy_prefix) ||
141
+ (stable_prefix && normalized.start_with?(stable_prefix)) ||
142
+ normalized.start_with?(legacy_prefix) ||
138
143
  File.basename(normalized).start_with?(flat_prefix) ||
139
144
  (named_flat_prefix && File.basename(normalized).start_with?(named_flat_prefix))
140
145
  end&.fetch("path")
@@ -151,12 +156,15 @@ module KamalBackup
151
156
  FileUtils.mkdir_p(File.dirname(target_path))
152
157
  temp_path = "#{target_path}.kamal-backup-#{$$}.tmp"
153
158
 
159
+ output = Command.output
160
+ context = output&.command_start(command, redactor: redactor)
154
161
  Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
155
162
  stdin.close
156
- stderr_reader = Thread.new { stderr.read }
163
+ stderr_reader = Thread.new { Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor) }
157
164
  File.open(temp_path, "wb") { |file| IO.copy_stream(stdout, file) }
158
165
  err = stderr_reader.value
159
166
  status = wait_thread.value
167
+ output&.command_exit(context, status.exitstatus)
160
168
  raise_command_error(command, status, "", err) unless status.success?
161
169
  end
162
170
  File.rename(temp_path, target_path)
@@ -174,8 +182,12 @@ module KamalBackup
174
182
  run(["restore", snapshot, "--target", target])
175
183
  end
176
184
 
177
- def run(args)
178
- Command.capture(CommandSpec.new(argv: ["restic"] + args, env: restic_env), redactor: redactor)
185
+ def run(args, log_output: true)
186
+ Command.capture(
187
+ CommandSpec.new(argv: ["restic"] + args, env: restic_env),
188
+ redactor: redactor,
189
+ log_output: log_output
190
+ )
179
191
  end
180
192
 
181
193
  def common_tags
@@ -213,6 +225,19 @@ module KamalBackup
213
225
  tags.compact.each_with_object([]) { |tag, args| args.concat(["--tag", tag]) }
214
226
  end
215
227
 
228
+ def host_args
229
+ ["--host", restic_host]
230
+ end
231
+
232
+ def restic_host
233
+ normalize_restic_host([config.app_name, config.accessory_name || "backup"].compact.join("-"))
234
+ end
235
+
236
+ def normalize_restic_host(value)
237
+ normalized = value.to_s.gsub(/[^A-Za-z0-9_.-]+/, "-").gsub(/\A-+|-+\z/, "")
238
+ normalized.empty? ? "kamal-backup" : normalized
239
+ end
240
+
216
241
  def filter_tag_args(tags)
217
242
  tags = tags.compact
218
243
  tags.empty? ? [] : ["--tag", tags.join(",")]
@@ -225,13 +250,16 @@ module KamalBackup
225
250
  end
226
251
 
227
252
  def pipe_commands(producer, consumer, producer_label:, consumer_label:)
253
+ output = Command.output
254
+ producer_context = output&.command_start(producer, redactor: redactor)
228
255
  Open3.popen3(producer.env, *producer.argv) do |producer_stdin, producer_stdout, producer_stderr, producer_wait|
229
256
  producer_stdin.close
230
257
 
258
+ consumer_context = output&.command_start(consumer, redactor: redactor)
231
259
  Open3.popen3(consumer.env, *consumer.argv) do |consumer_stdin, consumer_stdout, consumer_stderr, consumer_wait|
232
- producer_err_reader = Thread.new { producer_stderr.read }
233
- consumer_out_reader = Thread.new { consumer_stdout.read }
234
- consumer_err_reader = Thread.new { consumer_stderr.read }
260
+ producer_err_reader = Thread.new { Command.collect_stream(producer_stderr, command_output: output, context: producer_context, stream: :stderr, redactor: redactor) }
261
+ consumer_out_reader = Thread.new { Command.collect_stream(consumer_stdout, command_output: output, context: consumer_context, stream: :stdout, redactor: redactor) }
262
+ consumer_err_reader = Thread.new { Command.collect_stream(consumer_stderr, command_output: output, context: consumer_context, stream: :stderr, redactor: redactor) }
235
263
 
236
264
  copy_error = nil
237
265
  copy_thread = Thread.new do
@@ -245,6 +273,8 @@ module KamalBackup
245
273
  copy_thread.join
246
274
  producer_status = producer_wait.value
247
275
  consumer_status = consumer_wait.value
276
+ output&.command_exit(producer_context, producer_status.exitstatus)
277
+ output&.command_exit(consumer_context, consumer_status.exitstatus)
248
278
 
249
279
  producer_err = producer_err_reader.value
250
280
  consumer_out = consumer_out_reader.value
@@ -287,7 +317,11 @@ module KamalBackup
287
317
  end
288
318
 
289
319
  def log(message)
290
- $stdout.puts("[kamal-backup] #{redactor.redact_string(message)}")
320
+ if Command.output
321
+ Command.output.info(message, redactor: redactor)
322
+ else
323
+ $stdout.puts("[kamal-backup] #{redactor.redact_string(message)}")
324
+ end
291
325
  end
292
326
  end
293
327
  end
@@ -1,3 +1,3 @@
1
1
  module KamalBackup
2
- VERSION = "0.3.0.beta4"
2
+ VERSION = "0.3.0.beta6"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0.beta4
4
+ version: 0.3.0.beta6
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne