bolt 2.16.0 → 2.21.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +3 -1
  3. data/bolt-modules/boltlib/lib/puppet/functions/apply_prep.rb +20 -9
  4. data/bolt-modules/boltlib/lib/puppet/functions/download_file.rb +123 -0
  5. data/bolt-modules/boltlib/lib/puppet/functions/run_command.rb +2 -0
  6. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +6 -0
  7. data/bolt-modules/boltlib/lib/puppet/functions/run_script.rb +6 -4
  8. data/bolt-modules/dir/lib/puppet/functions/dir/children.rb +35 -0
  9. data/lib/bolt/applicator.rb +19 -14
  10. data/lib/bolt/apply_result.rb +1 -1
  11. data/lib/bolt/bolt_option_parser.rb +60 -16
  12. data/lib/bolt/catalog.rb +3 -2
  13. data/lib/bolt/cli.rb +121 -43
  14. data/lib/bolt/config.rb +37 -34
  15. data/lib/bolt/config/options.rb +340 -173
  16. data/lib/bolt/config/transport/options.rb +315 -160
  17. data/lib/bolt/config/transport/ssh.rb +24 -10
  18. data/lib/bolt/executor.rb +21 -0
  19. data/lib/bolt/inventory/group.rb +3 -2
  20. data/lib/bolt/inventory/inventory.rb +4 -3
  21. data/lib/bolt/logger.rb +24 -1
  22. data/lib/bolt/outputter.rb +1 -1
  23. data/lib/bolt/outputter/rainbow.rb +14 -3
  24. data/lib/bolt/pal.rb +28 -10
  25. data/lib/bolt/pal/yaml_plan/evaluator.rb +23 -2
  26. data/lib/bolt/pal/yaml_plan/step.rb +24 -2
  27. data/lib/bolt/pal/yaml_plan/step/download.rb +38 -0
  28. data/lib/bolt/pal/yaml_plan/step/message.rb +30 -0
  29. data/lib/bolt/pal/yaml_plan/step/upload.rb +3 -3
  30. data/lib/bolt/plugin/module.rb +2 -4
  31. data/lib/bolt/plugin/puppetdb.rb +3 -2
  32. data/lib/bolt/project.rb +20 -6
  33. data/lib/bolt/puppetdb/client.rb +2 -0
  34. data/lib/bolt/puppetdb/config.rb +16 -0
  35. data/lib/bolt/result.rb +7 -0
  36. data/lib/bolt/shell/bash.rb +45 -37
  37. data/lib/bolt/shell/powershell.rb +21 -11
  38. data/lib/bolt/shell/powershell/snippets.rb +15 -6
  39. data/lib/bolt/transport/base.rb +24 -0
  40. data/lib/bolt/transport/docker.rb +16 -4
  41. data/lib/bolt/transport/docker/connection.rb +20 -2
  42. data/lib/bolt/transport/local/connection.rb +14 -1
  43. data/lib/bolt/transport/orch.rb +20 -0
  44. data/lib/bolt/transport/simple.rb +6 -0
  45. data/lib/bolt/transport/ssh.rb +7 -1
  46. data/lib/bolt/transport/ssh/connection.rb +9 -1
  47. data/lib/bolt/transport/ssh/exec_connection.rb +23 -2
  48. data/lib/bolt/transport/winrm/connection.rb +118 -8
  49. data/lib/bolt/util.rb +26 -11
  50. data/lib/bolt/version.rb +1 -1
  51. data/lib/bolt_server/transport_app.rb +3 -2
  52. data/lib/bolt_spec/bolt_context.rb +7 -2
  53. data/lib/bolt_spec/plans.rb +15 -2
  54. data/lib/bolt_spec/plans/action_stubs.rb +2 -1
  55. data/lib/bolt_spec/plans/action_stubs/download_stub.rb +66 -0
  56. data/lib/bolt_spec/plans/mock_executor.rb +14 -1
  57. data/lib/bolt_spec/run.rb +22 -0
  58. data/libexec/bolt_catalog +3 -2
  59. data/modules/secure_env_vars/plans/init.pp +20 -0
  60. metadata +21 -29
@@ -85,12 +85,21 @@ module Bolt
85
85
 
86
86
  def shell_init
87
87
  <<~PS
