bolt 2.19.0 → 2.20.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/bolt-modules/boltlib/lib/puppet/functions/download_file.rb +123 -0
  3. data/lib/bolt/bolt_option_parser.rb +25 -2
  4. data/lib/bolt/catalog.rb +3 -2
  5. data/lib/bolt/cli.rb +139 -92
  6. data/lib/bolt/config.rb +1 -1
  7. data/lib/bolt/config/options.rb +14 -0
  8. data/lib/bolt/executor.rb +15 -0
  9. data/lib/bolt/inventory/group.rb +3 -2
  10. data/lib/bolt/inventory/inventory.rb +4 -3
  11. data/lib/bolt/outputter/rainbow.rb +3 -2
  12. data/lib/bolt/pal.rb +8 -2
  13. data/lib/bolt/pal/yaml_plan/evaluator.rb +18 -1
  14. data/lib/bolt/pal/yaml_plan/step.rb +11 -2
  15. data/lib/bolt/pal/yaml_plan/step/download.rb +38 -0
  16. data/lib/bolt/pal/yaml_plan/step/upload.rb +3 -3
  17. data/lib/bolt/plugin/puppetdb.rb +3 -2
  18. data/lib/bolt/project.rb +2 -1
  19. data/lib/bolt/puppetdb/client.rb +2 -0
  20. data/lib/bolt/puppetdb/config.rb +16 -0
  21. data/lib/bolt/result.rb +7 -0
  22. data/lib/bolt/shell/bash.rb +24 -4
  23. data/lib/bolt/shell/powershell.rb +10 -4
  24. data/lib/bolt/transport/base.rb +24 -0
  25. data/lib/bolt/transport/docker.rb +8 -0
  26. data/lib/bolt/transport/docker/connection.rb +20 -2
  27. data/lib/bolt/transport/local/connection.rb +14 -1
  28. data/lib/bolt/transport/orch.rb +12 -0
  29. data/lib/bolt/transport/simple.rb +6 -0
  30. data/lib/bolt/transport/ssh/connection.rb +9 -1
  31. data/lib/bolt/transport/ssh/exec_connection.rb +22 -1
  32. data/lib/bolt/transport/winrm/connection.rb +109 -8
  33. data/lib/bolt/util.rb +26 -11
  34. data/lib/bolt/version.rb +1 -1
  35. data/lib/bolt_server/transport_app.rb +3 -2
  36. data/lib/bolt_spec/bolt_context.rb +7 -2
  37. data/lib/bolt_spec/plans.rb +15 -2
  38. data/lib/bolt_spec/plans/action_stubs.rb +2 -1
  39. data/lib/bolt_spec/plans/action_stubs/download_stub.rb +66 -0
  40. data/lib/bolt_spec/plans/mock_executor.rb +14 -1
  41. data/lib/bolt_spec/run.rb +22 -0
  42. data/libexec/bolt_catalog +3 -2
  43. metadata +5 -2
@@ -167,6 +167,25 @@ module Bolt
167
167
  end
168
168
  end
169
169
 
170
+ # Downloads the given source file from a batch of targets to the destination location
171
+ # on the host.
172
+ #
173
+ # The default implementation only supports batches of size 1 and will fail otherwise.
174
+ #
175
+ # Transports may override this method to implement their own batch processing.
176
+ def batch_download(targets, source, destination, options = {}, &callback)
177
+ require 'erb'
178
+
179
+ assert_batch_size_one("batch_download()", targets)
180
+ target = targets.first
181
+ with_events(target, callback, 'download') do
182
+ escaped_name = ERB::Util.url_encode(target.safe_name)
183
+ target_destination = File.expand_path(escaped_name, destination)
184
+ @logger.debug { "Downloading: '#{source}' on #{target.safe_name} to #{target_destination}" }
185
+ download(target, source, target_destination, options)
186
+ end
187
+ end
188
+
170
189
  def batch_connected?(targets)
171
190
  assert_batch_size_one("connected?()", targets)
172
191
  connected?(targets.first)
@@ -201,6 +220,11 @@ module Bolt
201
220
  raise NotImplementedError, "upload() must be implemented by the transport class"
202
221
  end
203
222
 
223
+ # Transports should override this method with their own implementation of file download.
224
+ def download(*_args)
225
+ raise NotImplementedError, "download() must be implemented by the transport class"
226
+ end
227
+
204
228
  # Transports should override this method with their own implementation of a connection test.
205
229
  def connected?(_targets)
