test-kitchen 1.2.1 → 1.3.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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/.cane +1 -1
  3. data/.rubocop.yml +3 -0
  4. data/.travis.yml +20 -9
  5. data/CHANGELOG.md +219 -108
  6. data/Gemfile +10 -6
  7. data/Guardfile +38 -9
  8. data/README.md +11 -1
  9. data/Rakefile +21 -37
  10. data/bin/kitchen +4 -4
  11. data/features/kitchen_action_commands.feature +161 -0
  12. data/features/kitchen_console_command.feature +34 -0
  13. data/features/kitchen_diagnose_command.feature +64 -0
  14. data/features/kitchen_init_command.feature +29 -17
  15. data/features/kitchen_list_command.feature +2 -2
  16. data/features/kitchen_login_command.feature +56 -0
  17. data/features/{sink_command.feature → kitchen_sink_command.feature} +0 -0
  18. data/features/kitchen_test_command.feature +88 -0
  19. data/features/step_definitions/gem_steps.rb +8 -6
  20. data/features/step_definitions/git_steps.rb +4 -2
  21. data/features/step_definitions/output_steps.rb +5 -0
  22. data/features/support/env.rb +12 -9
  23. data/lib/kitchen.rb +60 -38
  24. data/lib/kitchen/base64_stream.rb +55 -0
  25. data/lib/kitchen/busser.rb +124 -58
  26. data/lib/kitchen/cli.rb +121 -38
  27. data/lib/kitchen/collection.rb +3 -3
  28. data/lib/kitchen/color.rb +4 -4
  29. data/lib/kitchen/command.rb +78 -11
  30. data/lib/kitchen/command/action.rb +3 -2
  31. data/lib/kitchen/command/console.rb +12 -5
  32. data/lib/kitchen/command/diagnose.rb +17 -3
  33. data/lib/kitchen/command/driver_discover.rb +26 -7
  34. data/lib/kitchen/command/exec.rb +41 -0
  35. data/lib/kitchen/command/list.rb +44 -14
  36. data/lib/kitchen/command/login.rb +2 -1
  37. data/lib/kitchen/command/sink.rb +2 -1
  38. data/lib/kitchen/command/test.rb +5 -4
  39. data/lib/kitchen/config.rb +146 -14
  40. data/lib/kitchen/configurable.rb +314 -0
  41. data/lib/kitchen/data_munger.rb +522 -18
  42. data/lib/kitchen/diagnostic.rb +43 -4
  43. data/lib/kitchen/driver.rb +4 -4
  44. data/lib/kitchen/driver/base.rb +80 -115
  45. data/lib/kitchen/driver/dummy.rb +34 -6
  46. data/lib/kitchen/driver/proxy.rb +14 -3
  47. data/lib/kitchen/driver/ssh_base.rb +61 -7
  48. data/lib/kitchen/errors.rb +109 -9
  49. data/lib/kitchen/generator/driver_create.rb +39 -5
  50. data/lib/kitchen/generator/init.rb +130 -45
  51. data/lib/kitchen/instance.rb +162 -28
  52. data/lib/kitchen/lazy_hash.rb +79 -7
  53. data/lib/kitchen/loader/yaml.rb +159 -27
  54. data/lib/kitchen/logger.rb +267 -21
  55. data/lib/kitchen/logging.rb +30 -3
  56. data/lib/kitchen/login_command.rb +11 -2
  57. data/lib/kitchen/metadata_chopper.rb +2 -2
  58. data/lib/kitchen/provisioner.rb +4 -4
  59. data/lib/kitchen/provisioner/base.rb +107 -103
  60. data/lib/kitchen/provisioner/chef/berkshelf.rb +36 -8
  61. data/lib/kitchen/provisioner/chef/librarian.rb +40 -11
  62. data/lib/kitchen/provisioner/chef_base.rb +206 -167
  63. data/lib/kitchen/provisioner/chef_solo.rb +25 -7
  64. data/lib/kitchen/provisioner/chef_zero.rb +105 -29
  65. data/lib/kitchen/provisioner/dummy.rb +1 -1
  66. data/lib/kitchen/provisioner/shell.rb +21 -6
  67. data/lib/kitchen/rake_tasks.rb +8 -3
  68. data/lib/kitchen/shell_out.rb +15 -18
  69. data/lib/kitchen/ssh.rb +122 -27
  70. data/lib/kitchen/state_file.rb +24 -7
  71. data/lib/kitchen/thor_tasks.rb +9 -4
  72. data/lib/kitchen/util.rb +43 -118
  73. data/lib/kitchen/version.rb +1 -1
  74. data/lib/vendor/hash_recursive_merge.rb +10 -2
  75. data/spec/kitchen/base64_stream_spec.rb +77 -0
  76. data/spec/kitchen/busser_spec.rb +490 -0
  77. data/spec/kitchen/collection_spec.rb +10 -10
  78. data/spec/kitchen/color_spec.rb +2 -2
  79. data/spec/kitchen/config_spec.rb +234 -62
  80. data/spec/kitchen/configurable_spec.rb +490 -0
  81. data/spec/kitchen/data_munger_spec.rb +1070 -862
  82. data/spec/kitchen/diagnostic_spec.rb +79 -0
  83. data/spec/kitchen/driver/base_spec.rb +80 -85
  84. data/spec/kitchen/driver/dummy_spec.rb +43 -14
  85. data/spec/kitchen/driver/proxy_spec.rb +134 -0
  86. data/spec/kitchen/driver/ssh_base_spec.rb +644 -0
  87. data/spec/kitchen/driver_spec.rb +15 -15
  88. data/spec/kitchen/errors_spec.rb +309 -0
  89. data/spec/kitchen/instance_spec.rb +143 -46
  90. data/spec/kitchen/lazy_hash_spec.rb +36 -9
  91. data/spec/kitchen/loader/yaml_spec.rb +237 -226
  92. data/spec/kitchen/logger_spec.rb +419 -0
  93. data/spec/kitchen/logging_spec.rb +59 -0
  94. data/spec/kitchen/login_command_spec.rb +49 -0
  95. data/spec/kitchen/metadata_chopper_spec.rb +82 -0
  96. data/spec/kitchen/platform_spec.rb +4 -4
  97. data/spec/kitchen/provisioner/base_spec.rb +65 -125
  98. data/spec/kitchen/provisioner/chef_base_spec.rb +798 -0
  99. data/spec/kitchen/provisioner/chef_solo_spec.rb +316 -0
  100. data/spec/kitchen/provisioner/chef_zero_spec.rb +624 -0
  101. data/spec/kitchen/provisioner/shell_spec.rb +269 -0
  102. data/spec/kitchen/provisioner_spec.rb +6 -6
  103. data/spec/kitchen/shell_out_spec.rb +143 -0
  104. data/spec/kitchen/ssh_spec.rb +683 -0
  105. data/spec/kitchen/state_file_spec.rb +28 -21
  106. data/spec/kitchen/suite_spec.rb +7 -7
  107. data/spec/kitchen/util_spec.rb +68 -10
  108. data/spec/kitchen_spec.rb +107 -0
  109. data/spec/spec_helper.rb +18 -13
  110. data/support/chef-client-zero.rb +10 -9
  111. data/support/chef_helpers.sh +16 -0
  112. data/support/download_helpers.sh +109 -0
  113. data/test-kitchen.gemspec +42 -33
  114. metadata +107 -33
