kamal-backup 0.3.0.beta21 → 0.3.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.
@@ -1,8 +1,9 @@
1
- require "open3"
2
- require "pty"
3
- require "securerandom"
4
- require "shellwords"
5
- require_relative "errors"
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'pty'
5
+ require 'shellwords'
6
+ require_relative 'errors'
6
7
 
7
8
  module KamalBackup
8
9
  class CommandSpec
@@ -17,198 +18,17 @@ module KamalBackup
17
18
  result[key.to_s] = value.to_s
18
19
  end
19
20
 
20
- raise ArgumentError, "command argv cannot be empty" if @argv.empty?
21
+ raise ArgumentError, 'command argv cannot be empty' if @argv.empty?
21
22
  end
22
23
 
23
24
  def display(redactor)
24
25
  env_prefix = env.keys.sort.map { |key| "#{key}=#{redactor.redact_value(key, env[key])}" }
25
- redactor.redact_string((env_prefix + [argv.shelljoin]).join(" "))
26
+ redactor.redact_string((env_prefix + [argv.shelljoin]).join(' '))
26
27
  end
27
28
  end
28
29
 
29
30
  CommandResult = Struct.new(:stdout, :stderr, :status, :streamed, keyword_init: true)
30
31
 
31
- class CommandOutput
32
- LEVELS = {
33
- "DEBUG" => 0,
34
- "INFO" => 1,
35
- "WARN" => 2,
36
- "ERROR" => 3,
37
- "FATAL" => 4
38
- }.freeze
39
- LEVEL_COLORS = {
40
- "DEBUG" => :black,
41
- "INFO" => :blue,
42
- "WARN" => :yellow,
43
- "ERROR" => :red,
44
- "FATAL" => :red
45
- }.freeze
46
- COLOR_CODES = {
47
- black: 30,
48
- red: 31,
49
- green: 32,
50
- yellow: 33,
51
- blue: 34,
52
- magenta: 35,
53
- cyan: 36,
54
- white: 37,
55
- light_black: 90,
56
- light_red: 91,
57
- light_green: 92,
58
- light_yellow: 93,
59
- light_blue: 94,
60
- light_magenta: 95,
61
- light_cyan: 96,
62
- light_white: 97
63
- }.freeze
64
-
65
- def initialize(io: $stdout, env: ENV, verbosity: :info)
66
- @io = io
67
- @env = env
68
- @verbosity = LEVELS.fetch(verbosity.to_s.upcase)
69
- @mutex = Mutex.new
70
- @buffers = {}
71
- end
72
-
73
- def info(message, redactor:)
74
- write_message("INFO", redactor.redact_string(message))
75
- end
76
-
77
- def error(message, redactor:)
78
- write_message("ERROR", colorize(redactor.redact_string(message), :red, :bold))
79
- end
80
-
81
- def decorate(value, color, mode = nil)
82
- colorize(value, color, mode)
83
- end
84
-
85
- def command_start(spec, redactor:)
86
- id = SecureRandom.hex(4)
87
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
- display = spec.display(redactor)
89
-
90
- write_message("INFO", "Running #{colorize(display, :yellow, :bold)} #{target_for(spec)}", id)
91
- write_message("DEBUG", "Command: #{colorize(display, :blue)}", id)
92
-
93
- { id: id, started_at: started_at, redactor: redactor }
94
- end
95
-
96
- def command_output(context, _stream, data, redactor:)
97
- raw = data.to_s
98
- return if raw.empty?
99
- return unless log_level?("DEBUG")
100
-
101
- synchronize do
102
- key = [context.fetch(:id), _stream]
103
- @buffers[key] = "#{@buffers[key]}#{raw}"
104
- flush_complete_output_lines(context, key, redactor: redactor)
105
- end
106
- end
107
-
108
- def command_exit(context, status)
109
- runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - context.fetch(:started_at)
110
- result = status.to_i.zero? ? "successful" : "failed"
111
- result_color = status.to_i.zero? ? :green : :red
112
-
113
- synchronize do
114
- flush_output_buffers(context)
115
- write_message_unlocked("INFO", "Finished in #{format("%.3f seconds", runtime)} with exit status #{status} (#{colorize(result, result_color, :bold)}).", context.fetch(:id))
116
- end
117
- end
118
-
119
- private
120
- def write_message(level, message, id = nil)
121
- return unless log_level?(level)
122
-
123
- synchronize { write_message_unlocked(level, message, id) }
124
- end
125
-
126
- def write_message_unlocked(level, message, id = nil)
127
- @io.puts(format_message(level, message, id)) if log_level?(level)
128
- end
129
-
130
- def synchronize(&block)
131
- @mutex.synchronize(&block)
132
- end
133
-
134
- def flush_complete_output_lines(context, key, redactor:)
135
- buffer = @buffers.fetch(key)
136
- output = +""
137
-
138
- while (index = buffer.index("\n"))
139
- output << buffer.slice!(0..index)
140
- end
141
-
142
- @buffers[key] = buffer
143
- write_output(context, output, redactor: redactor, stream: key.last) unless output.empty?
144
- end
145
-
146
- def flush_output_buffers(context)
147
- id = context.fetch(:id)
148
- keys = @buffers.keys.select { |key_id, _stream| key_id == id }
149
-
150
- keys.each do |key|
151
- output = @buffers.delete(key)
152
- next if output.to_s.empty?
153
-
154
- write_output(context, output, redactor: context.fetch(:redactor), stream: key.last)
155
- end
156
- end
157
-
158
- def write_output(context, output, redactor:, stream: nil)
159
- color = stream == :stderr ? :red : :green
160
-
161
- redactor.redact_string(output).each_line do |line|
162
- write_message_unlocked("DEBUG", colorize("\t#{line}".chomp, color), context.fetch(:id))
163
- end
164
- @io.flush if @io.respond_to?(:flush)
165
- end
166
-
167
- def format_message(level, message, id = nil)
168
- message = "[#{colorize(id, :green)}] #{message}" if id
169
- "#{colorize(level.rjust(6), LEVEL_COLORS.fetch(level))} #{message}"
170
- end
171
-
172
- def local_target
173
- if remote_host = @env["KAMAL_HOST"].to_s
174
- return "on #{colorize(remote_host, :blue)}" unless remote_host.empty?
175
- end
176
-
177
- user = @env["USER"].to_s.empty? ? @env["USERNAME"].to_s : @env["USER"].to_s
178
-
179
- if user.empty?
180
- "on #{colorize("localhost", :blue)}"
181
- else
182
- "as #{colorize(user, :blue)}@#{colorize("localhost", :blue)}"
183
- end
184
- end
185
-
186
- def target_for(spec)
187
- if spec.host
188
- "on #{colorize(spec.host, :blue)}"
189
- else
190
- local_target
191
- end
192
- end
193
-
194
- def log_level?(level)
195
- LEVELS.fetch(level) >= @verbosity
196
- end
197
-
198
- def colorize(value, color, mode = nil)
199
- string = value.to_s
200
- return string unless colorize?
201
- return string unless COLOR_CODES.key?(color)
202
-
203
- prefix = mode == :bold ? "\e[1;" : "\e[0;"
204
- "#{prefix}#{COLOR_CODES.fetch(color)};49m#{string}\e[0m"
205
- end
206
-
207
- def colorize?
208
- @env["SSHKIT_COLOR"] || (@io.respond_to?(:tty?) && @io.tty?)
209
- end
210
- end
211
-
212
32
  class Command