206
230
  raise NotImplementedError, "connected?() must be implemented by the transport class"
@@ -38,6 +38,14 @@ module Bolt
38
38
  end
39
39
  end
40
40
 
41
+ def download(target, source, destination, _options = {})
42
+ with_connection(target) do |conn|
43
+ download = File.join(destination, Bolt::Util.unix_basename(source))
44
+ conn.download_remote_content(source, destination)
45
+ Bolt::Result.for_download(target, source, destination, download)
46
+ end
47
+ end
48
+
41
49
  def run_command(target, command, options = {})
42
50
  execute_options = {}
43
51
  execute_options[:tty] = target.options['tty']
@@ -82,7 +82,9 @@ module Bolt
82
82
  def write_remote_file(source, destination)
83
83
  @logger.debug { "Uploading #{source}, to #{destination}" }
84
84
  _, stdout_str, status = execute_local_docker_command('cp', [source, "#{container_id}:#{destination}"])
85
- raise "Error writing file to container #{@container_id}: #{stdout_str}" unless status.exitstatus.zero?
85
+ unless status.exitstatus.zero?
86
+ raise "Error writing file to container #{@container_id}: #{stdout_str}"
87
+ end
86
88
  rescue StandardError => e
87
89
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
88
90
  end
@@ -90,7 +92,23 @@ module Bolt
90
92
  def write_remote_directory(source, destination)
91
93
  @logger.debug { "Uploading #{source}, to #{destination}" }
92
94
  _, stdout_str, status = execute_local_docker_command('cp', [source, "#{container_id}:#{destination}"])
93
- raise "Error writing directory to container #{@container_id}: #{stdout_str}" unless status.exitstatus.zero?
95
+ unless status.exitstatus.zero?
96
+ raise "Error writing directory to container #{@container_id}: #{stdout_str}"
97
+ end
98
+ rescue StandardError => e
99
+ raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
100
+ end
101
+
102
+ def download_remote_content(source, destination)
103
+ @logger.debug { "Downloading #{source} to #{destination}" }
104
+ # Create the destination directory, otherwise copying a source directory with Docker will
105
+ # copy the *contents* of the directory.
106
+ # https://docs.docker.com/engine/reference/commandline/cp/
107
+ FileUtils.mkdir_p(destination)
108
+ _, stdout_str, status = execute_local_docker_command('cp', ["#{container_id}:#{source}", destination])
109
+ unless status.exitstatus.zero?
110
+ raise "Error downloading content from container #{@container_id}: #{stdout_str}"
111
+ end
94
112
  rescue StandardError => e
95
113
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
96
114
  end
@@ -27,7 +27,7 @@ module Bolt
27
27
  end
28
28
  end
29
29
 
30
- def copy_file(source, dest)
30
+ def upload_file(source, dest)
31
31
  @logger.debug { "Uploading #{source}, to #{dest}" }
32
32
  if source.is_a?(StringIO)
33
33
  Tempfile.create(File.basename(dest)) do |f|
@@ -45,6 +45,19 @@ module Bolt
45
45
  raise Bolt::Node::FileError.new(message, 'COPY_ERROR')
46
46
  end
47
47
 
48
+ def download_file(source, dest, _download)
49
+ @logger.debug { "Downloading #{source} to #{dest}" }
50
+ # Create the destination directory for the target, or the
51
+ # copied file will have the target's name
52
+ FileUtils.mkdir_p(dest)
53
+ # Mimic the behavior of `cp --remove-destination`
54
+ # since the flag isn't supported on MacOS
55
+ FileUtils.cp_r(source, dest, remove_destination: true)
56
+ rescue StandardError => e
57
+ message = "Could not download file to #{dest}: #{e}"
58
+ raise Bolt::Node::FileError.new(message, 'DOWNLOAD_ERROR')
59
+ end
60
+
48
61
  def execute(command)
49
62
  if Bolt::Util.windows?
50
63
  # If it's already a powershell command then invoke it normally.
@@ -184,6 +184,18 @@ module Bolt
184
184
  end
185
185
  end
186
186
 
187
+ def batch_download(targets, *_args)
188
+ error = {
189
+ 'kind' => 'bolt/not-supported-error',
190
+ 'msg' => 'pcp transport does not support downloading files',
191
+ 'details' => {}
192
+ }
193
+
194
+ targets.map do |target|
195
+ Bolt::Result.new(target, error: error, action: 'download')
196
+ end
197
+ end
198
+
187
199
  def batches(targets)