88
- $ENV:PATH += ";${ENV:ProgramFiles}\\Puppet Labs\\Puppet\\bin\\;" +
89
- "${ENV:ProgramFiles}\\Puppet Labs\\Puppet\\puppet\\bin;" +
90
- "${ENV:ProgramFiles}\\Puppet Labs\\Puppet\\sys\\ruby\\bin\\"
91
- $ENV:RUBYLIB = "${ENV:ProgramFiles}\\Puppet Labs\\Puppet\\puppet\\lib;" +
92
- "${ENV:ProgramFiles}\\Puppet Labs\\Puppet\\facter\\lib;" +
93
- "${ENV:ProgramFiles}\\Puppet Labs\\Puppet\\hiera\\lib;" +
88
+ $installRegKey = Get-ItemProperty -Path "HKLM:\\Software\\Puppet Labs\\Puppet" -ErrorAction 0
89
+ if(![string]::IsNullOrEmpty($installRegKey.RememberedInstallDir64)){
90
+ $boltBaseDir = $installRegKey.RememberedInstallDir64
91
+ }elseif(![string]::IsNullOrEmpty($installRegKey.RememberedInstallDir)){
92
+ $boltBaseDir = $installRegKey.RememberedInstallDir
93
+ }else{
94
+ $boltBaseDir = "${ENV:ProgramFiles}\\Puppet Labs\\Puppet"
95
+ }
96
+
97
+ $ENV:PATH += ";${boltBaseDir}\\bin\\;" +
98
+ "${boltBaseDir}\\puppet\\bin;" +
99
+ "${boltBaseDir}\\sys\\ruby\\bin\\"
100
+ $ENV:RUBYLIB = "${boltBaseDir}\\puppet\\lib;" +
101
+ "${boltBaseDir}\\facter\\lib;" +
102
+ "${boltBaseDir}\\hiera\\lib;" +
94
103
  $ENV:RUBYLIB
95
104
 
96
105
  Add-Type -AssemblyName System.ServiceModel.Web, System.Runtime.Serialization
@@ -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,8 +38,18 @@ 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
- options[:tty] = target.options['tty']
50
+ execute_options = {}
51
+ execute_options[:tty] = target.options['tty']
52
+ execute_options[:environment] = options[:env_vars]
43
53
 
44
54
  if target.options['shell-command'] && !target.options['shell-command'].empty?
45
55
  # escape any double quotes in command
@@ -47,19 +57,21 @@ module Bolt
47
57
  command = "#{target.options['shell-command']} \" #{command}\""
48
58
  end
49
59
  with_connection(target) do |conn|
50
- stdout, stderr, exitcode = conn.execute(*Shellwords.split(command), options)
60
+ stdout, stderr, exitcode = conn.execute(*Shellwords.split(command), execute_options)
51
61
  Bolt::Result.for_command(target, stdout, stderr, exitcode, 'command', command)
52
62
  end
53
63
  end
54
64
 
55
- def run_script(target, script, arguments, _options = {})
65
+ def run_script(target, script, arguments, options = {})
56
66
  # unpack any Sensitive data
57
67
  arguments = unwrap_sensitive_args(arguments)
68
+ execute_options = {}
69
+ execute_options[:environment] = options[:env_vars]
58
70
 
59
71
  with_connection(target) do |conn|
60
72
  conn.with_remote_tmpdir do |dir|
61
73
  remote_path = conn.write_remote_executable(dir, script)
62
- stdout, stderr, exitcode = conn.execute(remote_path, *arguments, {})
74
+ stdout, stderr, exitcode = conn.execute(remote_path, *arguments, execute_options)
63
75
  Bolt::Result.for_command(target, stdout, stderr, exitcode, 'script', script)
64
76
  end
65
77
  end
@@ -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.
@@ -82,6 +82,10 @@ module Bolt
82
82
  end
83
83
 
84
84
  def batch_command(targets, command, options = {}, &callback)
85
+ if options[:env_vars] && !options[:env_vars].empty?
86
+ raise NotImplementedError, "pcp transport does not support setting environment variables"
87
+ end
88
+
85
89
  params = {
86
90
  'command' => command
87
91
  }
@@ -98,6 +102,10 @@ module Bolt
98
102
  end
99
103
 
100
104
  def batch_script(targets, script, arguments, options = {}, &callback)
105
+ if options[:env_vars] && !options[:env_vars].empty?
106
+ raise NotImplementedError, "pcp transport does not support setting environment variables"
107
+ end
108
+
101
109
  content = File.open(script, &:read)
102
110
  content = Base64.encode64(content)
103
111
  params = {
@@ -176,6 +184,18 @@ module Bolt
176
184
  end
177
185
  end
178
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
+
179
199
  def batches(targets)
180
200
  targets.group_by { |target| Connection.get_key(target.options) }.values
181
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)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'bolt/logger'
3
4
  require 'bolt/node/errors'
