kamal-backup 0.3.0.beta5 → 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: 0b92197e0f2d480ca2cebd5e2505304686b739ca0ed4c61c24b4b4caca39054d
4
- data.tar.gz: ff2b00e495d6690956d1ed8070bc43989ca1a9e4b9846b6503ababa26adbd01f
3
+ metadata.gz: 68d3201dc26c940938479dc76d1c0e17b206676ae69610d736a69879a78de21c
4
+ data.tar.gz: dd58ee38da83d4652515b108869e5fe441a25d80697b7db3ea1c606240a44766
5
5
  SHA512:
6
- metadata.gz: c8f93d59add03f4bf66b48ae0ea61cf46c7682c527ac604fcbd98a8c0790e6cc77d9d45759fabbfb5d6fcbce25da0e82b421f56cc8771820c57b1933539634c2
7
- data.tar.gz: ebd54357f4bdc1829a71cb76fcef4e60000661ce7eb8f4d1a974fb22438b1054fe96bfa26095509d74fd50e5a13d5e234fa0c845440c8f33ddfd49e798b307f4
6
+ metadata.gz: 37ca5f6463fe6eb76c2f6994a783e2180f3eecff24e834550743c1f9130e6ad3019ce1e296dc44ac523a36c065f1e05ac00aaaec7c7224254daea0c2d683d7a0
7
+ data.tar.gz: '0985cd5e2eb8a52db5dcc4265271eddde1513c8c8db80d0aec21ee3ca1be0bd42e2adcd60ffeb942aa7bb2d926cbad69f5d89051485ce64145f3f5c821abe3bb'
@@ -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)}",
@@ -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")
@@ -43,14 +43,17 @@ module KamalBackup
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)
@@ -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
@@ -153,12 +156,15 @@ module KamalBackup
153
156
  FileUtils.mkdir_p(File.dirname(target_path))
154
157
  temp_path = "#{target_path}.kamal-backup-#{$$}.tmp"
155
158
 
159
+ output = Command.output
160
+ context = output&.command_start(command, redactor: redactor)
156
161
  Open3.popen3(command.env, *command.argv) do |stdin, stdout, stderr, wait_thread|
157
162
  stdin.close
158
- stderr_reader = Thread.new { stderr.read }
163
+ stderr_reader = Thread.new { Command.collect_stream(stderr, command_output: output, context: context, stream: :stderr, redactor: redactor) }
159
164
  File.open(temp_path, "wb") { |file| IO.copy_stream(stdout, file) }
160
165
  err = stderr_reader.value
161
166
  status = wait_thread.value
167
+ output&.command_exit(context, status.exitstatus)
162
168
  raise_command_error(command, status, "", err) unless status.success?
163
169
  end
164
170
  File.rename(temp_path, target_path)
@@ -176,8 +182,12 @@ module KamalBackup
176
182
  run(["restore", snapshot, "--target", target])
177
183
  end
178
184
 
179
- def run(args)
180
- 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
+ )
181
191
  end
182
192
 
183
193
  def common_tags
@@ -240,13 +250,16 @@ module KamalBackup
240
250
  end
241
251
 
242
252
  def pipe_commands(producer, consumer, producer_label:, consumer_label:)
253
+ output = Command.output
254
+ producer_context = output&.command_start(producer, redactor: redactor)
243
255
  Open3.popen3(producer.env, *producer.argv) do |producer_stdin, producer_stdout, producer_stderr, producer_wait|
244
256
  producer_stdin.close
245
257
 
258
+ consumer_context = output&.command_start(consumer, redactor: redactor)
246
259
  Open3.popen3(consumer.env, *consumer.argv) do |consumer_stdin, consumer_stdout, consumer_stderr, consumer_wait|
247
- producer_err_reader = Thread.new { producer_stderr.read }
248
- consumer_out_reader = Thread.new { consumer_stdout.read }
249
- 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) }
250
263
 
251
264
  copy_error = nil
252
265
  copy_thread = Thread.new do
@@ -260,6 +273,8 @@ module KamalBackup
260
273
  copy_thread.join
261
274
  producer_status = producer_wait.value
262
275
  consumer_status = consumer_wait.value
276
+ output&.command_exit(producer_context, producer_status.exitstatus)
277
+ output&.command_exit(consumer_context, consumer_status.exitstatus)
263
278
 
264
279
  producer_err = producer_err_reader.value
265
280
  consumer_out = consumer_out_reader.value
@@ -302,7 +317,11 @@ module KamalBackup
302
317
  end
303
318
 
304
319
  def log(message)
305
- $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
306
325
  end
307
326
  end
308
327
  end
@@ -1,3 +1,3 @@
1
1
  module KamalBackup
2
- VERSION = "0.3.0.beta5"
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.beta5
4
+ version: 0.3.0.beta6
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne