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.

@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bolt
4
- module Transport
5
- class Sudoable < Base
4
+ class Shell
5
+ class Bash < Shell
6
6
  class Tmpdir
7
- def initialize(node, path)
8
- @conn = node
9
- @owner = node.user
7
+ def initialize(shell, path)
8
+ @shell = shell
9
+ @owner = shell.conn.user
10
10
  @path = path
11
- @logger = node.logger
11
+ @logger = shell.logger
12
12
  end
13
13
 
14
14
  def to_s
@@ -17,7 +17,7 @@ module Bolt
17
17
 
18
18
  def mkdirs(subdirs)
19
19
  abs_subdirs = subdirs.map { |subdir| File.join(@path, subdir) }
20
- result = @conn.execute(['mkdir', '-p'] + abs_subdirs)
20
+ result = @shell.execute(['mkdir', '-p'] + abs_subdirs)
21
21
  if result.exit_code != 0
22
22
  message = "Could not create subdirectories in '#{@path}': #{result.stderr.string}"
23
23
  raise Bolt::Node::FileError.new(message, 'MKDIR_ERROR')
@@ -27,7 +27,7 @@ module Bolt
27
27
  def chown(owner)
28
28
  return if owner.nil? || owner == @owner
29
29
 
30
- result = @conn.execute(['id', '-g', owner])
30
+ result = @shell.execute(['id', '-g', owner])
31
31
  if result.exit_code != 0
32
32
  message = "Could not identify group of user #{owner}: #{result.stderr.string}"
33
33
  raise Bolt::Node::FileError.new(message, 'ID_ERROR')
@@ -35,7 +35,7 @@ module Bolt
35
35
  group = result.stdout.string.chomp
36
36
 
37
37
  # Chown can only be run by root.
38
- result = @conn.execute(['chown', '-R', "#{owner}:#{group}", @path], sudoable: true, run_as: 'root')
38
+ result = @shell.execute(['chown', '-R', "#{owner}:#{group}", @path], sudoable: true, run_as: 'root')
39
39
  if result.exit_code != 0
40
40
  message = "Could not change owner of '#{@path}' to #{owner}: #{result.stderr.string}"
41
41
  raise Bolt::Node::FileError.new(message, 'CHOWN_ERROR')
@@ -46,7 +46,7 @@ module Bolt
46
46
  end
47
47
 
48
48
  def delete
49
- result = @conn.execute(['rm', '-rf', @path], sudoable: true, run_as: @owner)
49
+ result = @shell.execute(['rm', '-rf', @path], sudoable: true, run_as: @owner)
50
50
  if result.exit_code != 0
51
51
  @logger.warn("Failed to clean up tempdir '#{@path}': #{result.stderr.string}")
52
52
  end
data/lib/bolt/target.rb CHANGED
@@ -129,6 +129,11 @@ module Bolt
129
129
  inventory_target.transport
130
130
  end
131
131
 
132
+ def transport_config
133
+ inventory_target.transport_config.to_h
134
+ end
135
+ alias options transport_config
136
+
132
137
  def protocol
133
138
  inventory_target.protocol || inventory_target.transport
134
139
  end
@@ -141,10 +146,6 @@ module Bolt
141
146
  inventory_target.password
142
147
  end
143
148
 
144
- def options
145
- inventory_target.options
146
- end
147
-
148
149
  def plugin_hooks
149
150
  inventory_target.plugin_hooks
150
151
  end
data/lib/bolt/task.rb CHANGED
@@ -9,6 +9,9 @@ module Bolt
9
9
  end
10
10
 
11
11
  class Task
12
+ STDIN_METHODS = %w[both stdin].freeze
13
+ ENVIRONMENT_METHODS = %w[both environment].freeze
14
+
12
15
  METADATA_KEYS = %w[description extensions files implementations
13
16
  input_method parameters private puppet_task_version
14
17
  remote supports_noop].freeze
@@ -37,22 +37,19 @@ module Bolt
37
37
  # before executing, and a :node_result event for each Target after
38
38
  # execution.
39
39
  class Base
40
- STDIN_METHODS = %w[both stdin].freeze
41
- ENVIRONMENT_METHODS = %w[both environment].freeze
42
-
43
40
  attr_reader :logger
44
41
 
45
42
  def initialize
46
43
  @logger = Logging.logger[self]
47
44
  end
48
45
 
49
- def with_events(target, callback)
46
+ def with_events(target, callback, action)
50
47
  callback&.call(type: :node_start, target: target)
51
48
 
52
49
  result = begin
53
50
  yield
54
51
  rescue StandardError, NotImplementedError => e