4
5
  require 'bolt/transport/simple'
5
6
 
@@ -21,7 +22,12 @@ module Bolt
21
22
  end
22
23
 
23
24
  def with_connection(target)
24
- conn = if target.transport_config['ssh-command']
25
+ if target.transport_config['ssh-command'] && !target.transport_config['native-ssh']
26
+ Bolt::Logger.warn_once("ssh-command and native-ssh conflict",
27
+ "native-ssh must be true to use ssh-command")
28
+ end
29
+
30
+ conn = if target.transport_config['native-ssh']
25
31
  ExecConnection.new(target)
26
32
  else
27
33
  Connection.new(target, @transport_logger)
@@ -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)
@@ -58,14 +58,14 @@ module Bolt
58
58
  end
59
59
 
60
60
  def build_ssh_command(command)
61
- ssh_conf = @target.transport_config['ssh-command']
61
+ ssh_conf = @target.transport_config['ssh-command'] || 'ssh'
62
62
  ssh_cmd = Array(ssh_conf)
63
63
  ssh_cmd += ssh_opts
64
64
  ssh_cmd << userhost
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)
@@ -111,12 +111,21 @@ module Bolt
111
111
  out_rd, out_wr = IO.pipe('UTF-8')
112
112
  err_rd, err_wr = IO.pipe('UTF-8')
113
113
  th = Thread.new do
114
+ # By default, any exception raised in a thread will be reported to
115
+ # stderr as a stacktrace. Since we know these errors are going to
116
+ # propagate to the main thread via the shell, there's no chance
117
+ # they will be unhandled, so the default stack trace is unneeded.
118
+ Thread.current.report_on_exception = false
114
119
  result = @session.run(command)
115
120
  out_wr << result.stdout
116
121
  err_wr << result.stderr
117
122
  out_wr.close
118
123
  err_wr.close
119
124
  result.exitcode
125
+ ensure
126
+ # Close the streams to avoid the caller deadlocking
127
+ out_wr.close
128
+ err_wr.close
120
129
  end
121
130
 
122
131
  [inp, out_rd, err_rd, th]
@@ -129,23 +138,23 @@ module Bolt
129
138
  raise
130
139
  end
131
140
 
132
- def copy_file(source, destination)
141
+ def upload_file(source, destination)
133
142
  @logger.debug { "Uploading #{source}, to #{destination}" }
134
143
  if target.options['file-protocol'] == 'smb'
135
- copy_file_smb(source, destination)
144
+ upload_file_smb(source, destination)
136
145
  else
137
- copy_file_winrm(source, destination)
146
+ upload_file_winrm(source, destination)
138
147
  end
139
148
  end
140
149
 
141
- def copy_file_winrm(source, destination)
150
+ def upload_file_winrm(source, destination)
142
151
  fs = ::WinRM::FS::FileManager.new(@connection)
143
152
  fs.upload(source, destination)
144
153
  rescue StandardError => e
145
154
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
146
155
  end
147
156
 
148
- def copy_file_smb(source, destination)
157
+ def upload_file_smb(source, destination)
149
158
  # lazy-load expensive gem code
150
159
  require 'ruby_smb'
151
160
 
@@ -165,7 +174,7 @@ module Bolt
165
174
  client = smb_client_login
166
175
  tree = client.tree_connect(path)
167
176
  begin
168
- copy_file_smb_recursive(tree, source, dest)
177
+ upload_file_smb_recursive(tree, source, dest)
169
178
  ensure
170
179
  tree.disconnect!
171
180
  end
@@ -175,6 +184,61 @@ module Bolt
175
184
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
176
185
  end
177
186
 
