bolt 2.19.0 → 2.24.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +3 -1
  3. data/bolt-modules/boltlib/lib/puppet/functions/download_file.rb +123 -0
  4. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +6 -0
  5. data/bolt-modules/ctrl/lib/puppet/functions/ctrl/do_until.rb +12 -6
  6. data/bolt-modules/dir/lib/puppet/functions/dir/children.rb +35 -0
  7. data/bolt-modules/out/lib/puppet/functions/out/message.rb +1 -1
  8. data/exe/bolt +1 -0
  9. data/guides/inventory.txt +19 -0
  10. data/guides/project.txt +22 -0
  11. data/lib/bolt/analytics.rb +5 -5
  12. data/lib/bolt/applicator.rb +4 -3
  13. data/lib/bolt/bolt_option_parser.rb +100 -27
  14. data/lib/bolt/catalog.rb +12 -3
  15. data/lib/bolt/cli.rb +356 -156
  16. data/lib/bolt/config.rb +2 -2
  17. data/lib/bolt/config/options.rb +18 -4
  18. data/lib/bolt/executor.rb +30 -7
  19. data/lib/bolt/inventory/group.rb +6 -5
  20. data/lib/bolt/inventory/inventory.rb +4 -3
  21. data/lib/bolt/logger.rb +3 -4
  22. data/lib/bolt/module.rb +2 -1
  23. data/lib/bolt/outputter.rb +56 -0
  24. data/lib/bolt/outputter/human.rb +10 -9
  25. data/lib/bolt/outputter/json.rb +11 -4
  26. data/lib/bolt/outputter/logger.rb +2 -2
  27. data/lib/bolt/outputter/rainbow.rb +18 -2
  28. data/lib/bolt/pal.rb +13 -11
  29. data/lib/bolt/pal/yaml_plan/evaluator.rb +22 -1
  30. data/lib/bolt/pal/yaml_plan/step.rb +24 -2
  31. data/lib/bolt/pal/yaml_plan/step/download.rb +38 -0
  32. data/lib/bolt/pal/yaml_plan/step/message.rb +30 -0
  33. data/lib/bolt/pal/yaml_plan/step/upload.rb +3 -3
  34. data/lib/bolt/pal/yaml_plan/transpiler.rb +11 -3
  35. data/lib/bolt/plugin/prompt.rb +3 -3
  36. data/lib/bolt/plugin/puppetdb.rb +3 -2
  37. data/lib/bolt/project.rb +7 -4
  38. data/lib/bolt/project_migrate.rb +138 -0
  39. data/lib/bolt/puppetdb/client.rb +2 -0
  40. data/lib/bolt/puppetdb/config.rb +16 -0
  41. data/lib/bolt/result.rb +7 -0
  42. data/lib/bolt/shell/bash.rb +31 -11
  43. data/lib/bolt/shell/powershell.rb +10 -4
  44. data/lib/bolt/transport/base.rb +24 -0
  45. data/lib/bolt/transport/docker.rb +8 -0
  46. data/lib/bolt/transport/docker/connection.rb +28 -10
  47. data/lib/bolt/transport/local/connection.rb +15 -2
  48. data/lib/bolt/transport/orch.rb +15 -3
  49. data/lib/bolt/transport/simple.rb +6 -0
  50. data/lib/bolt/transport/ssh/connection.rb +13 -5
  51. data/lib/bolt/transport/ssh/exec_connection.rb +24 -3
  52. data/lib/bolt/transport/winrm/connection.rb +125 -15
  53. data/lib/bolt/util.rb +27 -12
  54. data/lib/bolt/util/puppet_log_level.rb +4 -3
  55. data/lib/bolt/version.rb +1 -1
  56. data/lib/bolt_server/base_config.rb +1 -1
  57. data/lib/bolt_server/pe/pal.rb +1 -1
  58. data/lib/bolt_server/transport_app.rb +79 -2
  59. data/lib/bolt_spec/bolt_context.rb +7 -2
  60. data/lib/bolt_spec/plans.rb +16 -3
  61. data/lib/bolt_spec/plans/action_stubs.rb +3 -2
  62. data/lib/bolt_spec/plans/action_stubs/download_stub.rb +66 -0
  63. data/lib/bolt_spec/plans/mock_executor.rb +14 -1
  64. data/lib/bolt_spec/run.rb +22 -0
  65. data/libexec/apply_catalog.rb +2 -2
  66. data/libexec/bolt_catalog +4 -3
  67. data/libexec/custom_facts.rb +1 -1
  68. data/libexec/query_resources.rb +1 -1
  69. data/modules/secure_env_vars/plans/init.pp +20 -0
  70. metadata +11 -2
@@ -79,6 +79,13 @@ module Bolt
79
79
  new(target, message: "Uploaded '#{source}' to '#{target.host}:#{destination}'", action: 'upload', object: source)
