kitchen-docker 3.3.4 → 3.4.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.
@@ -15,6 +15,7 @@ require "kitchen"
15
15
 
16
16
  require_relative "../docker/container/linux"
17
17
  require_relative "../docker/container/windows"
18
+ require_relative "../docker/docker_version"
18
19
 
19
20
  require_relative "../docker/helpers/inspec_helper"
20
21
 
@@ -31,19 +32,24 @@ module Kitchen
31
32
  # Raised when a docker command against the container fails.
32
33
  class DockerFailed < TransportFailed; end
33
34
 
34
- # kitchen_transport_api_version 1
35
- plugin_version Kitchen::VERSION
35
+ # Reported by `kitchen diagnose`. plugin_version was Kitchen::VERSION,
36
+ # which is Test Kitchen's version rather than this gem's, so a diagnose
37
+ # reported the transport as 4.1.1 while kitchen-docker was at 3.3.4.
38
+ kitchen_transport_api_version 1
39
+ plugin_version Kitchen::Docker::DOCKER_VERSION
36
40
 
37
41
  default_config :binary, "docker"
38
42
  default_config :env_variables, nil
39
43
  default_config :interactive, false
40
44
  default_config :privileged, false
45
+ default_config :sudo_command, nil
41
46
  default_config :tls, false
42
47
  default_config :tls_cacert, nil
43
48
  default_config :tls_cert, nil
44
49
  default_config :tls_key, nil
45
50
  default_config :tls_verify, false
46
51
  default_config :tty, false
52
+ default_config :use_sudo, false
47
53
  default_config :working_dir, nil
48
54
 
49
55
  default_config :socket do |transport|
@@ -77,6 +83,8 @@ module Kitchen
77
83
  # than from Test Kitchen's configuration.
78
84
  #
79
85
  # @param state [Hash] instance state naming the container
86
+ # @param block [Proc, nil] forwarded to the connection's constructor, which
87
+ # yields the new connection to it; the connection is not closed afterwards
80
88
  # @yieldparam connection [Connection] if a block is given
81
89
  # @return [Connection]
82
90
  def connection(state, &block)
@@ -298,6 +298,55 @@ describe Kitchen::Docker::Helpers::CliHelper do
298
298
  end
299
299
  end
300
300
 
301
+ describe "#docker_command" do
302
+ # `binary` is set to `echo` and `sudo_command` to `echo SUDO`, so the
303
+ # assembled command line is observable as output without needing real
304
+ # sudo -- or a real docker -- on the machine running the specs.
305
+ def echoed(config = {})
306
+ helper({ binary: "echo", socket: nil, sudo_command: "echo SUDO" }.merge(config))
307
+ .docker_command("ps -a")
308
+ end
309
+
310
+ it "runs the command as the invoking user by default" do
311
+ expect(echoed).to eq "ps -a\n"
312
+ end
313
+
314
+ # Cases from the README's "Permission denied talking to the daemon"
315
+ # advice. `use_sudo` reached only the `verify_dependencies` probe, so
316
+ # every command that actually touches the daemon still ran unprivileged
317
+ # and the documented fix did nothing.
318
+ it "runs the command through sudo when use_sudo is set" do
319
+ expect(echoed(use_sudo: true)).to eq "SUDO echo ps -a\n"
320
+ end
321
+
322
+ it "leaves the sudo command at its default when none is configured" do
323
+ expect(helper(binary: "echo", socket: nil, use_sudo: true).docker_sudo_opts({}))
324
+ .to eq(use_sudo: true)
325
+ end
326
+ end
327
+
328
+ describe "#docker_sudo_opts" do
329
+ it "adds nothing when use_sudo is unset" do
330
+ expect(helper.docker_sudo_opts(suppress_output: true)).to eq(suppress_output: true)
331
+ end
332
+
333
+ it "adds the sudo options the shell-out layer reads" do
334
+ expect(helper(use_sudo: true, sudo_command: "doas").docker_sudo_opts({}))
335
+ .to eq(use_sudo: true, sudo_command: "doas")
336
+ end
337
+
338
+ it "keeps the options it was given" do
339
+ expect(helper(use_sudo: true).docker_sudo_opts(suppress_output: true))
340
+ .to eq(suppress_output: true, use_sudo: true)
341
+ end
342
+
343
+ it "does not mutate the options it was given" do
344
+ options = {}
345
+ helper(use_sudo: true).docker_sudo_opts(options)
346
+ expect(options).to eq({})
347
+ end
348
+ end
349
+
301
350
  describe "#docker_shell_opts" do