213
33
  class << self
214
34
  attr_accessor :output
@@ -222,13 +42,13 @@ module KamalBackup
222
42
  end
223
43
 
224
44
  def available?(name)
225
- ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
45
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
226
46
  path = File.join(dir, name)
227
47
  File.executable?(path) && !File.directory?(path)
228
48
  end
229
49
  end
230
50
 
231
- def capture(spec, input: nil, redactor:, log: true, log_output: true, tee_stdout: nil, tee_stderr: nil)
51
+ def capture(spec, redactor:, input: nil, log: true, log_output: true, tee_stdout: nil, tee_stderr: nil)
232
52
  output = log ? self.output : nil
233
53
  return capture_quietly(spec, input: input, redactor: redactor) unless output || tee_stdout || tee_stderr
234
54
 
@@ -244,20 +64,19 @@ module KamalBackup
244
64
  tee_stderr: tee_stderr
245
65
  )
246
66
  output&.command_exit(context, status.exitstatus)
247
- result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus, streamed: !!(tee_stdout || tee_stderr))
67
+ result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus,
68
+ streamed: !(tee_stdout || tee_stderr).nil?)
248
69
 
249
- if status.success?
250
- result
251
- else
252
- raise command_failure(spec, status.exitstatus, stdout, stderr, redactor)
253
- end
70
+ raise command_failure(spec, status.exitstatus, stdout, stderr, redactor) unless status.success?
71
+
72
+ result
254
73
  rescue Errno::ENOENT => e
