bolt 1.15.0 → 1.16.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 +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +4 -4
- data/lib/bolt.rb +3 -0
- data/lib/bolt/analytics.rb +7 -2
- data/lib/bolt/applicator.rb +6 -2
- data/lib/bolt/bolt_option_parser.rb +4 -4
- data/lib/bolt/cli.rb +8 -4
- data/lib/bolt/config.rb +6 -6
- data/lib/bolt/executor.rb +2 -7
- data/lib/bolt/inventory.rb +37 -6
- data/lib/bolt/inventory/group2.rb +314 -0
- data/lib/bolt/inventory/inventory2.rb +261 -0
- data/lib/bolt/outputter/human.rb +3 -1
- data/lib/bolt/pal.rb +8 -7
- data/lib/bolt/puppetdb/client.rb +6 -5
- data/lib/bolt/target.rb +34 -14
- data/lib/bolt/task.rb +2 -2
- data/lib/bolt/transport/base.rb +2 -2
- data/lib/bolt/transport/docker.rb +1 -1
- data/lib/bolt/transport/docker/connection.rb +2 -0
- data/lib/bolt/transport/local.rb +9 -181
- data/lib/bolt/transport/local/shell.rb +202 -12
- data/lib/bolt/transport/local_windows.rb +203 -0
- data/lib/bolt/transport/orch.rb +6 -4
- data/lib/bolt/transport/orch/connection.rb +6 -2
- data/lib/bolt/transport/ssh.rb +10 -150
- data/lib/bolt/transport/ssh/connection.rb +15 -116
- data/lib/bolt/transport/sudoable.rb +163 -0
- data/lib/bolt/transport/sudoable/connection.rb +76 -0
- data/lib/bolt/transport/sudoable/tmpdir.rb +59 -0
- data/lib/bolt/transport/winrm.rb +4 -4
- data/lib/bolt/transport/winrm/connection.rb +1 -0
- data/lib/bolt/util.rb +2 -0
- data/lib/bolt/version.rb +1 -1
- data/lib/bolt_ext/puppetdb_inventory.rb +0 -1
- data/lib/bolt_server/transport_app.rb +3 -1
- data/lib/logging_extensions/logging.rb +13 -0
- data/lib/plan_executor/orch_client.rb +4 -0
- metadata +23 -2
data/lib/bolt/task.rb
CHANGED
@@ -72,9 +72,9 @@ module Bolt
|
|
72
72
|
|
73
73
|
# Returns a hash of implementation name, path to executable, input method (if defined),
|
74
74
|
# and any additional files (name and path)
|
75
|
-
def select_implementation(target,
|
75
|
+
def select_implementation(target, provided_features = [])
|
76
76
|
impl = if (impls = implementations)
|
77
|
-
available_features = target.features +
|
77
|
+
available_features = target.features + provided_features
|
78
78
|
impl = impls.find do |imp|
|
79
79
|
remote_impl = imp['remote']
|
80
80
|
remote_impl = metadata['remote'] if remote_impl.nil?
|
data/lib/bolt/transport/base.rb
CHANGED
@@ -69,8 +69,8 @@ module Bolt
|
|
69
69
|
|
70
70
|
result = begin
|
71
71
|
yield
|
72
|
-
rescue StandardError, NotImplementedError =>
|
73
|
-
Bolt::Result.from_exception(target,
|
72
|
+
rescue StandardError, NotImplementedError => e
|
73
|
+
Bolt::Result.from_exception(target, e)
|
74
74
|
end
|
75
75
|
|
76
76
|
callback&.call(type: :node_result, result: result)
|
data/lib/bolt/transport/local.rb
CHANGED
@@ -1,196 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'json'
|
4
|
-
require 'fileutils'
|
5
|
-
require 'tmpdir'
|
6
|
-
require 'bolt/transport/base'
|
7
|
-
require 'bolt/transport/powershell'
|
8
|
-
require 'bolt/util'
|
9
|
-
|
10
3
|
module Bolt
|
11
4
|
module Transport
|
12
|
-
class Local <
|
5
|
+
class Local < Sudoable
|
13
6
|
def self.options
|
14
|
-
%w[tmpdir interpreters]
|
15
|
-
end
|
16
|
-
|
17
|
-
def self.default_options
|
18
|
-
{
|
19
|
-
'interpreters' => { '.rb' => RbConfig.ruby }
|
20
|
-
}
|
7
|
+
%w[tmpdir interpreters sudo-password run-as run-as-command]
|
21
8
|
end
|
22
9
|
|
23
10
|
def provided_features
|
24
|
-
|
25
|
-
['powershell']
|
26
|
-
else
|
27
|
-
['shell']
|
28
|
-
end
|
29
|
-
end
|
30
|
-
|
31
|
-
def default_input_method(executable)
|
32
|
-
input_method ||= Powershell.powershell_file?(executable) ? 'powershell' : 'both'
|
33
|
-
input_method
|
34
|
-
end
|
35
|
-
|
36
|
-
def self.validate(_options); end
|
37
|
-
|
38
|
-
def initialize
|
39
|
-
super
|
40
|
-
@conn = Shell.new
|
41
|
-
end
|
42
|
-
|
43
|
-
def in_tmpdir(base)
|
44
|
-
args = base ? [nil, base] : []
|
45
|
-
dir = begin
|
46
|
-
Dir.mktmpdir(*args)
|
47
|
-
rescue StandardError => e
|
48
|
-
raise Bolt::Node::FileError.new("Could not make tempdir: #{e.message}", 'TEMPDIR_ERROR')
|
49
|
-
end
|
50
|
-
|
51
|
-
yield dir
|
52
|
-
ensure
|
53
|
-
FileUtils.remove_entry dir if dir
|
11
|
+
['shell']
|
54
12
|
end
|
55
|
-
private :in_tmpdir
|
56
13
|
|
57
|
-
def
|
58
|
-
|
59
|
-
|
60
|
-
raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
|
14
|
+
def self.validate(options)
|
15
|
+
logger = Logging.logger[self]
|
16
|
+
validate_sudo_options(options, logger)
|
61
17
|
end
|
62
18
|
|
63
|
-
def
|
64
|
-
|
65
|
-
|
66
|
-
copy_file(script, dest)
|
67
|
-
File.chmod(0o750, dest)
|
68
|
-
yield dest, dir
|
69
|
-
end
|
70
|
-
end
|
71
|
-
private :with_tmpscript
|
72
|
-
|
73
|
-
def upload(target, source, destination, _options = {})
|
74
|
-
copy_file(source, destination)
|
75
|
-
Bolt::Result.for_upload(target, source, destination)
|
76
|
-
end
|
77
|
-
|
78
|
-
def run_command(target, command, _options = {})
|
79
|
-
in_tmpdir(target.options['tmpdir']) do |dir|
|
80
|
-
output = @conn.execute(command, dir: dir)
|
81
|
-
Bolt::Result.for_command(target,
|
82
|
-
output.stdout.string,
|
83
|
-
output.stderr.string,
|
84
|
-
output.exit_code,
|
85
|
-
'command', command)
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
def run_script(target, script, arguments, _options = {})
|
90
|
-
with_tmpscript(File.absolute_path(script), target.options['tmpdir']) do |file, dir|
|
91
|
-
logger.debug "Running '#{file}' with #{arguments}"
|
92
|
-
|
93
|
-
# unpack any Sensitive data AFTER we log
|
94
|
-
arguments = unwrap_sensitive_args(arguments)
|
95
|
-
if Bolt::Util.windows?
|
96
|
-
if Powershell.powershell_file?(file)
|
97
|
-
command = Powershell.run_script(arguments, file)
|
98
|
-
output = @conn.execute(command, dir: dir, env: "powershell.exe")
|
99
|
-
else
|
100
|
-
path, args = *Powershell.process_from_extension(file)
|
101
|
-
args += Powershell.escape_arguments(arguments)
|
102
|
-
command = args.unshift(path).join(' ')
|
103
|
-
output = @conn.execute(command, dir: dir)
|
104
|
-
end
|
105
|
-
else
|
106
|
-
if arguments.empty?
|
107
|
-
# We will always provide separated arguments, so work-around Open3's handling of a single
|
108
|
-
# argument as the entire command string for script paths containing spaces.
|
109
|
-
arguments = ['']
|
110
|
-
end
|
111
|
-
output = @conn.execute(file, *arguments, dir: dir)
|
112
|
-
end
|
113
|
-
Bolt::Result.for_command(target,
|
114
|
-
output.stdout.string,
|
115
|
-
output.stderr.string,
|
116
|
-
output.exit_code,
|
117
|
-
'script', script)
|
118
|
-
end
|
119
|
-
end
|
120
|
-
|
121
|
-
def run_task(target, task, arguments, _options = {})
|
122
|
-
implementation = select_implementation(target, task)
|
123
|
-
executable = implementation['path']
|
124
|
-
input_method = implementation['input_method']
|
125
|
-
extra_files = implementation['files']
|
126
|
-
|
127
|
-
in_tmpdir(target.options['tmpdir']) do |dir|
|
128
|
-
if extra_files.empty?
|
129
|
-
script = File.join(dir, File.basename(executable))
|
130
|
-
else
|
131
|
-
arguments['_installdir'] = dir
|
132
|
-
script_dest = File.join(dir, task.tasks_dir)
|
133
|
-
FileUtils.mkdir_p([script_dest] + extra_files.map { |file| File.join(dir, File.dirname(file['name'])) })
|
134
|
-
|
135
|
-
script = File.join(script_dest, File.basename(executable))
|
136
|
-
extra_files.each do |file|
|
137
|
-
dest = File.join(dir, file['name'])
|
138
|
-
copy_file(file['path'], dest)
|
139
|
-
File.chmod(0o750, dest)
|
140
|
-
end
|
141
|
-
end
|
142
|
-
|
143
|
-
copy_file(executable, script)
|
144
|
-
File.chmod(0o750, script)
|
145
|
-
|
146
|
-
interpreter = select_interpreter(script, target.options['interpreters'])
|
147
|
-
interpreter_debug = interpreter ? " using '#{interpreter}' interpreter" : nil
|
148
|
-
# log the arguments with sensitive data redacted, do NOT log unwrapped_arguments
|
149
|
-
logger.debug("Running '#{script}' with #{arguments}#{interpreter_debug}")
|
150
|
-
unwrapped_arguments = unwrap_sensitive_args(arguments)
|
151
|
-
|
152
|
-
stdin = STDIN_METHODS.include?(input_method) ? JSON.dump(unwrapped_arguments) : nil
|
153
|
-
|
154
|
-
if Bolt::Util.windows?
|
155
|
-
# WINDOWS
|
156
|
-
if ENVIRONMENT_METHODS.include?(input_method)
|
157
|
-
environment_params = envify_params(unwrapped_arguments).each_with_object([]) do |(arg, val), list|
|
158
|
-
list << Powershell.set_env(arg, val)
|
159
|
-
end
|
160
|
-
environment_params = environment_params.join("\n") + "\n"
|
161
|
-
else
|
162
|
-
environment_params = ""
|
163
|
-
end
|
164
|
-
|
165
|
-
if Powershell.powershell_file?(script) && stdin.nil?
|
166
|
-
command = Powershell.run_ps_task(arguments, script, input_method)
|
167
|
-
command = environment_params + Powershell.shell_init + command
|
168
|
-
interpreter ||= 'powershell.exe'
|
169
|
-
output =
|
170
|
-
if input_method == 'powershell'
|
171
|
-
@conn.execute(command, dir: dir, interpreter: interpreter)
|
172
|
-
else
|
173
|
-
@conn.execute(command, dir: dir, stdin: stdin, interpreter: interpreter)
|
174
|
-
end
|
175
|
-
end
|
176
|
-
unless output
|
177
|
-
if interpreter
|
178
|
-
env = ENVIRONMENT_METHODS.include?(input_method) ? envify_params(unwrapped_arguments) : nil
|
179
|
-
output = @conn.execute(script, stdin: stdin, env: env, dir: dir, interpreter: interpreter)
|
180
|
-
else
|
181
|
-
path, args = *Powershell.process_from_extension(script)
|
182
|
-
command = args.unshift(path).join(' ')
|
183
|
-
command = environment_params + Powershell.shell_init + command
|
184
|
-
output = @conn.execute(command, dir: dir, stdin: stdin, interpreter: 'powershell.exe')
|
185
|
-
end
|
186
|
-
end
|
187
|
-
else
|
188
|
-
# POSIX
|
189
|
-
env = ENVIRONMENT_METHODS.include?(input_method) ? envify_params(unwrapped_arguments) : nil
|
190
|
-
output = @conn.execute(script, stdin: stdin, env: env, dir: dir, interpreter: interpreter)
|
191
|
-
end
|
192
|
-
Bolt::Result.for_task(target, output.stdout.string, output.stderr.string, output.exit_code, task.name)
|
193
|
-
end
|
19
|
+
def with_connection(target, *_args)
|
20
|
+
conn = Shell.new(target)
|
21
|
+
yield conn
|
194
22
|
end
|
195
23
|
|
196
24
|
def connected?(_targets)
|
@@ -1,26 +1,216 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'open3'
|
4
|
+
require 'fileutils'
|
4
5
|
require 'bolt/node/output'
|
6
|
+
require 'bolt/util'
|
5
7
|
|
6
8
|
module Bolt
|
7
9
|
module Transport
|
8
|
-
class Local
|
9
|
-
class Shell
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
10
|
+
class Local < Sudoable
|
11
|
+
class Shell < Sudoable::Connection
|
12
|
+
attr_accessor :user, :logger, :target
|
13
|
+
attr_writer :run_as
|
14
|
+
|
15
|
+
CHUNK_SIZE = 4096
|
16
|
+
|
17
|
+
def initialize(target)
|
18
|
+
@target = target
|
19
|
+
# The familiar problem: Etc.getlogin is broken on osx
|
20
|
+
@user = ENV['USER'] || Etc.getlogin
|
21
|
+
@run_as = target.options['run-as']
|
22
|
+
@logger = Logging.logger[self]
|
23
|
+
end
|
24
|
+
|
25
|
+
# If prompted for sudo password, send password to stdin and return an
|
26
|
+
# empty string. Otherwise, check for sudo errors and raise Bolt error.
|
27
|
+
# If error is not sudo-related, return the stderr string to be added to
|
28
|
+
# node output
|
29
|
+
def handle_sudo(stdin, err, pid)
|
30
|
+
if err.include?(Sudoable.sudo_prompt)
|
31
|
+
# A wild sudo prompt has appeared!
|
32
|
+
if @target.options['sudo-password']
|
33
|
+
# Hopefully no one's sudo-password is > 64kb
|
34
|
+
stdin.write("#{@target.options['sudo-password']}\n")
|
35
|
+
''
|
36
|
+
else
|
37
|
+
raise Bolt::Node::EscalateError.new(
|
38
|
+
"Sudo password for user #{@user} was not provided for localhost",
|
39
|
+
'NO_PASSWORD'
|
40
|
+
)
|
41
|
+
end
|
42
|
+
else
|
43
|
+
handle_sudo_errors(err, pid)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def handle_sudo_errors(err, pid)
|
48
|
+
if err =~ /^#{@user} is not in the sudoers file\./
|
49
|
+
@logger.debug { err }
|
50
|
+
raise Bolt::Node::EscalateError.new(
|
51
|
+
"User #{@user} does not have sudo permission on localhost",
|
52
|
+
'SUDO_DENIED'
|
53
|
+
)
|
54
|
+
elsif err =~ /^Sorry, try again\./
|
55
|
+
@logger.debug { err }
|
56
|
+
# CODEREVIEW can we kill a sudo process without sudo password?
|
57
|
+
Process.kill('TERM', pid)
|
58
|
+
raise Bolt::Node::EscalateError.new(
|
59
|
+
"Sudo password for user #{@user} not recognized on localhost",
|
60
|
+
'BAD_PASSWORD'
|
61
|
+
)
|
62
|
+
else
|
63
|
+
# No need to raise an error - just return the string
|
64
|
+
err
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def copy_file(source, dest)
|
69
|
+
if source.is_a?(StringIO)
|
70
|
+
File.open("tempfile", "w") { |f| f.write(source.read) }
|
71
|
+
execute(['mv', 'tempfile', dest])
|
16
72
|
else
|
17
|
-
|
73
|
+
# Mimic the behavior of `cp --remove-destination`
|
74
|
+
# since the flag isn't supported on MacOS
|
75
|
+
result = execute(['rm', '-rf', dest])
|
76
|
+
if result.exit_code != 0
|
77
|
+
message = "Could not remove existing file #{dest}: #{result.stderr.string}"
|
78
|
+
raise Bolt::Node::FileError.new(message, 'REMOVE_ERROR')
|
79
|
+
end
|
80
|
+
|
81
|
+
result = execute(['cp', '-r', source, dest])
|
82
|
+
if result.exit_code != 0
|
83
|
+
message = "Could not copy file to #{dest}: #{result.stderr.string}"
|
84
|
+
raise Bolt::Node::FileError.new(message, 'COPY_ERROR')
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def with_tmpscript(script)
|
90
|
+
with_tempdir do |dir|
|
91
|
+
dest = File.join(dir.to_s, File.basename(script))
|
92
|
+
copy_file(script, dest)
|
93
|
+
yield dest, dir
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# See if there's a sudo prompt in the output
|
98
|
+
# If not, return the output
|
99
|
+
def check_sudo(out, inp, pid)
|
100
|
+
buffer = out.readpartial(CHUNK_SIZE)
|
101
|
+
# Split on newlines, including the newline
|
102
|
+
lines = buffer.split(/(?<=[\n])/)
|
103
|
+
# handle_sudo will return the line if it is not a sudo prompt or error
|
104
|
+
lines.map! { |line| handle_sudo(inp, line, pid) }
|
105
|
+
lines.join("")
|
106
|
+
# If stream has reached EOF, no password prompt is expected
|
107
|
+
# return an empty string
|
108
|
+
rescue EOFError
|
109
|
+
''
|
110
|
+
end
|
111
|
+
|
112
|
+
def execute(command, sudoable: true, **options)
|
113
|
+
run_as = options[:run_as] || self.run_as
|
114
|
+
escalate = sudoable && run_as && @user != run_as
|
115
|
+
use_sudo = escalate && @target.options['run-as-command'].nil?
|
116
|
+
|
117
|
+
if options[:interpreter]
|
118
|
+
if command.is_a?(Array)
|
119
|
+
command.unshift(options[:interpreter])
|
120
|
+
else
|
121
|
+
command = [options[:interpreter], command]
|
122
|
+
end
|
18
123
|
end
|
19
124
|
|
125
|
+
command_str = command.is_a?(String) ? command : Shellwords.shelljoin(command)
|
126
|
+
|
127
|
+
if escalate
|
128
|
+
if use_sudo
|
129
|
+
sudo_flags = ["sudo", "-k", "-S", "-u", run_as, "-p", Sudoable.sudo_prompt]
|
130
|
+
sudo_flags += ["-E"] if options[:environment]
|
131
|
+
sudo_str = Shellwords.shelljoin(sudo_flags)
|
132
|
+
command_str = "#{sudo_str} #{command_str}"
|
133
|
+
else
|
134
|
+
run_as_str = Shellwords.shelljoin(@target.options['run-as-command'] + [run_as])
|
135
|
+
command_str = "#{run_as_str} #{command_str}"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
command_arr = options[:environment].nil? ? [command_str] : [options[:environment], command_str]
|
140
|
+
|
141
|
+
# Prepare the variables!
|
20
142
|
result_output = Bolt::Node::Output.new
|
21
|
-
|
22
|
-
|
23
|
-
|
143
|
+
in_buffer = options[:stdin] || ''
|
144
|
+
# Chunks of this size will be read in one iteration
|
145
|
+
index = 0
|
146
|
+
timeout = 0.1
|
147
|
+
|
148
|
+
inp, out, err, t = Open3.popen3(*command_arr)
|
149
|
+
read_streams = { out => String.new,
|
150
|
+
err => String.new }
|
151
|
+
write_stream = in_buffer.empty? ? [] : [inp]
|
152
|
+
|
153
|
+
# See if there's a sudo prompt
|
154
|
+
if use_sudo
|
155
|
+
ready_read = select([err], nil, nil, timeout * 5)
|
156
|
+
read_streams[err] << check_sudo(err, inp, t.pid) if ready_read
|
157
|
+
end
|
158
|
+
|
159
|
+
# True while the process is running or waiting for IO input
|
160
|
+
while t.alive?
|
161
|
+
# See if we can read from out or err, or write to in
|
162
|
+
ready_read, ready_write, = select(read_streams.keys, write_stream, nil, timeout)
|
163
|
+
|
164
|
+
# Read from out and err
|
165
|
+
ready_read&.each do |stream|
|
166
|
+
begin
|
167
|
+
# Check for sudo prompt
|
168
|
+
read_streams[stream] << if use_sudo
|
169
|
+
check_sudo(stream, inp, t.pid)
|
170
|
+
else
|
171
|
+
stream.readpartial(CHUNK_SIZE)
|
172
|
+
end
|
173
|
+
rescue EOFError
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# select will either return an empty array if there are no
|
178
|
+
# writable streams or nil if no IO object is available before the
|
179
|
+
# timeout is reached.
|
180
|
+
writable = if ready_write.respond_to?(:empty?)
|
181
|
+
!ready_write.empty?
|
182
|
+
else
|
183
|
+
!ready_write.nil?
|
184
|
+
end
|
185
|
+
|
186
|
+
begin
|
187
|
+
if writable && index < in_buffer.length
|
188
|
+
to_print = in_buffer[index..-1]
|
189
|
+
written = inp.write_nonblock to_print
|
190
|
+
index += written
|
191
|
+
|
192
|
+
if index >= in_buffer.length && !write_stream.empty?
|
193
|
+
inp.close
|
194
|
+
write_stream = []
|
195
|
+
end
|
196
|
+
end
|
197
|
+
# If a task has stdin as an input_method but doesn't actually
|
198
|
+
# read from stdin, the task may return and close the input stream
|
199
|
+
rescue Errno::EPIPE
|
200
|
+
write_stream = []
|
201
|
+
end
|
202
|
+
end
|
203
|
+
# Read any remaining data in the pipe. Do not wait for
|
204
|
+
# EOF in case the pipe is inherited by a child process.
|
205
|
+
read_streams.each do |stream, _|
|
206
|
+
begin
|
207
|
+
loop { read_streams[stream] << stream.read_nonblock(CHUNK_SIZE) }
|
208
|
+
rescue Errno::EAGAIN, EOFError
|
209
|
+
end
|
210
|
+
end
|
211
|
+
result_output.stdout << read_streams[out]
|
212
|
+
result_output.stderr << read_streams[err]
|
213
|
+
result_output.exit_code = t.value.exitstatus
|
24
214
|
result_output
|
25
215
|
end
|
26
216
|
end
|