kitchen-docker 3.3.0 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ #
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ #
14
+
15
+ require "spec_helper"
16
+ require "tmpdir"
17
+
18
+ describe Kitchen::Docker::Helpers::FileHelper do
19
+ around do |example|
20
+ Dir.mktmpdir { |dir| @dir = dir; example.run }
21
+ end
22
+
23
+ describe "#create_temp_file" do
24
+ it "writes the contents" do
25
+ path = File.join(@dir, "docker-abc.sh")
26
+ helper.create_temp_file(path, "echo hi\n")
27
+ expect(File.read(path)).to eq "echo hi\n"
28
+ end
29
+
30
+ it "creates the parent directory" do
31
+ # Container#execute writes to ./.kitchen/temp, which does not exist on a
32
+ # fresh checkout.
33
+ path = File.join(@dir, ".kitchen", "temp", "docker-abc.sh")
34
+ helper.create_temp_file(path, "echo hi\n")
35
+ expect(File.read(path)).to eq "echo hi\n"
36
+ end
37
+
38
+ it "replaces the contents of an existing file" do
39
+ path = File.join(@dir, "docker-abc.sh")
40
+ File.write(path, "old and longer content")
41
+ helper.create_temp_file(path, "new\n")
42
+ expect(File.read(path)).to eq "new\n"
43
+ end
44
+
45
+ it "leaves no open handle behind" do
46
+ path = File.join(@dir, "docker-abc.sh")
47
+ before = ObjectSpace.each_object(File).count { |f| !f.closed? }
48
+ helper.create_temp_file(path, "echo hi\n")
49
+ expect(ObjectSpace.each_object(File).count { |f| !f.closed? }).to eq before
50
+ end
51
+
52
+ # Every one of these used to raise "undefined method 'close' for an instance
53
+ # of String": the open failed, `file` was still the path, and the ensure
54
+ # tried to close it. The real cause never reached the user, who saw a Ruby
55
+ # NoMethodError reported as a Docker failure.
56
+ context "when the file cannot be written" do
57
+ it "reports a parent that is not a directory" do
58
+ # ".kitchen" existing as a file rather than a directory. The exact errno
59
+ # differs by platform -- macOS reports EEXIST from mkdir here, Linux
60
+ # ENOTDIR -- so the assertion is that the underlying cause reaches the
61
+ # user at all, which is what used to be lost.
62
+ blocker = File.join(@dir, "kitchen")
63
+ File.write(blocker, "not a directory")
64
+ path = File.join(blocker, "temp", "docker-abc.sh")
65
+
66
+ expect { helper.create_temp_file(path, "echo hi") }
67
+ .to raise_error(RuntimeError, /Failed to write temp file.*Error Details: \S+.*#{Regexp.escape(blocker)}/m)
68
+ end
69
+
70
+ it "reports a directory it may not write to" do
71
+ readonly = File.join(@dir, "readonly")
72
+ Dir.mkdir(readonly)
73
+ File.chmod(0o500, readonly)
74
+
75
+ begin
76
+ expect { helper.create_temp_file(File.join(readonly, "docker-abc.sh"), "echo hi") }
77
+ .to raise_error(RuntimeError, /Failed to write temp file.*Permission denied/m)
78
+ ensure
79
+ File.chmod(0o700, readonly)
80
+ end
81
+ end
82
+
83
+ it "reports a target that is itself a directory" do
84
+ target = File.join(@dir, "isadir")
85
+ Dir.mkdir(target)
86
+
87
+ expect { helper.create_temp_file(target, "echo hi") }
88
+ .to raise_error(RuntimeError, /Failed to write temp file/)
89
+ end
90
+
91
+ it "names the path it could not write" do
92
+ # The old message said only "Failed to write temp file", which did not
93
+ # say which one.
94
+ path = File.join(@dir, "isadir")
95
+ Dir.mkdir(path)
96
+
97
+ expect { helper.create_temp_file(path, "echo hi") }
98
+ .to raise_error(RuntimeError, /#{Regexp.escape(path)}/)
99
+ end
100
+
101
+ it "never raises NoMethodError" do
102
+ blocker = File.join(@dir, "kitchen")
103
+ File.write(blocker, "not a directory")
104
+
105
+ expect { helper.create_temp_file(File.join(blocker, "x.sh"), "echo hi") }
106
+ .not_to raise_error(NoMethodError)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,181 @@
1
+ #
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ #
14
+
15
+ require "spec_helper"
16
+
17
+ describe Kitchen::Docker::Helpers::ImageHelper do
18
+ describe "#parse_image_id" do
19
+ # Docker has changed how it reports the built image's id several times, and
20
+ # each change has broken this parser. Every format the driver claims to
21
+ # support gets a case here, against output copied from a real build.
22
+ {
23
+ "Docker 29.7 with BuildKit" =>
24
+ [DockerOutput::BUILD_29_7_BUILDKIT, DockerOutput::BUILD_29_7_IMAGE_ID],
25
+ "BuildKit emitting 'writing image'" =>
26
+ [DockerOutput::BUILD_BUILDKIT_WRITING_IMAGE, DockerOutput::BUILD_BUILDKIT_WRITING_IMAGE_ID],
27
+ "the pre-BuildKit builder" =>
28
+ [DockerOutput::BUILD_LEGACY, DockerOutput::BUILD_LEGACY_IMAGE_ID],
29
+ }.each do |description, (output, expected_id)|
30
+ context "with output from #{description}" do
31
+ it "finds the image id" do
32
+ expect(helper.parse_image_id(output)).to eq expected_id
33
+ end
34
+
35
+ it "returns something that looks like an image id" do
36
+ # Two of the three patterns pull the id out with a bare `split.last`,
37
+ # which will return whatever trailing token a matching line happens to
38
+ # end with. Asserting the shape catches a match on the wrong line.
39
+ expect(helper.parse_image_id(output)).to match(/\A(sha256:[0-9a-f]{64}|[0-9a-f]{12})\z/)
40
+ end
41
+ end
42
+ end
43
+
44
+ it "fails loudly when the output holds no image id" do
45
+ # Returning nil here would put nil into state[:image_id] and fail much
46
+ # later, while `docker run` complained about an empty image name.
47
+ expect { helper.parse_image_id("#1 [internal] load build definition\n#1 DONE 0.0s\n") }
48
+ .to raise_error(Kitchen::ActionFailed, /Could not parse Docker build output/)
49
+ end
50
+
51
+ it "fails loudly on empty output" do
52
+ expect { helper.parse_image_id("") }
53
+ .to raise_error(Kitchen::ActionFailed, /Could not parse Docker build output/)
54
+ end
55
+
56
+ it "prefers the last id in the output" do
57
+ # The scan runs in reverse so that a rebuild's final export wins over
58
+ # anything earlier in the log.
59
+ doubled = DockerOutput::BUILD_BUILDKIT_WRITING_IMAGE + DockerOutput::BUILD_29_7_BUILDKIT
60
+ expect(helper.parse_image_id(doubled)).to eq DockerOutput::BUILD_29_7_IMAGE_ID
61
+ end
62
+ end
63
+
64
+ describe "#build_image" do
65
+ # build_image writes a temp Dockerfile and shells out. Stubbing the shell-out
66
+ # leaves the part worth testing: the command line it assembles.
67
+ let(:built) { [] }
68
+
69
+ def build(config = {})
70
+ h = helper({ build_tempdir: ".", build_context: false, use_cache: true }.merge(config))
71
+ allow(h).to receive(:docker_command) do |cmd, _opts|
72
+ built << cmd
73
+ DockerOutput::BUILD_29_7_BUILDKIT
74
+ end
75
+ h.build_image({}, "FROM alpine:3.20\n")
76
+ argv(built.last)
77
+ end
78
+
79
+ it "builds, reading the Dockerfile from stdin when there is no build context" do
80
+ expect(build).to eq %w{build -}
81
+ end
82
+
83
+ it "passes a build context as the final argument when one is configured" do
84
+ expect(build(build_context: true).last).to eq "."
85
+ end
86
+
87
+ it "names the Dockerfile with -f when there is a build context" do
88
+ expect(build(build_context: true)).to include "-f"
89
+ end
90
+
91
+ it "disables the cache when use_cache is false" do
92
+ expect(build(use_cache: false)).to include "--no-cache"
93
+ end
94
+
95
+ it "uses the cache when use_cache is true, as the driver defaults it" do
96
+ expect(build).not_to include "--no-cache"
97
+ end
98
+
99
+ it "passes docker_platform through" do
100
+ expect(build(docker_platform: "linux/arm64")).to include "--platform=linux/arm64"
101
+ end
102
+
103
+ it "appends build_options" do
104
+ expect(build(build_options: { "build-arg" => "VERSION=1.2.3" }))
105
+ .to include "--build-arg=VERSION=1.2.3"
106
+ end
107
+
108
+ it "returns the parsed image id" do
109
+ h = helper(build_tempdir: ".", build_context: false, use_cache: true)
110
+ allow(h).to receive(:docker_command).and_return(DockerOutput::BUILD_29_7_BUILDKIT)
111
+ expect(h.build_image({}, "FROM alpine:3.20\n")).to eq DockerOutput::BUILD_29_7_IMAGE_ID
112
+ end
113
+
114
+ it "removes the temp Dockerfile even when the build fails" do
115
+ # A failed build that leaves Dockerfile-kitchen files behind pollutes the
116
+ # cookbook directory, and they end up in the next build's context.
117
+ before = Dir.glob("Dockerfile-kitchen*")
118
+ h = helper(build_tempdir: ".", build_context: false, use_cache: true)
119
+ allow(h).to receive(:docker_command).and_raise(Kitchen::ShellOut::ShellCommandFailed, "boom")
120
+ expect { h.build_image({}, "FROM alpine:3.20\n") }.to raise_error(Kitchen::ShellOut::ShellCommandFailed)
121
+ expect(Dir.glob("Dockerfile-kitchen*")).to eq before
122
+ end
123
+ end
124
+
125
+ describe "#image_in_use?" do
126
+ let(:image_id) { "sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" }
127
+
128
+ def helper_seeing(output)
129
+ h = helper
130
+ @asked = nil
131
+ allow(h).to receive(:docker_command) { |cmd, _opts = {}| @asked = cmd; output }
132
+ h
133
+ end
134
+
135
+ it "reports the image as in use when a container references it" do
136
+ expect(helper_seeing("331a7cd151e4\n").image_in_use?(image_id: image_id)).to be true
137
+ end
138
+
139
+ it "reports the image as free when nothing references it" do
140
+ expect(helper_seeing("").image_in_use?(image_id: image_id)).to be false
141
+ end
142
+
143
+ it "reports the image as free when state names no image" do
144
+ h = helper
145
+ expect(h).not_to receive(:docker_command)
146
+ expect(h.image_in_use?({})).to be false
147
+ end
148
+
149
+ it "asks docker to filter, rather than searching ps output for the id" do
150
+ # `docker ps -a` abbreviates the IMAGE column to twelve characters, so
151
+ # searching it for the full sha256 digest never matched and the guard
152
+ # was always false.
153
+ h = helper_seeing("331a7cd151e4\n")
154
+ h.image_in_use?(image_id: image_id)
155
+ expect(@asked).to eq "ps -a -q --filter ancestor=#{image_id}"
156
+ end
157
+
158
+ it "does not mistake a warning on stderr for a container" do
159
+ expect(helper_seeing("WARNING: something happened\n").image_in_use?(image_id: image_id))
160
+ .to be false
161
+ end
162
+ end
163
+
164
+ describe "#remove_image" do
165
+ let(:state) { { image_id: "sha256:abc" } }
166
+
167
+ it "removes the image when nothing is using it" do
168
+ h = helper
169
+ allow(h).to receive(:image_in_use?).and_return(false)
170
+ expect(h).to receive(:docker_command).with("rmi sha256:abc")
171
+ h.remove_image(state)
172
+ end
173
+
174
+ it "leaves the image alone when a container still references it" do
175
+ h = helper
176
+ allow(h).to receive(:image_in_use?).and_return(true)
177
+ expect(h).not_to receive(:docker_command)
178
+ h.remove_image(state)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,252 @@
1
+ #
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ #
14
+
15
+ require "spec_helper"
16
+ require "tmpdir"
17
+
18
+ describe Kitchen::Docker::Container::Linux do
19
+ around do |example|
20
+ Dir.mktmpdir do |dir|
21
+ @tmpdir = dir
22
+ @public_key = File.join(dir, "docker_id_rsa.pub")
23
+ File.write(@public_key, "ssh-rsa AAAAB3NzaC1yc2E kitchen_docker_key\n")
24
+ example.run
25
+ end
26
+ end
27
+
28
+ def container(config = {})
29
+ described_class.new({
30
+ image: "ubuntu:24.04",
31
+ platform: "ubuntu",
32
+ username: "kitchen",
33
+ public_key: @public_key,
34
+ private_key: File.join(@tmpdir, "docker_id_rsa"),
35
+ }.merge(config))
36
+ end
37
+
38
+ describe "#parse_container_ssh_port" do
39
+ def port(output)
40
+ container.send(:parse_container_ssh_port, output)
41
+ end
42
+
43
+ it "reads the published port from a dual-stack daemon" do
44
+ expect(port(DockerOutput::PORT_DUAL_STACK)).to eq DockerOutput::PUBLISHED_PORT
45
+ end
46
+
47
+ it "reads the published port from an IPv4-only daemon" do
48
+ expect(port(DockerOutput::PORT_IPV4_ONLY)).to eq DockerOutput::PUBLISHED_PORT
49
+ end
50
+
51
+ # `_host, port = output.split(":")` assumes the host is everything before
52
+ # the first colon. On IPv6 output the first two fields are "[" and "", so
53
+ # the port becomes "".to_i -- and `to_i` never raises, so the method's
54
+ # rescue clause cannot fire. Test Kitchen is then handed port 0 and fails
55
+ # far away from here, complaining that SSH was refused.
56
+ it "reads the published port from an IPv6-only daemon" do
57
+ expect(port(DockerOutput::PORT_IPV6_ONLY)).to eq DockerOutput::PUBLISHED_PORT
58
+ end
59
+
60
+ it "refuses to report a port it could not parse" do
61
+ expect { port("") }.to raise_error(Kitchen::ActionFailed)
62
+ end
63
+
64
+ it "reads the published port when docker names the host" do
65
+ expect(port("localhost:52239\n")).to eq DockerOutput::PUBLISHED_PORT
66
+ end
67
+
68
+ it "refuses to report a port from output with no port in it" do
69
+ expect { port("no public port '22/tcp' published\n") }
70
+ .to raise_error(Kitchen::ActionFailed, /Could not parse Docker port output/)
71
+ end
72
+
73
+ it "names the output it could not parse" do
74
+ # The previous message said only that parsing failed, which left nobody
75
+ # any way to tell what docker had actually printed.
76
+ expect { port("surprising\n") }.to raise_error(Kitchen::ActionFailed, /surprising/)
77
+ end
78
+
79
+ it "never returns a port that cannot be connected to" do
80
+ # The invariant behind both pending examples above: whatever this returns
81
+ # has to be usable. Zero is not.
82
+ expect(port(DockerOutput::PORT_DUAL_STACK)).to be_between(1, 65_535)
83
+ end
84
+ end
85
+
86
+ describe "#container_ssh_port" do
87
+ it "uses port 22 directly on the internal Docker network" do
88
+ # On the Docker network the container is addressed by its own IP, so the
89
+ # published mapping is irrelevant -- and asking for it would fail.
90
+ c = container(use_internal_docker_network: true)
91
+ expect(c).not_to receive(:docker_command)
92
+ expect(c.send(:container_ssh_port, {})).to eq 22
93
+ end
94
+
95
+ it "raises a useful error when docker reports no mapping" do
96
+ c = container
97
+ allow(c).to receive(:docker_command).and_raise(StandardError, "no public port")
98
+ expect { c.send(:container_ssh_port, container_id: "abc") }
99
+ .to raise_error(Kitchen::ActionFailed, /no ssh port mapped/)
100
+ end
101
+ end
102
+
103
+ describe "#dockerfile" do
104
+ subject(:dockerfile) { container.send(:dockerfile) }
105
+
106
+ it "starts from the configured image" do
107
+ expect(dockerfile.lines.first.strip).to eq "FROM ubuntu:24.04"
108
+ end
109
+
110
+ it "authorises the generated public key" do
111
+ # Without this line the container builds and starts, and then every
112
+ # connection is refused -- the single most confusing way for this driver
113
+ # to fail.
114
+ expect(dockerfile).to match(%r{>> /home/kitchen/\.ssh/authorized_keys})
115
+ end
116
+
117
+ it "escapes the public key so a shell cannot split it" do
118
+ # The key contains spaces, and it is appended with `RUN echo <key> >> ...`.
119
+ run_line = dockerfile.lines.grep(/authorized_keys/).last
120
+ expect(argv(run_line.sub(/\ARUN /, ""))).to include "ssh-rsa AAAAB3NzaC1yc2E kitchen_docker_key"
121
+ end
122
+
123
+ it "creates the login user" do
124
+ expect(dockerfile).to match(/useradd .*kitchen/)
125
+ end
126
+
127
+ it "puts root's home in the right place" do
128
+ expect(container(username: "root").send(:dockerfile)).to match(%r{>> /root/\.ssh/authorized_keys})
129
+ end
130
+
131
+ it "appends each provision_command as its own RUN line" do
132
+ generated = container(provision_command: ["apt-get install -y dnsutils", "apt-get install -y telnet"])
133
+ .send(:dockerfile)
134
+ expect(generated).to include "RUN apt-get install -y dnsutils\n"
135
+ expect(generated).to include "RUN apt-get install -y telnet\n"
136
+ end
137
+
138
+ it "accepts a single provision_command" do
139
+ expect(container(provision_command: "echo hi").send(:dockerfile)).to include "RUN echo hi\n"
140
+ end
141
+
142
+ it "ends with a newline" do
143
+ # A Dockerfile whose last line lacks a newline loses that instruction on
144
+ # some builders.
145
+ expect(dockerfile).to end_with "\n"
146
+ end
147
+
148
+ it "carries the proxy configuration into the image" do
149
+ expect(container(http_proxy: "http://proxy:8080").send(:dockerfile))
150
+ .to include "ENV http_proxy=http://proxy:8080"
151
+ end
152
+
153
+ context "when a custom dockerfile is configured" do
154
+ it "uses it verbatim, rendered through ERB" do
155
+ path = File.join(@tmpdir, "Dockerfile")
156
+ File.write(path, "FROM <%= @image %>\nRUN echo <%= @username %>\n")
157
+ expect(container(dockerfile: path).send(:dockerfile))
158
+ .to eq "FROM ubuntu:24.04\nRUN echo kitchen\n"
159
+ end
160
+
161
+ it "does not append the generated SSH setup to it" do
162
+ # The custom Dockerfile owns the whole image; silently appending would
163
+ # overwrite what the author set up.
164
+ path = File.join(@tmpdir, "Dockerfile")
165
+ File.write(path, "FROM scratch\n")
166
+ expect(container(dockerfile: path).send(:dockerfile)).not_to include "authorized_keys"
167
+ end
168
+ end
169
+ end
170
+
171
+ describe "#execute" do
172
+ # The command is staged as a script under .kitchen/temp and uploaded. That
173
+ # local copy has to go whether or not the rest of the run works, or a failing
174
+ # converge leaves a file behind on every attempt.
175
+ def container_in(dir, &upload)
176
+ c = container
177
+ allow(c).to receive(:create_dir_on_container)
178
+ allow(c).to receive(:replace_env_variables) { |_cfg, path| path }
179
+ allow(c).to receive(:container_exec).and_return("ok")
180
+ allow(c).to receive(:upload, &(upload || ->(*) { nil }))
181
+ allow(Dir).to receive(:pwd).and_return(dir)
182
+ c
183
+ end
184
+
185
+ around do |example|
186
+ Dir.chdir(@tmpdir) { example.run }
187
+ end
188
+
189
+ it "removes the staged script once the command has run" do
190
+ c = container_in(@tmpdir)
191
+ c.execute("echo hi")
192
+ expect(Dir.glob(".kitchen/temp/docker-*.sh")).to be_empty
193
+ end
194
+
195
+ it "removes the staged script when the upload fails" do
196
+ c = container_in(@tmpdir) { raise "upload exploded" }
197
+ expect { c.execute("echo hi") }.to raise_error(/Failed to execute command/)
198
+ expect(Dir.glob(".kitchen/temp/docker-*.sh")).to be_empty
199
+ end
200
+
201
+ it "removes the staged script when the command itself fails" do
202
+ c = container_in(@tmpdir)
203
+ allow(c).to receive(:container_exec).and_raise("command exploded")
204
+ expect { c.execute("echo hi") }.to raise_error(/Failed to execute command/)
205
+ expect(Dir.glob(".kitchen/temp/docker-*.sh")).to be_empty
206
+ end
207
+
208
+ it "uploads the command as the contents of the script" do
209
+ uploaded = nil
210
+ c = container_in(@tmpdir) { |local, _remote| uploaded = ::File.read(local) }
211
+ c.execute("echo hi")
212
+ expect(uploaded).to eq "echo hi"
213
+ end
214
+ end
215
+
216
+ describe "#generate_keys" do
217
+ it "writes a usable key pair when none exists" do
218
+ private_key = File.join(@tmpdir, "generated")
219
+ public_key = File.join(@tmpdir, "generated.pub")
220
+ container(private_key: private_key, public_key: public_key).send(:generate_keys)
221
+
222
+ expect(File.read(public_key)).to start_with "ssh-rsa "
223
+ expect { OpenSSL::PKey::RSA.new(File.read(private_key)) }.not_to raise_error
224
+ end
225
+
226
+ it "leaves an existing key pair alone" do
227
+ # Regenerating would invalidate the authorized_keys baked into images
228
+ # that were already built.
229
+ private_key = File.join(@tmpdir, "docker_id_rsa")
230
+ File.write(private_key, "existing private key")
231
+ before = File.read(@public_key)
232
+
233
+ container(private_key: private_key).send(:generate_keys)
234
+
235
+ expect(File.read(@public_key)).to eq before
236
+ expect(File.read(private_key)).to eq "existing private key"
237
+ end
238
+
239
+ it "regenerates when only one half of the pair is present" do
240
+ # A half-written pair cannot authenticate, so keeping it would strand the
241
+ # user with a container they can never reach.
242
+ File.delete(@public_key)
243
+ private_key = File.join(@tmpdir, "docker_id_rsa")
244
+ File.write(private_key, "orphaned private key")
245
+
246
+ container(private_key: private_key).send(:generate_keys)
247
+
248
+ expect(File.read(@public_key)).to start_with "ssh-rsa "
249
+ expect(File.read(private_key)).not_to eq "orphaned private key"
250
+ end
251
+ end
252
+ end
data/spec/spec_helper.rb CHANGED
@@ -19,8 +19,34 @@ require "rspec"
19
19
  require "rspec/its"
20
20
 
21
21
  require "kitchen/driver/docker"
22
+ require "kitchen/transport/docker"
22
23
 
24
+ Dir[File.join(__dir__, "support", "**", "*.rb")].sort.each { |f| require f }
25
+
26
+ # These specs never talk to a Docker daemon. Everything this gem does is turn
27
+ # configuration into Dockerfiles and `docker` command lines, and turn Docker's
28
+ # output back into ids and ports, so all of it can be exercised as pure string
29
+ # work. Anything that genuinely needs a daemon belongs in the Test Kitchen
30
+ # integration suites -- see CONTRIBUTING.md.
23
31
  RSpec.configure do |config|
32
+ config.expect_with :rspec do |expectations|
33
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
34
+ end
35
+
36
+ config.mock_with :rspec do |mocks|
37
+ # Fail if an example stubs a method the real object does not have. Without
38
+ # this, a rename in lib/ leaves the specs stubbing a method that no longer
39
+ # exists and passing while the driver is broken.
40
+ mocks.verify_partial_doubles = true
41
+ end
42
+
43
+ # Surface deprecations as failures rather than warnings that scroll past.
44
+ config.raise_errors_for_deprecations!
45
+
46
+ config.include ArgvHelpers
47
+ config.include HelperHarness
48
+ config.include DockerOutput
49
+
24
50
  # Basic configuration
25
51
  config.run_all_when_everything_filtered = true
26
52
  config.filter_run(:focus)
@@ -30,4 +56,5 @@ RSpec.configure do |config|
30
56
  # the seed, which is printed after each run.
31
57
  # --seed 1234
32
58
  config.order = "random"
59
+ Kernel.srand config.seed
33
60
  end
@@ -0,0 +1,55 @@
1
+ #
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ #
14
+
15
+ require "shellwords"
16
+
17
+ # Helpers for asserting on generated command lines.
18
+ #
19
+ # The driver builds `docker` invocations as single strings and hands them to a
20
+ # shell. Asserting on those strings with `include` is misleading: the string
21
+ # "-v /my volume:/data" contains "-v /my volume:/data", but the shell will tear
22
+ # it into three arguments and Docker will reject it. Splitting the command the
23
+ # way a shell would, and asserting on the resulting argument vector, is the only
24
+ # way for a test to see what Docker will actually receive.
25
+ module ArgvHelpers
26
+ # Splits a generated command line the way a shell would.
27
+ #
28
+ # @param command [String] the command line the driver built
29
+ # @return [Array<String>] the arguments Docker will actually receive
30
+ def argv(command)
31
+ Shellwords.split(command)
32
+ end
33
+ end
34
+
35
+ # Asserts that a flag and its value survive shell splitting as one argument
36
+ # each, and remain adjacent.
37
+ #
38
+ # `include` alone cannot express this: an argv of ["-v", "/my", "volume:/data"]
39
+ # includes "-v", and includes "/my", and would satisfy a naive assertion while
40
+ # being completely broken.
41
+ RSpec::Matchers.define :include_consecutive do |*expected|
42
+ match do |actual|
43
+ actual.each_cons(expected.length).any? { |window| window == expected }
44
+ end
45
+
46
+ failure_message do |actual|
47
+ "expected the argument vector to contain #{expected.inspect} as consecutive arguments\n" \
48
+ " got: #{actual.inspect}"
49
+ end
50
+
51
+ failure_message_when_negated do |actual|
52
+ "expected the argument vector not to contain #{expected.inspect} as consecutive arguments\n" \
53
+ " got: #{actual.inspect}"
54
+ end
55
+ end