188
200
  targets.group_by { |target| Connection.get_key(target.options) }.values
189
201
  end
@@ -32,6 +32,12 @@ module Bolt
32
32
  end
33
33
  end
34
34
 
35
+ def download(target, source, destination, options = {})
36
+ with_connection(target) do |conn|
37
+ conn.shell.download(source, destination, options)
38
+ end
39
+ end
40
+
35
41
  def run_script(target, script, arguments, options = {})
36
42
  with_connection(target) do |conn|
37
43
  conn.shell.run_script(script, arguments, options)
@@ -235,7 +235,7 @@ module Bolt
235
235
  raise Bolt::Error.new(msg, 'bolt/too-many-files')
236
236
  end
237
237
 
238
- def copy_file(source, destination)
238
+ def upload_file(source, destination)
239
239
  # Do not log wrapper script content
240
240
  @logger.debug { "Uploading #{source}, to #{destination}" } unless source.is_a?(StringIO)
241
241
  @session.scp.upload!(source, destination, recursive: true)
@@ -243,6 +243,14 @@ module Bolt
243
243
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
244
244
  end
245
245
 
246
+ def download_file(source, destination, _download)
247
+ # Do not log wrapper script content
248
+ @logger.debug { "Downloading #{source} to #{destination}" }
249
+ @session.scp.download!(source, destination, recursive: true)
250
+ rescue StandardError => e
251
+ raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
252
+ end
253
+
246
254
  # This handles renaming Net::SSH verifiers between version 4.x and 5.x
247
255
  # of the gem
248
256
  def net_ssh_verifier(verifier)
@@ -65,7 +65,7 @@ module Bolt
65
65
  ssh_cmd << command
66
66
  end
67
67
 
68
- def copy_file(source, dest)
68
+ def upload_file(source, dest)
69
69
  @logger.debug { "Uploading #{source}, to #{userhost}:#{dest}" } unless source.is_a?(StringIO)
70
70
 
71
71
  cp_conf = @target.transport_config['copy-command'] || ["scp", "-r"]
@@ -94,6 +94,27 @@ module Bolt
94
94
  end
95
95
  end
96
96
 
97
+ def download_file(source, dest, _download)
98
+ @logger.debug { "Downloading #{userhost}:#{source} to #{dest}" }
99
+
100
+ FileUtils.mkdir_p(dest)
101
+
102
+ cp_conf = @target.transport_config['copy-command'] || ["scp", "-r"]
103
+ cp_cmd = Array(cp_conf)
104
+ cp_cmd += ssh_opts
105
+ cp_cmd << "#{userhost}:#{Shellwords.escape(source)}"
106
+ cp_cmd << dest
107
+
108
+ _, err, stat = Open3.capture3(*cp_cmd)
109
+
110
+ if stat.success?
111
+ @logger.debug "Successfully downloaded #{userhost}:#{source} to #{dest}"
112
+ else
113
+ message = "Could not copy file to #{dest}: #{err}"
114
+ raise Bolt::Node::FileError.new(message, 'COPY_ERROR')
115
+ end
116
+ end
117
+
97
118
  def execute(command)
98
119
  cmd_array = build_ssh_command(command)
99
120
  Open3.popen3(*cmd_array)
@@ -129,23 +129,23 @@ module Bolt
129
129
  raise
130
130
  end
131
131
 
132
- def copy_file(source, destination)
132
+ def upload_file(source, destination)
133
133
  @logger.debug { "Uploading #{source}, to #{destination}" }
134
134
  if target.options['file-protocol'] == 'smb'
135
- copy_file_smb(source, destination)
135
+ upload_file_smb(source, destination)
136
136
  else
137
- copy_file_winrm(source, destination)
137
+ upload_file_winrm(source, destination)
138
138
  end
139
139
  end
140
140
 
141
- def copy_file_winrm(source, destination)
141
+ def upload_file_winrm(source, destination)
142
142
  fs = ::WinRM::FS::FileManager.new(@connection)
143
143
  fs.upload(source, destination)
144
144
  rescue StandardError => e
145
145
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
146
146
  end
147
147
 
148
- def copy_file_smb(source, destination)
148
+ def upload_file_smb(source, destination)
149
149
  # lazy-load expensive gem code
150
150
  require 'ruby_smb'
151
151
 
@@ -165,7 +165,7 @@ module Bolt
165
165
  client = smb_client_login
166
166
  tree = client.tree_connect(path)
167
167
  begin