255
74
  raise command_not_found(spec, e)
256
75
  end
257
76
 
258
77
  def capture_pty(spec, redactor:, tee_stdout: nil)
259
- output = +""
260
- tee_buffer = +""
78
+ output = +''
79
+ tee_buffer = +''
261
80
  result = nil
262
81
 
263
82
  PTY.spawn(spec.env, *spec.argv) do |reader, writer, pid|
@@ -276,11 +95,9 @@ module KamalBackup
276
95
  end
277
96
 
278
97
  _, status = Process.wait2(pid)
279
- result = CommandResult.new(stdout: output, stderr: "", status: status.exitstatus, streamed: !!tee_stdout)
98
+ result = CommandResult.new(stdout: output, stderr: '', status: status.exitstatus, streamed: !tee_stdout.nil?)
280
99
 
281
- unless status.success?
282
- raise command_failure(spec, status.exitstatus, output, output, redactor)
283
- end
100
+ raise command_failure(spec, status.exitstatus, output, output, redactor) unless status.success?
284
101
  end
285
102
 
286
103
  result
@@ -288,9 +105,10 @@ module KamalBackup
288
105
  raise command_not_found(spec, e)
289
106
  end
290
107
 
291
- def collect_stream(io, command_output: self.output, context: nil, stream: :stdout, log_output: true, tee_io: nil, redactor: nil)
292
- captured_output = +""
293
- tee_buffer = +""
108
+ def collect_stream(io, command_output: output, context: nil, stream: :stdout, log_output: true, tee_io: nil,
109
+ redactor: nil)
110
+ captured_output = +''
111
+ tee_buffer = +''
294
112
 
295
113
  loop do
296
114
  chunk = io.readpartial(16 * 1024)
@@ -306,72 +124,73 @@ module KamalBackup
306
124
  end
307
125
 
308
126
  private
309
- def tee_stream(io, redactor, buffer, chunk)
310
- buffer << chunk
311
127
 
312
- while (index = buffer.index("\n"))
313
- io.print(redactor.redact_string(buffer.slice!(0..index)))
314
- end
128
+ def tee_stream(io, redactor, buffer, chunk)
129
+ buffer << chunk
315
130
 
316
- io.flush if io.respond_to?(:flush)
317
- buffer
131
+ while (index = buffer.index("\n"))
132
+ io.print(redactor.redact_string(buffer.slice!(0..index)))
318
133
  end
319
134
 
320
- def flush_tee_stream(io, redactor, buffer)
321
- return if buffer.empty?
135
+ io.flush if io.respond_to?(:flush)
136
+ buffer
137
+ end
322
138
 
323
- io.print(redactor.redact_string(buffer))
324
- io.flush if io.respond_to?(:flush)
325
- end
139
+ def flush_tee_stream(io, redactor, buffer)
140
+ return if buffer.empty?
326
141
 
327
- def capture_quietly(spec, input:, redactor:)
328
- stdout, stderr, status = Open3.capture3(spec.env, *spec.argv, stdin_data: input)
329
- result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus, streamed: false)
142
+ io.print(redactor.redact_string(buffer))
143
+ io.flush if io.respond_to?(:flush)
144
+ end
330
145
 
331
- if status.success?
332
- result
333
- else
334
- raise command_failure(spec, status.exitstatus, stdout, stderr, redactor)
335
- end
336
- end
146
+ def capture_quietly(spec, input:, redactor:)
147
+ stdout, stderr, status = Open3.capture3(spec.env, *spec.argv, stdin_data: input)
148
+ result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus, streamed: false)
337
149
 
338
- def popen_capture(spec, input:, redactor:, output:, context:, log_output:, tee_stdout:, tee_stderr:)
339
- Open3.popen3(spec.env, *spec.argv) do |stdin, stdout, stderr, wait_thread|
340
- stdin_writer = Thread.new do
341
- stdin.write(input) if input
342
- ensure
343
- stdin.close unless stdin.closed?
344
- end
345
- stdout_reader = Thread.new do
346
- collect_stream(stdout, command_output: output, context: context, stream: :stdout, log_output: log_output, tee_io: tee_stdout, redactor: redactor)
347
- end
348
- stderr_reader = Thread.new do
349
- collect_stream(stderr, command_output: output, context: context, stream: :stderr, log_output: log_output, tee_io: tee_stderr, redactor: redactor)
350
- end
150
+ raise command_failure(spec, status.exitstatus, stdout, stderr, redactor) unless status.success?
151
+
152
+ result
153
+ end
351
154
 