55
- Bolt::Result.from_exception(target, e)
52
+ Bolt::Result.from_exception(target, e, action: action)
56
53
  end
57
54
 
58
55
  callback&.call(type: :node_result, result: result)
@@ -106,7 +103,7 @@ module Bolt
106
103
  def batch_task(targets, task, arguments, options = {}, &callback)
107
104
  assert_batch_size_one("batch_task()", targets)
108
105
  target = targets.first
109
- with_events(target, callback) do
106
+ with_events(target, callback, 'task') do
110
107
  @logger.debug { "Running task run '#{task}' on #{target.safe_name}" }
111
108
  run_task(target, task, arguments, options)
112
109
  end
@@ -120,7 +117,7 @@ module Bolt
120
117
  def batch_command(targets, command, options = {}, &callback)
121
118
  assert_batch_size_one("batch_command()", targets)
122
119
  target = targets.first
123
- with_events(target, callback) do
120
+ with_events(target, callback, 'command') do
124
121
  @logger.debug("Running command '#{command}' on #{target.safe_name}")
125
122
  run_command(target, command, options)
126
123
  end
@@ -134,7 +131,7 @@ module Bolt
134
131
  def batch_script(targets, script, arguments, options = {}, &callback)
135
132
  assert_batch_size_one("batch_script()", targets)
136
133
  target = targets.first
137
- with_events(target, callback) do
134
+ with_events(target, callback, 'script') do
138
135
  @logger.debug { "Running script '#{script}' on #{target.safe_name}" }
139
136
  run_script(target, script, arguments, options)
140
137
  end
@@ -148,7 +145,7 @@ module Bolt
148
145
  def batch_upload(targets, source, destination, options = {}, &callback)
149
146
  assert_batch_size_one("batch_upload()", targets)
150
147
  target = targets.first
151
- with_events(target, callback) do
148
+ with_events(target, callback, 'upload') do
152
149
  @logger.debug { "Uploading: '#{source}' to #{destination} on #{target.safe_name}" }
153
150
  upload(target, source, destination, options)
154
151
  end
@@ -92,11 +92,11 @@ module Bolt
92
92
 
93
93
  remote_task_path = conn.write_remote_executable(task_dir, executable)
94
94
 
95
- if STDIN_METHODS.include?(input_method)
95
+ if Bolt::Task::STDIN_METHODS.include?(input_method)
96
96
  execute_options[:stdin] = StringIO.new(JSON.dump(arguments))
97
97
  end
98
98
 
99
- if ENVIRONMENT_METHODS.include?(input_method)
99
+ if Bolt::Task::ENVIRONMENT_METHODS.include?(input_method)
100
100
  execute_options[:environment] = envify_params(arguments)
101
101
  end
102
102
 
@@ -1,22 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'bolt/transport/simple'
4
+
3
5
  module Bolt
4
6
  module Transport
5
- class Local < Sudoable
6
- def provided_features
7
- ['shell']
8
- end
9
-
10
- def with_connection(target, *_args)
11
- conn = Shell.new(target)
12
- yield conn
7
+ class Local < Simple
8
+ def connected?(_target)
9
+ true
13
10
  end
14
11
 
15
- def connected?(_targets)
16
- true
12
+ def with_connection(target)
13
+ yield Connection.new(target)
17
14
  end
18
15
  end
19
16
  end
20
17
  end
21
18
 
22
- require 'bolt/transport/local/shell'
19
+ require 'bolt/transport/local/connection'
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require 'bolt/node/output'
6
+ require 'bolt/util'
7
+
8
+ module Bolt
9
+ module Transport
10
+ class Local < Simple
11
+ class Connection
12
+ attr_accessor :user, :logger, :target
13
+
14
+ def initialize(target)
15
+ @target = target
16
+ # The familiar problem: Etc.getlogin is broken on osx
17
+ @user = ENV['USER'] || Etc.getlogin
18
+ @logger = Logging.logger[self]
19
+ end
20
+
21
+ def shell
22
+ @shell ||= if Bolt::Util.windows?
23
+ Bolt::Shell::Powershell.new(target, self)
24
+ else
25
+ Bolt::Shell::Bash.new(target, self)
26
+ end
27
+ end
28
+
29
+ def copy_file(source, dest)
30
+ @logger.debug { "Uploading #{source}, to #{dest}" }
31
+ if source.is_a?(StringIO)
32
+ Tempfile.create(File.basename(dest)) do |f|
33
+ f.write(source.read)
34
+ FileUtils.mv(t, dest)
35
+ end
36
+ else
37
+ # Mimic the behavior of `cp --remove-destination`
38
+ # since the flag isn't supported on MacOS
39
+ FileUtils.cp_r(source, dest, remove_destination: true)
40
+ end
41
+ rescue StandardError => e
42
+ message = "Could not copy file to #{dest}: #{e}"
43
+ raise Bolt::Node::FileError.new(message, 'COPY_ERROR')
44
+ end
45
+
46
+ def execute(command)
47
+ Open3.popen3(command)
48
+ end
49
+
50
+ # This is used by the Bash shell to decide whether to `cd` before
51
+ # executing commands as a run-as user
52
+ def reset_cwd?
53
+ false
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -141,8 +141,8 @@ module Bolt
141
141
  logger.debug("Running '#{script}' with #{arguments.to_json}#{interpreter_debug}")
