kitchen-docker 3.3.0 → 3.3.1
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 +381 -21
- 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 +50 -0
- data/lib/kitchen/docker/container/windows.rb +27 -0
- data/lib/kitchen/docker/container.rb +34 -0
- 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 +56 -3
- data/lib/kitchen/docker/helpers/container_helper.rb +91 -19
- data/lib/kitchen/docker/helpers/dockerfile_helper.rb +54 -0
- data/lib/kitchen/docker/helpers/file_helper.rb +8 -0
- data/lib/kitchen/docker/helpers/image_helper.rb +28 -0
- 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 +312 -0
- data/spec/container_helper_spec.rb +178 -0
- data/spec/dockerfile_helper_spec.rb +97 -61
- data/spec/erb_context_spec.rb +60 -0
- data/spec/image_helper_spec.rb +142 -0
- data/spec/linux_container_spec.rb +194 -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 +13 -2
|
@@ -13,97 +13,133 @@
|
|
|
13
13
|
#
|
|
14
14
|
|
|
15
15
|
require "spec_helper"
|
|
16
|
-
require "kitchen/docker/helpers/dockerfile_helper"
|
|
17
16
|
|
|
18
17
|
describe Kitchen::Docker::Helpers::DockerfileHelper do
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
# Every platform name the driver accepts, and the method it must route to.
|
|
19
|
+
# Adding a distribution means adding a `when` branch and a method, and it is
|
|
20
|
+
# easy to add one and forget the other -- the result being a platform that
|
|
21
|
+
# raises "Unknown platform" or, worse, quietly bootstraps with the wrong
|
|
22
|
+
# package manager.
|
|
23
|
+
PLATFORM_ROUTES = {
|
|
24
|
+
"arch" => :arch_platform,
|
|
25
|
+
"debian" => :debian_platform,
|
|
26
|
+
"ubuntu" => :debian_platform,
|
|
27
|
+
"fedora" => :fedora_platform,
|
|
28
|
+
"gentoo" => :gentoo_platform,
|
|
29
|
+
"gentoo-paludis" => :gentoo_paludis_platform,
|
|
30
|
+
"opensuse" => :opensuse_platform,
|
|
31
|
+
"opensuse/leap" => :opensuse_platform,
|
|
32
|
+
"opensuse/tumbleweed" => :opensuse_platform,
|
|
33
|
+
"sles" => :opensuse_platform,
|
|
34
|
+
"rhel" => :rhel_platform,
|
|
35
|
+
"centos" => :rhel_platform,
|
|
36
|
+
"oraclelinux" => :rhel_platform,
|
|
37
|
+
"amazonlinux" => :amazonlinux_platform,
|
|
38
|
+
"centosstream" => :centosstream_platform,
|
|
39
|
+
"almalinux" => :almalinux_platform,
|
|
40
|
+
"rockylinux" => :rockylinux_platform,
|
|
41
|
+
"photon" => :photonos_platform,
|
|
42
|
+
}.freeze
|
|
23
43
|
|
|
24
|
-
|
|
25
|
-
|
|
44
|
+
describe "#dockerfile_platform" do
|
|
45
|
+
PLATFORM_ROUTES.each do |platform, method|
|
|
46
|
+
it "routes #{platform} to ##{method}" do
|
|
47
|
+
# Compared by output rather than by stubbing the method, so this also
|
|
48
|
+
# proves the branch is wired to a method that actually exists.
|
|
49
|
+
h = helper(platform: platform, disable_upstart: true)
|
|
50
|
+
expect(h.dockerfile_platform).to eq h.public_send(method)
|
|
26
51
|
end
|
|
27
52
|
end
|
|
53
|
+
|
|
54
|
+
it "names the platform it could not handle" do
|
|
55
|
+
# The error is what a user sees after a typo in kitchen.yml, so it has to
|
|
56
|
+
# contain the value they typed.
|
|
57
|
+
expect { helper(platform: "ubunut").dockerfile_platform }
|
|
58
|
+
.to raise_error(Kitchen::ActionFailed, /Unknown platform 'ubunut'/)
|
|
59
|
+
end
|
|
28
60
|
end
|
|
29
61
|
|
|
30
|
-
|
|
62
|
+
# Test Kitchen reaches a Linux container over SSH as a sudo-capable user, so
|
|
63
|
+
# an image that lacks either an SSH server or sudo is useless no matter which
|
|
64
|
+
# distribution it is. These hold for every supported platform.
|
|
65
|
+
describe "invariants every platform's setup must satisfy" do
|
|
66
|
+
PLATFORM_ROUTES.values.uniq.each do |method|
|
|
67
|
+
context "##{method}" do
|
|
68
|
+
subject(:fragment) { helper(platform: "ubuntu", disable_upstart: true).public_send(method) }
|
|
69
|
+
|
|
70
|
+
it "installs sudo" do
|
|
71
|
+
expect(fragment).to match(/\bsudo\b/)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "installs an SSH server" do
|
|
75
|
+
expect(fragment).to match(/openssh|\bssh\b/)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "contains only Dockerfile instructions" do
|
|
79
|
+
# Catches a fragment that has picked up a stray shell line, which
|
|
80
|
+
# would fail the build with a parse error rather than anything that
|
|
81
|
+
# points back here.
|
|
82
|
+
instructions = fragment.lines.map(&:strip).reject(&:empty?)
|
|
83
|
+
continued = false
|
|
84
|
+
instructions.each do |line|
|
|
85
|
+
expect(line).to match(/\A(RUN|ENV|ARG|COPY|ADD|WORKDIR|USER)\b/) unless continued
|
|
86
|
+
continued = line.end_with?("\\")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
31
92
|
|
|
32
93
|
describe "#amazonlinux_platform" do
|
|
33
|
-
|
|
94
|
+
subject(:fragment) { helper(platform: "amazonlinux").amazonlinux_platform }
|
|
34
95
|
|
|
35
|
-
it "
|
|
36
|
-
|
|
37
|
-
expect(result).to include("yum install -y --allowerasing")
|
|
96
|
+
it "allows erasing conflicting packages, which Amazon Linux 2023 needs" do
|
|
97
|
+
expect(fragment).to include "--allowerasing"
|
|
38
98
|
end
|
|
39
99
|
|
|
40
|
-
it "installs
|
|
41
|
-
|
|
42
|
-
expect(result).to include("sudo openssh-server openssh-clients which curl")
|
|
100
|
+
it "installs curl" do
|
|
101
|
+
expect(fragment).to match(/curl/)
|
|
43
102
|
end
|
|
44
103
|
|
|
45
|
-
it "
|
|
46
|
-
|
|
47
|
-
expect(result).to include("ENV container=docker")
|
|
104
|
+
it "marks the image as running in a container" do
|
|
105
|
+
expect(fragment).to include "ENV container=docker"
|
|
48
106
|
end
|
|
49
107
|
|
|
50
|
-
it "generates SSH host key if missing" do
|
|
51
|
-
|
|
52
|
-
|
|
108
|
+
it "generates an SSH host key only if one is missing" do
|
|
109
|
+
# Regenerating on every build would change the host key under a user who
|
|
110
|
+
# already accepted it.
|
|
111
|
+
expect(fragment).to include '[ -f "/etc/ssh/ssh_host_rsa_key" ] ||'
|
|
53
112
|
end
|
|
54
113
|
end
|
|
55
114
|
|
|
56
115
|
describe "#rhel_platform" do
|
|
57
|
-
|
|
116
|
+
subject(:fragment) { helper(platform: "rhel").rhel_platform }
|
|
58
117
|
|
|
59
|
-
it "does not
|
|
60
|
-
|
|
61
|
-
expect(result).not_to include("--allowerasing")
|
|
118
|
+
it "does not pass --allowerasing, which older yum does not accept" do
|
|
119
|
+
expect(fragment).not_to include "--allowerasing"
|
|
62
120
|
end
|
|
63
121
|
|
|
64
|
-
it "installs
|
|
65
|
-
|
|
66
|
-
expect(result).to include("sudo openssh-server openssh-clients which")
|
|
67
|
-
expect(result).to include("which curl || yum install -y curl")
|
|
122
|
+
it "installs curl if it is not already present" do
|
|
123
|
+
expect(fragment).to match(/curl/)
|
|
68
124
|
end
|
|
69
125
|
end
|
|
70
126
|
|
|
71
|
-
describe "#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
it "calls amazonlinux_platform method" do
|
|
76
|
-
expect(helper).to receive(:amazonlinux_platform).and_call_original
|
|
77
|
-
result = helper.dockerfile_platform
|
|
78
|
-
expect(result).to include("--allowerasing")
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
context "when platform is rhel" do
|
|
83
|
-
let(:platform) { "rhel" }
|
|
84
|
-
|
|
85
|
-
it "calls rhel_platform method" do
|
|
86
|
-
expect(helper).to receive(:rhel_platform).and_call_original
|
|
87
|
-
result = helper.dockerfile_platform
|
|
88
|
-
expect(result).not_to include("--allowerasing")
|
|
89
|
-
end
|
|
127
|
+
describe "#debian_platform" do
|
|
128
|
+
it "neutralises initctl when disable_upstart is set" do
|
|
129
|
+
expect(helper(platform: "ubuntu", disable_upstart: true).debian_platform)
|
|
130
|
+
.to include "dpkg-divert"
|
|
90
131
|
end
|
|
91
132
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
it "calls rhel_platform method" do
|
|
96
|
-
expect(helper).to receive(:rhel_platform).and_call_original
|
|
97
|
-
helper.dockerfile_platform
|
|
98
|
-
end
|
|
133
|
+
it "leaves initctl alone when disable_upstart is false" do
|
|
134
|
+
expect(helper(platform: "ubuntu", disable_upstart: false).debian_platform)
|
|
135
|
+
.not_to include "dpkg-divert"
|
|
99
136
|
end
|
|
100
137
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
helper.dockerfile_platform
|
|
138
|
+
it "keeps apt non-interactive either way" do
|
|
139
|
+
# A prompt during the build hangs it until Docker times out.
|
|
140
|
+
[true, false].each do |setting|
|
|
141
|
+
expect(helper(platform: "ubuntu", disable_upstart: setting).debian_platform)
|
|
142
|
+
.to include "ENV DEBIAN_FRONTEND=noninteractive"
|
|
107
143
|
end
|
|
108
144
|
end
|
|
109
145
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
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::ERBContext do
|
|
19
|
+
def render(template, config)
|
|
20
|
+
ERB.new(template).result(described_class.new(config).get_binding)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A user-supplied `dockerfile:` is rendered through ERB with the driver
|
|
24
|
+
# configuration exposed as instance variables. That is a documented part of
|
|
25
|
+
# the driver's interface -- test/Dockerfile depends on it -- so the exposure
|
|
26
|
+
# rules are worth pinning.
|
|
27
|
+
it "exposes each configuration key as an instance variable" do
|
|
28
|
+
expect(render("<%= @image %>", image: "ubuntu:24.04")).to eq "ubuntu:24.04"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "exposes keys the driver has no option for" do
|
|
32
|
+
# kitchen.yml's dockerfile platform passes `password`, which the driver
|
|
33
|
+
# itself never reads; the template is the only consumer.
|
|
34
|
+
expect(render("<%= @password %>", password: "secret")).to eq "secret"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "renders a missing key as empty rather than failing the build" do
|
|
38
|
+
expect(render("[<%= @nope %>]", {})).to eq "[]"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "accepts string keys as well as symbols" do
|
|
42
|
+
expect(render("<%= @image %>", "image" => "alpine")).to eq "alpine"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "allows a template to read a file, as test/Dockerfile does with the public key" do
|
|
46
|
+
Dir.mktmpdir do |dir|
|
|
47
|
+
key = File.join(dir, "k.pub")
|
|
48
|
+
File.write(key, "ssh-rsa AAAA test\n")
|
|
49
|
+
expect(render("<%= IO.read(@public_key).strip %>", public_key: key))
|
|
50
|
+
.to eq "ssh-rsa AAAA test"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "keeps values verbatim, without escaping" do
|
|
55
|
+
# The template author decides on quoting; silently escaping here would
|
|
56
|
+
# corrupt Dockerfiles that already quote correctly.
|
|
57
|
+
expect(render("<%= @run_command %>", run_command: 'sshd -o "UseDNS=no"'))
|
|
58
|
+
.to eq 'sshd -o "UseDNS=no"'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
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 "#remove_image" do
|
|
126
|
+
let(:state) { { image_id: "sha256:abc" } }
|
|
127
|
+
|
|
128
|
+
it "removes the image when nothing is using it" do
|
|
129
|
+
h = helper
|
|
130
|
+
allow(h).to receive(:image_in_use?).and_return(false)
|
|
131
|
+
expect(h).to receive(:docker_command).with("rmi sha256:abc")
|
|
132
|
+
h.remove_image(state)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "leaves the image alone when a container still references it" do
|
|
136
|
+
h = helper
|
|
137
|
+
allow(h).to receive(:image_in_use?).and_return(true)
|
|
138
|
+
expect(h).not_to receive(:docker_command)
|
|
139
|
+
h.remove_image(state)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
pending "BUG: IPv6 output parses to port 0 rather than the published port"
|
|
58
|
+
expect(port(DockerOutput::PORT_IPV6_ONLY)).to eq DockerOutput::PUBLISHED_PORT
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "refuses to report a port it could not parse" do
|
|
62
|
+
pending "BUG: unparseable output silently yields port 0 instead of raising"
|
|
63
|
+
expect { port("") }.to raise_error(Kitchen::ActionFailed)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "never returns a port that cannot be connected to" do
|
|
67
|
+
# The invariant behind both pending examples above: whatever this returns
|
|
68
|
+
# has to be usable. Zero is not.
|
|
69
|
+
expect(port(DockerOutput::PORT_DUAL_STACK)).to be_between(1, 65_535)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "#container_ssh_port" do
|
|
74
|
+
it "uses port 22 directly on the internal Docker network" do
|
|
75
|
+
# On the Docker network the container is addressed by its own IP, so the
|
|
76
|
+
# published mapping is irrelevant -- and asking for it would fail.
|
|
77
|
+
c = container(use_internal_docker_network: true)
|
|
78
|
+
expect(c).not_to receive(:docker_command)
|
|
79
|
+
expect(c.send(:container_ssh_port, {})).to eq 22
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "raises a useful error when docker reports no mapping" do
|
|
83
|
+
c = container
|
|
84
|
+
allow(c).to receive(:docker_command).and_raise(StandardError, "no public port")
|
|
85
|
+
expect { c.send(:container_ssh_port, container_id: "abc") }
|
|
86
|
+
.to raise_error(Kitchen::ActionFailed, /no ssh port mapped/)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe "#dockerfile" do
|
|
91
|
+
subject(:dockerfile) { container.send(:dockerfile) }
|
|
92
|
+
|
|
93
|
+
it "starts from the configured image" do
|
|
94
|
+
expect(dockerfile.lines.first.strip).to eq "FROM ubuntu:24.04"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "authorises the generated public key" do
|
|
98
|
+
# Without this line the container builds and starts, and then every
|
|
99
|
+
# connection is refused -- the single most confusing way for this driver
|
|
100
|
+
# to fail.
|
|
101
|
+
expect(dockerfile).to match(%r{>> /home/kitchen/\.ssh/authorized_keys})
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "escapes the public key so a shell cannot split it" do
|
|
105
|
+
# The key contains spaces, and it is appended with `RUN echo <key> >> ...`.
|
|
106
|
+
run_line = dockerfile.lines.grep(/authorized_keys/).last
|
|
107
|
+
expect(argv(run_line.sub(/\ARUN /, ""))).to include "ssh-rsa AAAAB3NzaC1yc2E kitchen_docker_key"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "creates the login user" do
|
|
111
|
+
expect(dockerfile).to match(/useradd .*kitchen/)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "puts root's home in the right place" do
|
|
115
|
+
expect(container(username: "root").send(:dockerfile)).to match(%r{>> /root/\.ssh/authorized_keys})
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "appends each provision_command as its own RUN line" do
|
|
119
|
+
generated = container(provision_command: ["apt-get install -y dnsutils", "apt-get install -y telnet"])
|
|
120
|
+
.send(:dockerfile)
|
|
121
|
+
expect(generated).to include "RUN apt-get install -y dnsutils\n"
|
|
122
|
+
expect(generated).to include "RUN apt-get install -y telnet\n"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "accepts a single provision_command" do
|
|
126
|
+
expect(container(provision_command: "echo hi").send(:dockerfile)).to include "RUN echo hi\n"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "ends with a newline" do
|
|
130
|
+
# A Dockerfile whose last line lacks a newline loses that instruction on
|
|
131
|
+
# some builders.
|
|
132
|
+
expect(dockerfile).to end_with "\n"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "carries the proxy configuration into the image" do
|
|
136
|
+
expect(container(http_proxy: "http://proxy:8080").send(:dockerfile))
|
|
137
|
+
.to include "ENV http_proxy=http://proxy:8080"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
context "when a custom dockerfile is configured" do
|
|
141
|
+
it "uses it verbatim, rendered through ERB" do
|
|
142
|
+
path = File.join(@tmpdir, "Dockerfile")
|
|
143
|
+
File.write(path, "FROM <%= @image %>\nRUN echo <%= @username %>\n")
|
|
144
|
+
expect(container(dockerfile: path).send(:dockerfile))
|
|
145
|
+
.to eq "FROM ubuntu:24.04\nRUN echo kitchen\n"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "does not append the generated SSH setup to it" do
|
|
149
|
+
# The custom Dockerfile owns the whole image; silently appending would
|
|
150
|
+
# overwrite what the author set up.
|
|
151
|
+
path = File.join(@tmpdir, "Dockerfile")
|
|
152
|
+
File.write(path, "FROM scratch\n")
|
|
153
|
+
expect(container(dockerfile: path).send(:dockerfile)).not_to include "authorized_keys"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
describe "#generate_keys" do
|
|
159
|
+
it "writes a usable key pair when none exists" do
|
|
160
|
+
private_key = File.join(@tmpdir, "generated")
|
|
161
|
+
public_key = File.join(@tmpdir, "generated.pub")
|
|
162
|
+
container(private_key: private_key, public_key: public_key).send(:generate_keys)
|
|
163
|
+
|
|
164
|
+
expect(File.read(public_key)).to start_with "ssh-rsa "
|
|
165
|
+
expect { OpenSSL::PKey::RSA.new(File.read(private_key)) }.not_to raise_error
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "leaves an existing key pair alone" do
|
|
169
|
+
# Regenerating would invalidate the authorized_keys baked into images
|
|
170
|
+
# that were already built.
|
|
171
|
+
private_key = File.join(@tmpdir, "docker_id_rsa")
|
|
172
|
+
File.write(private_key, "existing private key")
|
|
173
|
+
before = File.read(@public_key)
|
|
174
|
+
|
|
175
|
+
container(private_key: private_key).send(:generate_keys)
|
|
176
|
+
|
|
177
|
+
expect(File.read(@public_key)).to eq before
|
|
178
|
+
expect(File.read(private_key)).to eq "existing private key"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "regenerates when only one half of the pair is present" do
|
|
182
|
+
# A half-written pair cannot authenticate, so keeping it would strand the
|
|
183
|
+
# user with a container they can never reach.
|
|
184
|
+
File.delete(@public_key)
|
|
185
|
+
private_key = File.join(@tmpdir, "docker_id_rsa")
|
|
186
|
+
File.write(private_key, "orphaned private key")
|
|
187
|
+
|
|
188
|
+
container(private_key: private_key).send(:generate_keys)
|
|
189
|
+
|
|
190
|
+
expect(File.read(@public_key)).to start_with "ssh-rsa "
|
|
191
|
+
expect(File.read(private_key)).not_to eq "orphaned private key"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
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
|