352
- stdin_writer.join
353
- [stdout_reader.value, stderr_reader.value, wait_thread.value]
155
+ def popen_capture(spec, input:, redactor:, output:, context:, log_output:, tee_stdout:, tee_stderr:)
156
+ Open3.popen3(spec.env, *spec.argv) do |stdin, stdout, stderr, wait_thread|
157
+ stdin_writer = Thread.new do
158
+ stdin.write(input) if input
159
+ ensure
160
+ stdin.close unless stdin.closed?
161
+ end
162
+ stdout_reader = Thread.new do
163
+ collect_stream(stdout, command_output: output, context: context, stream: :stdout, log_output: log_output,
164
+ tee_io: tee_stdout, redactor: redactor)
165
+ end
166
+ stderr_reader = Thread.new do
167
+ collect_stream(stderr, command_output: output, context: context, stream: :stderr, log_output: log_output,
168
+ tee_io: tee_stderr, redactor: redactor)
354
169
  end
355
- end
356
170
 
357
- def command_failure(spec, status, stdout, stderr, redactor)
358
- CommandError.new(
359
- "command failed (#{status}): #{spec.display(redactor)}\n#{redactor.redact_string(stderr)}",
360
- command: spec,
361
- status: status,
362
- stdout: redactor.redact_string(stdout),
363
- stderr: redactor.redact_string(stderr)
364
- )
171
+ stdin_writer.join
172
+ [stdout_reader.value, stderr_reader.value, wait_thread.value]
365
173
  end
174
+ end
366
175
 
367
- def command_not_found(spec, error)
368
- CommandError.new(
369
- "command not found: #{spec.argv.first}",
370
- command: spec,
371
- status: 127,
372
- stderr: error.message
373
- )
374
- end
176
+ def command_failure(spec, status, stdout, stderr, redactor)
177
+ CommandError.new(
178
+ "command failed (#{status}): #{spec.display(redactor)}\n#{redactor.redact_string(stderr)}",
179
+ command: spec,
180
+ status: status,
181
+ stdout: redactor.redact_string(stdout),
182
+ stderr: redactor.redact_string(stderr)
183
+ )
184
+ end
185
+
186
+ def command_not_found(spec, error)
187
+ CommandError.new(
188
+ "command not found: #{spec.argv.first}",
189
+ command: spec,
190
+ status: 127,
191
+ stderr: error.message
192
+ )
193
+ end
375
194
  end
376
195
  end