142
142
  unwrapped_arguments = unwrap_sensitive_args(arguments)
143
143
 
144
- stdin = STDIN_METHODS.include?(input_method) ? JSON.dump(unwrapped_arguments) : nil
145
- if ENVIRONMENT_METHODS.include?(input_method)
144
+ stdin = Bolt::Task::STDIN_METHODS.include?(input_method) ? JSON.dump(unwrapped_arguments) : nil
145
+ if Bolt::Task::ENVIRONMENT_METHODS.include?(input_method)
146
146
  environment_params = envify_params(unwrapped_arguments).each_with_object([]) do |(arg, val), list|
147
147
  list << Powershell.set_env(arg, val)
148
148
  end
@@ -164,7 +164,7 @@ module Bolt
164
164
  end
165
165
  unless output
166
166
  if interpreter
167
- env = ENVIRONMENT_METHODS.include?(input_method) ? envify_params(unwrapped_arguments) : nil
167
+ env = Bolt::Task::ENVIRONMENT_METHODS.include?(input_method) ? envify_params(unwrapped_arguments) : nil
168
168
  output = execute(script, stdin: stdin, env: env, dir: dir, interpreter: interpreter)
169
169
  else
170
170
  path, args = *Powershell.process_from_extension(script)
@@ -197,7 +197,7 @@ module Bolt
197
197
  end
198
198
  rescue StandardError => e
199
199
  targets.map do |target|
200
- Bolt::Result.from_exception(target, e)
200
+ Bolt::Result.from_exception(target, e, 'task')
201
201
  end
202
202
  end
203
203
  end
@@ -34,7 +34,7 @@ module Bolt
34
34
  remote_task = task.remote_instance
35
35
 
36
36
  result = transport.run_task(proxy_target, remote_task, arguments, options)
37
- Bolt::Result.new(target, value: result.value)
37
+ Bolt::Result.new(target, value: result.value, action: 'task', object: task.name)
38
38
  end
39
39
  end
40
40
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logging'
4
+ require 'bolt/result'
5
+ require 'bolt/shell'
6
+ require 'bolt/transport/base'
7
+
8
+ module Bolt
9
+ module Transport
10
+ # A simple transport has a single connection per target and delegates its
11
+ # operation to a target-specific shell.
12
+ class Simple < Base
13
+ def with_connection(_target)
14
+ raise NotImplementedError, "with_connection() must be implemented by the transport class"
15
+ end
16
+
17
+ def connected?(target)
18
+ with_connection(target) { true }
19
+ rescue Bolt::Node::ConnectError
20
+ false
21
+ end
22
+
23
+ def run_command(target, command, options = {})
24
+ with_connection(target) do |conn|
25
+ conn.shell.run_command(command, options)
26
+ end
27
+ end
28
+
29
+ def upload(target, source, destination, options = {})
30
+ with_connection(target) do |conn|
31
+ conn.shell.upload(source, destination, options)
32
+ end
33
+ end
34
+
35
+ def run_script(target, script, arguments, options = {})
36
+ with_connection(target) do |conn|
37
+ conn.shell.run_script(script, arguments, options)
38
+ end
39
+ end
40
+
41
+ def run_task(target, task, arguments, options = {})
42
+ with_connection(target) do |conn|
43
+ conn.shell.run_task(task, arguments, options)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,17 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bolt/node/errors'
4
- require 'bolt/transport/sudoable'
5
- require 'json'
6
- require 'shellwords'
4
+ require 'bolt/transport/simple'
7
5
 
8
6
  module Bolt
9
7
  module Transport
10
- class SSH < Sudoable
11
- def provided_features
12
- ['shell']
13
- end
14
-
8
+ class SSH < Simple
15
9
  def initialize
16
10
  super
17
11
 
