kitchen-docker 3.2.4 → 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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.rubocop.yml +0 -1
  5. data/.yardopts +11 -0
  6. data/CHANGELOG.md +385 -18
  7. data/CONTRIBUTING.md +203 -0
  8. data/Gemfile +5 -3
  9. data/README.md +418 -519
  10. data/Rakefile +18 -0
  11. data/kitchen-docker.gemspec +3 -2
  12. data/lib/kitchen/docker/container/linux.rb +50 -0
  13. data/lib/kitchen/docker/container/windows.rb +27 -0
  14. data/lib/kitchen/docker/container.rb +34 -0
  15. data/lib/kitchen/docker/docker_version.rb +4 -2
  16. data/lib/kitchen/docker/erb_context.rb +9 -0
  17. data/lib/kitchen/docker/helpers/cli_helper.rb +56 -3
  18. data/lib/kitchen/docker/helpers/container_helper.rb +91 -19
  19. data/lib/kitchen/docker/helpers/dockerfile_helper.rb +54 -0
  20. data/lib/kitchen/docker/helpers/file_helper.rb +8 -0
  21. data/lib/kitchen/docker/helpers/image_helper.rb +28 -0
  22. data/lib/kitchen/docker/helpers/inspec_helper.rb +1 -0
  23. data/lib/kitchen/driver/docker.rb +27 -0
  24. data/lib/kitchen/transport/docker.rb +92 -2
  25. data/spec/cli_helper_spec.rb +312 -0
  26. data/spec/container_helper_spec.rb +178 -0
  27. data/spec/dockerfile_helper_spec.rb +97 -61
  28. data/spec/erb_context_spec.rb +60 -0
  29. data/spec/image_helper_spec.rb +142 -0
  30. data/spec/inspec_helper_spec.rb +14 -11
  31. data/spec/linux_container_spec.rb +194 -0
  32. data/spec/spec_helper.rb +28 -1
  33. data/spec/support/argv.rb +55 -0
  34. data/spec/support/docker_output.rb +117 -0
  35. data/spec/support/harness.rb +70 -0
  36. data/spec/transport_docker_spec.rb +130 -0
  37. data/spec/windows_container_spec.rb +101 -0
  38. metadata +16 -7
  39. data/test/integration/capabilities/disabled/capabilities_drop_spec.rb +0 -24
  40. data/test/integration/default/disabled/default_spec.rb +0 -24
  41. data/test/integration/default/disabled/spec_helper.rb +0 -21
@@ -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
- let(:helper_class) do
20
- Class.new do
21
- include Kitchen::Docker::Helpers::DockerfileHelper
22
- attr_accessor :config
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
- def initialize(config = {})
25
- @config = config
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
- let(:helper) { helper_class.new(platform:) }
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
- let(:platform) { "amazonlinux" }
94
+ subject(:fragment) { helper(platform: "amazonlinux").amazonlinux_platform }
34
95
 
35
- it "includes --allowerasing flag for yum install" do
36
- result = helper.amazonlinux_platform
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 required packages including curl" do
41
- result = helper.amazonlinux_platform
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 "sets container environment variable" do
46
- result = helper.amazonlinux_platform
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
- result = helper.amazonlinux_platform
52
- expect(result).to include("ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key")
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
- let(:platform) { "rhel" }
116
+ subject(:fragment) { helper(platform: "rhel").rhel_platform }
58
117
 
59
- it "does not include --allowerasing flag" do
60
- result = helper.rhel_platform
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 required packages including curl if needed" do
65
- result = helper.rhel_platform
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 "#dockerfile_platform" do
72
- context "when platform is amazonlinux" do
73
- let(:platform) { "amazonlinux" }
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
- context "when platform is centos" do
93
- let(:platform) { "centos" }
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
- context "when platform is oraclelinux" do
102
- let(:platform) { "oraclelinux" }
103
-
104
- it "calls rhel_platform method" do
105
- expect(helper).to receive(:rhel_platform).and_call_original
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
@@ -7,12 +7,15 @@ RSpec.describe "inspec_helper patches" do
7
7
  describe "kitchen-inspec patch" do
8
8
  # Test actual post-load state rather than trying to stub Kernel.require,
9
9
  # which does not intercept require calls made inside a load'd file in Ruby 3.4.
10
- if defined?(Kitchen::Verifier::Inspec)
11
- it "adds runner_options_for_docker to Kitchen::Verifier::Inspec" do
10
+ # The availability check has to happen inside the example: RSpec loads every
11
+ # spec file before running any example, so a load-time `defined?` would be
12
+ # decided by whichever spec file happened to require the verifier first.
13
+ it "patches Kitchen::Verifier::Inspec when the gem is available" do
14
+ load helper_path
15
+
16
+ if defined?(Kitchen::Verifier::Inspec)
12
17
  expect(Kitchen::Verifier::Inspec.method_defined?(:runner_options_for_docker)).to be true
13
- end
14
- else
15
- it "Kitchen::Verifier::Inspec not available — patch correctly skipped" do
18
+ else
16
19
  expect(defined?(Kitchen::Verifier::Inspec)).to be_falsy
17
20
  end
18
21
  end
@@ -32,13 +35,13 @@ RSpec.describe "inspec_helper patches" do
32
35
 
33
36
  describe "kitchen-cinc-auditor patch" do
34
37
  # Test actual post-load state rather than trying to stub Kernel.require.
35
- if defined?(Kitchen::Verifier::CincAuditor) &&
36
- defined?(Kitchen::Verifier::CincAuditor::TransportOptions)
37
- it "adds build_docker to Kitchen::Verifier::CincAuditor::TransportOptions" do
38
+ # Checked inside the example for the same load-order reason as above.
39
+ it "patches Kitchen::Verifier::CincAuditor::TransportOptions when the gem is available" do
40
+ load helper_path
41
+
42
+ if defined?(Kitchen::Verifier::CincAuditor::TransportOptions)
38
43
  expect(Kitchen::Verifier::CincAuditor::TransportOptions.method_defined?(:build_docker)).to be true
39
- end
40
- else
41
- it "Kitchen::Verifier::CincAuditor not available — patch correctly skipped" do
44
+ else
42
45
  expect(defined?(Kitchen::Verifier::CincAuditor)).to be_falsy
43
46
  end
44
47
  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,9 +19,35 @@ 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|
24
- # Basic configuraiton
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
+
50
+ # Basic configuration
25
51
  config.run_all_when_everything_filtered = true
26
52
  config.filter_run(:focus)
27
53
 
@@ -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