302
351
  it "translates suppress_output into silencing the live stream" do
303
352
  expect(helper.docker_shell_opts(suppress_output: true)).to eq(live_stream: nil)
@@ -333,12 +333,164 @@ describe Kitchen::Docker::Helpers::ContainerHelper do
333
333
  end
334
334
  end
335
335
 
336
+ describe "#create_dir_on_container" do
337
+ def dir_maker(state)
338
+ helper.tap do |h|
339
+ @commands = []
340
+ allow(h).to receive(:replace_env_variables) { |_s, path| path }
341
+ allow(h).to receive(:docker_command) { |cmd, _opts = {}| @commands << cmd; "" }
342
+ end
343
+ end
344
+
345
+ it "creates the directory inside the container" do
346
+ dir_maker(nil).create_dir_on_container({ container_id: "abc", platform: "ubuntu-24.04" }, "/tmp")
347
+ expect(argv(@commands.first)).to include_consecutive("mkdir", "-p", "/tmp")
348
+ end
349
+
350
+ it "expands an environment variable reference before creating it" do
351
+ h = helper
352
+ allow(h).to receive(:container_env_variables).and_return("TEMP" => "/var/tmp")
353
+ allow(h).to receive(:docker_command) { |cmd, _opts = {}| @cmd = cmd; "" }
354
+ h.create_dir_on_container({ container_id: "abc", platform: "ubuntu-24.04" }, "$TEMP/kitchen")
355
+ expect(argv(@cmd)).to include_consecutive("mkdir", "-p", "/var/tmp/kitchen")
356
+ end
357
+
358
+ # A temp_dir with a space in it reached `mkdir -p` unquoted, so the shell
359
+ # tore it in two and mkdir made two directories -- neither of them the one
360
+ # asked for. Every later upload then landed somewhere that did not exist.
361
+ it "keeps a path containing a space as one argument" do
362
+ dir_maker(nil).create_dir_on_container({ container_id: "abc", platform: "ubuntu-24.04" },
363
+ "/var/tmp/kitchen docker")
364
+ expect(argv(@commands.first)).to include_consecutive("mkdir", "-p", "/var/tmp/kitchen docker")
365
+ end
366
+
367
+ it "uses PowerShell on a Windows container" do
368
+ dir_maker(nil).create_dir_on_container({ container_id: "abc", platform: "windows-2022" }, 'C:\\Temp')
369
+ expect(@commands.first).to include("powershell").and include("New-Item")
370
+ end
371
+
372
+ it "says which directory it could not create" do
373
+ h = helper
374
+ allow(h).to receive(:replace_env_variables) { |_s, path| path }
375
+ allow(h).to receive(:docker_command).and_raise("boom")
376
+ expect { h.create_dir_on_container({ container_id: "abc", platform: "ubuntu-24.04" }, "/tmp/kitchen") }
377
+ .to raise_error(RuntimeError, %r{Failed to create directory /tmp/kitchen})
378
+ end
379
+ end
380
+
381
+ describe "#container_exec" do
382
+ it "runs the command through docker exec" do
383
+ h = helper
384
+ allow(h).to receive(:docker_command) { |cmd, _opts = {}| @cmd = cmd; "output" }
385
+ expect(h.container_exec({ container_id: "abc" }, "echo hi")).to eq "output"
386
+ expect(argv(@cmd)).to include_consecutive("exec", "abc", "echo", "hi")
387
+ end
388
+
389
+ it "names the container operation when the command fails" do
390
+ h = helper
391
+ allow(h).to receive(:docker_command).and_raise("boom")
392
+ expect { h.container_exec({ container_id: "abc" }, "echo hi") }
393
+ .to raise_error(RuntimeError, /Failed to execute command on Docker container/)
394
+ end
395
+ end
396
+
397
+ describe "#run_container" do
398
+ it "returns the id docker printed for the container it started" do
399
+ h = helper(instance_name: "kitchen-test")
400
+ allow(h).to receive(:docker_command).and_return(DockerOutput::RUN_CLEAN)
401
+ expect(h.run_container({ image_id: "sha256:abc" }, 22)).to eq DockerOutput::RUN_CONTAINER_ID
402
+ end
403
+
404
+ it "publishes the port it was given" do
405
+ h = helper
406
+ allow(h).to receive(:docker_command) { |cmd, _opts = {}| @cmd = cmd; DockerOutput::RUN_CLEAN }
407
+ h.run_container({ image_id: "sha256:abc" }, 22)
408
+ expect(argv(@cmd)).to include_consecutive("-p", "22")
409
+ end
410
+
411
+ it "publishes no port when there is none, as for a Windows container" do
412
+ h = helper
413
+ allow(h).to receive(:docker_command) { |cmd, _opts = {}| @cmd = cmd; DockerOutput::RUN_CLEAN }
414
+ h.run_container(image_id: "sha256:abc")
415
+ expect(argv(@cmd)).not_to include("-p")
416
+ end
417
+ end
418
+
336
419
  describe "#copy_file_to_container" do
