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
@@ -0,0 +1,312 @@
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::CliHelper do
18
+ let(:image_id) { "sha256:abc123" }
19
+
20
+ # Assertions are made against the argument vector a shell would produce, not
21
+ # against the command string. See spec/support/argv.rb for why.
22
+ def run_argv(config = {})
23
+ argv(helper(config.merge(run_command: "/usr/sbin/sshd -D")).build_run_command(image_id))
24
+ end
25
+
26
+ describe "#build_run_command" do
27
+ describe "the baseline command" do
28
+ it "runs the image detached, with the configured run command" do
29
+ expect(run_argv).to eq ["run", "-d", image_id, "/usr/sbin/sshd", "-D"]
30
+ end
31
+
32
+ it "adds nothing that was not configured" do
33
+ # A flag leaking in when its option is unset is a silent behaviour
34
+ # change for every user; pinning the empty case catches it.
35
+ expect(run_argv.length).to eq 5
36
+ end
37
+ end
38
+
39
+ # Each entry pairs a configuration with the arguments Docker must receive.
40
+ # A dropped or misspelled flag is the failure mode this table exists for:
41
+ # the option is accepted in kitchen.yml, nothing happens, and nothing warns.
42
+ {
43
+ "privileged" => [{ privileged: true }, %w{--privileged}],
44
+ "publish_all" => [{ publish_all: true }, %w{-P}],
45
+ "interactive" => [{ interactive: true }, %w{-i}],
46
+ "tty" => [{ tty: true }, %w{-t}],
47
+ "hostname" => [{ hostname: "web.local" }, %w{-h web.local}],
48
+ "memory" => [{ memory: "512m" }, %w{-m 512m}],
49
+ "cpu" => [{ cpu: "512" }, %w{-c 512}],
50
+ "gpus" => [{ gpus: "all" }, %w{--gpus all}],
51
+ "isolation" => [{ isolation: "hyperv" }, %w{--isolation hyperv}],
52
+ "instance_name" => [{ instance_name: "web" }, %w{--name web}],
53
+ "forward" => [{ forward: "80:8080" }, %w{-p 80:8080}],
54
+ "dns" => [{ dns: "8.8.8.8" }, %w{--dns 8.8.8.8}],
55
+ "volume" => [{ volume: "/ftp:/ftp" }, %w{-v /ftp:/ftp}],
56
+ "volumes_from" => [{ volumes_from: "repos" }, %w{--volumes-from repos}],
57
+ "links" => [{ links: "db:db" }, %w{--link db:db}],
58
+ "devices" => [{ devices: "/dev/vboxdrv" }, %w{--device /dev/vboxdrv}],
59
+ "tmpfs" => [{ tmpfs: "/tmp" }, %w{--tmpfs /tmp}],
60
+ "mount" => [{ mount: "type=tmpfs,destination=/run" }, %w{--mount type=tmpfs,destination=/run}],
61
+ "add_host" => [{ add_host: { "db" => "10.0.0.5" } }, %w{--add-host=db:10.0.0.5}],
62
+ "cap_add" => [{ cap_add: "SYS_PTRACE" }, %w{--cap-add=SYS_PTRACE}],
63
+ "cap_drop" => [{ cap_drop: "CHOWN" }, %w{--cap-drop=CHOWN}],
64
+ "security_opt" => [{ security_opt: "apparmor:my_profile" }, %w{--security-opt=apparmor:my_profile}],
65
+ "docker_platform" => [{ docker_platform: "linux/arm64" }, %w{--platform=linux/arm64}],
66
+ "http_proxy" => [{ http_proxy: "http://p:8080" }, %w{-e http_proxy=http://p:8080}],
67
+ "https_proxy" => [{ https_proxy: "http://p:8080" }, %w{-e https_proxy=http://p:8080}],
68
+ }.each do |option, (config, expected)|
69
+ it "passes #{option} through as #{expected.join(" ")}" do
70
+ expect(run_argv(config)).to include_consecutive(*expected)
71
+ end
72
+ end
73
+
74
+ describe "options documented as accepting one value or a list" do
75
+ # README tells users these take either form. Array() is what makes that
76
+ # true, and it is easy to drop when an option is edited.
77
+ %i{forward dns volume volumes_from links devices tmpfs mount cap_add cap_drop security_opt}.each do |option|
78
+ it "accepts a bare value for #{option}" do
79
+ expect { run_argv(option => "one") }.not_to raise_error
80
+ end
81
+
82
+ it "emits one argument group per entry for #{option}" do
83
+ many = run_argv(option => %w{one two})
84
+ expect(many.grep(/one/)).not_to be_empty
85
+ expect(many.grep(/two/)).not_to be_empty
86
+ end
87
+ end
88
+ end
89
+
90
+ describe "the transport port" do
91
+ it "publishes the port the transport asks for" do
92
+ cmd = helper({ run_command: "sshd" }).build_run_command(image_id, 2222)
93
+ expect(argv(cmd)).to include_consecutive("-p", "2222")
94
+ end
95
+
96
+ it "publishes nothing when the transport asks for no port" do
97
+ cmd = helper({ run_command: "sshd" }).build_run_command(image_id, nil)
98
+ expect(argv(cmd)).not_to include "-p"
99
+ end
100
+ end
101
+
102
+ describe "quoting" do
103
+ # Docker command lines are built as strings and handed to a shell, so a
104
+ # configured value containing a space has to survive shell splitting as a
105
+ # single argument. These are the cases where it does not.
106
+ it "keeps a volume path containing a space as one argument" do
107
+ pending "BUG: build_run_command interpolates volume unquoted, so a path with a space becomes two arguments"
108
+ expect(run_argv(volume: "/host dir:/data")).to include_consecutive("-v", "/host dir:/data")
109
+ end
110
+
111
+ it "keeps a hostname containing a space as one argument" do
112
+ pending "BUG: build_run_command interpolates hostname unquoted"
113
+ expect(run_argv(hostname: "two words")).to include_consecutive("-h", "two words")
114
+ end
115
+
116
+ it "keeps a mount specification containing a space as one argument" do
117
+ pending "BUG: build_run_command interpolates mount unquoted"
118
+ expect(run_argv(mount: "type=bind,source=/my dir,destination=/d"))
119
+ .to include_consecutive("--mount", "type=bind,source=/my dir,destination=/d")
120
+ end
121
+ end
122
+ end
123
+
124
+ describe "#build_exec_command" do
125
+ let(:state) { { container_id: "abc123" } }
126
+
127
+ def exec_argv(config = {}, command: "whoami")
128
+ argv(helper(config).build_exec_command(state, command))
129
+ end
130
+
131
+ it "execs the command in the container" do
132
+ expect(exec_argv).to eq %w{exec abc123 whoami}
133
+ end
134
+
135
+ {
136
+ "detach" => [{ detach: true }, %w{-d}],
137
+ "privileged" => [{ privileged: true }, %w{--privileged}],
138
+ "tty" => [{ tty: true }, %w{-t}],
139
+ "interactive" => [{ interactive: true }, %w{-i}],
140
+ "username" => [{ username: "kitchen" }, %w{-u kitchen}],
141
+ "working_dir" => [{ working_dir: "/srv" }, %w{-w /srv}],
142
+ }.each do |option, (config, expected)|
143
+ it "passes #{option} through as #{expected.join(" ")}" do
144
+ expect(exec_argv(config)).to include_consecutive(*expected)
145
+ end
146
+ end
147
+
148
+ it "puts the container id before the command" do
149
+ # Reversing these makes Docker try to exec into the command name, with an
150
+ # error that does not point anywhere near the cause.
151
+ result = exec_argv({ username: "kitchen" }, command: "whoami")
152
+ expect(result.index("abc123")).to be < result.index("whoami")
153
+ end
154
+ end
155
+
156
+ describe "#build_env_variable_args" do
157
+ it "emits one -e flag per variable" do
158
+ expect(argv(helper.build_env_variable_args("A" => "1", "B" => "2")))
159
+ .to eq %w{-e A=1 -e B=2}
160
+ end
161
+
162
+ it "strips surrounding whitespace from names and values" do
163
+ expect(argv(helper.build_env_variable_args(" A " => " 1 "))).to eq %w{-e A=1}
164
+ end
165
+
166
+ it "refuses anything that is not a Hash" do
167
+ # The driver would otherwise build "-e" flags out of an Array's elements
168
+ # and produce a command line that fails somewhere far from the cause.
169
+ expect { helper.build_env_variable_args(%w{A=1}) }
170
+ .to raise_error(Kitchen::ActionFailed, /not of a Hash type/)
171
+ end
172
+
173
+ it "keeps a value containing a space as one argument" do
174
+ expect(argv(helper.build_env_variable_args("MSG" => "hello world")))
175
+ .to eq ["-e", "MSG=hello world"]
176
+ end
177
+
178
+ it "keeps a value containing a double quote intact" do
179
+ pending "BUG: values are wrapped in double quotes, so an embedded double quote ends the quoting early"
180
+ expect(argv(helper.build_env_variable_args("MSG" => 'say "hi"')))
181
+ .to eq ["-e", 'MSG=say "hi"']
182
+ end
183
+ end
184
+
185
+ describe "#build_copy_command" do
186
+ it "copies local to remote" do
187
+ expect(argv(helper.build_copy_command("/tmp/a", "abc:/tmp/a")))
188
+ .to eq %w{cp /tmp/a abc:/tmp/a}
189
+ end
190
+
191
+ it "preserves ownership and mode when asked" do
192
+ expect(argv(helper.build_copy_command("/tmp/a", "abc:/tmp/a", archive: true)))
193
+ .to eq %w{cp -a /tmp/a abc:/tmp/a}
194
+ end
195
+
196
+ it "keeps a local path containing a space as one argument" do
197
+ pending "BUG: build_copy_command interpolates both paths unquoted, so uploads from a path with a space fail"
198
+ expect(argv(helper.build_copy_command("/Users/me/My Cookbooks/f.rb", "abc:/tmp/f.rb")))
199
+ .to eq ["cp", "/Users/me/My Cookbooks/f.rb", "abc:/tmp/f.rb"]
200
+ end
201
+ end
202
+
203
+ describe "#config_to_options" do
204
+ subject { helper.config_to_options(input) }
205
+
206
+ context "with nil" do
207
+ let(:input) { nil }
208
+
209
+ it { is_expected.to eq "" }
210
+ end
211
+
212
+ context "with a string" do
213
+ let(:input) { "--foo" }
214
+
215
+ it { is_expected.to eq "--foo" }
216
+ end
217
+
218
+ context "with a string with spaces" do
219
+ let(:input) { "--foo bar" }
220
+
221
+ it { is_expected.to eq "--foo bar" }
222
+ end
223
+
224
+ context "with an array of strings" do
225
+ let(:input) { %w{--foo --bar} }
226
+
227
+ it { is_expected.to eq "--foo --bar" }
228
+ end
229
+
230
+ context "with an array of hashes" do
231
+ let(:input) { [{ foo: "bar" }, { other: "baz" }] }
232
+
233
+ it { is_expected.to eq "--foo=bar --other=baz" }
234
+ end
235
+
236
+ context "with a hash of strings" do
237
+ let(:input) { { foo: "bar", other: "baz" } }
238
+
239
+ it { is_expected.to eq "--foo=bar --other=baz" }
240
+ end
241
+
242
+ context "with a hash of arrays" do
243
+ let(:input) { { foo: %w{bar baz} } }
244
+
245
+ it { is_expected.to eq "--foo=bar --foo=baz" }
246
+ end
247
+
248
+ context "with a hash of strings with spaces" do
249
+ let(:input) { { foo: "bar two", other: "baz" } }
250
+
251
+ it { is_expected.to eq '--foo=bar\\ two --other=baz' }
252
+
253
+ it "survives shell splitting as one argument per flag" do
254
+ expect(argv(subject)).to eq ["--foo=bar two", "--other=baz"]
255
+ end
256
+ end
257
+
258
+ context "with a boolean value, as the README's build_options example uses" do
259
+ let(:input) { { rm: false } }
260
+
261
+ it { is_expected.to eq "--rm=false" }
262
+ end
263
+ end
264
+
265
+ describe "#run_command" do
266
+ it "returns stdout and stderr together" do
267
+ # docker writes build progress and image ids to stderr, so the driver
268
+ # reimplements run_command specifically to keep both streams.
269
+ expect(helper.run_command("echo out; echo err 1>&2")).to eq "out\nerr\n"
270
+ end
271
+
272
+ it "raises Kitchen::ShellOut::ShellCommandFailed when the command fails" do
273
+ # cli_helper raises a bare `ShellCommandFailed`, which resolves through
274
+ # the included Kitchen::ShellOut rather than being spelled out. Pin the
275
+ # class so a change to those includes cannot silently alter what callers
276
+ # have to rescue.
277
+ expect { helper.run_command("exit 7") }
278
+ .to raise_error(Kitchen::ShellOut::ShellCommandFailed, /exit with \[0\], but received '7'/)
279
+ end
280
+
281
+ it "prefixes the command with the sudo command when use_sudo is set" do
282
+ # Uses `echo` as the sudo command so the prefixing is observable without
283
+ # needing real sudo on the machine running the specs.
284
+ expect(helper.run_command("echo hi", use_sudo: true, sudo_command: "echo SUDO"))
285
+ .to eq "SUDO echo hi\n"
286
+ end
287
+
288
+ it "does not prefix anything when use_sudo is unset" do
289
+ expect(helper.run_command("echo hi")).to eq "hi\n"
290
+ end
291
+ end
292
+
293
+ describe "#docker_shell_opts" do
294
+ it "translates suppress_output into silencing the live stream" do
295
+ expect(helper.docker_shell_opts(suppress_output: true)).to eq(live_stream: nil)
296
+ end
297
+
298
+ it "removes suppress_output, which Mixlib::ShellOut would reject" do
299
+ expect(helper.docker_shell_opts(suppress_output: false)).not_to have_key(:suppress_output)
300
+ end
301
+
302
+ it "leaves other options alone" do
303
+ expect(helper.docker_shell_opts(timeout: 60)).to eq(timeout: 60)
304
+ end
305
+ end
306
+
307
+ describe "#dev_null" do
308
+ it "returns a device that exists on this platform" do
309
+ expect(helper.dev_null).to eq(Gem.win_platform? ? "NUL" : "/dev/null")
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,178 @@
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
+ # run_command returns stdout + stderr, and the parser requires the whole
34
+ # combined string to be exactly the id. Anything docker writes to stderr on
35
+ # a successful `run` therefore fails container creation, with an error that
36
+ # blames parsing rather than naming the warning.
37
+ it "finds the id when docker pulled the image and logged progress to stderr" do
38
+ pending "BUG: parse_container_id requires the entire output to be the id, so any stderr output fails the run"
39
+ expect(helper.parse_container_id(DockerOutput::RUN_WITH_PULL_ON_STDERR))
40
+ .to eq DockerOutput::RUN_CONTAINER_ID
41
+ end
42
+
43
+ it "finds the id when the kernel lacks swap accounting" do
44
+ pending "BUG: the 'No swap limit support' warning docker emits on many Linux hosts is concatenated onto the id"
45
+ expect(helper.parse_container_id(DockerOutput::RUN_WITH_SWAP_WARNING))
46
+ .to eq DockerOutput::RUN_CONTAINER_ID
47
+ end
48
+ end
49
+
50
+ describe "#remote_socket? and #socket_uri" do
51
+ {
52
+ "unix:///var/run/docker.sock" => false,
53
+ "npipe:////./pipe/docker_engine" => false,
54
+ "tcp://docker.example.com:2376" => true,
55
+ "tcp://127.0.0.1:2375" => true,
56
+ }.each do |socket, remote|
57
+ it "treats #{socket} as #{remote ? "remote" : "local"}" do
58
+ expect(helper(socket: socket).remote_socket?).to eq remote
59
+ end
60
+ end
61
+
62
+ it "reports a socket-less configuration as local" do
63
+ expect(helper(socket: nil).remote_socket?).to be false
64
+ end
65
+
66
+ it "exposes the socket as a URI" do
67
+ expect(helper(socket: "tcp://docker.example.com:2376").socket_uri.port).to eq 2376
68
+ end
69
+ end
70
+
71
+ describe "#dockerfile_proxy_config" do
72
+ it "is empty when no proxy is configured" do
73
+ expect(helper.dockerfile_proxy_config).to eq ""
74
+ end
75
+
76
+ # Tools inside the image disagree about the spelling, so both cases have to
77
+ # be set. Dropping either half breaks builds behind a proxy in a way that
78
+ # only shows up on somebody else's network.
79
+ {
80
+ http_proxy: %w{http_proxy HTTP_PROXY},
81
+ https_proxy: %w{https_proxy HTTPS_PROXY},
82
+ no_proxy: %w{no_proxy NO_PROXY},
83
+ }.each do |option, spellings|
84
+ it "sets #{spellings.join(" and ")} for #{option}" do
85
+ lines = helper(option => "http://proxy:8080").dockerfile_proxy_config.lines.map(&:strip)
86
+ spellings.each { |name| expect(lines).to include "ENV #{name}=http://proxy:8080" }
87
+ end
88
+ end
89
+
90
+ it "emits only the proxies that are configured" do
91
+ config = helper(http_proxy: "http://proxy:8080").dockerfile_proxy_config
92
+ expect(config).not_to match(/no_proxy|https_proxy/i)
93
+ end
94
+ end
95
+
96
+ describe "#container_env_variables" do
97
+ def with_printenv(output)
98
+ h = helper
99
+ allow(h).to receive(:docker_command).and_return(output)
100
+ h.container_env_variables(platform: "ubuntu-24.04")
101
+ end
102
+
103
+ it "reads the container's environment" do
104
+ expect(with_printenv("HOME=/root\nUSER=kitchen\n"))
105
+ .to eq("HOME" => "/root", "USER" => "kitchen")
106
+ end
107
+
108
+ # printenv output is `NAME=VALUE`, and values routinely contain `=`:
109
+ # LS_COLORS and any JAVA_OPTS-style variable do. Splitting on every `=`
110
+ # rather than the first silently truncates them, and the truncated value
111
+ # then flows into replace_env_variables and into upload paths.
112
+ it "keeps a value containing an equals sign intact" do
113
+ pending "BUG: values are split on every '=' rather than the first; use split(\"=\", 2)"
114
+ expect(with_printenv("LS_COLORS=rs=0:di=01;34\n"))
115
+ .to eq("LS_COLORS" => "rs=0:di=01;34")
116
+ end
117
+
118
+ it "parses the JSON a Windows container returns" do
119
+ h = helper
120
+ allow(h).to receive(:docker_command).and_return('{"TEMP":"C:\\\\Users\\\\ADMINI~1\\\\AppData\\\\Local\\\\Temp"}')
121
+ expect(h.container_env_variables(platform: "windows-2022"))
122
+ .to eq("TEMP" => 'C:\Users\ADMINI~1\AppData\Local\Temp')
123
+ end
124
+ end
125
+
126
+ describe "#replace_env_variables" do
127
+ let(:linux_state) { { platform: "ubuntu-24.04", container_id: "abc" } }
128
+ let(:windows_state) { { platform: "windows-2022", container_id: "abc" } }
129
+
130
+ def helper_returning(vars)
131
+ h = helper
132
+ allow(h).to receive(:container_env_variables).and_return(vars)
133
+ h
134
+ end
135
+
136
+ it "expands a POSIX $VAR reference" do
137
+ h = helper_returning("TEMP" => "/var/tmp")
138
+ expect(h.replace_env_variables(linux_state, "$TEMP/kitchen")).to eq "/var/tmp/kitchen"
139
+ end
140
+
141
+ it "expands a PowerShell $env:VAR reference" do
142
+ h = helper_returning("TEMP" => 'C:\Temp')
143
+ expect(h.replace_env_variables(windows_state, '$env:TEMP\kitchen')).to eq 'C:\Temp\kitchen'
144
+ end
145
+
146
+ it "leaves a path with no variable reference alone" do
147
+ expect(helper.replace_env_variables(linux_state, "/tmp/kitchen")).to eq "/tmp/kitchen"
148
+ end
149
+
150
+ it "substitutes an empty string when the variable is not set in the container" do
151
+ # Documents current behaviour rather than endorsing it: an unset variable
152
+ # silently yields a path like "/kitchen" instead of failing, and the
153
+ # upload then lands somewhere unexpected.
154
+ h = helper_returning({})
155
+ expect(h.replace_env_variables(linux_state, "$TEMP/kitchen")).to eq "/kitchen"
156
+ end
157
+ end
158
+
159
+ describe "#remove_container" do
160
+ it "stops the container before removing it" do
161
+ # `docker rm` refuses to remove a running container, so the order matters.
162
+ h = helper
163
+ calls = []
164
+ allow(h).to receive(:docker_command) { |cmd| calls << cmd }
165
+ h.remove_container(container_id: "abc")
166
+ expect(calls).to eq ["stop -t 0 abc", "rm abc"]
167
+ end
168
+ end
169
+
170
+ describe "#copy_file_to_container" do
171
+ it "addresses the destination as container:path" do
172
+ h = helper
173
+ allow(h).to receive(:replace_env_variables) { |_state, path| path }
174
+ expect(h).to receive(:docker_command).with("cp /local/f.rb abc:/tmp/f.rb")
175
+ h.copy_file_to_container({ container_id: "abc", platform: "ubuntu-24.04" }, "/local/f.rb", "/tmp/f.rb")
176
+ end
177
+ end
178
+ end