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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +0 -1
- data/.yardopts +11 -0
- data/CHANGELOG.md +399 -20
- data/CONTRIBUTING.md +203 -0
- data/Gemfile +4 -0
- data/README.md +417 -533
- data/Rakefile +18 -0
- data/lib/kitchen/docker/container/linux.rb +73 -6
- data/lib/kitchen/docker/container/windows.rb +34 -3
- data/lib/kitchen/docker/container.rb +45 -1
- data/lib/kitchen/docker/docker_version.rb +4 -2
- data/lib/kitchen/docker/erb_context.rb +9 -0
- data/lib/kitchen/docker/helpers/cli_helper.rb +111 -34
- data/lib/kitchen/docker/helpers/container_helper.rb +151 -25
- data/lib/kitchen/docker/helpers/dockerfile_helper.rb +54 -0
- data/lib/kitchen/docker/helpers/file_helper.rb +24 -10
- data/lib/kitchen/docker/helpers/image_helper.rb +47 -1
- data/lib/kitchen/docker/helpers/inspec_helper.rb +1 -0
- data/lib/kitchen/driver/docker.rb +27 -0
- data/lib/kitchen/transport/docker.rb +43 -2
- data/spec/cli_helper_spec.rb +320 -0
- data/spec/container_helper_spec.rb +292 -0
- data/spec/container_spec.rb +115 -0
- data/spec/dockerfile_helper_spec.rb +97 -61
- data/spec/erb_context_spec.rb +60 -0
- data/spec/file_helper_spec.rb +110 -0
- data/spec/image_helper_spec.rb +181 -0
- data/spec/linux_container_spec.rb +252 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/support/argv.rb +55 -0
- data/spec/support/docker_output.rb +117 -0
- data/spec/support/harness.rb +70 -0
- data/spec/transport_docker_spec.rb +7 -0
- data/spec/windows_container_spec.rb +101 -0
- metadata +15 -2
|
@@ -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
|