bolt 2.4.0 → 2.5.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of bolt might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba000a9935f93ebac58dc466b10dc06da8ca5468c9d2d531d2b1067154dd90f1
4
- data.tar.gz: 202b98a6d9f6d50dc6864f519a711c4e66a24dab5e16130231164b4c127ea82a
3
+ metadata.gz: 35079d3ff8276e6ebe7e1164e7336ef77b17d64c0f3c3df4a257b71fb3c6c599
4
+ data.tar.gz: ccc148bcec686d950741595e99dbcacd88825685d3dfb7641850d9ff54df02a6
5
5
  SHA512:
6
- metadata.gz: e76933cea7a2b3fd3ee860dd21a938b860892559578c2b494647da0c24308102d81c592c230ee22e676ab5b843db77e38b349b41dff5cfb75bbe7c498ee1e023
7
- data.tar.gz: df316bb99de15abb03b2d885f6a46e2a45e8649baea5745f8c9efc7b95cc874b9322a616c0fca7020cac9fb4748ec5d5577f3415833c56ad85e766fcd9bd6bfd
6
+ metadata.gz: 9f101955f9562b6e8365a277079fdaff192350c51e5505a36f5fad1be0736deb4f6ca9c458758177c8e5907cf9bbfb709e87bf3a6877075269250b98fd076b03
7
+ data.tar.gz: dfa5f451c469a9f787f4ed4be798af368463ee9d9f463c683a8c458ba6a552e5e161ddb13c99bc6e819cb95987ce73fca9b5194215a3863432e3e3cac82c709b
@@ -27,7 +27,9 @@ Puppet::DataTypes.create_type('Target') do
27
27
  password => Callable[[], Optional[String[1]]],
28
28
  port => Callable[[], Optional[Integer]],
29
29
  protocol => Callable[[], Optional[String[1]]],
30
- user => Callable[[], Optional[String[1]]],
30
+ transport => Callable[[], String[1]],
31
+ transport_config => Callable[[], Hash[String[1], Data]],
32
+ user => Callable[[], Optional[String[1]]]
31
33
  }
32
34
  PUPPET
33
35
 
@@ -564,7 +564,7 @@ module Bolt
564
564
  DESCRIPTION
565
565
  Run a task on the specified targets.
566
566
 
567
- Parameters take the form <parameter>=<value>.
567
+ Parameters take the form parameter=value.
568
568
 
569
569
  EXAMPLES
570
570
  bolt task run package --targets target1,target2 action=status name=bash
data/lib/bolt/executor.rb CHANGED
@@ -302,12 +302,12 @@ module Bolt
302
302
  batch_execute(targets) do |transport, batch|
303
303
  with_node_logging('Waiting until available', batch) do
304
304
  wait_until(wait_time, retry_interval) { transport.batch_connected?(batch) }
305
- batch.map { |target| Result.new(target) }
305
+ batch.map { |target| Result.new(target, action: 'wait_until_available', object: description) }
306
306
  rescue TimeoutError => e
307
307
  available, unavailable = batch.partition { |target| transport.batch_connected?([target]) }
308
308
  (
309
- available.map { |target| Result.new(target) } +
310
- unavailable.map { |target| Result.from_exception(target, e) }
309
+ available.map { |target| Result.new(target, action: 'wait_until_available', object: description) } +
310
+ unavailable.map { |target| Result.from_exception(target, e, action: 'wait_until_available') }
311
311
  )
312
312
  end
313
313
  end
@@ -32,7 +32,6 @@ module Bolt
32
32
  @vars = target_data['vars'] || {}
33
33
  @facts = target_data['facts'] || {}
34
34
  @features = target_data['features'] || Set.new
35
- @options = target_data['options'] || {}
36
35
  @plugin_hooks = target_data['plugin_hooks'] || {}
37
36
  # When alias is specified in a plan, the key will be `target_alias`, when
38
37
  # alias is specified in inventory the key will be `alias`.
@@ -166,10 +165,6 @@ module Bolt
166
165
  Addressable::URI.unencode_component(@uri_obj.password) || transport_config['password']
167
166
  end
168
167
 
169
- def options
170
- transport_config
171
- end
172
-
173
168
  # We only want to look up transport config keys for the configured
174
169
  # transport
175
170
  def transport_config
@@ -199,7 +194,6 @@ module Bolt
199
194
  'vars' => {},