168
- copy_file_smb_recursive(tree, source, dest)
168
+ upload_file_smb_recursive(tree, source, dest)
169
169
  ensure
170
170
  tree.disconnect!
171
171
  end
@@ -175,6 +175,61 @@ module Bolt
175
175
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
176
176
  end
177
177
 
178
+ def download_file(source, destination, download)
179
+ @logger.debug { "Downloading #{source} to #{destination}" }
180
+ if target.options['file-protocol'] == 'smb'
181
+ download_file_smb(source, destination)
182
+ else
183
+ download_file_winrm(source, destination, download)
184
+ end
185
+ end
186
+
187
+ def download_file_winrm(source, destination, download)
188
+ # The winrm gem doesn't create the destination directory if it's missing,
189
+ # so create it here
190
+ FileUtils.mkdir_p(destination)
191
+ fs = ::WinRM::FS::FileManager.new(@connection)
192
+ # params: source, destination, chunksize, first
193
+ # first needs to be set to false, otherwise if the source is a directory it
194
+ # will be nested inside a directory with the same name
195
+ fs.download(source, download, 1024 * 1024, false)
196
+ rescue StandardError => e
197
+ raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
198
+ end
199
+
200
+ def download_file_smb(source, destination)
201
+ # lazy-load expensive gem code
202
+ require 'ruby_smb'
203
+
204
+ win_source = source.tr('/', '\\')
205
+ if (md = win_source.match(/^([a-z]):\\(.*)/i))
206
+ # if drive, use admin share for that drive, so path is '\\host\C$'
207
+ path = "\\\\#{@target.host}\\#{md[1]}$"
208
+ src = md[2]
209
+ elsif (md = win_source.match(/^(\\\\[^\\]+\\[^\\]+)\\(.*)/))
210
+ # if unc, path is '\\host\share'
211
+ path = md[1]
212
+ src = md[2]
213
+ else
214
+ raise ArgumentError, "Unknown source '#{source}'"
215
+ end
216
+
217
+ client = smb_client_login
218
+ tree = client.tree_connect(path)
219
+
220
+ begin
221
+ # Make sure the root download directory for the target exists
222
+ FileUtils.mkdir_p(destination)
223
+ download_file_smb_recursive(tree, src, destination)
224
+ ensure
225
+ tree.disconnect!
226
+ end
227
+ rescue ::RubySMB::Error::UnexpectedStatusCode => e
228
+ raise Bolt::Node::FileError.new("SMB Error: #{e.message}", 'DOWNLOAD_ERROR')
229
+ rescue StandardError => e
230
+ raise Bolt::Node::FileError.new(e.message, 'DOWNLOAD_ERROR')
231
+ end
232
+
178
233
  def shell
179
234
  @shell ||= Bolt::Shell::Powershell.new(target, self)
180
235
  end
@@ -230,13 +285,13 @@ module Bolt
230
285
  )
231
286
  end
232
287
 
233
- def copy_file_smb_recursive(tree, source, dest)
288
+ def upload_file_smb_recursive(tree, source, dest)
234
289
  if Dir.exist?(source)
235
290
  tree.open_directory(directory: dest, write: true, disposition: ::RubySMB::Dispositions::FILE_OPEN_IF)
236
291
 
237
292
  Dir.children(source).each do |child|
238
293
  child_dest = dest + '\\' + child
239
- copy_file_smb_recursive(tree, File.join(source, child), child_dest)
294
+ upload_file_smb_recursive(tree, File.join(source, child), child_dest)
240
295
  end
241
296
  return
242
297
  end
@@ -255,6 +310,52 @@ module Bolt
255
310
  file.close
256
311
  end
257
312
  end