80
80
  end
81
81
 
82
+ def self.for_download(target, source, destination, download)
83
+ msg = "Downloaded '#{target.host}:#{source}' to '#{destination}'"
84
+ value = { 'path' => download }
85
+
86
+ new(target, value: value, message: msg, action: 'download', object: source)
87
+ end
88
+
82
89
  # Satisfies the Puppet datatypes API
83
90
  def self.from_asserted_args(target, value)
84
91
  new(target, value: value)
@@ -37,7 +37,7 @@ module Bolt
37
37
  with_tmpdir do |dir|
38
38
  basename = File.basename(source)
39
39
  tmpfile = File.join(dir.to_s, basename)
40
- conn.copy_file(source, tmpfile)
40
+ conn.upload_file(source, tmpfile)
41
41
  # pass over file ownership if we're using run-as to be a different user
42
42
  dir.chown(run_as)
43
43
  result = execute(['mv', '-f', tmpfile, destination], sudoable: true)
@@ -50,6 +50,27 @@ module Bolt
50
50
  end
51
51
  end
52
52
 
53
+ def download(source, destination, options = {})
54
+ running_as(options[:run_as]) do
55
+ # Target OS may be either Unix or Windows. Without knowing the target OS before-hand
56
+ # we can't assume whether the path separator is '/' or '\'. Assume we're connecting
57
+ # to a target with Unix and then check if the path exists after downloading.
58
+ download = File.join(destination, Bolt::Util.unix_basename(source))
59
+
60
+ conn.download_file(source, destination, download)
61
+
62
+ # If the download path doesn't exist, then the file was likely downloaded from Windows
63
+ # using a source path with backslashes (e.g. 'C:\Users\Administrator\foo'). The file
64
+ # should be saved to the expected location, so update the download path assuming a
65
+ # Windows basename so the result shows the correct local path.
66
+ unless File.exist?(download)
67
+ download = File.join(destination, Bolt::Util.windows_basename(source))
68
+ end
69
+
70
+ Bolt::Result.for_download(target, source, destination, download)
71
+ end
72
+ end
73
+
53
74
  def run_script(script, arguments, options = {})
54
75
  # unpack any Sensitive data
55
76
  arguments = unwrap_sensitive_args(arguments)
@@ -82,7 +103,7 @@ module Bolt
82
103
  " using '#{execute_options[:interpreter]}' interpreter"
83
104
  end
84
105
  # log the arguments with sensitive data redacted, do NOT log unwrapped_arguments
85
- logger.debug("Running '#{executable}' with #{arguments.to_json}#{interpreter_debug}")
106
+ logger.trace("Running '#{executable}' with #{arguments.to_json}#{interpreter_debug}")
86
107
  # unpack any Sensitive data
87
108
  arguments = unwrap_sensitive_args(arguments)
88
109
 
@@ -95,7 +116,7 @@ module Bolt
95
116
  task_dir = File.join(dir.to_s, task.tasks_dir)
96
117
  dir.mkdirs([task.tasks_dir] + extra_files.map { |file| File.dirname(file['name']) })
97
118
  extra_files.each do |file|
98
- conn.copy_file(file['path'], File.join(dir.to_s, file['name']))
119
+ conn.upload_file(file['path'], File.join(dir.to_s, file['name']))
99
120
  end
100
121
  end
101
122
 
@@ -182,13 +203,13 @@ module Bolt
182
203
 
183
204
  def handle_sudo_errors(err)
184
205
  if err =~ /^#{conn.user} is not in the sudoers file\./
185
- @logger.debug { err }
206
+ @logger.trace { err }
186
207
  raise Bolt::Node::EscalateError.new(
187
208
  "User #{conn.user} does not have sudo permission on #{target}",
188
209
  'SUDO_DENIED'
189
210
  )
190
211
  elsif err =~ /^Sorry, try again\./
