kamal-backup 0.3.0.beta21 → 0.3.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 +4 -4
- data/exe/kamal-backup +7 -6
- data/lib/kamal_backup/app.rb +350 -356
- data/lib/kamal_backup/cli.rb +107 -111
- data/lib/kamal_backup/command.rb +165 -161
- data/lib/kamal_backup/config.rb +533 -511
- data/lib/kamal_backup/databases/base.rb +17 -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 +62 -61
- data/lib/kamal_backup/kamal_bridge.rb +254 -250
- data/lib/kamal_backup/rails_app.rb +102 -101
- data/lib/kamal_backup/redactor.rb +18 -13
- data/lib/kamal_backup/restic.rb +195 -167
- 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.rb +19 -17
- metadata +30 -2
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'command'
|
|
4
6
|
|
|
5
7
|
module KamalBackup
|
|
6
8
|
class KamalBridge
|
|
7
|
-
DEFAULT_CONFIG_FILE =
|
|
9
|
+
DEFAULT_CONFIG_FILE = 'config/deploy.yml'
|
|
8
10
|
VERSION_LINE_PATTERN = /\A\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*\z/
|
|
9
11
|
|
|
10
|
-
def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd, stdout: $stdout,
|
|
12
|
+
def initialize(redactor:, config_file: nil, destination: nil, env: ENV, cwd: Dir.pwd, stdout: $stdout,
|
|
13
|
+
stderr: $stderr)
|
|
11
14
|
@redactor = redactor
|
|
12
15
|
@config_file = config_file
|
|
13
16
|
@destination = destination
|
|
@@ -24,16 +27,17 @@ module KamalBackup
|
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
matching = accessories.select do |_name, accessory|
|
|
27
|
-
fetch(accessory, :image).to_s.include?(
|
|
30
|
+
fetch(accessory, :image).to_s.include?('kamal-backup')
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
if matching.size == 1
|
|
31
34
|
matching.keys.first.to_s
|
|
32
|
-
elsif accessories.key?(
|
|
33
|
-
|
|
35
|
+
elsif accessories.key?('backup') || accessories.key?(:backup)
|
|
36
|
+
'backup'
|
|
34
37
|
else
|
|
35
38
|
names = accessories.keys.map(&:to_s).sort
|
|
36
|
-
raise ConfigurationError,
|
|
39
|
+
raise ConfigurationError,
|
|
40
|
+
"could not infer the backup accessory from #{names.join(', ')}; set accessory in config/kamal-backup.yml"
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
|
|
@@ -41,10 +45,10 @@ module KamalBackup
|
|
|
41
45
|
clear_env = accessory_clear_env(accessory_name)
|
|
42
46
|
|
|
43
47
|
{}.tap do |defaults|
|
|
44
|
-
defaults[
|
|
45
|
-
defaults[
|
|
46
|
-
defaults[
|
|
47
|
-
defaults[
|
|
48
|
+
defaults['APP_NAME'] = clear_env['APP_NAME'] if clear_env['APP_NAME']
|
|
49
|
+
defaults['DATABASE_ADAPTER'] = clear_env['DATABASE_ADAPTER'] if clear_env['DATABASE_ADAPTER']
|
|
50
|
+
defaults['RESTIC_REPOSITORY'] = clear_env['RESTIC_REPOSITORY'] if clear_env['RESTIC_REPOSITORY']
|
|
51
|
+
defaults['LOCAL_RESTORE_SOURCE_PATHS'] = clear_env['BACKUP_PATHS'] if clear_env['BACKUP_PATHS']
|
|
48
52
|
end
|
|
49
53
|
end
|
|
50
54
|
|
|
@@ -61,304 +65,304 @@ module KamalBackup
|
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def remote_version(accessory_name:)
|
|
64
|
-
result = execute_on_accessory(accessory_name: accessory_name, command:
|
|
68
|
+
result = execute_on_accessory(accessory_name: accessory_name, command: 'kamal-backup version')
|
|
65
69
|
version = parse_version_line(result.stdout)
|
|
66
70
|
|
|
67
|
-
if version.empty?
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
version
|
|
71
|
-
end
|
|
71
|
+
raise ConfigurationError, "could not determine remote kamal-backup version from accessory #{accessory_name}" if version.empty?
|
|
72
|
+
|
|
73
|
+
version
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
private
|
|
75
|
-
def config
|
|
76
|
-
@config ||= begin
|
|
77
|
-
result = capture_kamal(kamal_config_argv)
|
|
78
|
-
load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
|
|
79
|
-
YAML.public_send(load_method, result.stdout)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
77
|
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
def config
|
|
79
|
+
@config ||= begin
|
|
80
|
+
result = capture_kamal(kamal_config_argv)
|
|
81
|
+
load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
|
|
82
|
+
YAML.public_send(load_method, result.stdout)
|
|
85
83
|
end
|
|
84
|
+
end
|
|
86
85
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
def accessories
|
|
87
|
+
fetch(config, :accessories) || {}
|
|
88
|
+
end
|
|
90
89
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
def accessory_clear_env(accessory_name)
|
|
91
|
+
normalize_env(fetch(accessory_env(accessory_name), :clear) || {})
|
|
92
|
+
end
|
|
94
93
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
def accessory_secret_placeholders(accessory_name)
|
|
95
|
+
normalize_secret_env(fetch(accessory_env(accessory_name), :secret))
|
|
96
|
+
end
|
|
98
97
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
def accessory_env(accessory_name)
|
|
99
|
+
fetch(accessory(accessory_name), :env) || {}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def accessory(accessory_name)
|
|
103
|
+
accessories.fetch(accessory_name) do
|
|
104
|
+
accessories.fetch(accessory_name.to_sym) do
|
|
105
|
+
raise ConfigurationError,
|
|
106
|
+
"accessory #{accessory_name.inspect} is not defined in #{config_file || DEFAULT_CONFIG_FILE}"
|
|
104
107
|
end
|
|
105
108
|
end
|
|
109
|
+
end
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
end
|
|
111
|
+
def normalize_env(values)
|
|
112
|
+
values.each_with_object({}) do |(key, value), env|
|
|
113
|
+
env[key.to_s] = value.to_s
|
|
111
114
|
end
|
|
115
|
+
end
|
|
112
116
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
end
|
|
124
|
-
when String, Symbol
|
|
125
|
-
{}.tap do |env|
|
|
126
|
-
target, source = parse_secret_entry(values)
|
|
127
|
-
add_resolved_secret(env, target: target, source: source)
|
|
128
|
-
end
|
|
129
|
-
else
|
|
130
|
-
{}
|
|
117
|
+
def normalize_secret_env(values)
|
|
118
|
+
case values
|
|
119
|
+
when Hash
|
|
120
|
+
values.each_with_object({}) do |(key, secret_key), env|
|
|
121
|
+
add_resolved_secret(env, target: key, source: secret_key)
|
|
122
|
+
end
|
|
123
|
+
when Array
|
|
124
|
+
values.each_with_object({}) do |entry, env|
|
|
125
|
+
target, source = parse_secret_entry(entry)
|
|
126
|
+
add_resolved_secret(env, target: target, source: source)
|
|
131
127
|
end
|
|
128
|
+
when String, Symbol
|
|
129
|
+
{}.tap do |env|
|
|
130
|
+
target, source = parse_secret_entry(values)
|
|
131
|
+
add_resolved_secret(env, target: target, source: source)
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
{}
|
|
132
135
|
end
|
|
136
|
+
end
|
|
133
137
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
def parse_secret_entry(entry)
|
|
139
|
+
target, source = entry.to_s.split(':', 2)
|
|
140
|
+
[target, source || target]
|
|
141
|
+
end
|
|
138
142
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
end
|
|
143
|
+
def add_resolved_secret(env, target:, source:)
|
|
144
|
+
if (value = resolved_secret(source))
|
|
145
|
+
env[target.to_s] = value
|
|
143
146
|
end
|
|
147
|
+
end
|
|
144
148
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
def resolved_secret(key)
|
|
150
|
+
raw = resolved_secrets[key.to_s] || @env[key.to_s]
|
|
151
|
+
value = raw.to_s.strip
|
|
152
|
+
value.empty? ? nil : value
|
|
153
|
+
end
|
|
150
154
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
def resolved_secrets
|
|
156
|
+
@resolved_secrets ||= parse_secret_output(capture_kamal(kamal_secrets_print_argv).stdout)
|
|
157
|
+
end
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
def parse_secret_output(output)
|
|
160
|
+
output.to_s.lines.each_with_object({}) do |line, secrets|
|
|
161
|
+
tokens = Shellwords.split(line.chomp)
|
|
162
|
+
tokens.shift if tokens.first == 'export'
|
|
159
163
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
tokens.each do |assignment|
|
|
165
|
+
key, value = assignment.split('=', 2)
|
|
166
|
+
next if key.to_s.empty? || value.nil?
|
|
163
167
|
|
|
164
|
-
|
|
165
|
-
end
|
|
168
|
+
secrets[key] = value.to_s
|
|
166
169
|
end
|
|
167
170
|
end
|
|
171
|
+
end
|
|
168
172
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
173
|
+
def fetch(hash, key)
|
|
174
|
+
hash[key] || hash[key.to_s] || hash[key.to_sym]
|
|
175
|
+
end
|
|
172
176
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
def kamal_config_argv
|
|
178
|
+
[
|
|
179
|
+
*kamal_command,
|
|
180
|
+
'config',
|
|
181
|
+
*kamal_option_argv,
|
|
182
|
+
'--version',
|
|
183
|
+
'latest'
|
|
184
|
+
]
|
|
185
|
+
end
|
|
182
186
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
187
|
+
def kamal_exec_argv(accessory_name, command, interactive: false)
|
|
188
|
+
[
|
|
189
|
+
*kamal_command,
|
|
190
|
+
'accessory',
|
|
191
|
+
'exec',
|
|
192
|
+
*kamal_option_argv,
|
|
193
|
+
*(['--interactive'] if interactive),
|
|
194
|
+
'--reuse',
|
|
195
|
+
accessory_name,
|
|
196
|
+
command
|
|
197
|
+
]
|
|
198
|
+
end
|
|
195
199
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
def kamal_secrets_print_argv
|
|
201
|
+
[
|
|
202
|
+
*kamal_command,
|
|
203
|
+
'secrets',
|
|
204
|
+
'print',
|
|
205
|
+
*kamal_option_argv
|
|
206
|
+
]
|
|
207
|
+
end
|
|
204
208
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
end
|
|
209
|
+
def kamal_command
|
|
210
|
+
if File.executable?(File.join(@cwd, 'bin', 'kamal'))
|
|
211
|
+
['bin/kamal']
|
|
212
|
+
elsif File.file?(File.join(@cwd, 'Gemfile'))
|
|
213
|
+
%w[bundle exec kamal]
|
|
214
|
+
else
|
|
215
|
+
['kamal']
|
|
213
216
|
end
|
|
217
|
+
end
|
|
214
218
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
def kamal_option_argv
|
|
220
|
+
argv = []
|
|
221
|
+
argv.concat(['-c', @config_file]) if @config_file
|
|
222
|
+
argv.concat(['-d', @destination]) if @destination
|
|
223
|
+
argv
|
|
224
|
+
end
|
|
221
225
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
end
|
|
226
|
+
def capture_kamal(argv, stream: false, log: !stream, stdout: @stdout, stderr: @stderr, pty: false)
|
|
227
|
+
spec = CommandSpec.new(argv: argv, env: kamal_stream_env(stream))
|
|
228
|
+
options = {
|
|
229
|
+
redactor: @redactor,
|
|
230
|
+
log: log,
|
|
231
|
+
log_output: false,
|
|
232
|
+
tee_stdout: stream ? stdout : nil,
|
|
233
|
+
tee_stderr: stream ? stderr : nil
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if defined?(Bundler)
|
|
237
|
+
Bundler.with_unbundled_env { capture_command(spec, options, pty: pty) }
|
|
238
|
+
else
|
|
239
|
+
capture_command(spec, options, pty: pty)
|
|
237
240
|
end
|
|
241
|
+
end
|
|
238
242
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
end
|
|
243
|
+
def capture_command(spec, options, pty:)
|
|
244
|
+
if pty
|
|
245
|
+
Command.capture_pty(spec, redactor: options.fetch(:redactor), tee_stdout: options[:tee_stdout])
|
|
246
|
+
else
|
|
247
|
+
Command.capture(spec, **options)
|
|
245
248
|
end
|
|
249
|
+
end
|
|
246
250
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
251
|
+
def execute_on_accessory_live(accessory_name:, command:, target:)
|
|
252
|
+
@stdout.puts('Launching command from existing container...')
|
|
253
|
+
|
|
254
|
+
spec = CommandSpec.new(
|
|
255
|
+
argv: ['docker', 'exec', target.fetch(:service_name), *Shellwords.split(command)],
|
|
256
|
+
host: target.fetch(:host)
|
|
257
|
+
)
|
|
258
|
+
context = Command.output&.command_start(spec, redactor: @redactor)
|
|
259
|
+
|
|
260
|
+
result = capture_kamal(
|
|
261
|
+
kamal_exec_argv(accessory_name, command, interactive: true),
|
|
262
|
+
stream: true,
|
|
263
|
+
log: false,
|
|
264
|
+
stdout: filtered_interactive_stdout,
|
|
265
|
+
pty: true
|
|
266
|
+
)
|
|
267
|
+
Command.output&.command_exit(context, result.status) if context
|
|
268
|
+
result
|
|
269
|
+
rescue CommandError => e
|
|
270
|
+
Command.output&.command_exit(context, e.status || 1) if context
|
|
271
|
+
raise
|
|
272
|
+
end
|
|
269
273
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
end
|
|
274
|
+
def filtered_interactive_stdout
|
|
275
|
+
FilteringIO.new(@stdout) do |output|
|
|
276
|
+
stripped = output.to_s.gsub(/\e\[[0-9;]*m/, '').delete("\r").strip
|
|
277
|
+
stripped == 'Launching interactive command via SSH from existing container...' ||
|
|
278
|
+
stripped.match?(/\AConnection to .+ closed\.\z/)
|
|
276
279
|
end
|
|
280
|
+
end
|
|
277
281
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
accessory_config = accessory(accessory_name)
|
|
282
|
-
host = single_accessory_host(accessory_config)
|
|
283
|
-
service_name = fetch(accessory_config, :service) || default_accessory_service_name(accessory_name)
|
|
282
|
+
def live_accessory_target(accessory_name)
|
|
283
|
+
return unless defined?(@config)
|
|
284
284
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
end
|
|
285
|
+
accessory_config = accessory(accessory_name)
|
|
286
|
+
host = single_accessory_host(accessory_config)
|
|
287
|
+
service_name = fetch(accessory_config, :service) || default_accessory_service_name(accessory_name)
|
|
289
288
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
normalized_hosts(fetch(accessory_config, :hosts))
|
|
295
|
-
end
|
|
289
|
+
{ host: host, service_name: service_name } if host && service_name
|
|
290
|
+
rescue ConfigurationError, KeyError, NoMethodError, TypeError
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
296
293
|
|
|
297
|
-
|
|
298
|
-
|
|
294
|
+
def single_accessory_host(accessory_config)
|
|
295
|
+
hosts = if (host = fetch(accessory_config, :host))
|
|
296
|
+
normalized_hosts(host)
|
|
297
|
+
else
|
|
298
|
+
normalized_hosts(fetch(accessory_config, :hosts))
|
|
299
|
+
end
|
|
299
300
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
end
|
|
301
|
+
return hosts.first if hosts.size == 1
|
|
302
|
+
return if hosts.any?
|
|
303
303
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
[]
|
|
308
|
-
when Array
|
|
309
|
-
value.map(&:to_s).reject(&:empty?)
|
|
310
|
-
else
|
|
311
|
-
[value.to_s].reject(&:empty?)
|
|
312
|
-
end
|
|
313
|
-
end
|
|
304
|
+
all_hosts = normalized_hosts(fetch(config, :hosts))
|
|
305
|
+
all_hosts.first if all_hosts.size == 1
|
|
306
|
+
end
|
|
314
307
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
308
|
+
def normalized_hosts(value)
|
|
309
|
+
case value
|
|
310
|
+
when nil
|
|
311
|
+
[]
|
|
312
|
+
when Array
|
|
313
|
+
value.map(&:to_s).reject(&:empty?)
|
|
314
|
+
else
|
|
315
|
+
[value.to_s].reject(&:empty?)
|
|
319
316
|
end
|
|
317
|
+
end
|
|
320
318
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
319
|
+
def default_accessory_service_name(accessory_name)
|
|
320
|
+
service = fetch(config, :service).to_s
|
|
321
|
+
service = service_from_rendered_config if service.empty?
|
|
322
|
+
"#{service}-#{accessory_name}" unless service.empty?
|
|
323
|
+
end
|
|
325
324
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
325
|
+
def service_from_rendered_config
|
|
326
|
+
service_with_version = fetch(config, :service_with_version).to_s
|
|
327
|
+
version = fetch(config, :version).to_s
|
|
328
|
+
suffix = "-#{version}"
|
|
330
329
|
|
|
331
|
-
|
|
332
|
-
def initialize(io, &reject)
|
|
333
|
-
@io = io
|
|
334
|
-
@reject = reject
|
|
335
|
-
end
|
|
330
|
+
return unless !service_with_version.empty? && !version.empty? && service_with_version.end_with?(suffix)
|
|
336
331
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
end
|
|
332
|
+
service_with_version.delete_suffix(suffix)
|
|
333
|
+
end
|
|
340
334
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
335
|
+
class FilteringIO
|
|
336
|
+
def initialize(io, &reject)
|
|
337
|
+
@io = io
|
|
338
|
+
@reject = reject
|
|
344
339
|
end
|
|
345
340
|
|
|
346
|
-
def
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if @env["SSHKIT_COLOR"].to_s.empty?
|
|
350
|
-
stream_color? ? { "SSHKIT_COLOR" => "1" } : {}
|
|
351
|
-
else
|
|
352
|
-
{ "SSHKIT_COLOR" => @env["SSHKIT_COLOR"] }
|
|
353
|
-
end
|
|
341
|
+
def print(output)
|
|
342
|
+
@io.print(output) unless @reject.call(output.to_s)
|
|
354
343
|
end
|
|
355
344
|
|
|
356
|
-
def
|
|
357
|
-
|
|
345
|
+
def flush
|
|
346
|
+
@io.flush if @io.respond_to?(:flush)
|
|
358
347
|
end
|
|
348
|
+
end
|
|
359
349
|
|
|
360
|
-
|
|
361
|
-
|
|
350
|
+
def kamal_stream_env(stream)
|
|
351
|
+
return {} unless stream
|
|
352
|
+
|
|
353
|
+
if @env['SSHKIT_COLOR'].to_s.empty?
|
|
354
|
+
stream_color? ? { 'SSHKIT_COLOR' => '1' } : {}
|
|
355
|
+
else
|
|
356
|
+
{ 'SSHKIT_COLOR' => @env['SSHKIT_COLOR'] }
|
|
362
357
|
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def stream_color?
|
|
361
|
+
[@stdout, @stderr].any? { |io| io.respond_to?(:tty?) && io.tty? }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def parse_version_line(output)
|
|
365
|
+
output.to_s.lines.map(&:strip).reverse.find { |line| line.match?(VERSION_LINE_PATTERN) }.to_s
|
|
366
|
+
end
|
|
363
367
|
end
|
|
364
368
|
end
|