313
+
314
+ def download_file_smb_recursive(tree, source, destination)
315
+ dest = File.expand_path(Bolt::Util.windows_basename(source), destination)
316
+
317
+ # Check if the source is a directory by attempting to list its children.
318
+ # If the source is a directory, create the directory on the host and then
319
+ # recurse through the children.
320
+ if (children = list_directory_children_smb(tree, source))
321
+ FileUtils.mkdir_p(dest)
322
+
323
+ children.each do |child|
324
+ # File names are encoded UTF_16LE.
325
+ filename = child.file_name.encode(Encoding::UTF_8)
326
+
327
+ next if %w[. ..].include?(filename)
328
+
329
+ src = source + '\\' + filename
330
+ download_file_smb_recursive(tree, src, dest)
331
+ end
332
+ # If the source wasn't a directory and just returns 'STATUS_NOT_A_DIRECTORY, then
333
+ # it is a file. Write it to the host.
334
+ else
335
+ begin
336
+ file = tree.open_file(filename: source)
337
+ data = file.read
338
+
339
+ # Files may be encoded UTF_16LE
340
+ data = data.encode(Encoding::UTF_8) if data.encoding == Encoding::UTF_16LE
341
+
342
+ File.write(dest, data)
343
+ ensure
344
+ file.close
345
+ end
346
+ end
347
+ end
348
+
349
+ # Lists the children of a directory using rb_smb
350
+ # Returns an array of RubySMB::Fscc::FileInformation::FileIdFullDirectoryInformation objects
351
+ # if the source is a directory, or raises RubySMB::Error::UnexpectedStatusCode otherwise.
352
+ def list_directory_children_smb(tree, source)
353
+ tree.list(directory: source)
354
+ rescue RubySMB::Error::UnexpectedStatusCode => e
355
+ unless e.message == 'STATUS_NOT_A_DIRECTORY'
356
+ raise e
357
+ end
358
+ end
258
359
  end
259
360
  end
260
361
  end
@@ -107,12 +107,13 @@ module Bolt
107
107
  # Accepts a Data object and returns a copy with all hash keys
108
108
  # modified by block. use &:to_s to stringify keys or &:to_sym to symbolize them
109
109
  def walk_keys(data, &block)
110
- if data.is_a? Hash
110
+ case data
111
+ when Hash
111
112
  data.each_with_object({}) do |(k, v), acc|
112
113
  v = walk_keys(v, &block)
113
114
  acc[yield(k)] = v
114
115
  end
115
- elsif data.is_a? Array
116
+ when Array
116
117
  data.map { |v| walk_keys(v, &block) }
117
118
  else
118
119
  data
@@ -124,9 +125,10 @@ module Bolt
124
125
  # their descendants are.
125
126
  def walk_vals(data, skip_top = false, &block)
126
127
  data = yield(data) unless skip_top
127
- if data.is_a? Hash
128
+ case data
129
+ when Hash
128
130
  data.transform_values { |v| walk_vals(v, &block) }
129
- elsif data.is_a? Array
131
+ when Array
130
132
  data.map { |v| walk_vals(v, &block) }
131
133
  else
132
134
  data
@@ -137,9 +139,10 @@ module Bolt
137
139
  # modified by the given block. Descendants are modified before their
138
140
  # parents.
139
141
  def postwalk_vals(data, skip_top = false, &block)
140
- new_data = if data.is_a? Hash
142
+ new_data = case data
143
+ when Hash
141
144
  data.transform_values { |v| postwalk_vals(v, &block) }
142
- elsif data.is_a? Array
145
+ when Array
143
146
  data.map { |v| postwalk_vals(v, &block) }
144
147
  else
145
148
  data
@@ -193,11 +196,12 @@ module Bolt
193
196
  cloned[obj.object_id] = cl
194
197
  cloned[cl.object_id] = cl
195
198
 
196
- if cl.is_a? Hash
199
+ case cl
200
+ when Hash
197
201
  obj.each { |k, v| cl[k] = deep_clone(v, cloned) }
198
- elsif cl.is_a? Array
202
+ when Array
199
203
  cl.collect! { |v| deep_clone(v, cloned) }
200
- elsif cl.is_a? Struct
204
+ when Struct
201
205
  obj.each_pair { |k, v| cl[k] = deep_clone(v, cloned) }
202
206
  end
203
207
 
@@ -257,14 +261,25 @@ module Bolt
257
261
 
258
262
  # Recursively searches a data structure for plugin references
259
263
  def references?(input)
260
- if input.is_a?(Hash)
264
+ case input
265
+ when Hash
261
266
  input.key?('_plugin') || input.values.any? { |v| references?(v) }
262
- elsif input.is_a?(Array)
267
+ when Array
263
268
  input.any? { |v| references?(v) }
264
269
  else
265
270
  false
266
271
  end
267
272
  end
273
+
274
+ def unix_basename(path)
275
+ raise Bolt::ValidationError, "path must be a String, received #{path.class} #{path}" unless path.is_a?(String)
276
+ path.split('/').last
277
+ end
278
+
279
+ def windows_basename(path)
280
+ raise Bolt::ValidationError, "path must be a String, received #{path.class} #{path}" unless path.is_a?(String)
281
+ path.split(%r{[/\\]}).last
282
+ end
268
283
  end
269
284
  end
270
285
  end