187
+ def download_file(source, destination, download)
188
+ @logger.debug { "Downloading #{source} to #{destination}" }
189
+ if target.options['file-protocol'] == 'smb'
190
+ download_file_smb(source, destination)
191
+ else
192
+ download_file_winrm(source, destination, download)
193
+ end
194
+ end
195
+
196
+ def download_file_winrm(source, destination, download)
197
+ # The winrm gem doesn't create the destination directory if it's missing,
198
+ # so create it here
199
+ FileUtils.mkdir_p(destination)
200
+ fs = ::WinRM::FS::FileManager.new(@connection)
201
+ # params: source, destination, chunksize, first
202
+ # first needs to be set to false, otherwise if the source is a directory it
203
+ # will be nested inside a directory with the same name
204
+ fs.download(source, download, 1024 * 1024, false)
205
+ rescue StandardError => e
206
+ raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
207
+ end
208
+
209
+ def download_file_smb(source, destination)
210
+ # lazy-load expensive gem code
211
+ require 'ruby_smb'
212
+
213
+ win_source = source.tr('/', '\\')
214
+ if (md = win_source.match(/^([a-z]):\\(.*)/i))
215
+ # if drive, use admin share for that drive, so path is '\\host\C$'
216
+ path = "\\\\#{@target.host}\\#{md[1]}$"
217
+ src = md[2]
218
+ elsif (md = win_source.match(/^(\\\\[^\\]+\\[^\\]+)\\(.*)/))
219
+ # if unc, path is '\\host\share'
220
+ path = md[1]
221
+ src = md[2]
222
+ else
223
+ raise ArgumentError, "Unknown source '#{source}'"
224
+ end
225
+
226
+ client = smb_client_login
227
+ tree = client.tree_connect(path)
228
+
229
+ begin
230
+ # Make sure the root download directory for the target exists
231
+ FileUtils.mkdir_p(destination)
232
+ download_file_smb_recursive(tree, src, destination)
233
+ ensure
234
+ tree.disconnect!
235
+ end
236
+ rescue ::RubySMB::Error::UnexpectedStatusCode => e
237
+ raise Bolt::Node::FileError.new("SMB Error: #{e.message}", 'DOWNLOAD_ERROR')
238
+ rescue StandardError => e
239
+ raise Bolt::Node::FileError.new(e.message, 'DOWNLOAD_ERROR')
240
+ end
241
+
178
242
  def shell
179
243
  @shell ||= Bolt::Shell::Powershell.new(target, self)
180
244
  end
@@ -230,13 +294,13 @@ module Bolt
230
294
  )
231
295
  end
232
296
 
233
- def copy_file_smb_recursive(tree, source, dest)
297
+ def upload_file_smb_recursive(tree, source, dest)
234
298
  if Dir.exist?(source)
235
299
  tree.open_directory(directory: dest, write: true, disposition: ::RubySMB::Dispositions::FILE_OPEN_IF)
236
300
 
237
301
  Dir.children(source).each do |child|
238
302
  child_dest = dest + '\\' + child
239
- copy_file_smb_recursive(tree, File.join(source, child), child_dest)
303
+ upload_file_smb_recursive(tree, File.join(source, child), child_dest)
240
304
  end
241
305
  return
242
306
  end
@@ -255,6 +319,52 @@ module Bolt
255
319
  file.close
256
320
  end
257
321
  end
322
+
323
+ def download_file_smb_recursive(tree, source, destination)
324
+ dest = File.expand_path(Bolt::Util.windows_basename(source), destination)
325
+
326
+ # Check if the source is a directory by attempting to list its children.
327
+ # If the source is a directory, create the directory on the host and then
328
+ # recurse through the children.
329
+ if (children = list_directory_children_smb(tree, source))
330
+ FileUtils.mkdir_p(dest)
331
+
332
+ children.each do |child|
333
+ # File names are encoded UTF_16LE.
334
+ filename = child.file_name.encode(Encoding::UTF_8)
335
+
336
+ next if %w[. ..].include?(filename)
337
+
338
+ src = source + '\\' + filename
339
+ download_file_smb_recursive(tree, src, dest)
340
+ end
341
+ # If the source wasn't a directory and just returns 'STATUS_NOT_A_DIRECTORY, then
342
+ # it is a file. Write it to the host.
343
+ else
344
+ begin
345
+ file = tree.open_file(filename: source)
346
+ data = file.read
347
+
348
+ # Files may be encoded UTF_16LE
349
+ data = data.encode(Encoding::UTF_8) if data.encoding == Encoding::UTF_16LE
350
+
351
+ File.write(dest, data)
352
+ ensure
353
+ file.close
354
+ end
355
+ end
356
+ end
357
+
358
+ # Lists the children of a directory using rb_smb
359
+ # Returns an array of RubySMB::Fscc::FileInformation::FileIdFullDirectoryInformation objects
360
+ # if the source is a directory, or raises RubySMB::Error::UnexpectedStatusCode otherwise.
361
+ def list_directory_children_smb(tree, source)
362
+ tree.list(directory: source)
363
+ rescue RubySMB::Error::UnexpectedStatusCode => e
364
+ unless e.message == 'STATUS_NOT_A_DIRECTORY'
365
+ raise e
366
+ end
367
+ end
258
368
  end
259
369
  end
260
370
  end