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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/exe/kamal-backup +7 -6
- data/lib/kamal_backup/app.rb +330 -393
- data/lib/kamal_backup/cli/helpers.rb +298 -0
- data/lib/kamal_backup/cli.rb +73 -367
- data/lib/kamal_backup/command.rb +77 -258
- data/lib/kamal_backup/command_output.rb +189 -0
- data/lib/kamal_backup/config.rb +242 -624
- data/lib/kamal_backup/config_file.rb +376 -0
- data/lib/kamal_backup/databases/base.rb +28 -14
- data/lib/kamal_backup/databases/mysql.rb +68 -67
- data/lib/kamal_backup/databases/postgres.rb +59 -58
- data/lib/kamal_backup/databases/sqlite.rb +21 -20
- data/lib/kamal_backup/errors.rb +3 -1
- data/lib/kamal_backup/evidence.rb +61 -63
- data/lib/kamal_backup/kamal_bridge.rb +270 -254
- data/lib/kamal_backup/rails_app.rb +94 -104
- data/lib/kamal_backup/redactor.rb +18 -13
- data/lib/kamal_backup/restic.rb +207 -183
- data/lib/kamal_backup/scheduler.rb +17 -14
- data/lib/kamal_backup/schema.rb +2 -0
- data/lib/kamal_backup/version.rb +3 -1
- data/lib/kamal_backup/yaml_access.rb +13 -0
- data/lib/kamal_backup.rb +22 -17
- metadata +76 -2
data/lib/kamal_backup/command.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
67
|
+
result = CommandResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus,
|
|
68
|
+
streamed: !(tee_stdout || tee_stderr).nil?)
|
|
248
69
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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:
|
|
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:
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
end
|
|
128
|
+
def tee_stream(io, redactor, buffer, chunk)
|
|
129
|
+
buffer << chunk
|
|
315
130
|
|
|
316
|
-
|
|
317
|
-
buffer
|
|
131
|
+
while (index = buffer.index("\n"))
|
|
132
|
+
io.print(redactor.redact_string(buffer.slice!(0..index)))
|
|
318
133
|
end
|
|
319
134
|
|
|
320
|
-
|
|
321
|
-
|
|
135
|
+
io.flush if io.respond_to?(:flush)
|
|
136
|
+
buffer
|
|
137
|
+
end
|
|
322
138
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
end
|
|
139
|
+
def flush_tee_stream(io, redactor, buffer)
|
|
140
|
+
return if buffer.empty?
|
|
326
141
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
142
|
+
io.print(redactor.redact_string(buffer))
|
|
143
|
+
io.flush if io.respond_to?(:flush)
|
|
144
|
+
end
|
|
330
145
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
)
|
|
374
|
-
|
|
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
|