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