test-kitchen 1.3.1 → 1.4.0.beta.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.cane +2 -0
  3. data/.gitignore +4 -0
  4. data/CHANGELOG.md +45 -0
  5. data/Rakefile +15 -0
  6. data/features/kitchen_action_commands.feature +12 -9
  7. data/features/kitchen_defaults.feature +38 -0
  8. data/features/kitchen_init_command.feature +0 -1
  9. data/features/kitchen_list_command.feature +2 -2
  10. data/features/kitchen_login_command.feature +7 -1
  11. data/features/kitchen_test_command.feature +4 -4
  12. data/lib/kitchen.rb +40 -11
  13. data/lib/kitchen/cli.rb +38 -22
  14. data/lib/kitchen/command/list.rb +5 -2
  15. data/lib/kitchen/config.rb +45 -18
  16. data/lib/kitchen/configurable.rb +137 -1
  17. data/lib/kitchen/data_munger.rb +248 -17
  18. data/lib/kitchen/driver.rb +1 -1
  19. data/lib/kitchen/driver/base.rb +1 -83
  20. data/lib/kitchen/driver/dummy.rb +0 -5
  21. data/lib/kitchen/driver/ssh_base.rb +177 -22
  22. data/lib/kitchen/instance.rb +140 -20
  23. data/lib/kitchen/logger.rb +43 -8
  24. data/lib/kitchen/login_command.rb +14 -5
  25. data/lib/kitchen/platform.rb +19 -0
  26. data/lib/kitchen/provisioner.rb +5 -3
  27. data/lib/kitchen/provisioner/base.rb +46 -48
  28. data/lib/kitchen/provisioner/chef/common_sandbox.rb +322 -0
  29. data/lib/kitchen/provisioner/chef_base.rb +179 -286
  30. data/lib/kitchen/provisioner/chef_solo.rb +11 -5
  31. data/lib/kitchen/provisioner/chef_zero.rb +108 -94
  32. data/lib/kitchen/provisioner/dummy.rb +47 -0
  33. data/lib/kitchen/provisioner/shell.rb +45 -12
  34. data/lib/kitchen/rake_tasks.rb +1 -1
  35. data/lib/kitchen/ssh.rb +1 -1
  36. data/lib/kitchen/thor_tasks.rb +1 -1
  37. data/lib/kitchen/transport.rb +54 -0
  38. data/lib/kitchen/transport/base.rb +146 -0
  39. data/lib/kitchen/transport/dummy.rb +75 -0
  40. data/lib/kitchen/transport/ssh.rb +325 -0
  41. data/lib/kitchen/transport/winrm.rb +508 -0
  42. data/lib/kitchen/transport/winrm/command_executor.rb +188 -0
  43. data/lib/kitchen/transport/winrm/file_transporter.rb +454 -0
  44. data/lib/kitchen/transport/winrm/logging.rb +50 -0
  45. data/lib/kitchen/transport/winrm/template.rb +74 -0
  46. data/lib/kitchen/transport/winrm/tmp_zip.rb +187 -0
  47. data/lib/kitchen/verifier.rb +55 -0
  48. data/lib/kitchen/verifier/base.rb +191 -0
  49. data/lib/kitchen/verifier/busser.rb +266 -0
  50. data/lib/kitchen/verifier/dummy.rb +75 -0
  51. data/lib/kitchen/version.rb +1 -1
  52. data/spec/kitchen/cli_spec.rb +56 -0
  53. data/spec/kitchen/config_spec.rb +61 -20
  54. data/spec/kitchen/configurable_spec.rb +327 -1
  55. data/spec/kitchen/data_munger_spec.rb +777 -14
  56. data/spec/kitchen/driver/base_spec.rb +7 -38
  57. data/spec/kitchen/driver/dummy_spec.rb +0 -29
  58. data/spec/kitchen/driver/ssh_base_spec.rb +580 -236
  59. data/spec/kitchen/driver_spec.rb +1 -0
  60. data/spec/kitchen/instance_spec.rb +383 -83
  61. data/spec/kitchen/login_command_spec.rb +29 -10
  62. data/spec/kitchen/platform_spec.rb +58 -2
  63. data/spec/kitchen/provisioner/base_spec.rb +170 -18
  64. data/spec/kitchen/provisioner/chef_base_spec.rb +454 -104
  65. data/spec/kitchen/provisioner/chef_solo_spec.rb +307 -104
  66. data/spec/kitchen/provisioner/chef_zero_spec.rb +561 -230
  67. data/spec/kitchen/provisioner/dummy_spec.rb +91 -0
  68. data/spec/kitchen/provisioner/shell_spec.rb +158 -56
  69. data/spec/kitchen/provisioner_spec.rb +37 -0
  70. data/spec/kitchen/ssh_spec.rb +19 -19
  71. data/spec/kitchen/transport/base_spec.rb +89 -0
  72. data/spec/kitchen/transport/ssh_spec.rb +1147 -0
  73. data/spec/kitchen/transport/winrm/command_executor_spec.rb +400 -0
  74. data/spec/kitchen/transport/winrm/file_transporter_spec.rb +876 -0
  75. data/spec/kitchen/transport/winrm/logging_spec.rb +92 -0
  76. data/spec/kitchen/transport/winrm/template_spec.rb +51 -0
  77. data/spec/kitchen/transport/winrm/tmp_zip_spec.rb +132 -0
  78. data/spec/kitchen/transport/winrm_spec.rb +1069 -0
  79. data/spec/kitchen/transport_spec.rb +112 -0
  80. data/spec/kitchen/verifier/base_spec.rb +310 -0
  81. data/spec/kitchen/verifier/busser_spec.rb +540 -0
  82. data/spec/kitchen/verifier/dummy_spec.rb +91 -0
  83. data/spec/kitchen/verifier_spec.rb +120 -0
  84. data/spec/kitchen_spec.rb +7 -0
  85. data/spec/spec_helper.rb +8 -0
  86. data/spec/support/powershell_max_size_spec.rb +40 -0
  87. data/support/busser_install_command.ps1 +14 -0
  88. data/support/busser_install_command.sh +15 -0
  89. data/support/check_files.ps1.erb +48 -0
  90. data/support/chef_base_init_command.ps1 +18 -0
  91. data/support/chef_base_init_command.sh +2 -0
  92. data/support/chef_base_install_command.ps1 +76 -0
  93. data/support/chef_base_install_command.sh +137 -0
  94. data/support/chef_zero_prepare_command_legacy.ps1 +9 -0
  95. data/support/chef_zero_prepare_command_legacy.sh +10 -0
  96. data/support/decode_files.ps1.erb +61 -0
  97. data/test-kitchen.gemspec +2 -0
  98. metadata +97 -8
  99. data/lib/kitchen/busser.rb +0 -316
  100. data/spec/kitchen/busser_spec.rb +0 -490
  101. data/support/chef_helpers.sh +0 -16