@@ -38,12 +32,6 @@ module Bolt
38
32
  logger.info("Failed to close connection to #{target.safe_name} : #{e.message}")
39
33
  end
40
34
  end
41
-
42
- def connected?(target)
43
- with_connection(target) { true }
44
- rescue Bolt::Node::ConnectError
45
- false
46
- end
47
35
  end
48
36
  end
49
37
  end
@@ -4,15 +4,13 @@ require 'logging'
4
4
  require 'shellwords'
5
5
  require 'bolt/node/errors'
6
6
  require 'bolt/node/output'
7
- require 'bolt/transport/sudoable/connection'
8
7
  require 'bolt/util'
9
8
 
10
9
  module Bolt
11
10
  module Transport
12
- class SSH < Sudoable
13
- class Connection < Sudoable::Connection
11
+ class SSH < Simple
12
+ class Connection
14
13
  attr_reader :logger, :user, :target
15
- attr_writer :run_as
16
14
 
17
15
  def initialize(target, transport_logger)
18
16
  # lazy-load expensive gem code
@@ -20,22 +18,18 @@ module Bolt
20
18
  require 'net/ssh/proxy/jump'
21
19
 
22
20
  raise Bolt::ValidationError, "Target #{target.safe_name} does not have a host" unless target.host
23
- @sudo_id = SecureRandom.uuid
24
21
 
25
22
  @target = target
26
23
  @load_config = target.options['load-config']
27
24
 
28
25
  ssh_config = @load_config ? Net::SSH::Config.for(target.host) : {}
29
26
  @user = @target.user || ssh_config[:user] || Etc.getlogin
30
- @run_as = nil
31
27
  @strict_host_key_checking = ssh_config[:strict_host_key_checking]
32
28
 
33
29
  @logger = Logging.logger[@target.safe_name]
34
30
  @transport_logger = transport_logger
35
31
  @logger.debug("Initializing ssh connection to #{@target.safe_name}")
36
32
 
37
- @sudo_password = @target.options['sudo-password'] || @target.password
38
-
39
33
  if target.options['private-key']&.instance_of?(String)
40
34
  begin
41
35
  Bolt::Util.validate_file('ssh key', target.options['private-key'])
@@ -148,119 +142,70 @@ module Bolt
148
142
  end
149
143
  end
150
144
 
151
- def handled_sudo(channel, data, stdin)
152
- if data.lines.include?(Sudoable.sudo_prompt)
153
- if @sudo_password
154
- channel.send_data("#{@sudo_password}\n")
155
- channel.wait
156
- return true
157
- else
158
- # Cancel the sudo prompt to prevent later commands getting stuck
159
- channel.close
160
- raise Bolt::Node::EscalateError.new(
161
- "Sudo password for user #{@user} was not provided for #{target.safe_name}",
162
- 'NO_PASSWORD'
163
- )
164
- end
165
- elsif data =~ /^#{@sudo_id}/
166
- if stdin
167
- channel.send_data(stdin)
168
- channel.eof!
169
- end
170
- return true
171
- elsif data =~ /^#{@user} is not in the sudoers file\./
172
- @logger.debug { data }
173
- raise Bolt::Node::EscalateError.new(
174
- "User #{@user} does not have sudo permission on #{target.safe_name}",
175
- 'SUDO_DENIED'
176
- )
177
- elsif data =~ /^Sorry, try again\./
178
- @logger.debug { data }
179
- raise Bolt::Node::EscalateError.new(
180
- "Sudo password for user #{@user} not recognized on #{target.safe_name}",
181
- 'BAD_PASSWORD'
182
- )
183
- end
184
- false
185
- end
186
-
187
- def execute(command, sudoable: false, **options)
188
- result_output = Bolt::Node::Output.new
189
- run_as = options[:run_as] || self.run_as
190
- escalate = sudoable && run_as && @user != run_as
191
- use_sudo = escalate && @target.options['run-as-command'].nil?
192
-
193
- command_str = inject_interpreter(options[:interpreter], command)
194
- if escalate
195
- if use_sudo
196
- sudo_exec = target.options['sudo-executable'] || "sudo"
197
- sudo_flags = [sudo_exec, "-S", "-H", "-u", run_as, "-p", Sudoable.sudo_prompt]
198
- sudo_flags += ["-E"] if options[:environment]
199
- sudo_str = Shellwords.shelljoin(sudo_flags)
200
- else
201
- sudo_str = Shellwords.shelljoin(@target.options['run-as-command'] + [run_as])
202
- end
203
- command_str = build_sudoable_command_str(command_str, sudo_str, @sudo_id, options.merge(reset_cwd: true))
204
- end
205
-
206
- # Including the environment declarations in the shelljoin will escape
207
- # the = sign, so we have to handle them separately.
208
- if options[:environment]
209
- env_decls = options[:environment].map do |env, val|
210
- "#{env}=#{Shellwords.shellescape(val)}"
211
- end
212
- command_str = "#{env_decls.join(' ')} #{command_str}"
213
- end
214
-
215
- @logger.debug { "Executing: #{command_str}" }
216
-
217
- session_channel = @session.open_channel do |channel|
218
- # Request a pseudo tty
219
- channel.request_pty if target.options['tty']
145
+ def execute(command_str)
146
+ in_rd, in_wr = IO.pipe
147
+ out_rd, out_wr = IO.pipe
148
+ err_rd, err_wr = IO.pipe
149
+ th = Thread.new do
150
+ exit_code = nil
151
+ session_channel = @session.open_channel do |channel|
152
+ # Request a pseudo tty
153
+ channel.request_pty if target.options['tty']
220
154
 