420
+ let(:state) { { container_id: "abc", platform: "ubuntu-24.04" } }
421
+
422
+ # Records every docker subcommand, and answers the copy check with
423
+ # `landed`. `docker cp` itself prints nothing on success, so the probe is
424
+ # the only call whose output matters.
425
+ def copier(landed:)
426
+ helper.tap do |h|
427
+ @commands = []
428
+ allow(h).to receive(:replace_env_variables) { |_s, path| path }
429
+ allow(h).to receive(:docker_command) do |cmd, _opts = {}|
430
+ @commands << cmd
431
+ cmd.include?(described_class::COPIED_MARKER) && landed ? "#{described_class::COPIED_MARKER}\n" : ""
432
+ end
433
+ end
434
+ end
435
+
337
436
  it "addresses the destination as container:path" do
338
- h = helper
339
- allow(h).to receive(:replace_env_variables) { |_state, path| path }
340
- expect(h).to receive(:docker_command).with("cp /local/f.rb abc:/tmp/f.rb")
341
- h.copy_file_to_container({ container_id: "abc", platform: "ubuntu-24.04" }, "/local/f.rb", "/tmp/f.rb")
437
+ copier(landed: true).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb")
438
+ expect(@commands.first).to eq "cp /local/f.rb abc:/tmp/f.rb"
439
+ end
440
+
441
+ it "says nothing when the file arrived" do
442
+ expect { copier(landed: true).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb") }
443
+ .not_to raise_error
444
+ end
445
+
446
+ # From #387. `docker cp` writes to the container's filesystem layer, which
447
+ # a tmpfs or volume mounted over the destination then hides -- and it exits
448
+ # 0, so nothing about the copy says it did not happen. Left unchecked the
449
+ # first sign is the next command failing with "No such file or directory",
450
+ # which names neither the copy nor the mount.
451
+ context "when docker exits 0 but wrote nothing, as it does into a mount" do
452
+ it "fails at the copy rather than somewhere later" do
453
+ expect { copier(landed: false).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb") }
454
+ .to raise_error(RuntimeError, %r{Failed to copy file /local/f\.rb})
455
+ end
456
+
457
+ it "names the mount as the cause" do
458
+ expect { copier(landed: false).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb") }
459
+ .to raise_error(RuntimeError, /cannot write into a mount/)
460
+ end
461
+
462
+ it "names the settings that move the destination off the mount" do
463
+ expect { copier(landed: false).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb") }
464
+ .to raise_error(RuntimeError, /temp_dir.*root_path/m)
465
+ end
466
+ end
467
+
468
+ it "checks the path docker copies into, not the directory it was given" do
469
+ copier(landed: true).copy_file_to_container(state, "/local/f.rb", "/tmp")
470
+ # The probe is handed both parts and picks between them inside the
471
+ # container, since only there is it known whether /tmp is a directory.
472
+ expect(@commands.last).to include("/tmp f.rb")
473
+ end
474
+
475
+ it "does not print the probe's marker to the console" do
476
+ h = copier(landed: true)
477
+ allow(h).to receive(:logger).and_return(double(debug?: false, debug: nil))
478
+ opts = []
479
+ allow(h).to receive(:docker_command) { |_cmd, o = {}| opts << o; "" }
480
+ begin
481
+ h.copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb")
482
+ rescue RuntimeError
483
+ nil
484
+ end
485
+ expect(opts.last).to eq(suppress_output: true)
486
+ end
487
+
488
+ # Windows containers have no tmpfs, and `docker cp` against them is a
489
+ # different code path in Docker, so they keep the behaviour they had.
490
+ it "does not probe a Windows container" do
491
+ h = copier(landed: false)
492
+ h.copy_file_to_container({ container_id: "abc", platform: "windows-2022" }, "C:\\f.rb", "C:\\Temp")
493
+ expect(@commands.length).to eq 1
342
494
  end
343
495
  end
344
496
  end
data/spec/docker_spec.rb CHANGED
@@ -17,6 +17,24 @@
17
17
  require "spec_helper"
18
18
 
19
19
  describe Kitchen::Driver::Docker do
20
+ # `kitchen diagnose` is what bug reports are asked to include, so what it
21
+ # says about the plugin has to be true. The transport reported
22
+ # Kitchen::VERSION, which is Test Kitchen's version, and the driver reported
23
+ # nothing at all.
24
+ describe "plugin metadata" do
25
+ it "reports this gem's version, not Test Kitchen's" do
26
+ expect(described_class.diagnose[:version]).to eq Kitchen::Docker::DOCKER_VERSION
27
+ end
28
+
29
+ it "does not report Test Kitchen's version" do
30
+ expect(described_class.diagnose[:version]).not_to eq Kitchen::VERSION
31
+ end
32
+
33
+ it "declares the driver API version it is written against" do
34
+ expect(described_class.diagnose[:api_version]).to eq 2
35
+ end
36
+ end
37
+
20
38
  describe "#config_to_options" do
21
39
  let(:config) {}
22
40
  subject { described_class.new.send(:config_to_options, config) }
@@ -62,6 +80,159 @@ describe Kitchen::Driver::Docker do
62
80
  end # /context with a hash of strings with spaces
63
81
  end # /describe #config_to_options
64
82
 
83
+ # `kitchen package`, `kitchen doctor`, and `kitchen list --live` each ask the
84
+ # driver a question. Driver::Base answers all three with a shrug, and this
85
+ # driver used to inherit that: package produced nothing, doctor said nothing,
86
+ # and every instance listed as "unknown". Docker can answer all three.
87
+ def driver(config = {}, instance_name: "default-ubuntu-2404")
88
+ described_class.new(config).tap do |d|
89
+ allow(d).to receive(:instance).and_return(instance_double("Kitchen::Instance", name: instance_name))
90
+ allow(d).to receive(:logger).and_return(double(debug?: false, debug: nil))
91
+ %i{info error debug banner}.each { |level| allow(d).to receive(level) }
92
+ end
93
+ end
94
+
95
+ describe "#status" do
96
+ let(:state) { { container_id: "abc123abc123" } }
97
+
98
+ def status_of(exists:, running:, state: { container_id: "abc123abc123" })
99
+ d = driver
100
+ allow(d).to receive(:container_exists?).and_return(exists)
101
+ allow(d).to receive(:container_running?).and_return(running)
102
+ d.status(state)
103
+ end
104
+
105
+ it "reports a running container as live" do
106
+ expect(status_of(exists: true, running: true))
107
+ .to include(live: true, state: "running", source: "driver")
108
+ end
109
+
110
+ it "distinguishes a stopped container from a missing one" do
111
+ expect(status_of(exists: true, running: false)).to include(live: false, state: "stopped")
112
+ expect(status_of(exists: false, running: false)).to include(live: false, state: "gone")
113
+ end
114
+
115
+ it "reports an instance with no container as not created" do
116
+ expect(status_of(exists: false, running: false, state: {}))
117
+ .to include(live: false, state: "not created")
118
+ end
119
+
120
+ it "names the container so `kitchen list --live` can show it" do
121
+ expect(status_of(exists: true, running: true)[:resource_id]).to eq "abc123abc123"
122
+ end
123
+
124
+ it "does not ask docker about an instance that has no container" do
125
+ d = driver
126
+ expect(d).not_to receive(:container_exists?)
127
+ d.status({})
128
+ end
129
+
130
+ it "stamps when it looked" do
131
+ expect(status_of(exists: true, running: true)[:checked_at])
132
+ .to match(/\A\d{4}-\d{2}-\d{2}T[\d:]+Z\z/)
133
+ end
134
+ end
135
+
136
+ describe "#package" do
137
+ let(:state) { { container_id: "abc123abc123" } }
138
+ let(:digest) { "sha256:#{"a" * 64}" }
139
+
140
+ it "commits the container to the configured image name" do
141
+ d = driver({ package_name: "myapp:v1" })
142
+ allow(d).to receive(:container_exists?).and_return(true)
143
+ expect(d).to receive(:docker_command)
144
+ .with("commit abc123abc123 myapp:v1", hash_including(:suppress_output))
145
+ .and_return("#{digest}\n")
146
+ d.package(state)
147
+ end
148
+
149
+ it "names the image after the instance by default" do
150
+ d = driver
151
+ expect(d.send(:config)[:package_name]).to eq "default-ubuntu-2404:latest"
152
+ end
153
+
154
+ it "escapes a package name that would otherwise split" do
155
+ d = driver({ package_name: "my app:v1" })
156
+ allow(d).to receive(:container_exists?).and_return(true)
157
+ expect(d).to receive(:docker_command)
158
+ .with(%q{commit abc123abc123 my\ app:v1}, hash_including(:suppress_output))
159
+ .and_return("#{digest}\n")
160
+ d.package(state)
161
+ end
162
+
163
+ it "refuses to package an instance that was never created" do
164
+ expect { driver.package({}) }
165
+ .to raise_error(Kitchen::ActionFailed, /has not been created/)
166
+ end
167
+
168
+ it "does not run docker for an instance that was never created" do
169
+ d = driver
170
+ expect(d).not_to receive(:docker_command)
171
+ expect { d.package({}) }.to raise_error(Kitchen::ActionFailed)
172
+ end
173
+
174
+ # `docker commit` on a container that is gone says only "Error response
175
+ # from daemon: No such container: <64 hex characters>", which names neither
176
+ # the instance nor what to do about it.
177
+ it "names the instance when the container is gone" do
178
+ d = driver
179
+ allow(d).to receive(:container_exists?).and_return(false)
180
+ expect { d.package(state) }
181
+ .to raise_error(Kitchen::ActionFailed, /default-ubuntu-2404.*kitchen destroy/m)
182
+ end
183
+ end
184
+
185
+ describe "#doctor" do
186
+ let(:state) { {} }
187
+
188
+ def doctor_with(config = {}, daemon: "29.7.2", state: {})
189
+ d = driver(config)
190
+ allow(d).to receive(:container_exists?).and_return(true)
191
+ allow(d).to receive(:docker_command) do
192
+ raise Kitchen::ShellOut::ShellCommandFailed, "cannot connect" if daemon.nil?
193
+
194
+ "#{daemon}\n"
195
+ end
196
+ d.doctor(state)
197
+ end
198
+
199
+ it "reports no problem when the daemon answers" do
200
+ expect(doctor_with).to be false
201
+ end
202
+
203
+ it "reports a problem when the daemon cannot be reached" do
204
+ expect(doctor_with(daemon: nil)).to be true
205
+ end
206
+
207
+ it "reports a TLS file that is not there" do
208
+ expect(doctor_with({ tls_cert: "/nope/cert.pem" })).to be true
209
+ end
210
+
211
+ it "reports a dockerfile that is not there" do
212
+ expect(doctor_with({ dockerfile: "/nope/Dockerfile" })).to be true
213
+ end
214
+
215
+ it "accepts paths that do exist" do
216
+ expect(doctor_with({ dockerfile: __FILE__ })).to be false
217
+ end
218
+
219
+ it "reports a state file naming a container the daemon does not have" do
220
+ d = driver
221
+ allow(d).to receive(:docker_command).and_return("29.7.2\n")
222
+ allow(d).to receive(:container_exists?).and_return(false)
223
+ expect(d.doctor(container_id: "abc123abc123")).to be true
224
+ end
225
+
226
+ it "keeps checking after the first problem, so the whole list is reported" do
227
+ # `kitchen doctor` exists to tell you everything that is wrong at once.
228
+ d = driver({ tls_cert: "/nope/cert.pem", dockerfile: "/nope/Dockerfile" })
229
+ allow(d).to receive(:docker_command).and_raise(Kitchen::ShellOut::ShellCommandFailed, "nope")
230
+ allow(d).to receive(:container_exists?).and_return(false)
231
+ expect(d).to receive(:error).at_least(4).times
232
+ d.doctor(container_id: "abc123abc123")
233
+ end
234
+ end
235
+
65
236
  describe "socket default config logic" do
66
237
  def resolve_socket
67
238
  socket = "unix:///var/run/docker.sock"
@@ -16,6 +16,30 @@ require "spec_helper"
16
16
 
17
17
  describe Kitchen::Docker::Helpers::ImageHelper do
18
18
  describe "#parse_image_id" do
19
+ # From #225. `docker build -q` prints the id on a line of its own and
20
+ # nothing else -- no "writing image", no "naming to", no "successfully
21
+ # built" -- so every pattern the parser had missed it, and a build with
22
+ # `build_options: -q` failed with "Could not parse Docker build output for
23
+ # image ID" rather than producing an instance.
24
+ context "with a quiet build" do
25
+ it "reads the id docker printed on its own" do
26
+ expect(helper.parse_image_id(DockerOutput::BUILD_QUIET))
27
+ .to eq DockerOutput::BUILD_QUIET_IMAGE_ID
28
+ end
29
+
30
+ it "reads it when docker also wrote a warning to stderr" do
31
+ expect(helper.parse_image_id("WARNING: something happened\n#{DockerOutput::BUILD_QUIET}"))
32
+ .to eq DockerOutput::BUILD_QUIET_IMAGE_ID
33
+ end
34
+
35
+ it "does not mistake a digest that is part of a longer line for the id" do
36
+ # Ordinary build output is full of "... sha256:... done" lines. Only a
37
+ # line that is nothing but a digest is the quiet form.
38
+ expect { helper.parse_image_id("#6 exporting manifest sha256:#{"a" * 64} done\n") }
39
+ .to raise_error(Kitchen::ActionFailed)
40
+ end
41
+ end
42
+
19
43
  # Docker has changed how it reports the built image's id several times, and
20
44
  # each change has broken this parser. Every format the driver claims to
21
45
  # support gets a case here, against output copied from a real build.
@@ -178,4 +202,49 @@ describe Kitchen::Docker::Helpers::ImageHelper do
178
202
  h.remove_image(state)
179
203
  end
180
204
  end
205
+
206
+ describe "#image_exists?" do
207
+ let(:state) { { image_id: "sha256:abc" } }
208
+
209
+ def asking(&answer)
210
+ helper.tap do |h|
211
+ @opts = nil
212
+ allow(h).to receive(:logger).and_return(double(debug?: false, debug: nil))
213
+ allow(h).to receive(:docker_command) { |_cmd, opts = {}| @opts = opts; answer.call }
214
+ end
215
+ end
216
+
217
+ it "is true when docker knows the image" do
218
+ expect(asking { "[{}]" }.image_exists?(state)).to be true
219
+ end
220
+
221
+ it "is false when docker does not" do
222
+ expect(asking { raise Kitchen::ShellOut::ShellCommandFailed, "no such image" }
223
+ .image_exists?(state)).to be false
224
+ end
225
+
226
+ it "does not ask about an image that state does not name" do
227
+ h = helper
228
+ expect(h).not_to receive(:docker_command)
229
+ expect(h.image_exists?({})).to be false
230
+ end
231
+
232
+ # `kitchen destroy` with remove_images set printed the image's whole
233
+ # inspect JSON -- config, every layer digest, metadata -- between removing
234
+ # the container and removing the image. Only whether the command succeeded
235
+ # is used here, as in every other predicate in this file, all of which
236
+ # already silence their output.
237
+ it "does not print the inspect output" do
238
+ asking { "[{}]" }.image_exists?(state)
239
+ expect(@opts).to eq(suppress_output: true)
240
+ end
241
+
242
+ it "still prints it under -l debug" do
243
+ h = helper
244
+ allow(h).to receive(:logger).and_return(double(debug?: true, debug: nil))
245
+ allow(h).to receive(:docker_command) { |_cmd, opts = {}| @opts = opts; "[{}]" }
246
+ h.image_exists?(state)
247
+ expect(@opts).to eq(suppress_output: false)
248
+ end
249
+ end
181
250
  end
@@ -211,6 +211,33 @@ describe Kitchen::Docker::Container::Linux do
211
211
  c.execute("echo hi")
212
212
  expect(uploaded).to eq "echo hi"
213
213
  end
214
+
215
+ # The staged script is run by path, and that path is built from temp_dir,
216
+ # which the user sets.
217
+ def ran_in(temp_dir)
218
+ c = container(temp_dir: temp_dir)
219
+ allow(c).to receive(:create_dir_on_container)
220
+ allow(c).to receive(:replace_env_variables) { |_cfg, path| path }
221
+ allow(c).to receive(:upload)
222
+ ran = nil
223
+ allow(c).to receive(:container_exec) { |_cfg, cmd| ran = cmd }
224
+ c.execute("echo hi")
225
+ ran
226
+ end
227
+
228
+ it "runs the staged script with bash" do
229
+ ran = ran_in("/tmp")
230
+ expect(argv(ran).first).to eq "/bin/bash"
231
+ expect(argv(ran).last).to match(%r{\A/tmp/docker-[0-9a-f-]+\.sh\z})
232
+ end
233
+
234
+ # Interpolated unquoted, a temp_dir with a space in it reached `docker
235
+ # exec` as two arguments, and bash was handed the first half of the
236
+ # directory as the script to run.
237
+ it "keeps a temp_dir containing a space as one argument" do
238
+ expect(argv(ran_in("/var/tmp/kitchen docker")).last)
239
+ .to match(%r{\A/var/tmp/kitchen docker/docker-[0-9a-f-]+\.sh\z})
240
+ end
214
241
  end
215
242
 
216
243
  describe "#generate_keys" do
@@ -81,6 +81,15 @@ module DockerOutput
81
81
 
82
82
  BUILD_LEGACY_IMAGE_ID = "1a2b3c4d5e6f".freeze
83
83
 
84
+ # `docker build -q` on Docker 29.7.2. Quiet mode prints the id and nothing
85
+ # else -- no step lines, no exporting lines, none of the wording the other
86
+ # fixtures here are built around. Reachable through the driver as
87
+ # `build_options: -q`.
88
+ BUILD_QUIET = "sha256:ab86ce908a36ffb7de411a72550e416a4d8c268570a0f7313c284c79344d6c0f\n".freeze
89
+
90
+ BUILD_QUIET_IMAGE_ID =
91
+ "sha256:ab86ce908a36ffb7de411a72550e416a4d8c268570a0f7313c284c79344d6c0f".freeze
92
+
84
93
  # `docker run -d`, the ordinary case: the id and nothing else.
85
94
  RUN_CONTAINER_ID = "b89e1e8b07664a1ee0bd09decb833146eaf9cc810a152950e3576a56745944db".freeze
86
95
  RUN_CLEAN = "#{RUN_CONTAINER_ID}\n".freeze
@@ -15,6 +15,26 @@
15
15
  require "spec_helper"
16
16
  require "kitchen/transport/docker"
17
17
 
18
+ describe Kitchen::Transport::Docker do
19
+ # `kitchen diagnose` is what bug reports are asked to include, so what it
20
+ # says about the plugin has to be true.
21
+ describe "plugin metadata" do
22
+ it "reports this gem's version, not Test Kitchen's" do
23
+ expect(described_class.diagnose[:version]).to eq Kitchen::Docker::DOCKER_VERSION
24
+ end
25
+
26
+ it "does not report Test Kitchen's version" do
27
+ # It did: `plugin_version Kitchen::VERSION` made a diagnose say the
28
+ # transport was at Test Kitchen's version rather than this gem's.
29
+ expect(described_class.diagnose[:version]).not_to eq Kitchen::VERSION
30
+ end
31
+
32
+ it "declares the transport API version it is written against" do
33
+ expect(described_class.diagnose[:api_version]).to eq 1
34
+ end
35
+ end
36
+ end
37
+
18
38
  describe Kitchen::Transport::Docker::Connection do
19
39
  let(:options) do
20
40
  {
@@ -64,6 +64,60 @@ describe Kitchen::Docker::Container::Windows do
64
64
  end
65
65
  end
66
66
 
67
+ describe "#execute" do
68
+ def staged(config = {})
69
+ c = container({ temp_dir: 'C:\\Temp' }.merge(config))
70
+ allow(c).to receive(:create_dir_on_container)
71
+ allow(c).to receive(:replace_env_variables) { |_cfg, path| path }
72
+ allow(c).to receive(:upload)
73
+ allow(c).to receive(:container_exec) { |_cfg, cmd| @ran = cmd }
74
+ c
75
+ end
76
+
77
+ around do |example|
78
+ Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } }
79
+ end
80
+
81
+ it "runs the staged script with powershell" do
82
+ staged.execute("Write-Host hi")
83
+ expect(@ran).to start_with "powershell -ExecutionPolicy Bypass -NoLogo "
84
+ expect(@ran).to match(/-File .*docker-[0-9a-f-]+\.ps1/)
85
+ end
86
+
87
+ it "stages the script under the configured temp_dir" do
88
+ staged.execute("Write-Host hi")
89
+ expect(@ran).to include 'C:\\Temp\\docker-'
90
+ end
91
+
92
+ it "accepts a temp_dir written with forward slashes" do
93
+ # Kitchen configuration is YAML that is often shared with Linux suites,
94
+ # so the separator is normalised rather than rejected.
95
+ staged(temp_dir: "C:/Temp").execute("Write-Host hi")
96
+ expect(@ran).to include 'C:\\Temp\\docker-'
97
+ end
98
+
99
+ # A Windows temp_dir routinely contains a space -- $env:TEMP under a user
100
+ # whose name has one does. PowerShell's -File takes exactly one argument,
101
+ # so an unquoted path left it looking for a script named after the first
102
+ # word.
103
+ it "quotes a script path containing a space" do
104
+ staged(temp_dir: 'C:\\Users\\Foo Bar\\Temp').execute("Write-Host hi")
105
+ expect(@ran).to include('-File "C:\\Users\\Foo Bar\\Temp\\docker-').and end_with('.ps1"')
106
+ end
107
+
108
+ it "removes the staged script once the command has run" do
109
+ staged.execute("Write-Host hi")
110
+ expect(Dir.glob(".kitchen/temp/docker-*.ps1")).to be_empty
111
+ end
112
+
113
+ it "removes the staged script when the command fails" do
114
+ c = staged
115
+ allow(c).to receive(:container_exec).and_raise("command exploded")
116
+ expect { c.execute("Write-Host hi") }.to raise_error(/Failed to execute command/)
117
+ expect(Dir.glob(".kitchen/temp/docker-*.ps1")).to be_empty
118
+ end
119
+ end
120
+
67
121
  describe "the contract it shares with the Linux container" do
68
122
  # The two classes share almost no code but must agree on the state they
69
123
  # populate, because the driver and transport read the same keys whichever