winrm-transport 1.0.2 → 1.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.cane +2 -2
- data/.gitignore +15 -15
- data/.travis.yml +26 -26
- data/CHANGELOG.md +44 -38
- data/Gemfile +13 -13
- data/Guardfile +27 -27
- data/LICENSE.txt +15 -15
- data/README.md +80 -80
- data/Rakefile +49 -49
- data/bin/console +7 -7
- data/bin/setup +7 -7
- data/lib/winrm/transport.rb +28 -28
- data/lib/winrm/transport/command_executor.rb +217 -217
- data/lib/winrm/transport/file_transporter.rb +498 -498
- data/lib/winrm/transport/logging.rb +47 -47
- data/lib/winrm/transport/shell_closer.rb +71 -71
- data/lib/winrm/transport/tmp_zip.rb +184 -184
- data/lib/winrm/transport/version.rb +25 -25
- data/support/check_files.ps1 +47 -46
- data/support/decode_files.ps1 +52 -52
- data/winrm-transport.gemspec +51 -51
- metadata +3 -3
@@ -1,498 +1,498 @@
|
|
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
|
-
output = service.run_powershell_script(
|
182
|
-
[vars, check_files_script].join("\n")
|
183
|
-
)
|
184
|
-
parse_response(output)
|
185
|
-
end
|
186
|
-
|
187
|
-
# Constructs a collection of destination path/MD5 checksum pairs as a
|
188
|
-
# String representation of the contents of a PowerShell Hash Table.
|
189
|
-
#
|
190
|
-
# @param files [Hash] files hash, keyed by the local MD5 digest
|
191
|
-
# @return [String] the inner contents of a PowerShell Hash Table
|
192
|
-
# @api private
|
193
|
-
def check_files_ps_hash(files)
|
194
|
-
ps_hash(Hash[
|
195
|
-
files.map { |md5, data| [data.fetch("tmpzip", data["dst"]), md5] }
|
196
|
-
])
|
197
|
-
end
|
198
|
-
|
199
|
-
# @return [String] the check_files PowerShell script
|
200
|
-
# @api private
|
201
|
-
def check_files_script
|
202
|
-
@check_files_script ||= IO.read(File.join(
|
203
|
-
File.dirname(__FILE__), %W[.. .. .. support check_files.ps1]
|
204
|
-
))
|
205
|
-
end
|
206
|
-
|
207
|
-
# Performs any final cleanup on the report Hash and removes any
|
208
|
-
# temporary files/resources used in the upload task.
|
209
|
-
#
|
210
|
-
# @param files [Hash] a files hash
|
211
|
-
# @api private
|
212
|
-
def cleanup(files)
|
213
|
-
files.select { |_, data| data.key?("zip_io") }.each do |md5, data|
|
214
|
-
data.fetch("zip_io").unlink
|
215
|
-
files.fetch(md5).delete("zip_io")
|
216
|
-
debug { "Cleaned up src_zip #{data["src_zip"]}" }
|
217
|
-
end
|
218
|
-
end
|
219
|
-
|
220
|
-
# Creates a remote Base64-encoded temporary file containing a
|
221
|
-
# PowerShell hash table.
|
222
|
-
#
|
223
|
-
# @param hash [String] a String representation of a PowerShell hash
|
224
|
-
# table
|
225
|
-
# @return [String] the remote path to the temporary file
|
226
|
-
# @api private
|
227
|
-
def create_remote_hash_file(hash)
|
228
|
-
hash_file = "$env:TEMP\\hash-#{@id_generator.call}.txt"
|
229
|
-
hash.lines.each { |line| debug { line.chomp } }
|
230
|
-
StringIO.open(hash) { |io| stream_upload(io, hash_file) }
|
231
|
-
hash_file
|
232
|
-
end
|
233
|
-
|
234
|
-
# Runs the decode_files PowerShell script against a collection of
|
235
|
-
# temporary file/destination path pairs. The PowerShell script returns
|
236
|
-
# its results as a CSV-formatted report which is converted into a Ruby
|
237
|
-
# Hash. The script will not be invoked if there are no "dirty" files
|
238
|
-
# present in the incoming files Hash.
|
239
|
-
#
|
240
|
-
# @param files [Hash] files hash, keyed by the local MD5 digest
|
241
|
-
# @return [Hash] a report hash, keyed by the local MD5 digest
|
242
|
-
# @api private
|
243
|
-
def decode_files(files)
|
244
|
-
decoded_files = decode_files_ps_hash(files)
|
245
|
-
|
246
|
-
if decoded_files == ps_hash(Hash.new)
|
247
|
-
debug { "No remote files to decode, skipping" }
|
248
|
-
Hash.new
|
249
|
-
else
|
250
|
-
debug { "Running decode_files.ps1" }
|
251
|
-
hash_file = create_remote_hash_file(decoded_files)
|
252
|
-
vars = %{$hash_file = "#{hash_file}"\n}
|
253
|
-
|
254
|
-
output = service.run_powershell_script(
|
255
|
-
["#{decode_files_script}",
|
256
|
-
vars,
|
257
|
-
"Decode-Files (Invoke-Input $hash_file) | ConvertTo-Csv -NoTypeInformation"
|
258
|
-
].join("\n")
|
259
|
-
)
|
260
|
-
parse_response(output)
|
261
|
-
end
|
262
|
-
end
|
263
|
-
|
264
|
-
# Constructs a collection of temporary file/destination path pairs for
|
265
|
-
# all "dirty" files as a String representation of the contents of a
|
266
|
-
# PowerShell Hash Table. A "dirty" file is one which has the
|
267
|
-
# `"chk_dirty"` option set to `"True"` in the incoming files Hash.
|
268
|
-
#
|
269
|
-
# @param files [Hash] files hash, keyed by the local MD5 digest
|
270
|
-
# @return [String] the inner contents of a PowerShell Hash Table
|
271
|
-
# @api private
|
272
|
-
def decode_files_ps_hash(files)
|
273
|
-
result = files.select { |_, data| data["chk_dirty"] == "True" }.map { |_, data|
|
274
|
-
val = { "dst" => data["dst"] }
|
275
|
-
val["tmpzip"] = data["tmpzip"] if data["tmpzip"]
|
276
|
-
|
277
|
-
[data["tmpfile"], val]
|
278
|
-
}
|
279
|
-
|
280
|
-
ps_hash(Hash[result])
|
281
|
-
end
|
282
|
-
|
283
|
-
# @return [String] the decode_files PowerShell script
|
284
|
-
# @api private
|
285
|
-
def decode_files_script
|
286
|
-
@decode_files_script ||= IO.read(File.join(
|
287
|
-
File.dirname(__FILE__), %W[.. .. .. support decode_files.ps1]
|
288
|
-
))
|
289
|
-
end
|
290
|
-
|
291
|
-
# Returns a formatted string representing a duration in seconds.
|
292
|
-
#
|
293
|
-
# @param total [Integer] the total number of seconds
|
294
|
-
# @return [String] a formatted string of the form (XmYY.00s)
|
295
|
-
def duration(total)
|
296
|
-
total = 0 if total.nil?
|
297
|
-
minutes = (total / 60).to_i
|
298
|
-
seconds = (total - (minutes * 60))
|
299
|
-
format("(%dm%.2fs)", minutes, seconds)
|
300
|
-
end
|
301
|
-
|
302
|
-
# Contructs a Hash of files or directories, keyed by the local MD5
|
303
|
-
# digest. Each file entry has a source and destination set, at a
|
304
|
-
# minimum.
|
305
|
-
#
|
306
|
-
# @param locals [Array<String>] a collection of local files or
|
307
|
-
# directories
|
308
|
-
# @param remote [String] the base destination path on the remote host
|
309
|
-
# @return [Hash] files hash, keyed by the local MD5 digest
|
310
|
-
# @api private
|
311
|
-
def make_files_hash(locals, remote)
|
312
|
-
hash = Hash.new
|
313
|
-
locals.each do |local|
|
314
|
-
expanded = File.expand_path(local)
|
315
|
-
expanded += local[-1] if local.end_with?("/", "\\")
|
316
|
-
|
317
|
-
if File.file?(expanded)
|
318
|
-
add_file_hash!(hash, expanded, remote)
|
319
|
-
elsif File.directory?(expanded)
|
320
|
-
add_directory_hash!(hash, expanded, remote)
|
321
|
-
else
|
322
|
-
raise Errno::ENOENT, "No such file or directory #{expanded}"
|
323
|
-
end
|
324
|
-
end
|
325
|
-
hash
|
326
|
-
end
|
327
|
-
|
328
|
-
# @return [String] the MD5 digest of a local file
|
329
|
-
# @api private
|
330
|
-
def md5sum(local)
|
331
|
-
Digest::MD5.file(local).hexdigest
|
332
|
-
end
|
333
|
-
|
334
|
-
# Destructively merges a report Hash into an existing files Hash.
|
335
|
-
# **Note:** this method mutates the files Hash.
|
336
|
-
#
|
337
|
-
# @param files [Hash] files hash, keyed by the local MD5 digest
|
338
|
-
# @param report [Hash] report hash, keyed by the local MD5 digest
|
339
|
-
# @api private
|
340
|
-
def merge_with_report!(files, report)
|
341
|
-
files.merge!(report) { |_, oldval, newval| oldval.merge(newval) }
|
342
|
-
end
|
343
|
-
|
344
|
-
# @param depth [Integer] number of padding characters (default: `0`)
|
345
|
-
# @return [String] a whitespace padded string of the given length
|
346
|
-
# @api private
|
347
|
-
def pad(depth = 0)
|
348
|
-
" " * depth
|
349
|
-
end
|
350
|
-
|
351
|
-
# Parses CLIXML String into regular String (without any XML syntax).
|
352
|
-
# Inspired by https://github.com/WinRb/WinRM/issues/106.
|
353
|
-
#
|
354
|
-
# @param clixml [String] clixml text
|
355
|
-
# @return [String] parsed clixml into String
|
356
|
-
def clixml_to_s(clixml)
|
357
|
-
doc = REXML::Document.new(clixml)
|
358
|
-
text = doc.get_elements("//S").map(&:text).join
|
359
|
-
text.gsub(/_x(\h\h\h\h)_/) do
|
360
|
-
code = Regexp.last_match[1]
|
361
|
-
code.hex.chr
|
362
|
-
end
|
363
|
-
end
|
364
|
-
|
365
|
-
# Parses response of a PowerShell script or CMD command which contains
|
366
|
-
# a CSV-formatted document in the standard output stream.
|
367
|
-
#
|
368
|
-
# @param output [WinRM::Output] output object with stdout, stderr, and
|
369
|
-
# exit code
|
370
|
-
# @return [Hash] report hash, keyed by the local MD5 digest
|
371
|
-
# @api private
|
372
|
-
def parse_response(output)
|
373
|
-
exitcode = output[:exitcode]
|
374
|
-
stderr = output.stderr
|
375
|
-
if stderr.include?("The command line is too long")
|
376
|
-
# The powershell script which should result in `output` parameter
|
377
|
-
# is too long, remove some newlines, comments, etc from it.
|
378
|
-
raise StandardError, "The command line is too long" \
|
379
|
-
" (powershell script is too long)"
|
380
|
-
end
|
381
|
-
pretty_stderr = clixml_to_s(stderr)
|
382
|
-
|
383
|
-
if exitcode != 0
|
384
|
-
raise FileTransporterFailed, "[#{self.class}] Upload failed " \
|
385
|
-
"(exitcode: #{exitcode})\n#{pretty_stderr}"
|
386
|
-
elsif stderr != '\r\n' && stderr != ""
|
387
|
-
raise FileTransporterFailed, "[#{self.class}] Upload failed " \
|
388
|
-
"(exitcode: 0), but stderr present\n#{pretty_stderr}"
|
389
|
-
end
|
390
|
-
|
391
|
-
array = CSV.parse(output.stdout, :headers => true).map(&:to_hash)
|
392
|
-
array.each { |h| h.each { |key, value| h[key] = nil if value == "" } }
|
393
|
-
Hash[array.map { |entry| [entry.fetch("src_md5"), entry] }]
|
394
|
-
end
|
395
|
-
|
396
|
-
# Converts a Ruby hash into a PowerShell hash table, represented in a
|
397
|
-
# String.
|
398
|
-
#
|
399
|
-
# @param obj [Object] source Hash or object when used in recursive
|
400
|
-
# calls
|
401
|
-
# @param depth [Integer] padding depth, used in recursive calls
|
402
|
-
# (default: `0`)
|
403
|
-
# @return [String] a PowerShell hash table
|
404
|
-
# @api private
|
405
|
-
def ps_hash(obj, depth = 0)
|
406
|
-
if obj.is_a?(Hash)
|
407
|
-
obj.map { |k, v|
|
408
|
-
%{#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)}}
|
409
|
-
}.join(";\n").insert(0, "@{\n").insert(-1, "\n#{pad(depth)}}")
|
410
|
-
else
|
411
|
-
%{"#{obj}"}
|
412
|
-
end
|
413
|
-
end
|
414
|
-
|
415
|
-
# Uploads an IO stream to a Base64-encoded destination file.
|
416
|
-
#
|
417
|
-
# **Implementation Note:** Some of the code in this method may appear
|
418
|
-
# slightly too dense and while adding additional variables would help,
|
419
|
-
# the code is written very precisely to avoid unwanted allocations
|
420
|
-
# which will bloat the Ruby VM's object space (and memory footprint).
|
421
|
-
# The goal here is to stream potentially large files to a remote host
|
422
|
-
# while not loading the entire file into memory first, then Base64
|
423
|
-
# encoding it--duplicating the file in memory again.
|
424
|
-
#
|
425
|
-
# @param input_io [#read] a readable stream or object to be uploaded
|
426
|
-
# @param dest [String] path to the destination file on the remote host
|
427
|
-
# @return [Integer,Integer] the number of resulting upload chunks and
|
428
|
-
# the number of bytes transferred to the remote host
|
429
|
-
# @api private
|
430
|
-
def stream_upload(input_io, dest)
|
431
|
-
dest_cmd = dest.sub("$env:TEMP", "%TEMP%")
|
432
|
-
read_size = (MAX_ENCODED_WRITE.to_i / 4) * 3
|
433
|
-
chunk, bytes = 1, 0
|
434
|
-
buffer = ""
|
435
|
-
service.run_cmd(%{echo|set /p=>"#{dest_cmd}"}) # truncate empty file
|
436
|
-
while input_io.read(read_size, buffer)
|
437
|
-
bytes += (buffer.bytesize / 3 * 4)
|
438
|
-
service.run_cmd([buffer].pack(BASE64_PACK).
|
439
|
-
insert(0, "echo ").concat(%{ >> "#{dest_cmd}"}))
|
440
|
-
debug { "Wrote chunk #{chunk} for #{dest}" } if chunk % 25 == 0
|
441
|
-
chunk += 1
|
442
|
-
end
|
443
|
-
buffer = nil # rubocop:disable Lint/UselessAssignment
|
444
|
-
|
445
|
-
[chunk - 1, bytes]
|
446
|
-
end
|
447
|
-
|
448
|
-
# Uploads a local file to a Base64-encoded temporary file.
|
449
|
-
#
|
450
|
-
# @param src [String] path to a local file
|
451
|
-
# @param tmpfile [String] path to the temporary file on the remote
|
452
|
-
# host
|
453
|
-
# @return [Integer,Integer] the number of resulting upload chunks and
|
454
|
-
# the number of bytes transferred to the remote host
|
455
|
-
# @api private
|
456
|
-
def stream_upload_file(src, tmpfile)
|
457
|
-
debug { "Uploading #{src} to encoded tmpfile #{tmpfile}" }
|
458
|
-
chunks, bytes = 0, 0
|
459
|
-
elapsed = Benchmark.measure do
|
460
|
-
File.open(src, "rb") do |io|
|
461
|
-
chunks, bytes = stream_upload(io, tmpfile)
|
462
|
-
end
|
463
|
-
end
|
464
|
-
debug {
|
465
|
-
"Finished uploading #{src} to encoded tmpfile #{tmpfile} " \
|
466
|
-
"(#{bytes.to_f / 1000} KB over #{chunks} chunks) " \
|
467
|
-
"in #{duration(elapsed.real)}"
|
468
|
-
}
|
469
|
-
|
470
|
-
[chunks, bytes]
|
471
|
-
end
|
472
|
-
|
473
|
-
# Uploads a collection of "dirty" files to the remote host as
|
474
|
-
# Base64-encoded temporary files. A "dirty" file is one which has the
|
475
|
-
# `"chk_dirty"` option set to `"True"` in the incoming files Hash.
|
476
|
-
#
|
477
|
-
# @param files [Hash] files hash, keyed by the local MD5 digest
|
478
|
-
# @return [Hash] a report hash, keyed by the local MD5 digest
|
479
|
-
# @api private
|
480
|
-
def stream_upload_files(files)
|
481
|
-
response = Hash.new
|
482
|
-
files.each do |md5, data|
|
483
|
-
src = data.fetch("src_zip", data["src"])
|
484
|
-
if data["chk_dirty"] == "True"
|
485
|
-
tmpfile = "$env:TEMP\\b64-#{md5}.txt"
|
486
|
-
response[md5] = { "tmpfile" => tmpfile }
|
487
|
-
chunks, bytes = stream_upload_file(src, tmpfile)
|
488
|
-
response[md5]["chunks"] = chunks
|
489
|
-
response[md5]["xfered"] = bytes
|
490
|
-
else
|
491
|
-
debug { "File #{data["dst"]} is up to date, skipping" }
|
492
|
-
end
|
493
|
-
end
|
494
|
-
response
|
495
|
-
end
|
496
|
-
end
|
497
|
-
end
|
498
|
-
end
|
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
|
+
output = service.run_powershell_script(
|
182
|
+
[vars, check_files_script].join("\n")
|
183
|
+
)
|
184
|
+
parse_response(output)
|
185
|
+
end
|
186
|
+
|
187
|
+
# Constructs a collection of destination path/MD5 checksum pairs as a
|
188
|
+
# String representation of the contents of a PowerShell Hash Table.
|
189
|
+
#
|
190
|
+
# @param files [Hash] files hash, keyed by the local MD5 digest
|
191
|
+
# @return [String] the inner contents of a PowerShell Hash Table
|
192
|
+
# @api private
|
193
|
+
def check_files_ps_hash(files)
|
194
|
+
ps_hash(Hash[
|
195
|
+
files.map { |md5, data| [data.fetch("tmpzip", data["dst"]), md5] }
|
196
|
+
])
|
197
|
+
end
|
198
|
+
|
199
|
+
# @return [String] the check_files PowerShell script
|
200
|
+
# @api private
|
201
|
+
def check_files_script
|
202
|
+
@check_files_script ||= IO.read(File.join(
|
203
|
+
File.dirname(__FILE__), %W[.. .. .. support check_files.ps1]
|
204
|
+
))
|
205
|
+
end
|
206
|
+
|
207
|
+
# Performs any final cleanup on the report Hash and removes any
|
208
|
+
# temporary files/resources used in the upload task.
|
209
|
+
#
|
210
|
+
# @param files [Hash] a files hash
|
211
|
+
# @api private
|
212
|
+
def cleanup(files)
|
213
|
+
files.select { |_, data| data.key?("zip_io") }.each do |md5, data|
|
214
|
+
data.fetch("zip_io").unlink
|
215
|
+
files.fetch(md5).delete("zip_io")
|
216
|
+
debug { "Cleaned up src_zip #{data["src_zip"]}" }
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# Creates a remote Base64-encoded temporary file containing a
|
221
|
+
# PowerShell hash table.
|
222
|
+
#
|
223
|
+
# @param hash [String] a String representation of a PowerShell hash
|
224
|
+
# table
|
225
|
+
# @return [String] the remote path to the temporary file
|
226
|
+
# @api private
|
227
|
+
def create_remote_hash_file(hash)
|
228
|
+
hash_file = "$env:TEMP\\hash-#{@id_generator.call}.txt"
|
229
|
+
hash.lines.each { |line| debug { line.chomp } }
|
230
|
+
StringIO.open(hash) { |io| stream_upload(io, hash_file) }
|
231
|
+
hash_file
|
232
|
+
end
|
233
|
+
|
234
|
+
# Runs the decode_files PowerShell script against a collection of
|
235
|
+
# temporary file/destination path pairs. The PowerShell script returns
|
236
|
+
# its results as a CSV-formatted report which is converted into a Ruby
|
237
|
+
# Hash. The script will not be invoked if there are no "dirty" files
|
238
|
+
# present in the incoming files Hash.
|
239
|
+
#
|
240
|
+
# @param files [Hash] files hash, keyed by the local MD5 digest
|
241
|
+
# @return [Hash] a report hash, keyed by the local MD5 digest
|
242
|
+
# @api private
|
243
|
+
def decode_files(files)
|
244
|
+
decoded_files = decode_files_ps_hash(files)
|
245
|
+
|
246
|
+
if decoded_files == ps_hash(Hash.new)
|
247
|
+
debug { "No remote files to decode, skipping" }
|
248
|
+
Hash.new
|
249
|
+
else
|
250
|
+
debug { "Running decode_files.ps1" }
|
251
|
+
hash_file = create_remote_hash_file(decoded_files)
|
252
|
+
vars = %{$hash_file = "#{hash_file}"\n}
|
253
|
+
|
254
|
+
output = service.run_powershell_script(
|
255
|
+
["#{decode_files_script}",
|
256
|
+
vars,
|
257
|
+
"Decode-Files (Invoke-Input $hash_file) | ConvertTo-Csv -NoTypeInformation"
|
258
|
+
].join("\n")
|
259
|
+
)
|
260
|
+
parse_response(output)
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
# Constructs a collection of temporary file/destination path pairs for
|
265
|
+
# all "dirty" files as a String representation of the contents of a
|
266
|
+
# PowerShell Hash Table. A "dirty" file is one which has the
|
267
|
+
# `"chk_dirty"` option set to `"True"` in the incoming files Hash.
|
268
|
+
#
|
269
|
+
# @param files [Hash] files hash, keyed by the local MD5 digest
|
270
|
+
# @return [String] the inner contents of a PowerShell Hash Table
|
271
|
+
# @api private
|
272
|
+
def decode_files_ps_hash(files)
|
273
|
+
result = files.select { |_, data| data["chk_dirty"] == "True" }.map { |_, data|
|
274
|
+
val = { "dst" => data["dst"] }
|
275
|
+
val["tmpzip"] = data["tmpzip"] if data["tmpzip"]
|
276
|
+
|
277
|
+
[data["tmpfile"], val]
|
278
|
+
}
|
279
|
+
|
280
|
+
ps_hash(Hash[result])
|
281
|
+
end
|
282
|
+
|
283
|
+
# @return [String] the decode_files PowerShell script
|
284
|
+
# @api private
|
285
|
+
def decode_files_script
|
286
|
+
@decode_files_script ||= IO.read(File.join(
|
287
|
+
File.dirname(__FILE__), %W[.. .. .. support decode_files.ps1]
|
288
|
+
))
|
289
|
+
end
|
290
|
+
|
291
|
+
# Returns a formatted string representing a duration in seconds.
|
292
|
+
#
|
293
|
+
# @param total [Integer] the total number of seconds
|
294
|
+
# @return [String] a formatted string of the form (XmYY.00s)
|
295
|
+
def duration(total)
|
296
|
+
total = 0 if total.nil?
|
297
|
+
minutes = (total / 60).to_i
|
298
|
+
seconds = (total - (minutes * 60))
|
299
|
+
format("(%dm%.2fs)", minutes, seconds)
|
300
|
+
end
|
301
|
+
|
302
|
+
# Contructs a Hash of files or directories, keyed by the local MD5
|
303
|
+
# digest. Each file entry has a source and destination set, at a
|
304
|
+
# minimum.
|
305
|
+
#
|
306
|
+
# @param locals [Array<String>] a collection of local files or
|
307
|
+
# directories
|
308
|
+
# @param remote [String] the base destination path on the remote host
|
309
|
+
# @return [Hash] files hash, keyed by the local MD5 digest
|
310
|
+
# @api private
|
311
|
+
def make_files_hash(locals, remote)
|
312
|
+
hash = Hash.new
|
313
|
+
locals.each do |local|
|
314
|
+
expanded = File.expand_path(local)
|
315
|
+
expanded += local[-1] if local.end_with?("/", "\\")
|
316
|
+
|
317
|
+
if File.file?(expanded)
|
318
|
+
add_file_hash!(hash, expanded, remote)
|
319
|
+
elsif File.directory?(expanded)
|
320
|
+
add_directory_hash!(hash, expanded, remote)
|
321
|
+
else
|
322
|
+
raise Errno::ENOENT, "No such file or directory #{expanded}"
|
323
|
+
end
|
324
|
+
end
|
325
|
+
hash
|
326
|
+
end
|
327
|
+
|
328
|
+
# @return [String] the MD5 digest of a local file
|
329
|
+
# @api private
|
330
|
+
def md5sum(local)
|
331
|
+
Digest::MD5.file(local).hexdigest
|
332
|
+
end
|
333
|
+
|
334
|
+
# Destructively merges a report Hash into an existing files Hash.
|
335
|
+
# **Note:** this method mutates the files Hash.
|
336
|
+
#
|
337
|
+
# @param files [Hash] files hash, keyed by the local MD5 digest
|
338
|
+
# @param report [Hash] report hash, keyed by the local MD5 digest
|
339
|
+
# @api private
|
340
|
+
def merge_with_report!(files, report)
|
341
|
+
files.merge!(report) { |_, oldval, newval| oldval.merge(newval) }
|
342
|
+
end
|
343
|
+
|
344
|
+
# @param depth [Integer] number of padding characters (default: `0`)
|
345
|
+
# @return [String] a whitespace padded string of the given length
|
346
|
+
# @api private
|
347
|
+
def pad(depth = 0)
|
348
|
+
" " * depth
|
349
|
+
end
|
350
|
+
|
351
|
+
# Parses CLIXML String into regular String (without any XML syntax).
|
352
|
+
# Inspired by https://github.com/WinRb/WinRM/issues/106.
|
353
|
+
#
|
354
|
+
# @param clixml [String] clixml text
|
355
|
+
# @return [String] parsed clixml into String
|
356
|
+
def clixml_to_s(clixml)
|
357
|
+
doc = REXML::Document.new(clixml)
|
358
|
+
text = doc.get_elements("//S").map(&:text).join
|
359
|
+
text.gsub(/_x(\h\h\h\h)_/) do
|
360
|
+
code = Regexp.last_match[1]
|
361
|
+
code.hex.chr
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
# Parses response of a PowerShell script or CMD command which contains
|
366
|
+
# a CSV-formatted document in the standard output stream.
|
367
|
+
#
|
368
|
+
# @param output [WinRM::Output] output object with stdout, stderr, and
|
369
|
+
# exit code
|
370
|
+
# @return [Hash] report hash, keyed by the local MD5 digest
|
371
|
+
# @api private
|
372
|
+
def parse_response(output)
|
373
|
+
exitcode = output[:exitcode]
|
374
|
+
stderr = output.stderr
|
375
|
+
if stderr.include?("The command line is too long")
|
376
|
+
# The powershell script which should result in `output` parameter
|
377
|
+
# is too long, remove some newlines, comments, etc from it.
|
378
|
+
raise StandardError, "The command line is too long" \
|
379
|
+
" (powershell script is too long)"
|
380
|
+
end
|
381
|
+
pretty_stderr = clixml_to_s(stderr)
|
382
|
+
|
383
|
+
if exitcode != 0
|
384
|
+
raise FileTransporterFailed, "[#{self.class}] Upload failed " \
|
385
|
+
"(exitcode: #{exitcode})\n#{pretty_stderr}"
|
386
|
+
elsif stderr != '\r\n' && stderr != ""
|
387
|
+
raise FileTransporterFailed, "[#{self.class}] Upload failed " \
|
388
|
+
"(exitcode: 0), but stderr present\n#{pretty_stderr}"
|
389
|
+
end
|
390
|
+
|
391
|
+
array = CSV.parse(output.stdout, :headers => true).map(&:to_hash)
|
392
|
+
array.each { |h| h.each { |key, value| h[key] = nil if value == "" } }
|
393
|
+
Hash[array.map { |entry| [entry.fetch("src_md5"), entry] }]
|
394
|
+
end
|
395
|
+
|
396
|
+
# Converts a Ruby hash into a PowerShell hash table, represented in a
|
397
|
+
# String.
|
398
|
+
#
|
399
|
+
# @param obj [Object] source Hash or object when used in recursive
|
400
|
+
# calls
|
401
|
+
# @param depth [Integer] padding depth, used in recursive calls
|
402
|
+
# (default: `0`)
|
403
|
+
# @return [String] a PowerShell hash table
|
404
|
+
# @api private
|
405
|
+
def ps_hash(obj, depth = 0)
|
406
|
+
if obj.is_a?(Hash)
|
407
|
+
obj.map { |k, v|
|
408
|
+
%{#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)}}
|
409
|
+
}.join(";\n").insert(0, "@{\n").insert(-1, "\n#{pad(depth)}}")
|
410
|
+
else
|
411
|
+
%{"#{obj}"}
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
# Uploads an IO stream to a Base64-encoded destination file.
|
416
|
+
#
|
417
|
+
# **Implementation Note:** Some of the code in this method may appear
|
418
|
+
# slightly too dense and while adding additional variables would help,
|
419
|
+
# the code is written very precisely to avoid unwanted allocations
|
420
|
+
# which will bloat the Ruby VM's object space (and memory footprint).
|
421
|
+
# The goal here is to stream potentially large files to a remote host
|
422
|
+
# while not loading the entire file into memory first, then Base64
|
423
|
+
# encoding it--duplicating the file in memory again.
|
424
|
+
#
|
425
|
+
# @param input_io [#read] a readable stream or object to be uploaded
|
426
|
+
# @param dest [String] path to the destination file on the remote host
|
427
|
+
# @return [Integer,Integer] the number of resulting upload chunks and
|
428
|
+
# the number of bytes transferred to the remote host
|
429
|
+
# @api private
|
430
|
+
def stream_upload(input_io, dest)
|
431
|
+
dest_cmd = dest.sub("$env:TEMP", "%TEMP%")
|
432
|
+
read_size = (MAX_ENCODED_WRITE.to_i / 4) * 3
|
433
|
+
chunk, bytes = 1, 0
|
434
|
+
buffer = ""
|
435
|
+
service.run_cmd(%{echo|set /p=>"#{dest_cmd}"}) # truncate empty file
|
436
|
+
while input_io.read(read_size, buffer)
|
437
|
+
bytes += (buffer.bytesize / 3 * 4)
|
438
|
+
service.run_cmd([buffer].pack(BASE64_PACK).
|
439
|
+
insert(0, "echo ").concat(%{ >> "#{dest_cmd}"}))
|
440
|
+
debug { "Wrote chunk #{chunk} for #{dest}" } if chunk % 25 == 0
|
441
|
+
chunk += 1
|
442
|
+
end
|
443
|
+
buffer = nil # rubocop:disable Lint/UselessAssignment
|
444
|
+
|
445
|
+
[chunk - 1, bytes]
|
446
|
+
end
|
447
|
+
|
448
|
+
# Uploads a local file to a Base64-encoded temporary file.
|
449
|
+
#
|
450
|
+
# @param src [String] path to a local file
|
451
|
+
# @param tmpfile [String] path to the temporary file on the remote
|
452
|
+
# host
|
453
|
+
# @return [Integer,Integer] the number of resulting upload chunks and
|
454
|
+
# the number of bytes transferred to the remote host
|
455
|
+
# @api private
|
456
|
+
def stream_upload_file(src, tmpfile)
|
457
|
+
debug { "Uploading #{src} to encoded tmpfile #{tmpfile}" }
|
458
|
+
chunks, bytes = 0, 0
|
459
|
+
elapsed = Benchmark.measure do
|
460
|
+
File.open(src, "rb") do |io|
|
461
|
+
chunks, bytes = stream_upload(io, tmpfile)
|
462
|
+
end
|
463
|
+
end
|
464
|
+
debug {
|
465
|
+
"Finished uploading #{src} to encoded tmpfile #{tmpfile} " \
|
466
|
+
"(#{bytes.to_f / 1000} KB over #{chunks} chunks) " \
|
467
|
+
"in #{duration(elapsed.real)}"
|
468
|
+
}
|
469
|
+
|
470
|
+
[chunks, bytes]
|
471
|
+
end
|
472
|
+
|
473
|
+
# Uploads a collection of "dirty" files to the remote host as
|
474
|
+
# Base64-encoded temporary files. A "dirty" file is one which has the
|
475
|
+
# `"chk_dirty"` option set to `"True"` in the incoming files Hash.
|
476
|
+
#
|
477
|
+
# @param files [Hash] files hash, keyed by the local MD5 digest
|
478
|
+
# @return [Hash] a report hash, keyed by the local MD5 digest
|
479
|
+
# @api private
|
480
|
+
def stream_upload_files(files)
|
481
|
+
response = Hash.new
|
482
|
+
files.each do |md5, data|
|
483
|
+
src = data.fetch("src_zip", data["src"])
|
484
|
+
if data["chk_dirty"] == "True"
|
485
|
+
tmpfile = "$env:TEMP\\b64-#{md5}.txt"
|
486
|
+
response[md5] = { "tmpfile" => tmpfile }
|
487
|
+
chunks, bytes = stream_upload_file(src, tmpfile)
|
488
|
+
response[md5]["chunks"] = chunks
|
489
|
+
response[md5]["xfered"] = bytes
|
490
|
+
else
|
491
|
+
debug { "File #{data["dst"]} is up to date, skipping" }
|
492
|
+
end
|
493
|
+
end
|
494
|
+
response
|
495
|
+
end
|
496
|
+
end
|
497
|
+
end
|
498
|
+
end
|