221
- channel.exec(command_str) do |_, success|
222
- unless success
223
- raise Bolt::Node::ConnectError.new(
224
- "Could not execute command: #{command_str.inspect}",
225
- 'EXEC_ERROR'
226
- )
227
- end
155
+ channel.exec(command_str) do |_, success|
156
+ unless success
157
+ raise Bolt::Node::ConnectError.new(
158
+ "Could not execute command: #{command_str.inspect}",
159
+ 'EXEC_ERROR'
160
+ )
161
+ end
228
162
 
229
- channel.on_data do |_, data|
230
- unless use_sudo && handled_sudo(channel, data, options[:stdin])
231
- result_output.stdout << data
163
+ channel.on_data do |_, data|
164
+ out_wr << data
232
165
  end
233
- @logger.debug { "stdout: #{data.strip}" }
234
- end
235
166
 
236
- channel.on_extended_data do |_, _, data|
237
- unless use_sudo && handled_sudo(channel, data, options[:stdin])
238
- result_output.stderr << data
167
+ channel.on_extended_data do |_, _, data|
168
+ err_wr << data
239
169
  end
240
- @logger.debug { "stderr: #{data.strip}" }
241
- end
242
170
 
243
- channel.on_request("exit-status") do |_, data|
244
- result_output.exit_code = data.read_long
171
+ channel.on_request("exit-status") do |_, data|
172
+ exit_code = data.read_long
173
+ end
245
174
  end
246
- # A wrapper is used to direct stdin when elevating privilage or using tty
247
- if options[:stdin] && !use_sudo && !options[:wrapper]
248
- channel.send_data(options[:stdin])
249
- channel.eof!
175
+ end
176
+ write_th = Thread.new do
177
+ chunk_size = 4096
178
+ eof = false
179
+ active = true
180
+ readable = false
181
+ while active && !eof
182
+ @session.loop(0.1) do
183
+ active = session_channel.active?
184
+ readable = select([in_rd], [], [], 0)
185
+ # Loop as long as the channel is still live and there's nothing to be written
186
+ active && !readable
187
+ end
188
+ if readable
189
+ if in_rd.eof?
190
+ session_channel.eof!
191
+ eof = true
192
+ else
193
+ to_write = in_rd.readpartial(chunk_size)
194
+ session_channel.send_data(to_write)
195
+ end
196
+ end
250
197
  end
198
+ session_channel.wait
251
199
  end
200
+ write_th.join
201
+ exit_code
202
+ ensure
203
+ write_th.terminate
204
+ in_rd.close
205
+ out_wr.close
206
+ err_wr.close
252
207
  end
253
- session_channel.wait
254
-
255
- if result_output.exit_code == 0
256
- @logger.debug { "Command returned successfully" }
257
- else
258
- @logger.info { "Command failed with exit code #{result_output.exit_code}" }
259
- end
260
- result_output
261
- rescue StandardError
262
- @logger.debug { "Command aborted" }
263
- raise
208
+ [in_wr, out_rd, err_rd, th]
264
209
  end
265
210
 
266
211
  def copy_file(source, destination)
@@ -295,6 +240,16 @@ module Bolt
295
240
  end
296
241
  end
297
242
  end
243
+
244
+ def shell
245
+ @shell ||= Bolt::Shell::Bash.new(target, self)
246
+ end
247
+
248
+ # This is used by the Bash shell to decide whether to `cd` before
249
+ # executing commands as a run-as user
250
+ def reset_cwd?
251
+ true
252
+ end
298
253
  end
299
254
  end
300
255
  end