200
195
  'facts' => {},
201
196
  'features' => Set.new,
202
- 'options' => {},
203
197
  'plugin_hooks' => {},
204
198
  'target_alias' => []
205
199
  }
data/lib/bolt/result.rb CHANGED
@@ -7,7 +7,7 @@ module Bolt
7
7
  class Result
8
8
  attr_reader :target, :value, :action, :object
9
9
 
10
- def self.from_exception(target, exception)
10
+ def self.from_exception(target, exception, action: 'action')
11
11
  if exception.is_a?(Bolt::Error)
12
12
  error = exception.to_h
13
13
  else
@@ -19,7 +19,7 @@ module Bolt
19
19
  }
20
20
  error['details']['stack_trace'] = exception.backtrace.join('\n') if exception.backtrace
21
21
  end
22
- Result.new(target, error: error)
22
+ Result.new(target, error: error, action: action)
23
23
  end
24
24
 
25
25
  def self.for_command(target, stdout, stderr, exit_code, action, command)
@@ -90,7 +90,7 @@ module Bolt
90
90
  'object' => @object }
91
91
  end
92
92
 
93
- def initialize(target, error: nil, message: nil, value: nil, action: nil, object: nil)
93
+ def initialize(target, error: nil, message: nil, value: nil, action: 'action', object: nil)
94
94
  @target = target
95
95
  @value = value || {}
96
96
  @action = action
