test-kitchen 1.2.1 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
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