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 +4 -4
- data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +3 -1
- data/lib/bolt/bolt_option_parser.rb +1 -1
- data/lib/bolt/executor.rb +3 -3
- data/lib/bolt/inventory/target.rb +0 -6
- data/lib/bolt/result.rb +3 -3
- data/lib/bolt/shell.rb +88 -0
- data/lib/bolt/shell/bash.rb +431 -0
- data/lib/bolt/{transport/sudoable → shell/bash}/tmpdir.rb +10 -10
- data/lib/bolt/target.rb +5 -4
- data/lib/bolt/task.rb +3 -0
- data/lib/bolt/transport/base.rb +6 -9
- data/lib/bolt/transport/docker.rb +2 -2
- data/lib/bolt/transport/local.rb +8 -11
- data/lib/bolt/transport/local/connection.rb +58 -0
- data/lib/bolt/transport/local_windows.rb +3 -3
- data/lib/bolt/transport/orch.rb +1 -1
- data/lib/bolt/transport/remote.rb +1 -1
- data/lib/bolt/transport/simple.rb +48 -0
- data/lib/bolt/transport/ssh.rb +2 -14
- data/lib/bolt/transport/ssh/connection.rb +66 -111
- data/lib/bolt/transport/winrm.rb +2 -2
- data/lib/bolt/util.rb +17 -1
- data/lib/bolt/version.rb +1 -1
- data/lib/bolt_spec/bolt_context.rb +206 -0
- data/lib/bolt_spec/plans.rb +7 -94
- metadata +8 -6
- data/lib/bolt/transport/local/shell.rb +0 -216
- data/lib/bolt/transport/sudoable.rb +0 -150
- data/lib/bolt/transport/sudoable/connection.rb +0 -118
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 35079d3ff8276e6ebe7e1164e7336ef77b17d64c0f3c3df4a257b71fb3c6c599
|
4
|
+
data.tar.gz: ccc148bcec686d950741595e99dbcacd88825685d3dfb7641850d9ff54df02a6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
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:
|
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
|