191
- @logger.debug { err }
212
+ @logger.trace { err }
192
213
  raise Bolt::Node::EscalateError.new(
193
214
  "Sudo password for user #{conn.user} not recognized on #{target}",
194
215
  'BAD_PASSWORD'
@@ -257,7 +278,7 @@ module Bolt
257
278
  def write_executable(dir, file, filename = nil)
258
279
  filename ||= File.basename(file)
259
280
  remote_path = File.join(dir.to_s, filename)
260
- conn.copy_file(file, remote_path)
281
+ conn.upload_file(file, remote_path)
261
282
  make_executable(remote_path)
262
283
  remote_path
263
284
  end
@@ -314,7 +335,6 @@ module Bolt
314
335
  sudo_str = if use_sudo
315
336
  sudo_exec = target.options['sudo-executable'] || "sudo"
316
337
  sudo_flags = [sudo_exec, "-S", "-H", "-u", run_as, "-p", sudo_prompt]
317
- sudo_flags += ["-E"] if options[:environment]
318
338
  Shellwords.shelljoin(sudo_flags)
319
339
  else
320
340
  Shellwords.shelljoin(@target.options['run-as-command'] + [run_as])
@@ -331,7 +351,7 @@ module Bolt
331
351
 
332
352
  command_str = [sudo_str, env_decl, command_str].compact.join(' ')
333
353
 
334
- @logger.debug { "Executing: #{command_str}" }
354
+ @logger.trace { "Executing `#{command_str}`" }
335
355
 
336
356
  in_buffer = if !use_sudo && options[:stdin]
337
357
  String.new(options[:stdin], encoding: 'binary')
@@ -411,16 +431,16 @@ module Bolt
411
431
  result_output.exit_code = t.value.respond_to?(:exitstatus) ? t.value.exitstatus : t.value
412
432
 
413
433
  if result_output.exit_code == 0
414
- @logger.debug { "Command returned successfully" }
434
+ @logger.trace { "Command `#{command_str}` returned successfully" }
415
435
  else
416
- @logger.info { "Command failed with exit code #{result_output.exit_code}" }
436
+ @logger.trace { "Command #{command_str} failed with exit code #{result_output.exit_code}" }
417
437
  end
418
438
  result_output
419
439
  rescue StandardError
420
440
  # Ensure we close stdin and kill the child process
421
441
  inp&.close
422
442
  t&.terminate if t&.alive?
423
- @logger.debug { "Command aborted" }
443
+ @logger.trace { "Command aborted" }
424
444
  raise
425
445
  end
426
446
 
@@ -85,7 +85,7 @@ module Bolt
85
85
  filename ||= File.basename(file)
86
86
  validate_extensions(File.extname(filename))
87
87
  destination = "#{dir}\\#{filename}"
88
- conn.copy_file(file, destination)
88
+ conn.upload_file(file, destination)
89
89
  destination
90
90
  end
91
91
 
@@ -164,10 +164,16 @@ module Bolt
164
164
  end
165
165
 
166
166
  def upload(source, destination, _options = {})
167
- conn.copy_file(source, destination)
167
+ conn.upload_file(source, destination)
168
168
  Bolt::Result.for_upload(target, source, destination)
169
169
  end
170
170
 
171
+ def download(source, destination, _options = {})
172
+ download = File.join(destination, Bolt::Util.windows_basename(source))
173
+ conn.download_file(source, destination, download)
174
+ Bolt::Result.for_download(target, source, destination, download)
175
+ end
176
+
171
177
  def run_command(command, options = {})
172
178
  command = [*env_declarations(options[:env_vars]), command].join("\r\n") if options[:env_vars]
173
179
 
@@ -220,7 +226,7 @@ module Bolt
220
226
  task_dir = File.join(dir, task.tasks_dir)
221
227
  mkdirs([task_dir] + extra_files.map { |file| File.join(dir, File.dirname(file['name'])) })
222
228
  extra_files.each do |file|
223
- conn.copy_file(file['path'], File.join(dir, file['name']))
229
+ conn.upload_file(file['path'], File.join(dir, file['name']))
224
230
  end
225
231
  end
226
232
 
@@ -262,7 +268,7 @@ module Bolt
262
268
  return with_tmpdir do |dir|
263
269
  command += "\r\nif (!$?) { if($LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 } }"
264
270
  script_file = File.join(dir, "#{SecureRandom.uuid}_wrapper.ps1")
265
- conn.copy_file(StringIO.new(command), script_file)
271
+ conn.upload_file(StringIO.new(command), script_file)
266
272
  args = escape_arguments([script_file])
267
273
  script_invocation = ['powershell.exe', *PS_ARGS, '-File', *args].join(' ')
268
274
  execute(script_invocation)
@@ -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']
@@ -12,7 +12,7 @@ module Bolt
12
12
  @target = target
13
13
  @logger = Logging.logger[target.safe_name]
14
14
  @docker_host = @target.options['service-url']
15
- @logger.debug("Initializing docker connection to #{@target.safe_name}")
15
+ @logger.trace("Initializing docker connection to #{@target.safe_name}")
16
16
  end
17
17
 
18
18
  def connect
@@ -25,7 +25,7 @@ module Bolt
25
25
  output = execute_local_docker_json_command('inspect', [output[index]["ID"]])
26
26
  # Store the container information for later
27
27
  @container_info = output[0]
28
- @logger.debug { "Opened session" }
28
+ @logger.trace { "Opened session" }
29
29
  true
30
30
  rescue StandardError => e
31
31
  raise Bolt::Node::ConnectError.new(
@@ -57,16 +57,16 @@ module Bolt
57
57
  command_options << container_id
58
58
  command_options.concat(command)
59
59
 
60
- @logger.debug { "Executing: exec #{command_options}" }
60
+ @logger.trace { "Executing: exec #{command_options}" }
61
61
 
62
62
  stdout_str, stderr_str, status = execute_local_docker_command('exec', command_options, options[:stdin])
63
63
 
64
64
  # The actual result is the exitstatus not the process object
65
65
  status = status.nil? ? -32768 : status.exitstatus
66
66
  if status == 0
67
- @logger.debug { "Command returned successfully" }
67
+ @logger.trace { "Command returned successfully" }
68
68
  else
69
- @logger.info { "Command failed with exit code #{status}" }
69
+ @logger.trace { "Command failed with exit code #{status}" }
70
70
  end
71
71
  stdout_str.force_encoding(Encoding::UTF_8)
72
72
  stderr_str.force_encoding(Encoding::UTF_8)
@@ -75,22 +75,40 @@ module Bolt
75
75
  stderr_str.gsub!("\r\n", "\n")
76
76
  [stdout_str, stderr_str, status]
77
77
  rescue StandardError
78
- @logger.debug { "Command aborted" }
78
+ @logger.trace { "Command aborted" }
79
79
  raise
80
80
  end
81
81
 
82
82
  def write_remote_file(source, destination)
83
- @logger.debug { "Uploading #{source}, to #{destination}" }
83
+ @logger.trace { "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
89
91
 
90
92
  def write_remote_directory(source, destination)
91
- @logger.debug { "Uploading #{source}, to #{destination}" }
93
+ @logger.trace { "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.trace { "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,8 +27,8 @@ module Bolt
27
27
  end
28
28
  end
29
29
 
30
- def copy_file(source, dest)
31
- @logger.debug { "Uploading #{source}, to #{dest}" }
30
+ def upload_file(source, dest)
31
+ @logger.trace { "Uploading #{source} to #{dest}" }
32
32
  if source.is_a?(StringIO)
33
33
  Tempfile.create(File.basename(dest)) do |f|
34
34
  f.write(source.read)
@@ -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.trace { "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.
@@ -38,7 +38,7 @@ module Bolt
38
38
  @connections.each_value do |conn|
39
39
  conn.finish_plan(result)
40
40
  rescue StandardError => e
41
- @logger.debug("Failed to finish plan on #{conn.key}: #{e.message}")
41
+ @logger.trace("Failed to finish plan on #{conn.key}: #{e.message}")
42
42
  end
43
43
  end
44
44
  end
@@ -133,7 +133,7 @@ module Bolt
133
133
  next unless File.file?(file)
134
134
 
135
135
  tar_path = Pathname.new(file).relative_path_from(Pathname.new(directory))
136
- @logger.debug("Packing #{file} to #{tar_path}")
136
+ @logger.trace("Packing #{file} to #{tar_path}")
137
137
  stat = File.stat(file)
138
138
  content = File.binread(file)
139
139
  output.tar.add_file_simple(
@@ -146,7 +146,7 @@ module Bolt
146
146
  end
147
147
 
148
148
  duration = Time.now - start_time
149
- @logger.debug("Packed upload in #{duration * 1000} ms")
149
+ @logger.trace("Packed upload in #{duration * 1000} ms")
150
150
 
151
151
  output.close
152
152
  io.string
@@ -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)
@@ -28,7 +28,7 @@ module Bolt
28
28
 
29
29
  @logger = Logging.logger[@target.safe_name]
30
30
  @transport_logger = transport_logger
31
- @logger.debug("Initializing ssh connection to #{@target.safe_name}")
31
+ @logger.trace("Initializing ssh connection to #{@target.safe_name}")
32
32
 
33
33
  if target.options['private-key']&.instance_of?(String)
34
34
  begin
@@ -131,7 +131,7 @@ module Bolt
131
131
 
132
132
  @session = Net::SSH.start(target.host, @user, options)
133
133
  validate_ssh_version
134
- @logger.debug { "Opened session" }
134
+ @logger.trace { "Opened session" }
135
135
  rescue Net::SSH::AuthenticationFailed => e
136
136
  raise Bolt::Node::ConnectError.new(
137
137
  e.message,
@@ -161,7 +161,7 @@ module Bolt
161
161
  rescue Timeout::Error
162
162
  @session.shutdown!
163
163
  end
164
- @logger.debug { "Closed session" }
164
+ @logger.trace { "Closed session" }
165
165
  end
166
166
  end
167
167
 
@@ -235,14 +235,22 @@ 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
- @logger.debug { "Uploading #{source}, to #{destination}" } unless source.is_a?(StringIO)
240
+ @logger.trace { "Uploading #{source} to #{destination}" } unless source.is_a?(StringIO)
241
241
  @session.scp.upload!(source, destination, recursive: true)
242
242
  rescue StandardError => e
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.trace { "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)