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.
- checksums.yaml +4 -4
- data/.cane +2 -0
- data/.gitignore +4 -0
- data/CHANGELOG.md +45 -0
- data/Rakefile +15 -0
- data/features/kitchen_action_commands.feature +12 -9
- data/features/kitchen_defaults.feature +38 -0
- data/features/kitchen_init_command.feature +0 -1
- data/features/kitchen_list_command.feature +2 -2
- data/features/kitchen_login_command.feature +7 -1
- data/features/kitchen_test_command.feature +4 -4
- data/lib/kitchen.rb +40 -11
- data/lib/kitchen/cli.rb +38 -22
- data/lib/kitchen/command/list.rb +5 -2
- data/lib/kitchen/config.rb +45 -18
- data/lib/kitchen/configurable.rb +137 -1
- data/lib/kitchen/data_munger.rb +248 -17
- data/lib/kitchen/driver.rb +1 -1
- data/lib/kitchen/driver/base.rb +1 -83
- data/lib/kitchen/driver/dummy.rb +0 -5
- data/lib/kitchen/driver/ssh_base.rb +177 -22
- data/lib/kitchen/instance.rb +140 -20
- data/lib/kitchen/logger.rb +43 -8
- data/lib/kitchen/login_command.rb +14 -5
- data/lib/kitchen/platform.rb +19 -0
- data/lib/kitchen/provisioner.rb +5 -3
- data/lib/kitchen/provisioner/base.rb +46 -48
- data/lib/kitchen/provisioner/chef/common_sandbox.rb +322 -0
- data/lib/kitchen/provisioner/chef_base.rb +179 -286
- data/lib/kitchen/provisioner/chef_solo.rb +11 -5
- data/lib/kitchen/provisioner/chef_zero.rb +108 -94
- data/lib/kitchen/provisioner/dummy.rb +47 -0
- data/lib/kitchen/provisioner/shell.rb +45 -12
- data/lib/kitchen/rake_tasks.rb +1 -1
- data/lib/kitchen/ssh.rb +1 -1
- data/lib/kitchen/thor_tasks.rb +1 -1
- data/lib/kitchen/transport.rb +54 -0
- data/lib/kitchen/transport/base.rb +146 -0
- data/lib/kitchen/transport/dummy.rb +75 -0
- data/lib/kitchen/transport/ssh.rb +325 -0
- data/lib/kitchen/transport/winrm.rb +508 -0
- data/lib/kitchen/transport/winrm/command_executor.rb +188 -0
- data/lib/kitchen/transport/winrm/file_transporter.rb +454 -0
- data/lib/kitchen/transport/winrm/logging.rb +50 -0
- data/lib/kitchen/transport/winrm/template.rb +74 -0
- data/lib/kitchen/transport/winrm/tmp_zip.rb +187 -0
- data/lib/kitchen/verifier.rb +55 -0
- data/lib/kitchen/verifier/base.rb +191 -0
- data/lib/kitchen/verifier/busser.rb +266 -0
- data/lib/kitchen/verifier/dummy.rb +75 -0
- data/lib/kitchen/version.rb +1 -1
- data/spec/kitchen/cli_spec.rb +56 -0
- data/spec/kitchen/config_spec.rb +61 -20
- data/spec/kitchen/configurable_spec.rb +327 -1
- data/spec/kitchen/data_munger_spec.rb +777 -14
- data/spec/kitchen/driver/base_spec.rb +7 -38
- data/spec/kitchen/driver/dummy_spec.rb +0 -29
- data/spec/kitchen/driver/ssh_base_spec.rb +580 -236
- data/spec/kitchen/driver_spec.rb +1 -0
- data/spec/kitchen/instance_spec.rb +383 -83
- data/spec/kitchen/login_command_spec.rb +29 -10
- data/spec/kitchen/platform_spec.rb +58 -2
- data/spec/kitchen/provisioner/base_spec.rb +170 -18
- data/spec/kitchen/provisioner/chef_base_spec.rb +454 -104
- data/spec/kitchen/provisioner/chef_solo_spec.rb +307 -104
- data/spec/kitchen/provisioner/chef_zero_spec.rb +561 -230
- data/spec/kitchen/provisioner/dummy_spec.rb +91 -0
- data/spec/kitchen/provisioner/shell_spec.rb +158 -56
- data/spec/kitchen/provisioner_spec.rb +37 -0
- data/spec/kitchen/ssh_spec.rb +19 -19
- data/spec/kitchen/transport/base_spec.rb +89 -0
- data/spec/kitchen/transport/ssh_spec.rb +1147 -0
- data/spec/kitchen/transport/winrm/command_executor_spec.rb +400 -0
- data/spec/kitchen/transport/winrm/file_transporter_spec.rb +876 -0
- data/spec/kitchen/transport/winrm/logging_spec.rb +92 -0
- data/spec/kitchen/transport/winrm/template_spec.rb +51 -0
- data/spec/kitchen/transport/winrm/tmp_zip_spec.rb +132 -0
- data/spec/kitchen/transport/winrm_spec.rb +1069 -0
- data/spec/kitchen/transport_spec.rb +112 -0
- data/spec/kitchen/verifier/base_spec.rb +310 -0
- data/spec/kitchen/verifier/busser_spec.rb +540 -0
- data/spec/kitchen/verifier/dummy_spec.rb +91 -0
- data/spec/kitchen/verifier_spec.rb +120 -0
- data/spec/kitchen_spec.rb +7 -0
- data/spec/spec_helper.rb +8 -0
- data/spec/support/powershell_max_size_spec.rb +40 -0
- data/support/busser_install_command.ps1 +14 -0
- data/support/busser_install_command.sh +15 -0
- data/support/check_files.ps1.erb +48 -0
- data/support/chef_base_init_command.ps1 +18 -0
- data/support/chef_base_init_command.sh +2 -0
- data/support/chef_base_install_command.ps1 +76 -0
- data/support/chef_base_install_command.sh +137 -0
- data/support/chef_zero_prepare_command_legacy.ps1 +9 -0
- data/support/chef_zero_prepare_command_legacy.sh +10 -0
- data/support/decode_files.ps1.erb +61 -0
- data/test-kitchen.gemspec +2 -0
- metadata +97 -8
- data/lib/kitchen/busser.rb +0 -316
- data/spec/kitchen/busser_spec.rb +0 -490
- 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
|