@@ -0,0 +1,188 @@
1
+ # -*- encoding: utf-8 -*-
2
+ #
3
+ # Author:: Matt Wrock (<matt@mattwrock.com>)
4
+ #
5
+ # Copyright (C) 2014, Matt Wrock
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+
19
+ require "kitchen/transport/winrm/logging"
20
+
21
+ module Kitchen
22
+
23
+ module Transport
24
+
25
+ class Winrm < Kitchen::Transport::Base
26
+
27
+ # Object which can execute multiple commands and Powershell scripts in
28
+ # one shared remote shell session. The maximum number of commands per
29
+ # shell is determined by interrogating the remote host when the session
30
+ # is opened and the remote shell is automatically recycled before the
31
+ # threshold is reached.
32
+ #
33
+ # @author Matt Wrock <matt@mattwrock.com>
34
+ # @author Fletcher Nichol <fnichol@nichol.ca>
35
+ class CommandExecutor
36
+
37
+ include Logging
38
+
39
+ # @return [Integer,nil] the safe maximum number of commands that can
40
+ # be executed in one remote shell session, or `nil` if the
41
+ # threshold has not yet been determined
42
+ attr_reader :max_commands
43
+
44
+ # @return [String,nil] the identifier for the current open remote
45
+ # shell session, or `nil` if the session is not open
46
+ attr_reader :shell
47
+
48
+ # Creates a CommandExecutor given a `WinRM::WinRMWebService` object.
49
+ #
50
+ # @param service [WinRM::WinRMWebService] a winrm web service object
51
+ # @param logger [#debug,#info] an optional logger/ui object that
52
+ # responds to `#debug` and `#info` (default: `nil`)
53
+ def initialize(service, logger = nil)
54
+ @service = service
55
+ @logger = logger
56
+ @command_count = 0
57
+ end
58
+
59
+ # Closes the open remote shell session. This method can be called
60
+ # multiple times, even if there is no open session.
61
+ def close
62
+ return if shell.nil?
63
+
64
+ service.close_shell(shell)
65
+ @shell = nil
66
+ end
67
+
68
+ # Opens a remote shell session for reuse. The maxiumum
69
+ # command-per-shell threshold is also determined the first time this
70
+ # method is invoked and cached for later invocations.
71
+ #
72
+ # @return [String] the remote shell session indentifier
73
+ def open
74
+ close
75
+ @shell = service.open_shell
76
+ @command_count = 0
77
+ determine_max_commands unless max_commands
78
+ shell
79
+ end
80
+
81
+ # Runs a CMD command.
82
+ #
83
+ # @param command [String] the command to run on the remote system
84
+ # @param arguments [Array<String>] arguments to the command
85
+ # @yield [stdout, stderr] yields more live access the standard
86
+ # output and standard error streams as they are returns, if
87
+ # streaming behavior is desired
88
+ # @return [WinRM::Output] output object with stdout, stderr, and
89
+ # exit code
90
+ def run_cmd(command, arguments = [], &block)
91
+ reset if command_count_exceeded?
92
+ ensure_open_shell!
93
+
94
+ @command_count += 1
95
+ result = nil
96
+ service.run_command(shell, command, arguments) do |command_id|
97
+ result = service.get_command_output(shell, command_id, &block)
98
+ end
99
+ result
100
+ end
101
+
102
+ # Run a Powershell script that resides on the local box.
103
+ #
104
+ # @param script_file [IO,String] an IO reference for reading the
105
+ # Powershell script or the actual file contents
106
+ # @yield [stdout, stderr] yields more live access the standard
107
+ # output and standard error streams as they are returns, if
108
+ # streaming behavior is desired
109
+ # @return [WinRM::Output] output object with stdout, stderr, and
110
+ # exit code
111
+ def run_powershell_script(script_file, &block)
112
+ # this code looks overly compact in an attempt to limit local
113
+ # variable assignments that may contain large strings and
114
+ # consequently bloat the Ruby VM
115
+ run_cmd(
116
+ "powershell",
117
+ [
118
+ "-encodedCommand",
119
+ WinRM::PowershellScript.new(
120
+ script_file.is_a?(IO) ? script_file.read : script_file
121
+ ).encoded
122
+ ],
123
+ &block
124
+ )
125
+ end
126
+
127
+ private
128
+
129
+ LEGACY_LIMIT = 15
130
+
131
+ MODERN_LIMIT = 1500
132
+
133
+ PS1_OS_VERSION = "[environment]::OSVersion.Version.tostring()".freeze
134
+
135
+ # @return [Integer] the number of executed commands on the remote
136
+ # shell session
137
+ # @api private
138
+ attr_accessor :command_count
139
+
140
+ # @return [#debug,#info] the logger
141
+ # @api private
142
+ attr_reader :logger
143
+
144
+ # @return [WinRM::WinRMWebService] a WinRM web service object
145
+ # @api private
146
+ attr_reader :service
147
+
148
+ # @return [true,false] whether or not the number of exeecuted commands
149
+ # have exceeded the maxiumum threshold
150
+ # @api private
151
+ def command_count_exceeded?
152
+ command_count > max_commands.to_i
153
+ end
154
+
155
+ # Ensures that there is an open remote shell session.
156
+ #
157
+ # @raise [WinRM::WinRMError] if there is no open shell
158
+ # @api private
159
+ def ensure_open_shell!
160
+ if shell.nil?
161
+ raise WinRM::WinRMError, "#{self.class}#open must be called " \
162
+ "before any run methods are invoked"
163
+ end
164
+ end
165
+
166
+ # Determines the safe maximum number of commands that can be executed
167
+ # on a remote shell session by interrogating the remote host.
168
+ #
169
+ # @api private
170
+ def determine_max_commands
171
+ os_version = run_powershell_script(PS1_OS_VERSION).stdout.chomp
172
+ @max_commands = os_version < "6.2" ? LEGACY_LIMIT : MODERN_LIMIT
173
+ @max_commands -= 2 # to be safe
174
+ end
175
+
176
+ # Closes the remote shell session and opens a new one.
177
+ #
178
+ # @api private
179
+ def reset
180
+ debug {
181
+ "Resetting WinRM shell (Max command limit is #{max_commands})"
182
+ }
183
+ open
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,454 @@
1
+ # -*- encoding: utf-8 -*-
2
+ #
3
+ # Author:: Fletcher (<fnichol@nichol.ca>)
4
+ #
5
+ # Copyright (C) 2015, Fletcher Nichol
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+
19
+ require "benchmark"
20
+ require "csv"
21
+ require "digest"
22
+ require "securerandom"
23
+ require "stringio"
24
+
25
+ require "kitchen/transport/winrm/logging"
26
+ require "kitchen/transport/winrm/template"
27
+ require "kitchen/transport/winrm/tmp_zip"
28
+
29
+ module Kitchen
30
+
31
+ module Transport
32
+
33
+ class Winrm < Kitchen::Transport::Base
34
+
35
+ # Wrapped exception for any internally raised WinRM-related errors.
36
+ #
37
+ # @author Fletcher Nichol <fnichol@nichol.ca>
38
+ class FileTransporterFailed < ::WinRM::WinRMError; end
39
+
40
+ # Object which can upload one or more files or directories to a remote
41
+ # host over WinRM using PowerShell scripts and CMD commands. Note that
42
+ # this form of file transfer is *not* ideal and extremely costly on both
43
+ # the local and remote sides. Great pains are made to minimize round
44
+ # trips to the remote host and to minimize the number of PowerShell
45
+ # sessions being invoked which can be 2 orders of magnitude more
46
+ # expensive than vanilla CMD commands.
47
+ #
48
+ # This object is supported by either a `WinRM::WinRMWebService` or
49
+ # `Winrm::CommandExecutor` instance as it depends on the `#run_cmd` and
50
+ # `#run_powershell_script` API contracts.
51
+ #
52
+ # An optional logger can be supplied, assuming it can respond to the
53
+ # `#debug` and `#debug?` messages.
54
+ #
55
+ # @author Fletcher Nichol <fnichol@nichol.ca>
56
+ # @author Matt Wrock <matt@mattwrock.com>
57
+ class FileTransporter
58
+
59
+ include Logging
60
+
61
+ # Creates a FileTransporter given a service object and optional logger.
62
+ # The service object may be a `WinRM::WinRMWebService` or
63
+ # `Winrm::CommandExecutor` instance.
64
+ #
65
+ # @param service [WinRM::WinRMWebService,Winrm::CommandExecutor] a
66
+ # winrm web service object
67
+ # @param logger [#debug,#debug?] an optional logger/ui object that
68
+ # responds to `#debug` and `#debug?` (default: `nil`)
69
+ def initialize(service, logger = nil, opts = {})
70
+ @service = service
71
+ @logger = logger
72
+ @id_generator = opts.fetch(:id_generator) { -> { SecureRandom.uuid } }
73
+ end
74
+
75
+ # Uploads a collection of files and/or directories to the remote host.
76
+ #
77
+ # **TODO Notes:**
78
+ # * options could specify zip mode, zip options, etc.
79
+ # * maybe option to set tmpfile base dir to override $env:PATH?
80
+ # * progress yields block like net-scp progress
81
+ # * final API: def upload(locals, remote, _options = {}, &_progress)
82
+ #
83
+ # @param locals [Array<String>,String] one or more local file or
84
+ # directory paths
85
+ # @param remote [String] the base destination path on the remote host
86
+ # @return [Hash] report hash, keyed by the local MD5 digest
87
+ def upload(locals, remote)
88
+ files = nil
89
+
90
+ elapsed = Benchmark.measure do
91
+ files = make_files_hash(Array(locals), remote)
92
+
93
+ report = check_files(files)
94
+ merge_with_report!(files, report)
95
+
96
+ report = stream_upload_files(files)
97
+ merge_with_report!(files, report)
98
+
99
+ report = decode_files(files)
100
+ merge_with_report!(files, report)
101
+
102
+ cleanup(files)
103
+ end
104
+
105
+ debug {
106
+ "Uploaded #{files.keys.size} items " \
107
+ "in #{Util.duration(elapsed.real)}"
108
+ }
109
+
110
+ files
111
+ end
112
+
113
+ private
114
+
115
+ MAX_ENCODED_WRITE = 8000
116
+
117
+ BASE64_PACK = "m0".freeze
118
+
119
+ # @return [#debug,#debug?] the logger
120
+ # @api private
121
+ attr_reader :logger
122
+
123
+ # @return [WinRM::WinRMWebService,Winrm::CommandExecutor] a WinRM web
124
+ # service object
125
+ # @api private
126
+ attr_reader :service
127
+
128
+ # Adds an entry to a files Hash (keyed by local MD5 digest) for a
129
+ # directory. When a directory is added, a temporary Zip file is created
130
+ # containing the contents of the directory and any file-related data
131
+ # such as MD5 digest, size, etc. will be referring to the Zip file.
132
+ #
133
+ # @param hash [Hash] hash to be mutated
134
+ # @param dir [String] directory path to be Zipped and added
135
+ # @param remote [String] path to destination on remote host
136
+ # @api private
137
+ def add_directory_hash!(hash, dir, remote)
138
+ zip_io = TmpZip.new(dir, logger)
139
+ zip_md5 = md5sum(zip_io.path)
140
+
141
+ hash[zip_md5] = {
142
+ "src" => dir,
143
+ "src_zip" => zip_io.path.to_s,
144
+ "zip_io" => zip_io,
145
+ "tmpzip" => "$env:TEMP\\tmpzip-#{zip_md5}.zip",
146
+ "dst" => remote,
147
+ "size" => File.size(zip_io.path)
148
+ }
149
+ end
150
+
151
+ # Adds an entry to a files Hash (keyed by local MD5 digest) for a file.
152
+ #
153
+ # @param hash [Hash] hash to be mutated
154
+ # @param local [String] file path
155
+ # @param remote [String] path to destination on remote host
156
+ # @api private
157
+ def add_file_hash!(hash, local, remote)
158
+ hash[md5sum(local)] = {
159
+ "src" => local,
160
+ "dst" => "#{remote}\\#{File.basename(local)}",
161
+ "size" => File.size(local)
162
+ }
163
+ end
164
+
165
+ # Runs the check_files PowerShell script against a collection of
166
+ # destination path/MD5 checksum pairs. The PowerShell script returns
167
+ # its results as a CSV-formatted report which is converted into a Ruby
168
+ # Hash.
169
+ #
170
+ # @param files [Hash] files hash, keyed by the local MD5 digest
171
+ # @return [Hash] a report hash, keyed by the local MD5 digest
172
+ # @api private
173
+ def check_files(files)
174
+ debug { "Running check_files.ps1" }
175
+ hash_file = create_remote_hash_file(check_files_ps_hash(files))
176
+ output = service.run_powershell_script(
177
+ check_files_template % { :hash_file => hash_file }
178
+ )
179
+ parse_response(output)
180
+ end
181
+
182
+ # Constructs a collection of destination path/MD5 checksum pairs as a
183
+ # String representation of the contents of a PowerShell Hash Table.
184
+ #
185
+ # @param files [Hash] files hash, keyed by the local MD5 digest
186
+ # @return [String] the inner contents of a PowerShell Hash Table
187
+ # @api private
188
+ def check_files_ps_hash(files)
189
+ ps_hash(Hash[
190
+ files.map { |md5, data| [data.fetch("tmpzip", data["dst"]), md5] }
191
+ ])
192
+ end
193
+
194
+ # @return [Template] an un-rendered template of the check_files
195
+ # PowerShell script
196
+ # @api private
197
+ def check_files_template
198
+ @check_files_template ||= Template.new(File.join(
199
+ File.dirname(__FILE__),
200
+ %W[.. .. .. .. support check_files.ps1.erb]
201
+ ))
202
+ end
203
+
204
+ # Performs any final cleanup on the report Hash and removes any
205
+ # temporary files/resources used in the upload task.
206
+ #
207
+ # @param files [Hash] a files hash
208
+ # @api private
209
+ def cleanup(files)
210
+ files.select { |_, data| data.key?("zip_io") }.each do |md5, data|
211
+ data.fetch("zip_io").unlink
212
+ files.fetch(md5).delete("zip_io")
213
+ debug { "Cleaned up src_zip #{data["src_zip"]}" }
214
+ end
215
+ end
216
+
217
+ # Creates a remote Base64-encoded temporary file containing a
218
+ # PowerShell hash table.
219
+ #
220
+ # @param hash [String] a String representation of a PowerShell hash
221
+ # table
222
+ # @return [String] the remote path to the temporary file
223
+ # @api private
224
+ def create_remote_hash_file(hash)
225
+ hash_file = "$env:TEMP\\hash-#{@id_generator.call}.txt"
226
+ hash.lines.each { |line| debug { line.chomp } }
227
+ StringIO.open(hash) { |io| stream_upload(io, hash_file) }
228
+ hash_file
229
+ end
230
+
231
+ # Runs the decode_files PowerShell script against a collection of
232
+ # temporary file/destination path pairs. The PowerShell script returns
233
+ # its results as a CSV-formatted report which is converted into a Ruby
234
+ # Hash. The script will not be invoked if there are no "dirty" files
235
+ # present in the incoming files Hash.
236
+ #
237
+ # @param files [Hash] files hash, keyed by the local MD5 digest
238
+ # @return [Hash] a report hash, keyed by the local MD5 digest
239
+ # @api private
240
+ def decode_files(files)
241
+ decoded_files = decode_files_ps_hash(files)
242
+
243
+ if decoded_files == ps_hash(Hash.new)
244
+ debug { "No remote files to decode, skipping" }
245
+ Hash.new
246
+ else
247
+ debug { "Running decode_files.ps1" }
248
+ hash_file = create_remote_hash_file(decoded_files)
249
+ output = service.run_powershell_script(
250
+ decode_files_template % { :hash_file => hash_file }
251
+ )
252
+ parse_response(output)
253
+ end
254
+ end
255
+
256
+ # Constructs a collection of temporary file/destination path pairs for
257
+ # all "dirty" files as a String representation of the contents of a
258
+ # PowerShell Hash Table. A "dirty" file is one which has the
259
+ # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
260
+ #
261
+ # @param files [Hash] files hash, keyed by the local MD5 digest
262
+ # @return [String] the inner contents of a PowerShell Hash Table
263
+ # @api private
264
+ def decode_files_ps_hash(files)
265
+ result = files.select { |_, data| data["chk_dirty"] == "True" }.map { |_, data|
266
+ val = { "dst" => data["dst"] }
267
+ val["tmpzip"] = data["tmpzip"] if data["tmpzip"]
268
+
269
+ [data["tmpfile"], val]
270
+ }
271
+
272
+ ps_hash(Hash[result])
273
+ end
274
+
275
+ # @return [Template] an un-rendered template of the decode_files
276
+ # PowerShell script
277
+ # @api private
278
+ def decode_files_template
279
+ @decode_files_template ||= Template.new(File.join(
280
+ File.dirname(__FILE__),
281
+ %W[.. .. .. .. support decode_files.ps1.erb]
282
+ ))
283
+ end
284
+
285
+ # Contructs a Hash of files or directories, keyed by the local MD5
286
+ # digest. Each file entry has a source and destination set, at a
287
+ # minimum.
288
+ #
289
+ # @param locals [Array<String>] a collection of local files or
290
+ # directories
291
+ # @param remote [String] the base destination path on the remote host
292
+ # @return [Hash] files hash, keyed by the local MD5 digest
293
+ # @api private
294
+ def make_files_hash(locals, remote)
295
+ hash = Hash.new
296
+ locals.each do |local|
297
+ expanded = File.expand_path(local)
298
+ expanded += local[-1] if local.end_with?("/", "\\")
299
+
300
+ if File.file?(expanded)
301
+ add_file_hash!(hash, expanded, remote)
302
+ elsif File.directory?(expanded)
303
+ add_directory_hash!(hash, expanded, remote)
304
+ else
305
+ raise Errno::ENOENT, "No such file or directory #{expanded}"
306
+ end
307
+ end
308
+ hash
309
+ end
310
+
311
+ # @return [String] the MD5 digest of a local file
312
+ # @api private
313
+ def md5sum(local)
314
+ Digest::MD5.file(local).hexdigest
315
+ end
316
+
317
+ # Destructively merges a report Hash into an existing files Hash.
318
+ # **Note:** this method mutates the files Hash.
319
+ #
320
+ # @param files [Hash] files hash, keyed by the local MD5 digest
321
+ # @param report [Hash] report hash, keyed by the local MD5 digest
322
+ # @api private
323
+ def merge_with_report!(files, report)
324
+ files.merge!(report) { |_, oldval, newval| oldval.merge(newval) }
325
+ end
326
+
327
+ # @param depth [Integer] number of padding characters (default: `0`)
328
+ # @return [String] a whitespace padded string of the given length
329
+ # @api private
330
+ def pad(depth = 0)
331
+ " " * depth
332
+ end
333
+
334
+ # Parses response of a PowerShell script or CMD command which contains
335
+ # a CSV-formatted document in the standard output stream.
336
+ #
337
+ # @param output [WinRM::Output] output object with stdout, stderr, and
338
+ # exit code
339
+ # @return [Hash] report hash, keyed by the local MD5 digest
340
+ # @api private
341
+ def parse_response(output)
342
+ if output[:exitcode] != 0
343
+ raise FileTransporterFailed, "[#{self.class}] Upload failed " \
344
+ "(exitcode: #{output[:exitcode]})\n#{output.stderr}"
345
+ end
346
+ array = CSV.parse(output.stdout, :headers => true).map(&:to_hash)
347
+ array.each { |h| h.each { |key, value| h[key] = nil if value == "" } }
348
+ Hash[array.map { |entry| [entry.fetch("src_md5"), entry] }]
349
+ end
350
+
351
+ # Converts a Ruby hash into a PowerShell hash table, represented in a
352
+ # String.
353
+ #
354
+ # @param obj [Object] source Hash or object when used in recursive
355
+ # calls
356
+ # @param depth [Integer] padding depth, used in recursive calls
357
+ # (default: `0`)
358
+ # @return [String] a PowerShell hash table
359
+ # @api private
360
+ def ps_hash(obj, depth = 0)
361
+ if obj.is_a?(Hash)
362
+ obj.map { |k, v|
363
+ %{#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)}}
364
+ }.join("\n").insert(0, "@{\n").insert(-1, "\n#{pad(depth)}}")
365
+ else
366
+ %{"#{obj}"}
367
+ end
368
+ end
369
+
370
+ # Uploads an IO stream to a Base64-encoded destination file.
371
+ #
372
+ # **Implementation Note:** Some of the code in this method may appear
373
+ # slightly too dense and while adding additional variables would help,
374
+ # the code is written very precisely to avoid unwanted allocations
375
+ # which will bloat the Ruby VM's object space (and memory footprint).
376
+ # The goal here is to stream potentially large files to a remote host
377
+ # while not loading the entire file into memory first, then Base64
378
+ # encoding it--duplicating the file in memory again.
379
+ #
380
+ # @param input_io [#read] a readable stream or object to be uploaded
381
+ # @param dest [String] path to the destination file on the remote host
382
+ # @return [Integer,Integer] the number of resulting upload chunks and
383
+ # the number of bytes transferred to the remote host
384
+ # @api private
385
+ def stream_upload(input_io, dest)
386
+ dest_cmd = dest.sub("$env:TEMP", "%TEMP%")
387
+ read_size = (MAX_ENCODED_WRITE.to_i / 4) * 3
388
+ chunk, bytes = 1, 0
389
+ buffer = ""
390
+ service.run_cmd(%{echo|set /p=>"#{dest_cmd}"}) # truncate empty file
391
+ while input_io.read(read_size, buffer)
392
+ bytes += (buffer.bytesize / 3 * 4)
393
+ service.run_cmd([buffer].pack(BASE64_PACK).
394
+ insert(0, "echo ").concat(%{ >> "#{dest_cmd}"}))
395
+ debug { "Wrote chunk #{chunk} for #{dest}" } if chunk % 25 == 0
396
+ chunk += 1
397
+ end
398
+ buffer = nil # rubocop:disable Lint/UselessAssignment
399
+
400
+ [chunk - 1, bytes]
401
+ end
402
+
403
+ # Uploads a local file to a Base64-encoded temporary file.
404
+ #
405
+ # @param src [String] path to a local file
406
+ # @param tmpfile [String] path to the temporary file on the remote
407
+ # host
408
+ # @return [Integer,Integer] the number of resulting upload chunks and
409
+ # the number of bytes transferred to the remote host
410
+ # @api private
411
+ def stream_upload_file(src, tmpfile)
412
+ debug { "Uploading #{src} to encoded tmpfile #{tmpfile}" }
413
+ chunks, bytes = 0, 0
414
+ elapsed = Benchmark.measure do
415
+ File.open(src, "rb") do |io|
416
+ chunks, bytes = stream_upload(io, tmpfile)
417
+ end
418
+ end
419
+ debug {
420
+ "Finished uploading #{src} to encoded tmpfile #{tmpfile} " \
421
+ "(#{bytes.to_f / 1000} KB over #{chunks} chunks) " \
422
+ "in #{Util.duration(elapsed.real)}"
423
+ }
424
+
425
+ [chunks, bytes]
426
+ end
427
+
428
+ # Uploads a collection of "dirty" files to the remote host as
429
+ # Base64-encoded temporary files. A "dirty" file is one which has the
430
+ # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
431
+ #
432
+ # @param files [Hash] files hash, keyed by the local MD5 digest
433
+ # @return [Hash] a report hash, keyed by the local MD5 digest
434
+ # @api private
435
+ def stream_upload_files(files)
436
+ response = Hash.new
437
+ files.each do |md5, data|
438
+ src = data.fetch("src_zip", data["src"])
439
+ if data["chk_dirty"] == "True"
440
+ tmpfile = "$env:TEMP\\b64-#{md5}.txt"
441
+ response[md5] = { "tmpfile" => tmpfile }
442
+ chunks, bytes = stream_upload_file(src, tmpfile)
443
+ response[md5]["chunks"] = chunks
444
+ response[md5]["xfered"] = bytes
445
+ else
446
+ debug { "File #{data["dst"]} is up to date, skipping" }
447
+ end
448
+ end
449
+ response
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end