377
196
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module KamalBackup
6
+ class CommandOutput
7
+ LEVELS = {
8
+ 'DEBUG' => 0,
9
+ 'INFO' => 1,
10
+ 'WARN' => 2,
11
+ 'ERROR' => 3,
12
+ 'FATAL' => 4
13
+ }.freeze
14
+ LEVEL_COLORS = {
15
+ 'DEBUG' => :black,
16
+ 'INFO' => :blue,
17
+ 'WARN' => :yellow,
18
+ 'ERROR' => :red,
19
+ 'FATAL' => :red
20
+ }.freeze
21
+ COLOR_CODES = {
22
+ black: 30,
23
+ red: 31,
24
+ green: 32,
25
+ yellow: 33,
26
+ blue: 34,
27
+ magenta: 35,
28
+ cyan: 36,
29
+ white: 37,
30
+ light_black: 90,
31
+ light_red: 91,
32
+ light_green: 92,
33
+ light_yellow: 93,
34
+ light_blue: 94,
35
+ light_magenta: 95,
36
+ light_cyan: 96,
37
+ light_white: 97
38
+ }.freeze
39
+
40
+ def initialize(io: $stdout, env: ENV, verbosity: :info)
41
+ @io = io
42
+ @env = env
43
+ @verbosity = LEVELS.fetch(verbosity.to_s.upcase)
44
+ @mutex = Mutex.new
45
+ @buffers = {}
46
+ end
47
+
48
+ def info(message, redactor:)
49
+ write_message('INFO', redactor.redact_string(message))
50
+ end
51
+
52
+ def error(message, redactor:)
53
+ write_message('ERROR', colorize(redactor.redact_string(message), :red, :bold))
54
+ end
55
+
56
+ def decorate(value, color, mode = nil)
57
+ colorize(value, color, mode)
58
+ end
59
+
60
+ def command_start(spec, redactor:)
61
+ id = SecureRandom.hex(4)
62
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ display = spec.display(redactor)
64
+
65
+ write_message('INFO', "Running #{colorize(display, :yellow, :bold)} #{target_for(spec)}", id)
66
+ write_message('DEBUG', "Command: #{colorize(display, :blue)}", id)
67
+
68
+ { id: id, started_at: started_at, redactor: redactor }
69
+ end
70
+
71
+ def command_output(context, stream, data, redactor:)
72
+ raw = data.to_s
73
+ return if raw.empty?
74
+ return unless log_level?('DEBUG')
75
+
76
+ synchronize do
77
+ key = [context.fetch(:id), stream]
78
+ @buffers[key] = "#{@buffers[key]}#{raw}"
79
+ flush_complete_output_lines(context, key, redactor: redactor)
80
+ end
81
+ end
82
+
83
+ def command_exit(context, status)
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
87
+
88
+ synchronize do
89
+ flush_output_buffers(context)
90
+ message = "Finished in #{format('%.3f seconds', runtime)} with exit status #{status} " \
91
+ "(#{colorize(result, result_color, :bold)})."
92
+ write_message_unlocked('INFO', message, context.fetch(:id))
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def write_message(level, message, id = nil)
99
+ return unless log_level?(level)
100
+
101
+ synchronize { write_message_unlocked(level, message, id) }
102
+ end
103
+
104
+ def write_message_unlocked(level, message, id = nil)
105
+ @io.puts(format_message(level, message, id)) if log_level?(level)
106
+ end
107
+
108
+ def synchronize(&block)
109
+ @mutex.synchronize(&block)
110
+ end
111
+
112
+ def flush_complete_output_lines(context, key, redactor:)
113
+ buffer = @buffers.fetch(key)
114
+ output = +''
115
+
116
+ while (index = buffer.index("\n"))
117
+ output << buffer.slice!(0..index)
118
+ end
119
+
120
+ @buffers[key] = buffer
121
+ write_output(context, output, redactor: redactor, stream: key.last) unless output.empty?
122
+ end
123
+
124
+ def flush_output_buffers(context)
125
+ id = context.fetch(:id)
126
+ keys = @buffers.keys.select { |key_id, _stream| key_id == id }
127
+
128
+ keys.each do |key|
129
+ output = @buffers.delete(key)
130
+ next if output.to_s.empty?
131
+
132
+ write_output(context, output, redactor: context.fetch(:redactor), stream: key.last)
133
+ end
134
+ end
135
+
136
+ def write_output(context, output, redactor:, stream: nil)
137
+ color = stream == :stderr ? :red : :green
138
+
139
+ redactor.redact_string(output).each_line do |line|
140
+ write_message_unlocked('DEBUG', colorize("\t#{line}".chomp, color), context.fetch(:id))
141
+ end
142
+ @io.flush if @io.respond_to?(:flush)
143
+ end
144
+
145
+ def format_message(level, message, id = nil)
146
+ message = "[#{colorize(id, :green)}] #{message}" if id
147
+ "#{colorize(level.rjust(6), LEVEL_COLORS.fetch(level))} #{message}"
148
+ end
149
+
150
+ def local_target
151
+ if (remote_host = @env['KAMAL_HOST'].to_s) && !remote_host.empty?
152
+ return "on #{colorize(remote_host, :blue)}"
153
+ end
154
+
155
+ user = @env['USER'].to_s.empty? ? @env['USERNAME'].to_s : @env['USER'].to_s
156
+
157
+ if user.empty?
158
+ "on #{colorize('localhost', :blue)}"
159
+ else
160
+ "as #{colorize(user, :blue)}@#{colorize('localhost', :blue)}"
161
+ end
162
+ end
163
+
164
+ def target_for(spec)
165
+ if spec.host
166
+ "on #{colorize(spec.host, :blue)}"
167
+ else
168
+ local_target
169
+ end
170
+ end
171
+
172
+ def log_level?(level)
173
+ LEVELS.fetch(level) >= @verbosity
174
+ end
175
+
176
+ def colorize(value, color, mode = nil)
177
+ string = value.to_s
178
+ return string unless colorize?
179
+ return string unless COLOR_CODES.key?(color)
180
+
181
+ prefix = mode == :bold ? "\e[1;" : "\e[0;"
182
+ "#{prefix}#{COLOR_CODES.fetch(color)};49m#{string}\e[0m"
183
+ end
184
+
185
+ def colorize?
186
+ @env['SSHKIT_COLOR'] || (@io.respond_to?(:tty?) && @io.tty?)
187
+ end
188
+ end
189
+ end