@@ -0,0 +1,269 @@
1
+ # -*- encoding: utf-8 -*-
2
+ #
3
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
4
+ #
5
+ # Copyright (C) 2014, 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_relative "../../spec_helper"
20
+
21
+ require "kitchen"
22
+ require "kitchen/provisioner/shell"
23
+
24
+ describe Kitchen::Provisioner::Shell do
25
+
26
+ let(:logged_output) { StringIO.new }
27
+ let(:logger) { Logger.new(logged_output) }
28
+
29
+ let(:config) do
30
+ { :test_base_path => "/basist", :kitchen_root => "/rooty" }
31
+ end
32
+
33
+ let(:suite) do
34
+ stub(:name => "fries")
35
+ end
36
+
37
+ let(:instance) do
38
+ stub(:name => "coolbeans", :logger => logger, :suite => suite)
39
+ end
40
+
41
+ let(:provisioner) do
42
+ Class.new(Kitchen::Provisioner::Shell) {
43
+ def calculate_path(path, _opts = {})
44
+ "<calculated>/#{path}"
45
+ end
46
+ }.new(config).finalize_config!(instance)
47
+ end
48
+
49
+ describe "configuration" do
50
+
51
+ it ":script uses calculate_path and is expanded" do
52
+ provisioner[:script].must_equal "/rooty/<calculated>/bootstrap.sh"
53
+ end
54
+
55
+ it ":data_path uses calculate_path and is expanded" do
56
+ provisioner[:data_path].must_equal "/rooty/<calculated>/data"
57
+ end
58
+ end
59
+
60
+ describe "#init_command" do
61
+
62
+ let(:cmd) { provisioner.init_command }
63
+
64
+ it "uses bourne shell" do
65
+ cmd.must_match(/\Ash -c '$/)
66
+ cmd.must_match(/'\Z/)
67
+ end
68
+
69
+ it "uses sudo for rm when configured" do
70
+ config[:sudo] = true
71
+
72
+ cmd.must_match regexify("sudo -E rm -rf ", :partial_line)
73
+ end
74
+
75
+ it "does not use sudo for rm when configured" do
76
+ config[:sudo] = false
77
+
78
+ provisioner.init_command.
79
+ must_match regexify("rm -rf ", :partial_line)
80
+ provisioner.init_command.
81
+ wont_match regexify("sudo -E rm -rf ", :partial_line)
82
+ end
83
+
84
+ it "removes the data directory" do
85
+ config[:root_path] = "/route"
86
+
87
+ cmd.must_match %r{rm -rf\b.*\s+/route/data\s+}
88
+ end
89
+
90
+ it "creates :root_path directory" do
91
+ config[:root_path] = "/root/path"
92
+
93
+ cmd.must_match regexify("mkdir -p /root/path", :partial_line)
94
+ end
95
+ end
96
+
97
+ describe "#run_command" do
98
+
99
+ let(:cmd) { provisioner.run_command }
100
+
101
+ it "uses bourne shell" do
102
+ cmd.must_match(/\Ash -c '$/)
103
+ cmd.must_match(/'\Z/)
104
+ end
105
+
106
+ it "uses sudo for script when configured" do
107
+ config[:root_path] = "/r"
108
+ config[:sudo] = true
109
+
110
+ cmd.must_match regexify("sudo -E /r/bootstrap.sh", :partial_line)
111
+ end
112
+
113
+ it "does not use sudo for script when configured" do
114
+ config[:root_path] = "/r"
115
+ config[:sudo] = false
116
+
117
+ cmd.must_match regexify("/r/bootstrap.sh", :partial_line)
118
+ cmd.wont_match regexify("sudo -E /r/bootstrap.sh", :partial_line)
119
+ end
120
+ end
121
+
122
+ describe "#create_sandbox" do
123
+
124
+ before do
125
+ @root = Dir.mktmpdir
126
+ config[:kitchen_root] = @root
127
+ end
128
+
129
+ after do
130
+ FileUtils.remove_entry(@root)
131
+ begin
132
+ provisioner.cleanup_sandbox
133
+ rescue # rubocop:disable Lint/HandleExceptions
134
+ end
135
+ end
136
+
137
+ let(:provisioner) do
138
+ Kitchen::Provisioner::Shell.new(config).finalize_config!(instance)
139
+ end
140
+
141
+ describe "data files" do
142
+
143
+ before do
144
+ create_files_under("#{config[:kitchen_root]}/my_data")
145
+ config[:data_path] = "#{config[:kitchen_root]}/my_data"
146
+ end
147
+
148
+ it "skips directory creation if :data_path is not set" do
149
+ config[:data_path] = nil
150
+ provisioner.create_sandbox
151
+
152
+ sandbox_path("data").directory?.must_equal false
153
+ end
154
+
155
+ it "copies tree from :data_path into sandbox" do
156
+ provisioner.create_sandbox
157
+
158
+ sandbox_path("data/alpha.txt").file?.must_equal true
159
+ IO.read(sandbox_path("data/alpha.txt")).must_equal "stuff"
160
+ sandbox_path("data/sub").directory?.must_equal true
161
+ sandbox_path("data/sub/bravo.txt").file?.must_equal true
162
+ IO.read(sandbox_path("data/sub/bravo.txt")).must_equal "junk"
163
+ end
164
+
165
+ it "logs a message on info" do
166
+ provisioner.create_sandbox
167
+
168
+ logged_output.string.must_match info_line("Preparing data")
169
+ end
170
+
171
+ it "logs a message on debug" do
172
+ provisioner.create_sandbox
173
+
174
+ logged_output.string.must_match debug_line(
175
+ "Using data from #{config[:kitchen_root]}/my_data")
176
+ end
177
+ end
178
+
179
+ describe "script file" do
180
+
181
+ describe "with a valid :script file" do
182
+
183
+ before do
184
+ File.open("#{config[:kitchen_root]}/my_script", "wb") do |file|
185
+ file.write("gonuts")
186
+ end
187
+ config[:script] = "#{config[:kitchen_root]}/my_script"
188
+ end
189
+
190
+ it "creates a file in the sandbox directory" do
191
+ provisioner.create_sandbox
192
+
193
+ sandbox_path("my_script").file?.must_equal true
194
+ sandbox_path("my_script").executable?.must_equal true
195
+ IO.read(sandbox_path("my_script")).must_equal "gonuts"
196
+ end
197
+
198
+ it "logs a message on info" do
199
+ provisioner.create_sandbox
200
+
201
+ logged_output.string.must_match info_line("Preparing script")
202
+ end
203
+
204
+ it "logs a message on debug" do
205
+ provisioner.create_sandbox
206
+
207
+ logged_output.string.must_match debug_line(
208
+ "Using script from #{config[:kitchen_root]}/my_script")
209
+ end
210
+ end
211
+
212
+ describe "with no :script file" do
213
+
214
+ before { config[:script] = nil }
215
+
216
+ it "logs a message on info" do
217
+ provisioner.create_sandbox
218
+
219
+ logged_output.string.must_match info_line("Preparing script")
220
+ end
221
+
222
+ it "logs a warning on info" do
223
+ provisioner.create_sandbox
224
+
225
+ logged_output.string.must_match info_line(
226
+ "bootstrap.sh not found so Kitchen will run a stubbed script. " \
227
+ "Is this intended?")
228
+ end
229
+
230
+ it "creates a file in the sandbox directory" do
231
+ provisioner.create_sandbox
232
+
233
+ sandbox_path("bootstrap.sh").file?.must_equal true
234
+ sandbox_path("bootstrap.sh").executable?.must_equal true
235
+ IO.read(sandbox_path("bootstrap.sh")).
236
+ must_match(/NO BOOTSTRAP SCRIPT PRESENT/)
237
+ end
238
+ end
239
+ end
240
+
241
+ def sandbox_path(path)
242
+ Pathname.new(provisioner.sandbox_path).join(path)
243
+ end
244
+
245
+ def create_files_under(path)
246
+ FileUtils.mkdir_p(File.join(path, "sub"))
247
+ File.open(File.join(path, "alpha.txt"), "wb") do |file|
248
+ file.write("stuff")
249
+ end
250
+ File.open(File.join(path, "sub", "bravo.txt"), "wb") do |file|
251
+ file.write("junk")
252
+ end
253
+ end
254
+
255
+ def info_line(msg)
256
+ %r{^I, .* : #{Regexp.escape(msg)}$}
257
+ end
258
+
259
+ def debug_line(msg)
260
+ %r{^D, .* : #{Regexp.escape(msg)}$}
261
+ end
262
+ end
263
+
264
+ def regexify(str, line = :whole_line)
265
+ r = Regexp.escape(str)
266
+ r = "^\s*#{r}$" if line == :whole_line
267
+ Regexp.new(r)
268
+ end
269
+ end
@@ -16,12 +16,12 @@
16
16
  # See the License for the specific language governing permissions and
17
17
  # limitations under the License.
18
18
 
19
- require_relative '../spec_helper'
19
+ require_relative "../spec_helper"
20
20
 
21
- require 'kitchen/errors'
22
- require 'kitchen/logging'
23
- require 'kitchen/provisioner'
24
- require 'kitchen/provisioner/base'
21
+ require "kitchen/errors"
22
+ require "kitchen/logging"
23
+ require "kitchen/provisioner"
24
+ require "kitchen/provisioner/base"
25
25
 
26
26
  module Kitchen
27
27
 
@@ -63,7 +63,7 @@ describe Kitchen::Provisioner do
63
63
  # pretend require worked
64
64
  Kitchen::Provisioner.stubs(:require).returns(true)
65
65
 
66
- proc { Kitchen::Provisioner.for_plugin('nope', {}) }.
66
+ proc { Kitchen::Provisioner.for_plugin("nope", {}) }.
67
67
  must_raise Kitchen::ClientError
68
68
  end
69
69
  end
@@ -0,0 +1,143 @@
1
+ # -*- encoding: utf-8 -*-
2
+ #
3
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
4
+ #
5
+ # Copyright (C) 2014, 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_relative "../spec_helper"
20
+
21
+ require "kitchen/errors"
22
+ require "kitchen/shell_out"
23
+ require "kitchen/util"
24
+
25
+ module Kitchen
26
+
27
+ class Shelly
28
+
29
+ include Kitchen::ShellOut
30
+
31
+ attr_reader :logs
32
+
33
+ def debug(msg)
34
+ @logs ||= []
35
+ @logs << msg
36
+ end
37
+
38
+ def logger
39
+ "alogger"
40
+ end
41
+ end
42
+ end
43
+
44
+ describe Kitchen::ShellOut do
45
+
46
+ let(:command) do
47
+ stub(
48
+ :run_command => true,
49
+ :error! => true,
50
+ :stdout => "",
51
+ :execution_time => 123
52
+ )
53
+ end
54
+
55
+ let(:subject) { Kitchen::Shelly.new }
56
+
57
+ describe "#run_command" do
58
+
59
+ let(:opts) do
60
+ { :live_stream => "alogger", :timeout => 60000 }
61
+ end
62
+
63
+ before do
64
+ Mixlib::ShellOut.stubs(:new).returns(command)
65
+ end
66
+
67
+ it "builds a Mixlib::ShellOut object with default options" do
68
+ Mixlib::ShellOut.unstub(:new)
69
+ Mixlib::ShellOut.expects(:new).with("yoyo", opts).returns(command)
70
+
71
+ subject.run_command("yoyo")
72
+ end
73
+
74
+ [:timeout, :cwd, :environment].each do |attr|
75
+ it "builds a Mixlib::ShellOut object with a custom #{attr}" do
76
+ opts[attr] = "custom"
77
+
78
+ Mixlib::ShellOut.unstub(:new)
79
+ Mixlib::ShellOut.expects(:new).with("yoyo", opts).returns(command)
80
+
81
+ subject.run_command("yoyo", attr => "custom")
82
+ end
83
+ end
84
+
85
+ it "returns the command's standard out" do
86
+ command.stubs(:stdout).returns("sweetness")
87
+
88
+ subject.run_command("icecream").must_equal "sweetness"
89
+ end
90
+
91
+ it "raises a ShellCommandFailed if the command does not cleanly exit" do
92
+ command.stubs(:error!).
93
+ raises(Mixlib::ShellOut::ShellCommandFailed, "boom bad")
94
+
95
+ err = proc { subject.run_command("boom") }.
96
+ must_raise Kitchen::ShellOut::ShellCommandFailed
97
+ err.message.must_equal "boom bad"
98
+ end
99
+
100
+ it "raises a Kitchen::Errror tagged exception for unknown exceptions" do
101
+ command.stubs(:error!).raises(IOError, "boom bad")
102
+
103
+ err = proc { subject.run_command("boom") }.must_raise IOError
104
+ err.must_be_kind_of Kitchen::Error
105
+ err.message.must_equal "boom bad"
106
+ end
107
+
108
+ it "prepends with sudo if :use_sudo is truthy" do
109
+ Mixlib::ShellOut.unstub(:new)
110
+ Mixlib::ShellOut.expects(:new).with("sudo -E yo", opts).returns(command)
111
+
112
+ subject.run_command("yo", :use_sudo => true)
113
+ end
114
+
115
+ it "logs a debug BEGIN message" do
116
+ subject.run_command("echo whoopa\ndoopa\ndo")
117
+
118
+ subject.logs.first.
119
+ must_equal "[local command] BEGIN (echo whoopa\ndoopa\ndo)"
120
+ end
121
+
122
+ it "logs a debug BEGIN message with custom log subject" do
123
+ subject.run_command("tenac", :log_subject => "thed")
124
+
125
+ subject.logs.first.must_equal "[thed command] BEGIN (tenac)"
126
+ end
127
+
128
+ it "truncates the debug BEGIN command if it spans multiple lines" do
129
+ end
130
+
131
+ it "logs a debug END message" do
132
+ subject.run_command("echo whoopa doopa")
133
+
134
+ subject.logs.last.must_equal "[local command] END (2m3.00s)"
135
+ end
136
+
137
+ it "logs a debug END message with custom log subject" do
138
+ subject.run_command("tenac", :log_subject => "thed")
139
+
140
+ subject.logs.last.must_equal "[thed command] END (2m3.00s)"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,683 @@
1
+ # -*- encoding: utf-8 -*-
2
+ #
3
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
4
+ #
5
+ # Copyright (C) 2014, 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_relative "../spec_helper"
20
+
21
+ require "net/ssh/test"
22
+
23
+ require "kitchen/ssh"
24
+
25
+ # Hack to sort results in `Dir.entries` only within the yielded block, to limit
26
+ # the "behavior pollution" to other code. This was needed for Net::SCP, as
27
+ # recursive directory upload doesn't sort the file and directory upload
28
+ # candidates which leads to different results based on the underlying
29
+ # filesystem (i.e. lexically sorted, inode insertion, mtime/atime, total
30
+ # randomness, etc.)
31
+ #
32
+ # See: https://github.com/net-ssh/net-scp/blob/a24948/lib/net/scp/upload.rb#L52
33
+
34
+ def with_sorted_dir_entries
35
+ Dir.class_exec do
36
+ class << self
37
+ alias_method :__entries__, :entries unless method_defined?(:__entries__)
38
+
39
+ def entries(*args)
40
+ send(:__entries__, *args).sort
41
+ end
42
+ end
43
+ end
44
+
45
+ yield
46
+
47
+ Dir.class_exec do
48
+ class << self
49
+ alias_method :entries, :__entries__
50
+ end
51
+ end
52
+ end
53
+
54
+ # Terrible hack to deal with Net::SSH:Test::Extensions which monkey patches
55
+ # `IO.select` with a version for testing Net::SSH code. Unfortunetly this
56
+ # impacts other code, so we'll "un-patch" this after each spec and "re-patch"
57
+ # it before the next one.
58
+
59
+ def depatch_io
60
+ IO.class_exec do
61
+ class << self
62
+ alias_method :select, :select_for_real
63
+ end
64
+ end
65
+ end
66
+
67
+ def repatch_io
68
+ IO.class_exec do
69
+ class << self
70
+ alias_method :select, :select_for_test
71
+ end
72
+ end
73
+ end
74
+
75
+ # Major hack-and-a-half to add basic `Channel#request_pty` support to
76
+ # Net::SSH's testing framework. The `Net::SSH::Test::LocalPacket` does not
77
+ # recognize the `"pty-req"` request type, so bombs out whenever this channel
78
+ # request is sent.
79
+ #
80
+ # This "make-work" fix adds a method (`#sends_request_pty`) which works just
81
+ # like `#sends_exec` expcept that it enqueues a patched subclass of
82
+ # `LocalPacket` which can deal with the `"pty-req"` type.
83
+ #
84
+ # An upstream patch to Net::SSH will be required to retire this yak shave ;)
85
+
86
+ module Net
87
+
88
+ module SSH
89
+
90
+ module Test
91
+
92
+ class Channel
93
+
94
+ def sends_request_pty
95
+ pty_data = ["xterm", 80, 24, 640, 480, "\0"]
96
+
97
+ script.events << Class.new(Net::SSH::Test::LocalPacket) do
98
+ def types
99
+ if @type == 98 && @data[1] == "pty-req"
100
+ @types ||= [
101
+ :long, :string, :bool, :string,
102
+ :long, :long, :long, :long, :string
103
+ ]
104
+ else
105
+ super
106
+ end
107
+ end
108
+ end.new(:channel_request, remote_id, "pty-req", false, *pty_data)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ describe Kitchen::SSH do
116
+
117
+ include Net::SSH::Test
118
+
119
+ let(:logged_output) { StringIO.new }
120
+ let(:logger) { Logger.new(logged_output) }
121
+ let(:opts) { Hash.new }
122
+ let(:ssh) { Kitchen::SSH.new("foo", "me", opts) }
123
+ let(:conn) { connection }
124
+
125
+ before do
126
+ repatch_io
127
+ logger.level = Logger::DEBUG
128
+ opts[:logger] = logger
129
+ Net::SSH.stubs(:start).returns(conn)
130
+ end
131
+
132
+ after do
133
+ depatch_io
134
+ end
135
+
136
+ describe "establishing a connection" do
137
+
138
+ [
139
+ Errno::EACCES, Errno::EADDRINUSE, Errno::ECONNREFUSED,
140
+ Errno::ECONNRESET, Errno::ENETUNREACH, Errno::EHOSTUNREACH,
141
+ Net::SSH::Disconnect, Net::SSH::AuthenticationFailed
142
+ ].each do |klass|
143
+ describe "raising #{klass}" do
144
+
145
+ before do
146
+ Net::SSH.stubs(:start).raises(klass)
147
+ opts[:ssh_retries] = 3
148
+ ssh.stubs(:sleep)
149
+ end
150
+
151
+ it "reraises the #{klass} exception" do
152
+ proc { ssh.exec("nope") }.must_raise klass
153
+ end
154
+
155
+ it "attempts to connect ':ssh_retries' times" do
156
+ begin
157
+ ssh.exec("nope")
158
+ rescue # rubocop:disable Lint/HandleExceptions
159
+ end
160
+
161
+ logged_output.string.lines.select { |l|
162
+ l =~ debug_line("[SSH] opening connection to me@foo:22<{:ssh_retries=>3}>")
163
+ }.size.must_equal opts[:ssh_retries]
164
+ end
165
+
166
+ it "sleeps for 1 second between retries" do
167
+ ssh.unstub(:sleep)
168
+ ssh.expects(:sleep).with(1).twice
169
+
170
+ begin
171
+ ssh.exec("nope")
172
+ rescue # rubocop:disable Lint/HandleExceptions
173
+ end
174
+ end
175
+
176
+ it "logs the first 2 retry failures on info" do
177
+ begin
178
+ ssh.exec("nope")
179
+ rescue # rubocop:disable Lint/HandleExceptions
180
+ end
181
+
182
+ logged_output.string.lines.select { |l|
183
+ l =~ info_line_with("[SSH] connection failed, retrying ")
184
+ }.size.must_equal 2
185
+ end
186
+
187
+ it "logs the last retry failures on warn" do
188
+ begin
189
+ ssh.exec("nope")
190
+ rescue # rubocop:disable Lint/HandleExceptions
191
+ end
192
+
193
+ logged_output.string.lines.select { |l|
194
+ l =~ warn_line_with("[SSH] connection failed, terminating ")
195
+ }.size.must_equal 1
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ describe "#exec" do
202
+
203
+ describe "for a successful command" do
204
+
205
+ before do
206
+ story do |script|
207
+ channel = script.opens_channel
208
+ channel.sends_request_pty
209
+ channel.sends_exec("doit")
210
+ channel.gets_data("ok\n")
211
+ channel.gets_extended_data("some stderr stuffs\n")
212
+ channel.gets_exit_status(0)
213
+ channel.gets_close
214
+ channel.sends_close
215
+ end
216
+ end
217
+
218
+ it "logger displays command on debug" do
219
+ assert_scripted { ssh.exec("doit") }
220
+
221
+ logged_output.string.must_match debug_line(
222
+ "[SSH] me@foo:22<{}> (doit)"
223
+ )
224
+ end
225
+
226
+ it "logger displays establishing connection on debug" do
227
+ assert_scripted { ssh.exec("doit") }
228
+
229
+ logged_output.string.must_match debug_line(
230
+ "[SSH] opening connection to me@foo:22<{}>"
231
+ )
232
+ end
233
+
234
+ it "logger captures stdout" do
235
+ assert_scripted { ssh.exec("doit") }
236
+
237
+ logged_output.string.must_match(/^ok$/)
238
+ end
239
+
240
+ it "logger captures stderr" do
241
+ assert_scripted { ssh.exec("doit") }
242
+
243
+ logged_output.string.must_match(/^some stderr stuffs$/)
244
+ end
245
+ end
246
+
247
+ describe "for a failed command" do
248
+
249
+ before do
250
+ story do |script|
251
+ channel = script.opens_channel
252
+ channel.sends_request_pty
253
+ channel.sends_exec("doit")
254
+ channel.gets_data("nope\n")
255
+ channel.gets_extended_data("youdead\n")
256
+ channel.gets_exit_status(42)
257
+ channel.gets_close
258
+ channel.sends_close
259
+ end
260
+ end
261
+
262
+ it "logger displays command on debug" do
263
+ begin
264
+ assert_scripted { ssh.exec("doit") }
265
+ rescue # rubocop:disable Lint/HandleExceptions
266
+ end
267
+
268
+ logged_output.string.must_match debug_line(
269
+ "[SSH] me@foo:22<{}> (doit)"
270
+ )
271
+ end
272
+
273
+ it "logger displays establishing connection on debug" do
274
+ begin
275
+ assert_scripted { ssh.exec("doit") }
276
+ rescue # rubocop:disable Lint/HandleExceptions
277
+ end
278
+
279
+ logged_output.string.must_match debug_line(
280
+ "[SSH] opening connection to me@foo:22<{}>"
281
+ )
282
+ end
283
+
284
+ it "logger captures stdout" do
285
+ begin
286
+ assert_scripted { ssh.exec("doit") }
287
+ rescue # rubocop:disable Lint/HandleExceptions
288
+ end
289
+
290
+ logged_output.string.must_match(/^nope$/)
291
+ end
292
+
293
+ it "logger captures stderr" do
294
+ begin
295
+ assert_scripted { ssh.exec("doit") }
296
+ rescue # rubocop:disable Lint/HandleExceptions
297
+ end
298
+
299
+ logged_output.string.must_match(/^youdead$/)
300
+ end
301
+
302
+ it "raises an SSHFailed exception" do
303
+ err = proc { ssh.exec("doit") }.must_raise Kitchen::SSHFailed
304
+ err.message.must_equal "SSH exited (42) for command: [doit]"
305
+ end
306
+ end
307
+ end
308
+
309
+ describe "#upload!" do
310
+
311
+ let(:content) { "a" * 1234 }
312
+
313
+ let(:src) do
314
+ file = Tempfile.new("file")
315
+ file.write("a" * 1234)
316
+ file.close
317
+ FileUtils.chmod(0755, file.path)
318
+ file
319
+ end
320
+
321
+ before do
322
+ expect_scp_session("-t /tmp/remote") do |channel|
323
+ channel.gets_data("\0")
324
+ channel.sends_data("C0755 1234 #{File.basename(src.path)}\n")
325
+ channel.gets_data("\0")
326
+ channel.sends_data("a" * 1234)
327
+ channel.sends_data("\0")
328
+ channel.gets_data("\0")
329
+ end
330
+ end
331
+
332
+ after do
333
+ src.unlink
334
+ end
335
+
336
+ it "uploads a file to remote over scp" do
337
+ assert_scripted do
338
+ ssh.upload!(src.path, "/tmp/remote")
339
+ end
340
+ end
341
+
342
+ it "logs upload progress to debug" do
343
+ assert_scripted do
344
+ ssh.upload!(src.path, "/tmp/remote")
345
+ end
346
+
347
+ logged_output.string.must_match debug_line(
348
+ "[SSH] opening connection to me@foo:22<{}>"
349
+ )
350
+ logged_output.string.must_match debug_line(
351
+ "Uploaded #{src.path} (1234 bytes)"
352
+ )
353
+ end
354
+ end
355
+
356
+ describe "#upload_path!" do
357
+
358
+ before do
359
+ @dir = Dir.mktmpdir("local")
360
+ FileUtils.chmod(0700, @dir)
361
+ File.open("#{@dir}/alpha", "wb") { |f| f.write("alpha-contents\n") }
362
+ FileUtils.chmod(0644, "#{@dir}/alpha")
363
+ FileUtils.mkdir_p("#{@dir}/subdir")
364
+ FileUtils.chmod(0755, "#{@dir}/subdir")
365
+ File.open("#{@dir}/subdir/beta", "wb") { |f| f.write("beta-contents\n") }
366
+ FileUtils.chmod(0555, "#{@dir}/subdir/beta")
367
+ File.open("#{@dir}/zulu", "wb") { |f| f.write("zulu-contents\n") }
368
+ FileUtils.chmod(0444, "#{@dir}/zulu")
369
+
370
+ expect_scp_session("-t -r /tmp/remote") do |channel|
371
+ channel.gets_data("\0")
372
+ channel.sends_data("D0700 0 #{File.basename(@dir)}\n")
373
+ channel.gets_data("\0")
374
+ channel.sends_data("C0644 15 alpha\n")
375
+ channel.gets_data("\0")
376
+ channel.sends_data("alpha-contents\n")
377
+ channel.sends_data("\0")
378
+ channel.gets_data("\0")
379
+ channel.sends_data("D0755 0 subdir\n")
380
+ channel.gets_data("\0")
381
+ channel.sends_data("C0555 14 beta\n")
382
+ channel.gets_data("\0")
383
+ channel.sends_data("beta-contents\n")
384
+ channel.sends_data("\0")
385
+ channel.gets_data("\0")
386
+ channel.sends_data("E\n")
387
+ channel.gets_data("\0")
388
+ channel.sends_data("C0444 14 zulu\n")
389
+ channel.gets_data("\0")
390
+ channel.sends_data("zulu-contents\n")
391
+ channel.sends_data("\0")
392
+ channel.gets_data("\0")
393
+ channel.sends_data("E\n")
394
+ channel.gets_data("\0")
395
+ end
396
+ end
397
+
398
+ after do
399
+ FileUtils.remove_entry_secure(@dir)
400
+ end
401
+
402
+ it "uploads a file to remote over scp" do
403
+ with_sorted_dir_entries do
404
+ assert_scripted { ssh.upload_path!(@dir, "/tmp/remote") }
405
+ end
406
+ end
407
+
408
+ it "logs upload progress to debug" do
409
+ remote_base = "/tmp/#{File.basename(@dir)}"
410
+
411
+ with_sorted_dir_entries do
412
+ assert_scripted { ssh.upload_path!(@dir, "/tmp/remote") }
413
+ end
414
+
415
+ logged_output.string.must_match debug_line(
416
+ "[SSH] opening connection to me@foo:22<{}>"
417
+ )
418
+ logged_output.string.must_match debug_line(
419
+ "Uploaded #{remote_base}/alpha (15 bytes)"
420
+ )
421
+ logged_output.string.must_match debug_line(
422
+ "Uploaded #{remote_base}/subdir/beta (14 bytes)"
423
+ )
424
+ logged_output.string.must_match debug_line(
425
+ "Uploaded #{remote_base}/zulu (14 bytes)"
426
+ )
427
+ end
428
+ end
429
+
430
+ describe "#shutdown" do
431
+
432
+ before do
433
+ story do |script|
434
+ channel = script.opens_channel
435
+ channel.sends_request_pty
436
+ channel.sends_exec("doit")
437
+ channel.gets_data("ok\n")
438
+ channel.gets_exit_status(0)
439
+ channel.gets_close
440
+ channel.sends_close
441
+ end
442
+ end
443
+
444
+ it "logger displays closing connection on debug" do
445
+ conn.expects(:shutdown!)
446
+
447
+ assert_scripted do
448
+ ssh.exec("doit")
449
+ ssh.shutdown
450
+ end
451
+
452
+ logged_output.string.must_match debug_line(
453
+ "[SSH] closing connection to me@foo:22<{}>"
454
+ )
455
+ end
456
+
457
+ it "only closes the connection once for multiple calls" do
458
+ conn.expects(:shutdown!).once
459
+
460
+ assert_scripted do
461
+ ssh.exec("doit")
462
+ ssh.shutdown
463
+ ssh.shutdown
464
+ ssh.shutdown
465
+ end
466
+ end
467
+ end
468
+
469
+ describe "block form" do
470
+
471
+ before do
472
+ story do |script|
473
+ channel = script.opens_channel
474
+ channel.sends_request_pty
475
+ channel.sends_exec("doit")
476
+ channel.gets_data("ok\n")
477
+ channel.gets_exit_status(0)
478
+ channel.gets_close
479
+ channel.sends_close
480
+ end
481
+ end
482
+
483
+ it "shuts down the connection when block closes" do
484
+ conn.expects(:shutdown!)
485
+
486
+ Kitchen::SSH.new("foo", "me", opts) do |ssh|
487
+ ssh.exec("doit")
488
+ end
489
+ end
490
+ end
491
+
492
+ describe "#login_command" do
493
+
494
+ let(:login_command) { ssh.login_command }
495
+ let(:cmd) { login_command.cmd_array.join(" ") }
496
+
497
+ it "returns a LoginCommand" do
498
+ login_command.must_be_instance_of Kitchen::LoginCommand
499
+ end
500
+
501
+ it "is an SSH command" do
502
+ cmd.must_match %r{^ssh }
503
+ cmd.must_match %r{ me@foo$}
504
+ end
505
+
506
+ it "sets the UserKnownHostsFile option" do
507
+ cmd.must_match regexify(" -o UserKnownHostsFile=/dev/null ")
508
+ end
509
+
510
+ it "sets the StrictHostKeyChecking option" do
511
+ cmd.must_match regexify(" -o StrictHostKeyChecking=no ")
512
+ end
513
+
514
+ it "won't set IdentitiesOnly option by default" do
515
+ cmd.wont_match regexify(" -o IdentitiesOnly=")
516
+ end
517
+
518
+ it "sets the IdentiesOnly option if :keys option is given" do
519
+ opts[:keys] = ["yep"]
520
+
521
+ cmd.must_match regexify(" -o IdentitiesOnly=yes ")
522
+ end
523
+
524
+ it "sets the LogLevel option to VERBOSE if logger is set to debug" do
525
+ logger.level = ::Logger::DEBUG
526
+ opts[:logger] = logger
527
+
528
+ cmd.must_match regexify(" -o LogLevel=VERBOSE ")
529
+ end
530
+
531
+ it "sets the LogLevel option to ERROR if logger is not set to debug" do
532
+ logger.level = ::Logger::INFO
533
+ opts[:logger] = logger
534
+
535
+ cmd.must_match regexify(" -o LogLevel=ERROR ")
536
+ end
537
+
538
+ it "won't set the ForwardAgent option by default" do
539
+ cmd.wont_match regexify(" -o ForwardAgent=")
540
+ end
541
+
542
+ it "sets the ForwardAgent option to yes if truthy" do
543
+ opts[:forward_agent] = "yep"
544
+
545
+ cmd.must_match regexify(" -o ForwardAgent=yes")
546
+ end
547
+
548
+ it "sets the ForwardAgent option to no if falsey" do
549
+ opts[:forward_agent] = false
550
+
551
+ cmd.must_match regexify(" -o ForwardAgent=no")
552
+ end
553
+
554
+ it "won't add any SSH keys by default" do
555
+ cmd.wont_match regexify(" -i ")
556
+ end
557
+
558
+ it "sets SSH keys options if given" do
559
+ opts[:keys] = %w[one two]
560
+
561
+ cmd.must_match regexify(" -i one ")
562
+ cmd.must_match regexify(" -i two ")
563
+ end
564
+
565
+ it "sets the port option to 22 by default" do
566
+ cmd.must_match regexify(" -p 22 ")
567
+ end
568
+
569
+ it "sets the port option" do
570
+ opts[:port] = 1234
571
+
572
+ cmd.must_match regexify(" -p 1234 ")
573
+ end
574
+ end
575
+
576
+ describe "#test_ssh" do
577
+
578
+ let(:tcp_socket) { stub(:select_for_read? => true, :close => true) }
579
+
580
+ before { ssh.stubs(:sleep) }
581
+
582
+ it "returns a truthy value" do
583
+ TCPSocket.stubs(:new).returns(tcp_socket)
584
+
585
+ result = ssh.send(:test_ssh)
586
+ result.wont_equal nil
587
+ result.wont_equal false
588
+ end
589
+
590
+ it "closes socket when finished" do
591
+ TCPSocket.stubs(:new).returns(tcp_socket)
592
+ tcp_socket.expects(:close)
593
+
594
+ ssh.send(:test_ssh)
595
+ end
596
+
597
+ [
598
+ SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
599
+ Errno::ENETUNREACH, IOError
600
+ ].each do |klass|
601
+ describe "when #{klass} is raised" do
602
+
603
+ before { TCPSocket.stubs(:new).raises(klass) }
604
+
605
+ it "returns false" do
606
+ ssh.send(:test_ssh).must_equal false
607
+ end
608
+
609
+ it "sleeps for 2 seconds" do
610
+ ssh.expects(:sleep).with(2)
611
+
612
+ ssh.send(:test_ssh)
613
+ end
614
+ end
615
+ end
616
+
617
+ [
618
+ Errno::EPERM, Errno::ETIMEDOUT
619
+ ].each do |klass|
620
+ describe "when #{klass} is raised" do
621
+
622
+ it "returns false when #{klass} is raised" do
623
+ TCPSocket.stubs(:new).raises(klass)
624
+
625
+ ssh.send(:test_ssh).must_equal false
626
+ end
627
+ end
628
+ end
629
+ end
630
+
631
+ describe "#wait" do
632
+
633
+ let(:not_ready) do
634
+ stub(:select_for_read? => false, :idle! => true, :close => true)
635
+ end
636
+
637
+ let(:ready) do
638
+ stub(:select_for_read? => true, :close => true)
639
+ end
640
+
641
+ it "logs to info for each retry" do
642
+ TCPSocket.stubs(:new).returns(not_ready, not_ready, ready)
643
+ ssh.wait
644
+
645
+ logged_output.string.lines.select { |l|
646
+ l =~ info_line_with("Waiting for foo:22...")
647
+ }.size.must_equal 2
648
+ end
649
+ end
650
+
651
+ def expect_scp_session(args)
652
+ story do |script|
653
+ channel = script.opens_channel
654
+ channel.sends_exec("scp #{args}")
655
+ yield channel if block_given?
656
+ channel.sends_eof
657
+ channel.gets_exit_status(0)
658
+ channel.gets_eof
659
+ channel.gets_close
660
+ channel.sends_close
661
+ end
662
+ end
663
+
664
+ def regexify(string)
665
+ Regexp.new(Regexp.escape(string))
666
+ end
667
+
668
+ def debug_line(msg)
669
+ %r{^D, .* : #{Regexp.escape(msg)}$}
670
+ end
671
+
672
+ def debug_line_with(msg)
673
+ %r{^D, .* : #{Regexp.escape(msg)}}
674
+ end
675
+
676
+ def info_line_with(msg)
677
+ %r{^I, .* : #{Regexp.escape(msg)}}
678
+ end
679
+
680
+ def warn_line_with(msg)
681
+ %r{^W, .* : #{Regexp.escape(msg)}}
682
+ end
683
+ end