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.

Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +1 -1
  3. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +4 -4
  4. data/lib/bolt.rb +3 -0
  5. data/lib/bolt/analytics.rb +7 -2
  6. data/lib/bolt/applicator.rb +6 -2
  7. data/lib/bolt/bolt_option_parser.rb +4 -4
  8. data/lib/bolt/cli.rb +8 -4
  9. data/lib/bolt/config.rb +6 -6
  10. data/lib/bolt/executor.rb +2 -7
  11. data/lib/bolt/inventory.rb +37 -6
  12. data/lib/bolt/inventory/group2.rb +314 -0
  13. data/lib/bolt/inventory/inventory2.rb +261 -0
  14. data/lib/bolt/outputter/human.rb +3 -1
  15. data/lib/bolt/pal.rb +8 -7
  16. data/lib/bolt/puppetdb/client.rb +6 -5
  17. data/lib/bolt/target.rb +34 -14
  18. data/lib/bolt/task.rb +2 -2
  19. data/lib/bolt/transport/base.rb +2 -2
  20. data/lib/bolt/transport/docker.rb +1 -1
  21. data/lib/bolt/transport/docker/connection.rb +2 -0
  22. data/lib/bolt/transport/local.rb +9 -181
  23. data/lib/bolt/transport/local/shell.rb +202 -12
  24. data/lib/bolt/transport/local_windows.rb +203 -0
  25. data/lib/bolt/transport/orch.rb +6 -4
  26. data/lib/bolt/transport/orch/connection.rb +6 -2
  27. data/lib/bolt/transport/ssh.rb +10 -150
  28. data/lib/bolt/transport/ssh/connection.rb +15 -116
  29. data/lib/bolt/transport/sudoable.rb +163 -0
  30. data/lib/bolt/transport/sudoable/connection.rb +76 -0
  31. data/lib/bolt/transport/sudoable/tmpdir.rb +59 -0
  32. data/lib/bolt/transport/winrm.rb +4 -4
  33. data/lib/bolt/transport/winrm/connection.rb +1 -0
  34. data/lib/bolt/util.rb +2 -0
  35. data/lib/bolt/version.rb +1 -1
  36. data/lib/bolt_ext/puppetdb_inventory.rb +0 -1
  37. data/lib/bolt_server/transport_app.rb +3 -1
  38. data/lib/logging_extensions/logging.rb +13 -0
  39. data/lib/plan_executor/orch_client.rb +4 -0
  40. 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, additional_features = [])
75
+ def select_implementation(target, provided_features = [])
76
76
  impl = if (impls = implementations)
77
- available_features = target.features + additional_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?
@@ -69,8 +69,8 @@ module Bolt
69
69
 
70
70
  result = begin
71
71
  yield
72
- rescue StandardError, NotImplementedError => ex
73
- Bolt::Result.from_exception(target, ex)
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)
@@ -8,7 +8,7 @@ module Bolt
8
8
  module Transport
9
9
  class Docker < Base
10
10
  def self.options
11
- %w[service-url service-options tmpdir interpreters]
11
+ %w[host service-url service-options tmpdir interpreters]
12
12
  end
13
13
 
14
14
  def provided_features
@@ -11,6 +11,8 @@ module Bolt
11
11
  # lazy-load expensive gem code
12
12
  require 'docker'
13
13
 
14
+ raise Bolt::ValidationError, "Target #{target.name} does not have a host" unless target.host
15
+
14
16
  @target = target
15
17
  @logger = Logging.logger[target.host]
16
18
  end
@@ -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 < Base
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
- if Bolt::Util.windows?
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 copy_file(source, destination)
58
- FileUtils.cp_r(source, destination, remove_destination: true)
59
- rescue StandardError => e
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 with_tmpscript(script, base)
64
- in_tmpdir(base) do |dir|
65
- dest = File.join(dir, File.basename(script))
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
- def execute(*command, options)
11
- command.unshift(options[:interpreter]) if options[:interpreter]
12
- command = [options[:env]] + command if options[:env]
13
-
14
- if options[:stdin]
15
- stdout, stderr, rc = Open3.capture3(*command, stdin_data: options[:stdin], chdir: options[:dir])
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
- stdout, stderr, rc = Open3.capture3(*command, chdir: options[:dir])
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
- result_output.stdout << stdout unless stdout.nil?
22
- result_output.stderr << stderr unless stderr.nil?
23
- result_output.exit_code = rc.exitstatus
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