winrm-transport 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,468 @@
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 "winrm/transport/logging"
26
+ require "winrm/transport/tmp_zip"
27
+
28
+ module WinRM
29
+
30
+ module Transport
31
+
32
+ # Wrapped exception for any internally raised WinRM-related errors.
33
+ #
34
+ # @author Fletcher Nichol <fnichol@nichol.ca>
35
+ class FileTransporterFailed < ::WinRM::WinRMError; end
36
+
37
+ # Object which can upload one or more files or directories to a remote
38
+ # host over WinRM using PowerShell scripts and CMD commands. Note that
39
+ # this form of file transfer is *not* ideal and extremely costly on both
40
+ # the local and remote sides. Great pains are made to minimize round
41
+ # trips to the remote host and to minimize the number of PowerShell
42
+ # sessions being invoked which can be 2 orders of magnitude more
43
+ # expensive than vanilla CMD commands.
44
+ #
45
+ # This object is supported by either a `WinRM::WinRMWebService` or
46
+ # `CommandExecutor` instance as it depends on the `#run_cmd` and
47
+ # `#run_powershell_script` API contracts.
48
+ #
49
+ # An optional logger can be supplied, assuming it can respond to the
50
+ # `#debug` and `#debug?` messages.
51
+ #
52
+ # @author Fletcher Nichol <fnichol@nichol.ca>
53
+ # @author Matt Wrock <matt@mattwrock.com>
54
+ class FileTransporter
55
+
56
+ include Logging
57
+
58
+ # Creates a FileTransporter given a service object and optional logger.
59
+ # The service object may be a `WinRM::WinRMWebService` or
60
+ # `CommandExecutor` instance.
61
+ #
62
+ # @param service [WinRM::WinRMWebService,CommandExecutor] a
63
+ # winrm web service object
64
+ # @param logger [#debug,#debug?] an optional logger/ui object that
65
+ # responds to `#debug` and `#debug?` (default: `nil`)
66
+ def initialize(service, logger = nil, opts = {})
67
+ @service = service
68
+ @logger = logger
69
+ @id_generator = opts.fetch(:id_generator) { -> { SecureRandom.uuid } }
70
+ end
71
+
72
+ # Uploads a collection of files and/or directories to the remote host.
73
+ #
74
+ # **TODO Notes:**
75
+ # * options could specify zip mode, zip options, etc.
76
+ # * maybe option to set tmpfile base dir to override $env:PATH?
77
+ # * progress yields block like net-scp progress
78
+ # * final API: def upload(locals, remote, _options = {}, &_progress)
79
+ #
80
+ # @param locals [Array<String>,String] one or more local file or
81
+ # directory paths
82
+ # @param remote [String] the base destination path on the remote host
83
+ # @return [Hash] report hash, keyed by the local MD5 digest
84
+ def upload(locals, remote)
85
+ files = nil
86
+
87
+ elapsed = Benchmark.measure do
88
+ files = make_files_hash(Array(locals), remote)
89
+
90
+ report = check_files(files)
91
+ merge_with_report!(files, report)
92
+
93
+ report = stream_upload_files(files)
94
+ merge_with_report!(files, report)
95
+
96
+ report = decode_files(files)
97
+ merge_with_report!(files, report)
98
+
99
+ cleanup(files)
100
+ end
101
+
102
+ debug {
103
+ "Uploaded #{files.keys.size} items " \
104
+ "in #{duration(elapsed.real)}"
105
+ }
106
+
107
+ files
108
+ end
109
+
110
+ private
111
+
112
+ # @return [Integer] the maximum number of bytes that can be supplied on
113
+ # a Windows CMD prompt without exceeded the maximum command line
114
+ # length
115
+ # @api private
116
+ MAX_ENCODED_WRITE = 8000
117
+
118
+ # @return [String] the Array pack template for Base64 encoding a stream
119
+ # of data
120
+ # @api private
121
+ BASE64_PACK = "m0".freeze
122
+
123
+ # @return [#debug,#debug?] the logger
124
+ # @api private
125
+ attr_reader :logger
126
+
127
+ # @return [WinRM::WinRMWebService,Winrm::CommandExecutor] a WinRM web
128
+ # service object
129
+ # @api private
130
+ attr_reader :service
131
+
132
+ # Adds an entry to a files Hash (keyed by local MD5 digest) for a
133
+ # directory. When a directory is added, a temporary Zip file is created
134
+ # containing the contents of the directory and any file-related data
135
+ # such as MD5 digest, size, etc. will be referring to the Zip file.
136
+ #
137
+ # @param hash [Hash] hash to be mutated
138
+ # @param dir [String] directory path to be Zipped and added
139
+ # @param remote [String] path to destination on remote host
140
+ # @api private
141
+ def add_directory_hash!(hash, dir, remote)
142
+ zip_io = TmpZip.new(dir, logger)
143
+ zip_md5 = md5sum(zip_io.path)
144
+
145
+ hash[zip_md5] = {
146
+ "src" => dir,
147
+ "src_zip" => zip_io.path.to_s,
148
+ "zip_io" => zip_io,
149
+ "tmpzip" => "$env:TEMP\\tmpzip-#{zip_md5}.zip",
150
+ "dst" => remote,
151
+ "size" => File.size(zip_io.path)
152
+ }
153
+ end
154
+
155
+ # Adds an entry to a files Hash (keyed by local MD5 digest) for a file.
156
+ #
157
+ # @param hash [Hash] hash to be mutated
158
+ # @param local [String] file path
159
+ # @param remote [String] path to destination on remote host
160
+ # @api private
161
+ def add_file_hash!(hash, local, remote)
162
+ hash[md5sum(local)] = {
163
+ "src" => local,
164
+ "dst" => "#{remote}\\#{File.basename(local)}",
165
+ "size" => File.size(local)
166
+ }
167
+ end
168
+
169
+ # Runs the check_files PowerShell script against a collection of
170
+ # destination path/MD5 checksum pairs. The PowerShell script returns
171
+ # its results as a CSV-formatted report which is converted into a Ruby
172
+ # Hash.
173
+ #
174
+ # @param files [Hash] files hash, keyed by the local MD5 digest
175
+ # @return [Hash] a report hash, keyed by the local MD5 digest
176
+ # @api private
177
+ def check_files(files)
178
+ debug { "Running check_files.ps1" }
179
+ hash_file = create_remote_hash_file(check_files_ps_hash(files))
180
+ vars = %{$hash_file = "#{hash_file}"\n}
181
+
182
+ output = service.run_powershell_script(
183
+ [vars, check_files_script].join("\n")
184
+ )
185
+ parse_response(output)
186
+ end
187
+
188
+ # Constructs a collection of destination path/MD5 checksum pairs as a
189
+ # String representation of the contents of a PowerShell Hash Table.
190
+ #
191
+ # @param files [Hash] files hash, keyed by the local MD5 digest
192
+ # @return [String] the inner contents of a PowerShell Hash Table
193
+ # @api private
194
+ def check_files_ps_hash(files)
195
+ ps_hash(Hash[
196
+ files.map { |md5, data| [data.fetch("tmpzip", data["dst"]), md5] }
197
+ ])
198
+ end
199
+
200
+ # @return [String] the check_files PowerShell script
201
+ # @api private
202
+ def check_files_script
203
+ @check_files_script ||= IO.read(File.join(
204
+ File.dirname(__FILE__), %W[.. .. .. support check_files.ps1]
205
+ ))
206
+ end
207
+
208
+ # Performs any final cleanup on the report Hash and removes any
209
+ # temporary files/resources used in the upload task.
210
+ #
211
+ # @param files [Hash] a files hash
212
+ # @api private
213
+ def cleanup(files)
214
+ files.select { |_, data| data.key?("zip_io") }.each do |md5, data|
215
+ data.fetch("zip_io").unlink
216
+ files.fetch(md5).delete("zip_io")
217
+ debug { "Cleaned up src_zip #{data["src_zip"]}" }
218
+ end
219
+ end
220
+
221
+ # Creates a remote Base64-encoded temporary file containing a
222
+ # PowerShell hash table.
223
+ #
224
+ # @param hash [String] a String representation of a PowerShell hash
225
+ # table
226
+ # @return [String] the remote path to the temporary file
227
+ # @api private
228
+ def create_remote_hash_file(hash)
229
+ hash_file = "$env:TEMP\\hash-#{@id_generator.call}.txt"
230
+ hash.lines.each { |line| debug { line.chomp } }
231
+ StringIO.open(hash) { |io| stream_upload(io, hash_file) }
232
+ hash_file
233
+ end
234
+
235
+ # Runs the decode_files PowerShell script against a collection of
236
+ # temporary file/destination path pairs. The PowerShell script returns
237
+ # its results as a CSV-formatted report which is converted into a Ruby
238
+ # Hash. The script will not be invoked if there are no "dirty" files
239
+ # present in the incoming files Hash.
240
+ #
241
+ # @param files [Hash] files hash, keyed by the local MD5 digest
242
+ # @return [Hash] a report hash, keyed by the local MD5 digest
243
+ # @api private
244
+ def decode_files(files)
245
+ decoded_files = decode_files_ps_hash(files)
246
+
247
+ if decoded_files == ps_hash(Hash.new)
248
+ debug { "No remote files to decode, skipping" }
249
+ Hash.new
250
+ else
251
+ debug { "Running decode_files.ps1" }
252
+ hash_file = create_remote_hash_file(decoded_files)
253
+ vars = %{$hash_file = "#{hash_file}"\n}
254
+
255
+ output = service.run_powershell_script(
256
+ [vars, decode_files_script].join("\n")
257
+ )
258
+ parse_response(output)
259
+ end
260
+ end
261
+
262
+ # Constructs a collection of temporary file/destination path pairs for
263
+ # all "dirty" files as a String representation of the contents of a
264
+ # PowerShell Hash Table. A "dirty" file is one which has the
265
+ # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
266
+ #
267
+ # @param files [Hash] files hash, keyed by the local MD5 digest
268
+ # @return [String] the inner contents of a PowerShell Hash Table
269
+ # @api private
270
+ def decode_files_ps_hash(files)
271
+ result = files.select { |_, data| data["chk_dirty"] == "True" }.map { |_, data|
272
+ val = { "dst" => data["dst"] }
273
+ val["tmpzip"] = data["tmpzip"] if data["tmpzip"]
274
+
275
+ [data["tmpfile"], val]
276
+ }
277
+
278
+ ps_hash(Hash[result])
279
+ end
280
+
281
+ # @return [String] the decode_files PowerShell script
282
+ # @api private
283
+ def decode_files_script
284
+ @decode_files_script ||= IO.read(File.join(
285
+ File.dirname(__FILE__), %W[.. .. .. support decode_files.ps1]
286
+ ))
287
+ end
288
+
289
+ # Returns a formatted string representing a duration in seconds.
290
+ #
291
+ # @param total [Integer] the total number of seconds
292
+ # @return [String] a formatted string of the form (XmYY.00s)
293
+ def duration(total)
294
+ total = 0 if total.nil?
295
+ minutes = (total / 60).to_i
296
+ seconds = (total - (minutes * 60))
297
+ format("(%dm%.2fs)", minutes, seconds)
298
+ end
299
+
300
+ # Contructs a Hash of files or directories, keyed by the local MD5
301
+ # digest. Each file entry has a source and destination set, at a
302
+ # minimum.
303
+ #
304
+ # @param locals [Array<String>] a collection of local files or
305
+ # directories
306
+ # @param remote [String] the base destination path on the remote host
307
+ # @return [Hash] files hash, keyed by the local MD5 digest
308
+ # @api private
309
+ def make_files_hash(locals, remote)
310
+ hash = Hash.new
311
+ locals.each do |local|
312
+ expanded = File.expand_path(local)
313
+ expanded += local[-1] if local.end_with?("/", "\\")
314
+
315
+ if File.file?(expanded)
316
+ add_file_hash!(hash, expanded, remote)
317
+ elsif File.directory?(expanded)
318
+ add_directory_hash!(hash, expanded, remote)
319
+ else
320
+ raise Errno::ENOENT, "No such file or directory #{expanded}"
321
+ end
322
+ end
323
+ hash
324
+ end
325
+
326
+ # @return [String] the MD5 digest of a local file
327
+ # @api private
328
+ def md5sum(local)
329
+ Digest::MD5.file(local).hexdigest
330
+ end
331
+
332
+ # Destructively merges a report Hash into an existing files Hash.
333
+ # **Note:** this method mutates the files Hash.
334
+ #
335
+ # @param files [Hash] files hash, keyed by the local MD5 digest
336
+ # @param report [Hash] report hash, keyed by the local MD5 digest
337
+ # @api private
338
+ def merge_with_report!(files, report)
339
+ files.merge!(report) { |_, oldval, newval| oldval.merge(newval) }
340
+ end
341
+
342
+ # @param depth [Integer] number of padding characters (default: `0`)
343
+ # @return [String] a whitespace padded string of the given length
344
+ # @api private
345
+ def pad(depth = 0)
346
+ " " * depth
347
+ end
348
+
349
+ # Parses response of a PowerShell script or CMD command which contains
350
+ # a CSV-formatted document in the standard output stream.
351
+ #
352
+ # @param output [WinRM::Output] output object with stdout, stderr, and
353
+ # exit code
354
+ # @return [Hash] report hash, keyed by the local MD5 digest
355
+ # @api private
356
+ def parse_response(output)
357
+ if output[:exitcode] != 0
358
+ raise FileTransporterFailed, "[#{self.class}] Upload failed " \
359
+ "(exitcode: #{output[:exitcode]})\n#{output.stderr}"
360
+ end
361
+ array = CSV.parse(output.stdout, :headers => true).map(&:to_hash)
362
+ array.each { |h| h.each { |key, value| h[key] = nil if value == "" } }
363
+ Hash[array.map { |entry| [entry.fetch("src_md5"), entry] }]
364
+ end
365
+
366
+ # Converts a Ruby hash into a PowerShell hash table, represented in a
367
+ # String.
368
+ #
369
+ # @param obj [Object] source Hash or object when used in recursive
370
+ # calls
371
+ # @param depth [Integer] padding depth, used in recursive calls
372
+ # (default: `0`)
373
+ # @return [String] a PowerShell hash table
374
+ # @api private
375
+ def ps_hash(obj, depth = 0)
376
+ if obj.is_a?(Hash)
377
+ obj.map { |k, v|
378
+ %{#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)}}
379
+ }.join("\n").insert(0, "@{\n").insert(-1, "\n#{pad(depth)}}")
380
+ else
381
+ %{"#{obj}"}
382
+ end
383
+ end
384
+
385
+ # Uploads an IO stream to a Base64-encoded destination file.
386
+ #
387
+ # **Implementation Note:** Some of the code in this method may appear
388
+ # slightly too dense and while adding additional variables would help,
389
+ # the code is written very precisely to avoid unwanted allocations
390
+ # which will bloat the Ruby VM's object space (and memory footprint).
391
+ # The goal here is to stream potentially large files to a remote host
392
+ # while not loading the entire file into memory first, then Base64
393
+ # encoding it--duplicating the file in memory again.
394
+ #
395
+ # @param input_io [#read] a readable stream or object to be uploaded
396
+ # @param dest [String] path to the destination file on the remote host
397
+ # @return [Integer,Integer] the number of resulting upload chunks and
398
+ # the number of bytes transferred to the remote host
399
+ # @api private
400
+ def stream_upload(input_io, dest)
401
+ dest_cmd = dest.sub("$env:TEMP", "%TEMP%")
402
+ read_size = (MAX_ENCODED_WRITE.to_i / 4) * 3
403
+ chunk, bytes = 1, 0
404
+ buffer = ""
405
+ service.run_cmd(%{echo|set /p=>"#{dest_cmd}"}) # truncate empty file
406
+ while input_io.read(read_size, buffer)
407
+ bytes += (buffer.bytesize / 3 * 4)
408
+ service.run_cmd([buffer].pack(BASE64_PACK).
409
+ insert(0, "echo ").concat(%{ >> "#{dest_cmd}"}))
410
+ debug { "Wrote chunk #{chunk} for #{dest}" } if chunk % 25 == 0
411
+ chunk += 1
412
+ end
413
+ buffer = nil # rubocop:disable Lint/UselessAssignment
414
+
415
+ [chunk - 1, bytes]
416
+ end
417
+
418
+ # Uploads a local file to a Base64-encoded temporary file.
419
+ #
420
+ # @param src [String] path to a local file
421
+ # @param tmpfile [String] path to the temporary file on the remote
422
+ # host
423
+ # @return [Integer,Integer] the number of resulting upload chunks and
424
+ # the number of bytes transferred to the remote host
425
+ # @api private
426
+ def stream_upload_file(src, tmpfile)
427
+ debug { "Uploading #{src} to encoded tmpfile #{tmpfile}" }
428
+ chunks, bytes = 0, 0
429
+ elapsed = Benchmark.measure do
430
+ File.open(src, "rb") do |io|
431
+ chunks, bytes = stream_upload(io, tmpfile)
432
+ end
433
+ end
434
+ debug {
435
+ "Finished uploading #{src} to encoded tmpfile #{tmpfile} " \
436
+ "(#{bytes.to_f / 1000} KB over #{chunks} chunks) " \
437
+ "in #{duration(elapsed.real)}"
438
+ }
439
+
440
+ [chunks, bytes]
441
+ end
442
+
443
+ # Uploads a collection of "dirty" files to the remote host as
444
+ # Base64-encoded temporary files. A "dirty" file is one which has the
445
+ # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
446
+ #
447
+ # @param files [Hash] files hash, keyed by the local MD5 digest
448
+ # @return [Hash] a report hash, keyed by the local MD5 digest
449
+ # @api private
450
+ def stream_upload_files(files)
451
+ response = Hash.new
452
+ files.each do |md5, data|
453
+ src = data.fetch("src_zip", data["src"])
454
+ if data["chk_dirty"] == "True"
455
+ tmpfile = "$env:TEMP\\b64-#{md5}.txt"
456
+ response[md5] = { "tmpfile" => tmpfile }
457
+ chunks, bytes = stream_upload_file(src, tmpfile)
458
+ response[md5]["chunks"] = chunks
459
+ response[md5]["xfered"] = bytes
460
+ else
461
+ debug { "File #{data["dst"]} is up to date, skipping" }
462
+ end
463
+ end
464
+ response
465
+ end
466
+ end
467
+ end
468
+ end