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,292 @@
|
|
|
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::ContainerHelper do
|
|
18
|
+
describe "#parse_container_id" do
|
|
19
|
+
it "accepts a full-length id" do
|
|
20
|
+
expect(helper.parse_container_id(DockerOutput::RUN_CLEAN))
|
|
21
|
+
.to eq DockerOutput::RUN_CONTAINER_ID
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "accepts a short id" do
|
|
25
|
+
expect(helper.parse_container_id("abc123abc123\n")).to eq "abc123abc123"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "fails loudly when the output holds no id" do
|
|
29
|
+
expect { helper.parse_container_id("Error response from daemon: no such image\n") }
|
|
30
|
+
.to raise_error(Kitchen::ActionFailed, /Could not parse Docker run output/)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "fails loudly on empty output" do
|
|
34
|
+
expect { helper.parse_container_id("") }
|
|
35
|
+
.to raise_error(Kitchen::ActionFailed, /Could not parse Docker run output/)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Cases from #452. Rootless Docker emits this on every `docker run`, which
|
|
39
|
+
# makes it the most common way to reach this code path.
|
|
40
|
+
context "when docker wrote a warning to stderr" do
|
|
41
|
+
let(:warning) { "WARNING: IPv4 forwarding is disabled. Networking will not work." }
|
|
42
|
+
|
|
43
|
+
it "finds an id the warning follows" do
|
|
44
|
+
expect(helper.parse_container_id("#{DockerOutput::RUN_CONTAINER_ID}\n#{warning}\n"))
|
|
45
|
+
.to eq DockerOutput::RUN_CONTAINER_ID
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "finds an id the warning precedes" do
|
|
49
|
+
expect(helper.parse_container_id("#{warning}\n#{DockerOutput::RUN_CONTAINER_ID}\n"))
|
|
50
|
+
.to eq DockerOutput::RUN_CONTAINER_ID
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "finds a short id" do
|
|
54
|
+
expect(helper.parse_container_id("abcdef123456\n#{warning}\n")).to eq "abcdef123456"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "still fails when the warning is all there is" do
|
|
58
|
+
expect { helper.parse_container_id("#{warning}\nno container id here") }
|
|
59
|
+
.to raise_error(Kitchen::ActionFailed, /Could not parse Docker run output/)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "takes the id docker printed, not a later hex line" do
|
|
64
|
+
# run_command concatenates stdout before stderr, so the id always comes
|
|
65
|
+
# first. Scanning backwards would prefer a bare hex line that a warning
|
|
66
|
+
# happened to leave on stderr.
|
|
67
|
+
expect(helper.parse_container_id("#{DockerOutput::RUN_CONTAINER_ID}\ndeadbeefcafe\n"))
|
|
68
|
+
.to eq DockerOutput::RUN_CONTAINER_ID
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "does not mistake a hex-looking word inside a message for an id" do
|
|
72
|
+
expect { helper.parse_container_id("WARNING: layer abcdef123456 was skipped\n") }
|
|
73
|
+
.to raise_error(Kitchen::ActionFailed, /Could not parse Docker run output/)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "tolerates surrounding whitespace on the id line" do
|
|
77
|
+
expect(helper.parse_container_id(" #{DockerOutput::RUN_CONTAINER_ID} \n"))
|
|
78
|
+
.to eq DockerOutput::RUN_CONTAINER_ID
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# run_command returns stdout + stderr, and the parser requires the whole
|
|
82
|
+
# combined string to be exactly the id. Anything docker writes to stderr on
|
|
83
|
+
# a successful `run` therefore fails container creation, with an error that
|
|
84
|
+
# blames parsing rather than naming the warning.
|
|
85
|
+
it "finds the id when docker pulled the image and logged progress to stderr" do
|
|
86
|
+
expect(helper.parse_container_id(DockerOutput::RUN_WITH_PULL_ON_STDERR))
|
|
87
|
+
.to eq DockerOutput::RUN_CONTAINER_ID
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "finds the id when the kernel lacks swap accounting" do
|
|
91
|
+
expect(helper.parse_container_id(DockerOutput::RUN_WITH_SWAP_WARNING))
|
|
92
|
+
.to eq DockerOutput::RUN_CONTAINER_ID
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe "#remote_socket? and #socket_uri" do
|
|
97
|
+
{
|
|
98
|
+
"unix:///var/run/docker.sock" => false,
|
|
99
|
+
"npipe:////./pipe/docker_engine" => false,
|
|
100
|
+
"tcp://docker.example.com:2376" => true,
|
|
101
|
+
"tcp://127.0.0.1:2375" => true,
|
|
102
|
+
}.each do |socket, remote|
|
|
103
|
+
it "treats #{socket} as #{remote ? "remote" : "local"}" do
|
|
104
|
+
expect(helper(socket: socket).remote_socket?).to eq remote
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "reports a socket-less configuration as local" do
|
|
109
|
+
expect(helper(socket: nil).remote_socket?).to be false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "exposes the socket as a URI" do
|
|
113
|
+
expect(helper(socket: "tcp://docker.example.com:2376").socket_uri.port).to eq 2376
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe "#dockerfile_proxy_config" do
|
|
118
|
+
it "is empty when no proxy is configured" do
|
|
119
|
+
expect(helper.dockerfile_proxy_config).to eq ""
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Tools inside the image disagree about the spelling, so both cases have to
|
|
123
|
+
# be set. Dropping either half breaks builds behind a proxy in a way that
|
|
124
|
+
# only shows up on somebody else's network.
|
|
125
|
+
{
|
|
126
|
+
http_proxy: %w{http_proxy HTTP_PROXY},
|
|
127
|
+
https_proxy: %w{https_proxy HTTPS_PROXY},
|
|
128
|
+
no_proxy: %w{no_proxy NO_PROXY},
|
|
129
|
+
}.each do |option, spellings|
|
|
130
|
+
it "sets #{spellings.join(" and ")} for #{option}" do
|
|
131
|
+
lines = helper(option => "http://proxy:8080").dockerfile_proxy_config.lines.map(&:strip)
|
|
132
|
+
spellings.each { |name| expect(lines).to include "ENV #{name}=http://proxy:8080" }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "emits only the proxies that are configured" do
|
|
137
|
+
config = helper(http_proxy: "http://proxy:8080").dockerfile_proxy_config
|
|
138
|
+
expect(config).not_to match(/no_proxy|https_proxy/i)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe "#container_env_variables" do
|
|
143
|
+
def with_printenv(output)
|
|
144
|
+
h = helper
|
|
145
|
+
allow(h).to receive(:docker_command).and_return(output)
|
|
146
|
+
h.container_env_variables(platform: "ubuntu-24.04")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "reads the container's environment" do
|
|
150
|
+
expect(with_printenv("HOME=/root\nUSER=kitchen\n"))
|
|
151
|
+
.to eq("HOME" => "/root", "USER" => "kitchen")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# printenv output is `NAME=VALUE`, and values routinely contain `=`:
|
|
155
|
+
# LS_COLORS and any JAVA_OPTS-style variable do. Splitting on every `=`
|
|
156
|
+
# rather than the first silently truncates them, and the truncated value
|
|
157
|
+
# then flows into replace_env_variables and into upload paths.
|
|
158
|
+
it "keeps a value containing an equals sign intact" do
|
|
159
|
+
expect(with_printenv("LS_COLORS=rs=0:di=01;34\n"))
|
|
160
|
+
.to eq("LS_COLORS" => "rs=0:di=01;34")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "keeps a value that is itself a key=value list" do
|
|
164
|
+
expect(with_printenv("JAVA_OPTS=-Dfoo=bar -Dbaz=qux\n"))
|
|
165
|
+
.to eq("JAVA_OPTS" => "-Dfoo=bar -Dbaz=qux")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "keeps a variable with an empty value" do
|
|
169
|
+
expect(with_printenv("EMPTY=\n")).to eq("EMPTY" => "")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "ignores a continuation line of a multi-line value" do
|
|
173
|
+
# printenv prints an embedded newline literally, so the second line has no
|
|
174
|
+
# name. Recording it would put a nil-valued entry under a bogus key.
|
|
175
|
+
expect(with_printenv("CERT=-----BEGIN-----\nMIIBIjANBg\n"))
|
|
176
|
+
.to eq("CERT" => "-----BEGIN-----")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "parses the JSON a Windows container returns" do
|
|
180
|
+
h = helper
|
|
181
|
+
allow(h).to receive(:docker_command).and_return('{"TEMP":"C:\\\\Users\\\\ADMINI~1\\\\AppData\\\\Local\\\\Temp"}')
|
|
182
|
+
expect(h.container_env_variables(platform: "windows-2022"))
|
|
183
|
+
.to eq("TEMP" => 'C:\Users\ADMINI~1\AppData\Local\Temp')
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "#replace_env_variables" do
|
|
188
|
+
let(:linux_state) { { platform: "ubuntu-24.04", container_id: "abc" } }
|
|
189
|
+
let(:windows_state) { { platform: "windows-2022", container_id: "abc" } }
|
|
190
|
+
|
|
191
|
+
def helper_returning(vars)
|
|
192
|
+
h = helper
|
|
193
|
+
allow(h).to receive(:container_env_variables).and_return(vars)
|
|
194
|
+
h
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "expands a POSIX $VAR reference" do
|
|
198
|
+
h = helper_returning("TEMP" => "/var/tmp")
|
|
199
|
+
expect(h.replace_env_variables(linux_state, "$TEMP/kitchen")).to eq "/var/tmp/kitchen"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "expands a PowerShell $env:VAR reference" do
|
|
203
|
+
h = helper_returning("TEMP" => 'C:\Temp')
|
|
204
|
+
expect(h.replace_env_variables(windows_state, '$env:TEMP\kitchen')).to eq 'C:\Temp\kitchen'
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it "leaves a path with no variable reference alone" do
|
|
208
|
+
expect(helper.replace_env_variables(linux_state, "/tmp/kitchen")).to eq "/tmp/kitchen"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "substitutes an empty string when the variable is not set in the container" do
|
|
212
|
+
# Documents current behaviour rather than endorsing it: an unset variable
|
|
213
|
+
# silently yields a path like "/kitchen" instead of failing, and the
|
|
214
|
+
# upload then lands somewhere unexpected.
|
|
215
|
+
h = helper_returning({})
|
|
216
|
+
expect(h.replace_env_variables(linux_state, "$TEMP/kitchen")).to eq "/kitchen"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
describe "#container_exists? and #container_running?" do
|
|
221
|
+
# `docker inspect` answers for a container in any state; `docker top` only
|
|
222
|
+
# for a running one. The two predicates have to disagree on a stopped
|
|
223
|
+
# container, or destroy cannot clean it up.
|
|
224
|
+
def helper_answering(inspect_ok:, running: "false")
|
|
225
|
+
h = helper
|
|
226
|
+
allow(h).to receive(:docker_command) do |cmd, _opts = {}|
|
|
227
|
+
raise Kitchen::ShellOut::ShellCommandFailed, "No such container" unless inspect_ok
|
|
228
|
+
|
|
229
|
+
cmd.include?("{{.State.Running}}") ? "#{running}\n" : "[{...}]"
|
|
230
|
+
end
|
|
231
|
+
h
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
let(:state) { { container_id: "abc123abc123" } }
|
|
235
|
+
|
|
236
|
+
it "reports a running container as existing and running" do
|
|
237
|
+
h = helper_answering(inspect_ok: true, running: "true")
|
|
238
|
+
expect(h.container_exists?(state)).to be true
|
|
239
|
+
expect(h.container_running?(state)).to be true
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it "reports a stopped container as existing but not running" do
|
|
243
|
+
h = helper_answering(inspect_ok: true, running: "false")
|
|
244
|
+
expect(h.container_exists?(state)).to be true
|
|
245
|
+
expect(h.container_running?(state)).to be false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "reports a container docker does not know as neither" do
|
|
249
|
+
h = helper_answering(inspect_ok: false)
|
|
250
|
+
expect(h.container_exists?(state)).to be false
|
|
251
|
+
expect(h.container_running?(state)).to be false
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it "reports no container when state names none" do
|
|
255
|
+
h = helper
|
|
256
|
+
expect(h).not_to receive(:docker_command)
|
|
257
|
+
expect(h.container_exists?({})).to be false
|
|
258
|
+
expect(h.container_running?({})).to be false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "asks docker about a container, not about its processes" do
|
|
262
|
+
# `docker top` was the original implementation and is the bug: it fails
|
|
263
|
+
# on a stopped container, which read as the container not existing.
|
|
264
|
+
h = helper
|
|
265
|
+
seen = []
|
|
266
|
+
allow(h).to receive(:docker_command) { |cmd, _opts = {}| seen << cmd; "[{...}]" }
|
|
267
|
+
h.container_exists?(state)
|
|
268
|
+
expect(seen.join).to include("inspect --type=container")
|
|
269
|
+
expect(seen.join).not_to include("top ")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
describe "#remove_container" do
|
|
274
|
+
it "stops the container before removing it" do
|
|
275
|
+
# `docker rm` refuses to remove a running container, so the order matters.
|
|
276
|
+
h = helper
|
|
277
|
+
calls = []
|
|
278
|
+
allow(h).to receive(:docker_command) { |cmd| calls << cmd }
|
|
279
|
+
h.remove_container(container_id: "abc")
|
|
280
|
+
expect(calls).to eq ["stop -t 0 abc", "rm abc"]
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
describe "#copy_file_to_container" do
|
|
285
|
+
it "addresses the destination as container:path" do
|
|
286
|
+
h = helper
|
|
287
|
+
allow(h).to receive(:replace_env_variables) { |_state, path| path }
|
|
288
|
+
expect(h).to receive(:docker_command).with("cp /local/f.rb abc:/tmp/f.rb")
|
|
289
|
+
h.copy_file_to_container({ container_id: "abc", platform: "ubuntu-24.04" }, "/local/f.rb", "/tmp/f.rb")
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
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::Container do
|
|
18
|
+
subject(:container) { described_class.new(config) }
|
|
19
|
+
|
|
20
|
+
let(:config) { { username: "kitchen", remove_images: false } }
|
|
21
|
+
let(:state) { { container_id: "abc123abc123" } }
|
|
22
|
+
|
|
23
|
+
# A container that exists but has stopped is the case these two guard. It
|
|
24
|
+
# happens whenever the host reboots, the container's PID 1 exits, or someone
|
|
25
|
+
# runs `docker stop` -- so it is not an exotic state to be in.
|
|
26
|
+
def stub_container(exists:, running:)
|
|
27
|
+
allow(container).to receive(:container_exists?).and_return(exists)
|
|
28
|
+
allow(container).to receive(:container_running?).and_return(running)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe "#destroy" do
|
|
32
|
+
it "removes a running container" do
|
|
33
|
+
stub_container(exists: true, running: true)
|
|
34
|
+
expect(container).to receive(:remove_container).with(state)
|
|
35
|
+
container.destroy(state)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "removes a container that exists but has stopped" do
|
|
39
|
+
# Test Kitchen deletes the state file once destroy returns, so a stopped
|
|
40
|
+
# container skipped here is orphaned for good: nothing records its id any
|
|
41
|
+
# more, and `kitchen list` reports the instance as not created.
|
|
42
|
+
stub_container(exists: true, running: false)
|
|
43
|
+
expect(container).to receive(:remove_container).with(state)
|
|
44
|
+
container.destroy(state)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "removes nothing when the container is already gone" do
|
|
48
|
+
stub_container(exists: false, running: false)
|
|
49
|
+
expect(container).not_to receive(:remove_container)
|
|
50
|
+
container.destroy(state)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
context "with remove_images set" do
|
|
54
|
+
let(:config) { { username: "kitchen", remove_images: true } }
|
|
55
|
+
let(:state) { { container_id: "abc123abc123", image_id: "sha256:abc" } }
|
|
56
|
+
|
|
57
|
+
it "removes the image after the container" do
|
|
58
|
+
stub_container(exists: true, running: true)
|
|
59
|
+
allow(container).to receive(:image_exists?).and_return(true)
|
|
60
|
+
order = []
|
|
61
|
+
allow(container).to receive(:remove_container) { order << :container }
|
|
62
|
+
allow(container).to receive(:remove_image) { order << :image }
|
|
63
|
+
|
|
64
|
+
container.destroy(state)
|
|
65
|
+
|
|
66
|
+
expect(order).to eq %i{container image}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe "#create" do
|
|
72
|
+
it "accepts a running container it has seen before" do
|
|
73
|
+
stub_container(exists: true, running: true)
|
|
74
|
+
expect { container.create(state) }.not_to raise_error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "records the login user" do
|
|
78
|
+
stub_container(exists: true, running: true)
|
|
79
|
+
new_state = state.dup
|
|
80
|
+
container.create(new_state)
|
|
81
|
+
expect(new_state[:username]).to eq "kitchen"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "refuses a container that exists but is not running, and says how to clear it" do
|
|
85
|
+
# Reporting this as "the container does not exist" left no way forward:
|
|
86
|
+
# the container is there, so the message has to point at destroy.
|
|
87
|
+
stub_container(exists: true, running: false)
|
|
88
|
+
expect { container.create(state) }
|
|
89
|
+
.to raise_error(Kitchen::ActionFailed, /is not running.*kitchen destroy/m)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "refuses a container that state names but docker does not have" do
|
|
93
|
+
stub_container(exists: false, running: false)
|
|
94
|
+
expect { container.create(state) }
|
|
95
|
+
.to raise_error(Kitchen::ActionFailed, /does not exist/)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "is happy to start from nothing" do
|
|
99
|
+
stub_container(exists: false, running: false)
|
|
100
|
+
expect { container.create({}) }.not_to raise_error
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "asks docker about the container only as often as it needs to" do
|
|
104
|
+
# The previous implementation called container_exists? twice on the path
|
|
105
|
+
# that raises, which is two `docker inspect` round trips for one answer.
|
|
106
|
+
stub_container(exists: false, running: false)
|
|
107
|
+
expect(container).to receive(:container_exists?).once.and_return(false)
|
|
108
|
+
begin
|
|
109
|
+
container.create(state)
|
|
110
|
+
rescue Kitchen::ActionFailed
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -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
|