data/lib/bolt/shell.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bolt
4
+ class Shell
5
+ attr_reader :target, :conn, :logger
6
+
7
+ def initialize(target, conn)
8
+ @target = target
9
+ @conn = conn
10
+ @logger = Logging.logger[@target.safe_name]
11
+ end
12
+
13
+ def run_command(*_args)
14
+ raise NotImplementedError, "run_command() must be implemented by the shell class"
15
+ end
16
+
17
+ def upload(*_args)
18
+ raise NotImplementedError, "upload() must be implemented by the shell class"
19
+ end
20
+
21
+ def run_script(*_args)
22
+ raise NotImplementedError, "run_script() must be implemented by the shell class"
23
+ end
24
+
25
+ def run_task(*_args)
26
+ raise NotImplementedError, "run_task() must be implemented by the shell class"
27
+ end
28
+
29
+ def provided_features
30
+ []
31
+ end
32
+
33
+ def default_input_method(_executable)
34
+ 'both'
35
+ end
36
+
37
+ # The above methods are the API that must be implemented by a Shell. Below
38
+ # are helper methods.
39
+
40
+ def select_implementation(target, task)
41
+ impl = task.select_implementation(target, provided_features)
42
+ impl['input_method'] ||= default_input_method(impl['path'])
43
+ impl
44
+ end
45
+
46
+ def select_interpreter(executable, interpreters)
47
+ interpreters[Pathname(executable).extname] if interpreters
48
+ end
49
+
50
+ # Transform a parameter map to an environment variable map, with parameter names prefixed
51
+ # with 'PT_' and values transformed to JSON unless they're strings.
52
+ def envify_params(params)
53
+ params.each_with_object({}) do |(k, v), h|
54
+ v = v.to_json unless v.is_a?(String)
55
+ h["PT_#{k}"] = v
56
+ end
57
+ end
58
+
59
+ # Unwraps any Sensitive data in an arguments Hash, so the plain-text is passed
60
+ # to the Task/Script.
61
+ #
62
+ # This works on deeply nested data structures composed of Hashes, Arrays, and
63
+ # and plain-old data types (int, string, etc).
64
+ def unwrap_sensitive_args(arguments)
65
+ # Skip this if Puppet isn't loaded
66
+ return arguments unless defined?(Puppet::Pops::Types::PSensitiveType::Sensitive)
67
+
68
+ case arguments
69
+ when Array
70
+ # iterate over the array, unwrapping all elements
71
+ arguments.map { |x| unwrap_sensitive_args(x) }
72
+ when Hash
73
+ # iterate over the arguments hash and unwrap all keys and values
74
+ arguments.each_with_object({}) { |(k, v), h|
75
+ h[unwrap_sensitive_args(k)] = unwrap_sensitive_args(v)
76
+ }
77
+ when Puppet::Pops::Types::PSensitiveType::Sensitive
78
+ # this value is Sensitive, unwrap it
79
+ unwrap_sensitive_args(arguments.unwrap)
80
+ else
81
+ # unknown data type, just return it
82
+ arguments
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ require 'bolt/shell/bash'
@@ -0,0 +1,431 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bolt/shell/bash/tmpdir'
4
+ require 'shellwords'
5
+
6
+ module Bolt
7
+ class Shell
8
+ class Bash < Shell
9
+ CHUNK_SIZE = 4096
10
+
11
+ def initialize(target, conn)
12
+ super
13
+
14
+ @run_as = nil
15
+
16
+ @sudo_id = SecureRandom.uuid
17
+ @sudo_password = @target.options['sudo-password'] || @target.password
18
+ end
19
+
20
+ def provided_features
21
+ ['shell']
22
+ end
23
+
24
+ def run_command(command, options = {})
25
+ running_as(options[:run_as]) do
26
+ output = execute(command, sudoable: true)
27
+ Bolt::Result.for_command(target,
28
+ output.stdout.string,
29
+ output.stderr.string,
30
+ output.exit_code,
31
+ 'command', command)
32
+ end
33
+ end
34
+
35
+ def upload(source, destination, options = {})
36
+ running_as(options[:run_as]) do
37
+ with_tempdir do |dir|
38
+ basename = File.basename(destination)
39
+ tmpfile = File.join(dir.to_s, basename)
40
+ conn.copy_file(source, tmpfile)
41
+ # pass over file ownership if we're using run-as to be a different user
42
+ dir.chown(run_as)
43
+ result = execute(['mv', '-f', tmpfile, destination], sudoable: true)
44
+ if result.exit_code != 0
45
+ message = "Could not move temporary file '#{tmpfile}' to #{destination}: #{result.stderr.string}"
46
+ raise Bolt::Node::FileError.new(message, 'MV_ERROR')
47
+ end
48
+ end
49
+ Bolt::Result.for_upload(target, source, destination)
50
+ end
51
+ end
52
+
53
+ def run_script(script, arguments, options = {})
54
+ # unpack any Sensitive data
55
+ arguments = unwrap_sensitive_args(arguments)
56
+
57
+ running_as(options[:run_as]) do
58
+ with_tempdir do |dir|
59
+ path = write_executable(dir.to_s, script)
60
+ dir.chown(run_as)
61
+ output = execute([path, *arguments], sudoable: true)
62
+ Bolt::Result.for_command(target,
63
+ output.stdout.string,
64
+ output.stderr.string,
65
+ output.exit_code,
66
+ 'script', script)
67
+ end
68
+ end
69
+ end
70
+
71
+ def run_task(task, arguments, options = {})
72
+ implementation = select_implementation(target, task)
73
+ executable = implementation['path']
74
+ input_method = implementation['input_method']
75
+ extra_files = implementation['files']
76
+
77
+ running_as(options[:run_as]) do
78
+ stdin, output = nil
79
+ execute_options = {}
80
+ execute_options[:interpreter] = select_interpreter(executable, target.options['interpreters'])
81
+ interpreter_debug = if execute_options[:interpreter]
82
+ " using '#{execute_options[:interpreter]}' interpreter"
83
+ end
84
+ # log the arguments with sensitive data redacted, do NOT log unwrapped_arguments
85
+ logger.debug("Running '#{executable}' with #{arguments.to_json}#{interpreter_debug}")
86
+ # unpack any Sensitive data
87
+ arguments = unwrap_sensitive_args(arguments)
88
+
89
+ with_tempdir do |dir|
90
+ if extra_files.empty?
91
+ task_dir = dir
92
+ else
93
+ # TODO: optimize upload of directories
94
+ arguments['_installdir'] = dir.to_s
95
+ task_dir = File.join(dir.to_s, task.tasks_dir)
96
+ dir.mkdirs([task.tasks_dir] + extra_files.map { |file| File.dirname(file['name']) })
97
+ extra_files.each do |file|
98
+ conn.copy_file(file['path'], File.join(dir.to_s, file['name']))
99
+ end
100
+ end
101
+
102
+ if Bolt::Task::STDIN_METHODS.include?(input_method)
103
+ stdin = JSON.dump(arguments)
104
+ end
105
+
106
+ if Bolt::Task::ENVIRONMENT_METHODS.include?(input_method)
107
+ execute_options[:environment] = envify_params(arguments)
108
+ end
109
+
110
+ remote_task_path = write_executable(task_dir, executable)
111
+
112
+ # Avoid the horrors of passing data on stdin via a tty on multiple platforms
113
+ # by writing a wrapper script that directs stdin to the task.
114
+ if stdin && target.options['tty']
115
+ wrapper = make_wrapper_stringio(remote_task_path, stdin, execute_options[:interpreter])
116
+ execute_options.delete(:interpreter)
117
+ execute_options[:wrapper] = true
118
+ remote_task_path = write_executable(dir, wrapper, 'wrapper.sh')
119
+ end
120
+
121
+ dir.chown(run_as)
122
+
123
+ execute_options[:stdin] = stdin
124
+ execute_options[:sudoable] = true if run_as
125
+ output = execute(remote_task_path, execute_options)
126
+ end
127
+ Bolt::Result.for_task(target, output.stdout.string,
128
+ output.stderr.string,
129
+ output.exit_code,
130
+ task.name)
131
+ end
132
+ end
133
+
134
+ # If prompted for sudo password, send password to stdin and return an
135
+ # empty string. Otherwise, check for sudo errors and raise Bolt error.
136
+ # If sudo_id is detected, that means the task needs to have stdin written.
137
+ # If error is not sudo-related, return the stderr string to be added to
138
+ # node output
139
+ def handle_sudo(stdin, err, sudo_stdin)
140
+ if err.include?(sudo_prompt)
141
+ # A wild sudo prompt has appeared!
142
+ if @sudo_password
143
+ stdin.write("#{@sudo_password}\n")
144
+ ''
145
+ else
146
+ raise Bolt::Node::EscalateError.new(
147
+ "Sudo password for user #{conn.user} was not provided for #{target}",
148
+ 'NO_PASSWORD'
149
+ )
150
+ end
151
+ elsif err =~ /^#{@sudo_id}/
152
+ if sudo_stdin
153
+ stdin.write("#{sudo_stdin}\n")
154
+ stdin.close
155
+ end
156
+ ''
157
+ else
158
+ handle_sudo_errors(err)
159
+ end
160
+ end
161
+
162
+ # See if there's a sudo prompt in the output
163
+ # If not, return the output
164
+ def check_sudo(out, inp, stdin)
165
+ buffer = out.readpartial(CHUNK_SIZE)
166
+ # Split on newlines, including the newline
167
+ lines = buffer.split(/(?<=[\n])/)
168
+ # handle_sudo will return the line if it is not a sudo prompt or error
169
+ lines.map! { |line| handle_sudo(inp, line, stdin) }
170
+ lines.join("")
171
+ # If stream has reached EOF, no password prompt is expected
172
+ # return an empty string
173
+ rescue EOFError
174
+ ''
175
+ end
176
+
177
+ def handle_sudo_errors(err)
178
+ if err =~ /^#{conn.user} is not in the sudoers file\./
179
+ @logger.debug { err }
180
+ raise Bolt::Node::EscalateError.new(
181
+ "User #{conn.user} does not have sudo permission on #{target}",
182
+ 'SUDO_DENIED'
183
+ )
184
+ elsif err =~ /^Sorry, try again\./
185
+ @logger.debug { err }
186
+ raise Bolt::Node::EscalateError.new(
187
+ "Sudo password for user #{conn.user} not recognized on #{target}",
188
+ 'BAD_PASSWORD'
189
+ )
190
+ else
191
+ # No need to raise an error - just return the string
192
+ err
193
+ end
194
+ end
195
+
196
+ def make_wrapper_stringio(task_path, stdin, interpreter = nil)
197
+ if interpreter
198
+ StringIO.new(<<~SCRIPT)
199
+ #!/bin/sh
200
+ '#{interpreter}' '#{task_path}' <<'EOF'
201
+ #{stdin}
202
+ EOF
203
+ SCRIPT
204
+ else
205
+ StringIO.new(<<~SCRIPT)
206
+ #!/bin/sh
207
+ '#{task_path}' <<'EOF'
208
+ #{stdin}
209
+ EOF
210
+ SCRIPT
211
+ end
212
+ end
213
+
214
+ # This method allows the @run_as variable to be used as a per-operation
215
+ # override for the user to run as. When @run_as is unset, the user
216
+ # specified on the target will be used.
217
+ def run_as
218
+ @run_as || target.options['run-as']
219
+ end
220
+
221
+ # Run as the specified user for the duration of the block.
222
+ def running_as(user)
223
+ @run_as = user
224
+ yield
225
+ ensure
226
+ @run_as = nil
227
+ end
228
+
229
+ def make_executable(path)
230
+ result = execute(['chmod', 'u+x', path])
231
+ if result.exit_code != 0
232
+ message = "Could not make file '#{path}' executable: #{result.stderr.string}"
233
+ raise Bolt::Node::FileError.new(message, 'CHMOD_ERROR')
234
+ end
235
+ end
236
+
237
+ def make_tempdir
238
+ tmpdir = @target.options.fetch('tmpdir', '/tmp')
239
+ script_dir = @target.options.fetch('script-dir', SecureRandom.uuid)
240
+ tmppath = File.join(tmpdir, script_dir)
241
+ command = ['mkdir', '-m', 700, tmppath]
242
+
243
+ result = execute(command)
244
+ if result.exit_code != 0
245
+ raise Bolt::Node::FileError.new("Could not make tempdir: #{result.stderr.string}", 'TEMPDIR_ERROR')
246
+ end
247
+ path = tmppath || result.stdout.string.chomp
248
+ Bolt::Shell::Bash::Tmpdir.new(self, path)
249
+ end
250
+
251
+ def write_executable(dir, file, filename = nil)
252
+ filename ||= File.basename(file)
253
+ remote_path = File.join(dir.to_s, filename)
254
+ conn.copy_file(file, remote_path)
255
+ make_executable(remote_path)
256
+ remote_path
257
+ end
258
+
259
+ # A helper to create and delete a tempdir on the remote system. Yields the
260
+ # directory name.
261
+ def with_tempdir
262
+ dir = make_tempdir
263
+ yield dir
264
+ ensure
265
+ dir&.delete
266
+ end
267
+
268
+ # In the case where a task is run with elevated privilege and needs stdin
269
+ # a random string is echoed to stderr indicating that the stdin is available
270
+ # for task input data because the sudo password has already either been
271
+ # provided on stdin or was not needed.
272
+ def prepend_sudo_success(sudo_id, command_str)
273
+ command_str = "cd; #{command_str}" if conn.reset_cwd?
274
+ "sh -c #{Shellwords.shellescape("echo #{sudo_id} 1>&2; #{command_str}")}"
275
+ end
276
+
277
+ def prepend_chdir(command_str)
278
+ "sh -c #{Shellwords.shellescape("cd; #{command_str}")}"
279
+ end
280
+
281
+ # A helper to build up a single string that contains all of the options for
282
+ # privilege escalation. A wrapper script is used to direct task input to stdin
283
+ # when a tty is allocated and thus we do not need to prepend_sudo_success when
284
+ # using the wrapper or when the task does not require stdin data.
285
+ def build_sudoable_command_str(command_str, sudo_str, sudo_id, options)
286
+ if options[:stdin] && !options[:wrapper]
287
+ "#{sudo_str} #{prepend_sudo_success(sudo_id, command_str)}"
288
+ elsif conn.reset_cwd?
289
+ "#{sudo_str} #{prepend_chdir(command_str)}"
290
+ else
291
+ "#{sudo_str} #{command_str}"
292
+ end
293
+ end
294
+
295
+ # Returns string with the interpreter conditionally prepended
296
+ def inject_interpreter(interpreter, command)
297
+ if interpreter
298
+ if command.is_a?(Array)
299
+ command.unshift(interpreter)
300
+ else
301
+ command = [interpreter, command]
302
+ end
303
+ end
304
+
305
+ command.is_a?(String) ? command : Shellwords.shelljoin(command)
306
+ end
307
+
308
+ def execute(command, sudoable: false, **options)
309
+ run_as = options[:run_as] || self.run_as
310
+ escalate = sudoable && run_as && conn.user != run_as
311
+ use_sudo = escalate && @target.options['run-as-command'].nil?
312
+
313
+ command_str = inject_interpreter(options[:interpreter], command)
314
+
315
+ if options[:environment]
316
+ env_decls = options[:environment].map do |env, val|
317
+ "#{env}=#{Shellwords.shellescape(val)}"
318
+ end
319
+ command_str = "#{env_decls.join(' ')} #{command_str}"
320
+ end
321
+
322
+ if escalate
323
+ if use_sudo
324
+ sudo_exec = target.options['sudo-executable'] || "sudo"
325
+ sudo_flags = [sudo_exec, "-S", "-H", "-u", run_as, "-p", sudo_prompt]
326
+ sudo_flags += ["-E"] if options[:environment]
327
+ sudo_str = Shellwords.shelljoin(sudo_flags)
328
+ else
329
+ sudo_str = Shellwords.shelljoin(@target.options['run-as-command'] + [run_as])
330
+ end
331
+ command_str = build_sudoable_command_str(command_str, sudo_str, @sudo_id, options)
332
+ end
333
+
334
+ @logger.debug { "Executing: #{command_str}" }
335
+
336
+ in_buffer = if !use_sudo && options[:stdin]
337
+ String.new(options[:stdin], encoding: 'binary')
338
+ else
339
+ String.new(encoding: 'binary')
340
+ end
341
+ # Chunks of this size will be read in one iteration
342
+ index = 0
343
+ timeout = 0.1
344
+
345
+ inp, out, err, t = conn.execute(command_str)
346
+ result_output = Bolt::Node::Output.new
347
+ read_streams = { out => String.new,
348
+ err => String.new }
349
+ write_stream = in_buffer.empty? ? [] : [inp]
350
+
351
+ # See if there's a sudo prompt
352
+ if use_sudo
353
+ ready_read = select([err], nil, nil, timeout * 5)
354
+ read_streams[err] << check_sudo(err, inp, options[:stdin]) if ready_read
355
+ end
356
+
357
+ # True while the process is running or waiting for IO input
358
+ while t.alive?
359
+ # See if we can read from out or err, or write to in
360
+ ready_read, ready_write, = select(read_streams.keys, write_stream, nil, timeout)
361
+
362
+ # Read from out and err
363
+ ready_read&.each do |stream|
364
+ # Check for sudo prompt
365
+ read_streams[stream] << if use_sudo
366
+ check_sudo(stream, inp, options[:stdin])
367
+ else
368
+ stream.readpartial(CHUNK_SIZE)
369
+ end
370
+ rescue EOFError
371
+ end
372
+
373
+ # select will either return an empty array if there are no
374
+ # writable streams or nil if no IO object is available before the
375
+ # timeout is reached.
376
+ writable = if ready_write.respond_to?(:empty?)
377
+ !ready_write.empty?
378
+ else
379
+ !ready_write.nil?
380
+ end
381
+
382
+ begin
383
+ if writable && index < in_buffer.length
384
+ to_print = in_buffer[index..-1]
385
+ # On Windows, select marks the input stream as writable even if
386
+ # it's full. We need to check whether we received wait_writable
387
+ # and treat that as not having written anything.
388
+ written = inp.write_nonblock(to_print, exception: false)
389
+ index += written unless written == :wait_writable
390
+
391
+ if index >= in_buffer.length && !write_stream.empty?
392
+ inp.close
393
+ write_stream = []
394
+ end
395
+ end
396
+ # If a task has stdin as an input_method but doesn't actually
397
+ # read from stdin, the task may return and close the input stream
398
+ rescue Errno::EPIPE
399
+ write_stream = []
400
+ end
401
+ end
402
+ # Read any remaining data in the pipe. Do not wait for
403
+ # EOF in case the pipe is inherited by a child process.
404
+ read_streams.each do |stream, _|
405
+ loop { read_streams[stream] << stream.read_nonblock(CHUNK_SIZE) }
406
+ rescue Errno::EAGAIN, EOFError
407
+ end
408
+ result_output.stdout << read_streams[out]
409
+ result_output.stderr << read_streams[err]
410
+ result_output.exit_code = t.value.respond_to?(:exitstatus) ? t.value.exitstatus : t.value
411
+
412
+ if result_output.exit_code == 0
413
+ @logger.debug { "Command returned successfully" }
414
+ else
415
+ @logger.info { "Command failed with exit code #{result_output.exit_code}" }
416
+ end
417
+ result_output
418
+ rescue StandardError
419
+ # Ensure we close stdin and kill the child process
420
+ inp&.close
421
+ t&.terminate if t&.alive?
422
+ @logger.debug { "Command aborted" }
423
+ raise
424
+ end
425
+
426
+ def sudo_prompt
427
+ '[sudo] Bolt needs to run as another user, password: '
428
+ end
429
+ end
430
+ end
431
+ end