kitchen-docker 3.3.4 → 3.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be40fecdd59251c4d7b1a20de831bd939fd7018ce19d19a2a2f205ac2464f4fb
4
- data.tar.gz: 04b4579a2569e8be9f943d77eca1d4fd9f5e72e2aefc2ad3009855bf1e565935
3
+ metadata.gz: 7cacd3efc6d10490456e00f5d077a3a1eb8f96bb5aef71c6c93b8d2044823a1f
4
+ data.tar.gz: f6c3337ecdc3da7c134da8506472e7237b85a9784dd63fb5b46fa996303539b6
5
5
  SHA512:
6
- metadata.gz: d14d1fcddd8c823cc51231351311d2130bb9e9dd8355bb09e0551635a982a38b8abc52fca32cb03b5813d484bc256a688574ad8da6ef44a3d20525bdeac8fb21
7
- data.tar.gz: 93f7faf2b089f727c6c78808b6c3e4d3fc9234e22a4defa21a0b463b61992324d8ecd26fb5bb9bfbc7da415d00234c67815d7bc20e889c563da50324c76c1040
6
+ metadata.gz: ec737baa038bf371481c82599263ed09cf98921f687da8cb45e52f1becd13d75417fb545d72338806c533dee99d4b1a39d4c47618e8c15709487937557c30c75
7
+ data.tar.gz: e8434376fb8dd607f224de53137d18ee4f9c644c1205b7341cbe3bcc43c7c07ad2aa575fb0ba5cdc7350ee721d6b0fcf6cb781f73421b2251cbfede68f154a0d
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "3.3.4"
2
+ ".": "3.4.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ Future CHANGELOG notes will be in GitHub release notes
6
6
 
7
7
  * Docs: document the last four options and split contributor docs ([#463](https://github.com/test-kitchen/kitchen-docker/pull/463)) ([4818bce](https://github.com/test-kitchen/kitchen-docker/commit/4818bce))
8
8
 
9
+ ## [3.4.0](https://github.com/test-kitchen/kitchen-docker/compare/v3.3.5...v3.4.0) (2026-08-23)
10
+
11
+
12
+ ### Features
13
+
14
+ * implement the driver's package, doctor, and status hooks ([#491](https://github.com/test-kitchen/kitchen-docker/issues/491)) ([1366895](https://github.com/test-kitchen/kitchen-docker/commit/13668957b5b802f91c30aa7e34fc1b9f761e3b8d))
15
+
16
+ ## [3.3.5](https://github.com/test-kitchen/kitchen-docker/compare/v3.3.4...v3.3.5) (2026-08-23)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * report when docker cp silently writes nothing ([#487](https://github.com/test-kitchen/kitchen-docker/issues/487)) ([1bc62ba](https://github.com/test-kitchen/kitchen-docker/commit/1bc62ba615d25d402a97775dbf6ac252af5cbdd8))
22
+ * require test-kitchen 3.0 or newer ([#492](https://github.com/test-kitchen/kitchen-docker/issues/492)) ([0c64225](https://github.com/test-kitchen/kitchen-docker/commit/0c64225dd22aca8a2a32e6f61a6d648bcd8c0b2b))
23
+ * run every docker command through sudo when use_sudo is set ([#485](https://github.com/test-kitchen/kitchen-docker/issues/485)) ([2481aad](https://github.com/test-kitchen/kitchen-docker/commit/2481aadfc6415e5b7ed597e1095d0fe8db89afbb))
24
+ * stop printing the image inspect JSON on destroy ([#488](https://github.com/test-kitchen/kitchen-docker/issues/488)) ([0011558](https://github.com/test-kitchen/kitchen-docker/commit/00115588236900d9ef3275a73892f9d3bdb4e3c8))
25
+
9
26
  ## [3.3.4](https://github.com/test-kitchen/kitchen-docker/compare/v3.3.3...v3.3.4) (2026-08-23)
10
27
 
11
28
 
data/README.md CHANGED
@@ -28,6 +28,7 @@ machine to boot.
28
28
  * [Driver configuration](#driver-configuration)
29
29
  * [Transport configuration](#transport-configuration)
30
30
  * [Logging into a container](#logging-into-a-container)
31
+ * [Other commands](#other-commands)
31
32
  * [Examples](#examples)
32
33
  * [Using with Chef](#using-with-chef)
33
34
  * [Troubleshooting](#troubleshooting)
@@ -207,6 +208,7 @@ platforms:
207
208
  | `build_tempdir` | working directory | Where the generated Dockerfile is written, relative to `build_context`. |
208
209
  | `use_cache` | `true` | Use Docker's build cache. `false` adds `--no-cache`. |
209
210
  | `remove_images` | `false` | Remove the built image on `kitchen destroy`. |
211
+ | `package_name` | the instance name, tagged `latest` | Image `kitchen package` commits the container to. |
210
212
  | `docker_platform` | *(none)* | Target architecture, passed as `--platform` to both build and run — e.g. `linux/arm64`. |
211
213
 
212
214
  ### Provisioning the image
@@ -284,6 +286,7 @@ Each of these accepts a single value or a list.
284
286
  | `binary` | `docker` | Docker CLI to invoke — e.g. `docker.io`, or an absolute path. |
285
287
  | `socket` | `$DOCKER_HOST`, else `unix:///var/run/docker.sock` (`npipe:////./pipe/docker_engine` on Windows) | Daemon to talk to. A `tcp://` value also supplies the host used for SSH to the container. |
286
288
  | `use_sudo` | `false` | Run every `docker` command through `sudo`. |
289
+ | `sudo_command` | `sudo -E` | The command `use_sudo` prefixes, for hosts that use something else (`doas`, say). |
287
290
  | `tls` | `false` | Use TLS when connecting. |
288
291
  | `tls_verify` | `false` | Verify the daemon's certificate. |
289
292
  | `tls_cacert` | *(none)* | Path to the CA certificate. |
@@ -314,11 +317,13 @@ These options go under `transport:`, not `driver:`.
314
317
  | `privileged` | `false` | Run commands with `--privileged`. |
315
318
  | `interactive` | `false` | Pass `-i`. |
316
319
  | `tty` | `false` | Pass `-t`. |
320
+ | `use_sudo` | `false` | Run every `docker` command through `sudo`. |
321
+ | `sudo_command` | `sudo -E` | The command `use_sudo` prefixes. |
317
322
  | `tls`, `tls_verify`, `tls_cacert`, `tls_cert`, `tls_key` | as for the driver | TLS settings for the daemon connection. |
318
323
 
319
324
  The driver and transport each read their own copy of `binary`, `socket`,
320
- `username`, and the TLS settings. If you point one at a non-default daemon,
321
- point the other at it too.
325
+ `username`, `use_sudo`, and the TLS settings. If you point one at a non-default
326
+ daemon, or need `sudo` to reach it, configure the other the same way.
322
327
 
323
328
  ## Logging into a container
324
329
 
@@ -334,6 +339,68 @@ starts `powershell`. The transport's `username`, `working_dir`,
334
339
  `env_variables`, and `privileged` settings are honoured, so the shell matches
335
340
  the environment the provisioner ran in.
336
341
 
342
+ ## Other commands
343
+
344
+ ### `kitchen package`
345
+
346
+ Commits the container to a Docker image, so a converged instance can be kept
347
+ and reused:
348
+
349
+ ```sh
350
+ kitchen package default-ubuntu-2404
351
+ ```
352
+
353
+ ```text
354
+ -----> Packaging remote instance
355
+ [Docker] Packaged default-ubuntu-2404 as default-ubuntu-2404:latest (sha256:1f51c590...)
356
+ ```
357
+
358
+ The image is named after the instance. Set `package_name` for something else:
359
+
360
+ ```yaml
361
+ driver:
362
+ name: docker
363
+ package_name: myapp/under-test:candidate
364
+ ```
365
+
366
+ Run `docker save` against the result if you want a tarball.
367
+
368
+ ### `kitchen doctor`
369
+
370
+ Checks that the daemon is reachable and that the configuration points at
371
+ things that exist:
372
+
373
+ ```sh
374
+ kitchen doctor default-ubuntu-2404
375
+ ```
376
+
377
+ ```text
378
+ -----> The doctor is in
379
+ Docker daemon at unix:///var/run/docker.sock is reachable, running 29.7.2.
380
+ ```
381
+
382
+ It reports every problem it finds rather than stopping at the first, and exits
383
+ non-zero when there is one: a daemon it cannot reach, a `tls_cacert`,
384
+ `tls_cert`, `tls_key`, or `dockerfile` naming a path that is not there, or a
385
+ state file naming a container the daemon no longer has.
386
+
387
+ ### `kitchen list --live`
388
+
389
+ Asks Docker what state each container is actually in, rather than reporting
390
+ only the last action Test Kitchen took:
391
+
392
+ ```sh
393
+ kitchen list --live
394
+ ```
395
+
396
+ ```text
397
+ Instance Driver Provisioner Verifier Transport Last Action Last Error Live Status
398
+ default-ubuntu-2404 Docker Shell Dummy Docker Created <None> running
399
+ ```
400
+
401
+ `running`, `stopped`, `gone` (the state file names a container the daemon does
402
+ not have), or `not created`.
403
+
337
404
  ## Examples
338
405
 
339
406
  ### Testing a systemd service
@@ -534,7 +601,8 @@ bootstrap. Set it to a supported family, or supply your own
534
601
  to `false` against a remote daemon.
535
602
 
536
603
  **Permission denied talking to the daemon.** Either add your user to the
537
- `docker` group, or set `use_sudo: true`.
604
+ `docker` group, or set `use_sudo: true` under **both** `driver:` and
605
+ `transport:` -- the transport runs its own `docker exec` and `docker cp`.
538
606
 
539
607
  **Anything else.** Run with `-l debug`:
540
608
 
@@ -16,5 +16,5 @@ Gem::Specification.new do |spec|
16
16
  spec.files = `git ls-files`.split($/)
17
17
  spec.require_paths = ["lib"]
18
18
 
19
- spec.add_dependency "test-kitchen", ">= 1.0.0", "< 5.0"
19
+ spec.add_dependency "test-kitchen", ">= 3.0", "< 5.0"
20
20
  end
@@ -18,6 +18,6 @@ module Kitchen
18
18
  # Everything belonging to the kitchen-docker plugin.
19
19
  module Docker
20
20
  # The version of the kitchen-docker gem.
21
- DOCKER_VERSION = "3.3.4".freeze
21
+ DOCKER_VERSION = "3.4.0".freeze
22
22
  end
23
23
  end
@@ -64,11 +64,39 @@ module Kitchen
64
64
  docker << " --tlscacert=#{shell_escape(config[:tls_cacert])}" if config[:tls_cacert]
65
65
  docker << " --tlscert=#{shell_escape(config[:tls_cert])}" if config[:tls_cert]
66
66
  docker << " --tlskey=#{shell_escape(config[:tls_key])}" if config[:tls_key]
67
+ options = docker_sudo_opts(options)
67
68
  logger.debug("docker_command: #{docker} #{cmd} shell_opts: #{docker_shell_opts(options)}")
68
69
  run_command("#{docker} #{cmd}", docker_shell_opts(options))
69
70
  end
70
71
  # rubocop:enable Metrics/AbcSize
71
72
 
73
+ # Adds the sudo options {#run_command} reads, when +use_sudo+ is set.
74
+ #
75
+ # Sudo is a property of the call rather than of the configuration as far
76
+ # as the shell-out layer is concerned: it reads +:use_sudo+ from the
77
+ # options hash it is handed and knows nothing about +config+. So a
78
+ # docker command only runs through sudo if these are passed to it.
79
+ #
80
+ # Without this, +use_sudo+ reached exactly one command -- the
81
+ # `docker` probe in +verify_dependencies+ -- and every build, run,
82
+ # exec, cp, and rm still ran as the invoking user. On a host where the
83
+ # daemon socket needs root, that made the documented answer to
84
+ # "permission denied while trying to connect to the Docker daemon
85
+ # socket" do nothing at all.
86
+ #
87
+ # A copy is returned rather than the hash being edited in place, so a
88
+ # caller that reuses its options hash does not accumulate sudo.
89
+ #
90
+ # @param options [Hash] shell-out options
91
+ # @return [Hash] those options, with sudo added when configured
92
+ def docker_sudo_opts(options = {})
93
+ return options unless config[:use_sudo]
94
+
95
+ options = options.merge(use_sudo: true)
96
+ options[:sudo_command] = config[:sudo_command] if config[:sudo_command]
97
+ options
98
+ end
99
+
72
100
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
73
101
 
74
102
  # Runs a shell command, returning stderr as well as stdout.
@@ -33,6 +33,11 @@ module Kitchen
33
33
  include Configurable
34
34
  include Kitchen::Docker::Helpers::CliHelper
35
35
 
36
+ # Printed by the probe in {#file_on_container?} when the file is there.
37
+ # A marker is echoed rather than the exit status being read, because a
38
+ # non-zero exit from `docker exec` is raised rather than returned.
39
+ COPIED_MARKER = "kitchen_docker_copied".freeze
40
+
36
41
  # Pulls the container id out of `docker run` output.
37
42
  #
38
43
  # Docker prints ids in short (12) or full (64) hex form, on a line of
@@ -172,23 +177,84 @@ module Kitchen
172
177
 
173
178
  # Copies a local file into the container.
174
179
  #
180
+ # The copy is checked afterwards, because `docker cp` cannot write into
181
+ # a mount and does not say so. A destination under a tmpfs or a volume
182
+ # is written to the container's own filesystem layer, which the mount
183
+ # then hides, and docker exits 0 with no output -- so the copy looks
184
+ # like it worked and the file is simply not there.
185
+ #
186
+ # Left unchecked, that surfaces later and somewhere else. Running with
187
+ # `tmpfs: /tmp`, which is how the Docker documentation suggests running
188
+ # systemd, the first sign is the next command failing with
189
+ # `/bin/bash: /tmp/docker-<uuid>.sh: No such file or directory` (#387),
190
+ # which names neither the copy nor the mount.
191
+ #
175
192
  # @param state [Hash] instance state naming the container
176
193
  # @param local_file [String] source path
177
194
  # @param remote_file [String] destination path inside the container
178
195
  # @return [String] the command's combined output
179
- # @raise [RuntimeError] if the copy fails
196
+ # @raise [RuntimeError] if the copy fails, or if it silently wrote
197
+ # nothing
180
198
  def copy_file_to_container(state, local_file, remote_file)
181
199
  debug("Copying local file #{local_file} to #{remote_file} on container")
182
200
 
183
201
  remote_file = replace_env_variables(state, remote_file)
184
202
 
185
- remote_file = "#{state[:container_id]}:#{remote_file}"
186
- cmd = build_copy_command(local_file, remote_file)
187
- docker_command(cmd)
203
+ cmd = build_copy_command(local_file, "#{state[:container_id]}:#{remote_file}")
204
+ output = docker_command(cmd)
205
+ verify_file_copied(state, local_file, remote_file)
206
+ output
188
207
  rescue => e
189
208
  raise "Failed to copy file #{local_file} to container. #{e}"
190
209
  end
191
210
 
211
+ # Checks that a copied file arrived, and says why if it did not.
212
+ #
213
+ # Only Linux containers are checked. tmpfs mounts are a Linux container
214
+ # feature, and `docker cp` against a Windows container is a different
215
+ # code path in Docker that this cannot be tried against, so those keep
216
+ # the behaviour they have always had.
217
+ #
218
+ # @param state [Hash] instance state naming the container
219
+ # @param local_file [String] the source that was copied
220
+ # @param remote_file [String] the destination it was copied to
221
+ # @return [void]
222
+ # @raise [Kitchen::ActionFailed] if the file is not there
223
+ def verify_file_copied(state, local_file, remote_file)
224
+ return if state[:platform].to_s.include?("windows")
225
+ return if file_on_container?(state, remote_file, ::File.basename(local_file))
226
+
227
+ raise ActionFailed,
228
+ "docker reported no error copying it to #{remote_file}, but the file is " \
229
+ "not there. `docker cp` cannot write into a mount -- if #{remote_file} is " \
230
+ "a tmpfs or a volume, set the transport's temp_dir and the provisioner's " \
231
+ "root_path to a path that is not."
232
+ end
233
+
234
+ # Whether a `docker cp` destination now holds the file that was copied.
235
+ #
236
+ # `docker cp SRC CONTAINER:DEST` copies into DEST when DEST is a
237
+ # directory and to DEST otherwise. Which of those happened is only known
238
+ # inside the container, so the choice is made there, in the one command,
239
+ # rather than by asking twice from here.
240
+ #
241
+ # The two paths are passed as arguments to `sh` rather than interpolated
242
+ # into the script, so that nothing in either is read as shell syntax.
243
+ #
244
+ # @param state [Hash] instance state naming the container
245
+ # @param remote_file [String] the destination that was copied to
246
+ # @param basename [String] the source's file name
247
+ # @return [Boolean] whether the file is there
248
+ def file_on_container?(state, remote_file, basename)
249
+ script = 'p="$1"; if [ -d "$p" ]; then p="$p/$2"; fi; ' \
250
+ "if [ -e \"$p\" ]; then echo #{COPIED_MARKER}; fi"
251
+ probe = "/bin/sh -c #{Shellwords.escape(script)} sh " \
252
+ "#{Shellwords.escape(remote_file)} #{Shellwords.escape(basename)}"
253
+
254
+ output = docker_command(build_exec_command(state, probe), suppress_output: !logger.debug?)
255
+ output.include?(COPIED_MARKER)
256
+ end
257
+
192
258
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
193
259
 
194
260
  # Reads the container's environment.
@@ -126,10 +126,28 @@ module Kitchen
126
126
  parse_image_id(output)
127
127
  end
128
128
 
129
+ # Whether the image named in state is present locally.
130
+ #
131
+ # The inspect is silenced, as every other predicate that shells out to
132
+ # docker is. Left speaking, `kitchen destroy` on an instance with
133
+ # +remove_images+ set printed the image's entire `docker inspect` JSON
134
+ # -- config, every layer digest, metadata -- into the middle of the
135
+ # destroy output, between the container being removed and the image
136
+ # being removed. Nothing read it: only whether the command succeeded is
137
+ # used.
138
+ #
139
+ # It is still printed under `-l debug`, where the rest of the driver's
140
+ # docker traffic is.
141
+ #
129
142
  # @param state [Hash] instance state naming the image
130
143
  # @return [Boolean] whether the image is present locally
131
144
  def image_exists?(state)
132
- state[:image_id] && !!docker_command("inspect --type=image #{state[:image_id]}") rescue false
145
+ return false unless state[:image_id]
146
+
147
+ !!docker_command("inspect --type=image #{state[:image_id]}",
148
+ suppress_output: !logger.debug?)
149
+ rescue
150
+ false
133
151
  end
134
152
  end
135
153
  end
@@ -17,6 +17,7 @@ require "kitchen"
17
17
  require "json" unless defined?(JSON)
18
18
  require "securerandom" unless defined?(SecureRandom)
19
19
  require "net/ssh" unless defined?(Net::SSH)
20
+ require "time" unless defined?(Time)
20
21
 
21
22
  require "kitchen/driver/base"
22
23
 
@@ -52,6 +53,7 @@ module Kitchen
52
53
  default_config :remove_images, false
53
54
  default_config :run_options, nil
54
55
  default_config :security_opt, nil
56
+ default_config :sudo_command, nil
55
57
  default_config :tls, false
56
58
  default_config :tls_cacert, nil
57
59
  default_config :tls_cert, nil
@@ -79,6 +81,13 @@ module Kitchen
79
81
  ].join("-").downcase
80
82
  end
81
83
 
84
+ # The image `kitchen package` commits to. Derived from the instance name,
85
+ # which is already lowercase and dash-separated, so it is a valid Docker
86
+ # repository name as it stands.
87
+ default_config :package_name do |driver|
88
+ "#{driver.instance.name.downcase.gsub(/[^a-z0-9_.-]/, "-")}:latest"
89
+ end
90
+
82
91
  default_config :platform, &:default_platform
83
92
 
84
93
  default_config :run_command do |driver|
@@ -140,6 +149,82 @@ module Kitchen
140
149
  container.destroy(state)
141
150
  end
142
151
 
152
+ # Commits the container to a Docker image.
153
+ #
154
+ # `kitchen package` asks a driver to turn a converged instance into
155
+ # something reusable. For Docker that is an image: `docker commit` on the
156
+ # running container, which is the artifact every other docker tool
157
+ # already takes. Run `docker save` against the result for a tarball.
158
+ #
159
+ # @param state [Hash] instance state naming the container
160
+ # @return [void]
161
+ # @raise [Kitchen::ActionFailed] if the instance has not been created
162
+ def package(state)
163
+ unless state[:container_id]
164
+ raise ActionFailed, "Cannot package #{instance.name}: it has not been created."
165
+ end
166
+
167
+ # Asked here rather than left to `docker commit`, which reports a
168
+ # container that is gone as a bare "Error response from daemon: No such
169
+ # container: <64 hex characters>" with nothing about the instance or
170
+ # what to do next.
171
+ unless container_exists?(state)
172
+ raise ActionFailed, "Cannot package #{instance.name}: the state file names container " \
173
+ "#{state[:container_id]}, which the daemon does not have. " \
174
+ "Run `kitchen destroy` to clear it."
175
+ end
176
+
177
+ name = config[:package_name]
178
+ info("[Docker] Committing container #{state[:container_id]} to #{name}")
179
+ output = docker_command("commit #{shell_escape(state[:container_id])} #{shell_escape(name)}",
180
+ suppress_output: !logger.debug?)
181
+ image_id = output.lines.map(&:strip).find { |line| line.match?(/\Asha256:[[:xdigit:]]{64}\z/) }
182
+ info("[Docker] Packaged #{instance.name} as #{name}#{" (#{image_id})" if image_id}")
183
+ end
184
+
185
+ # Checks the configuration and the daemon it points at.
186
+ #
187
+ # Run by `kitchen doctor`. A true return is how Test Kitchen decides to
188
+ # exit non-zero, so every check runs and the results are OR-ed together
189
+ # rather than returning at the first problem -- somebody running `doctor`
190
+ # wants the whole list, not the first item on it.
191
+ #
192
+ # @param state [Hash] instance state
193
+ # @return [Boolean] whether a problem was found
194
+ def doctor(state)
195
+ [
196
+ doctor_daemon,
197
+ doctor_files,
198
+ doctor_container(state),
199
+ ].any?
200
+ end
201
+
202
+ # Reports whether the container backing this instance is up.
203
+ #
204
+ # Read by `kitchen list --live`, which showed "unknown" for every
205
+ # instance: {Kitchen::Driver::Base} cannot know, and this driver never
206
+ # said. Docker can answer directly, and these are the same two questions
207
+ # create and destroy already ask.
208
+ #
209
+ # @param state [Hash] instance state naming the container
210
+ # @return [Hash] normalized status data
211
+ def status(state)
212
+ common = { source: "driver", checked_at: Time.now.utc.iso8601, resource_id: state[:container_id] }
213
+
214
+ if !state[:container_id]
215
+ common.merge(live: false, state: "not created",
216
+ message: "No container is recorded in the state file")
217
+ elsif !container_exists?(state)
218
+ common.merge(live: false, state: "gone",
219
+ message: "The state file names a container the daemon does not have")
220
+ elsif container_running?(state)
221
+ common.merge(live: true, state: "running")
222
+ else
223
+ common.merge(live: false, state: "stopped",
224
+ message: "The container exists but is not running")
225
+ end
226
+ end
227
+
143
228
  # Waits for the transport to accept a connection, unless disabled.
144
229
  #
145
230
  # @param state [Hash] instance state describing how to connect
@@ -171,6 +256,44 @@ module Kitchen
171
256
 
172
257
  protected
173
258
 
259
+ # @return [Boolean] whether the daemon could not be reached
260
+ def doctor_daemon
261
+ version = docker_command("version --format '{{.Server.Version}}'", suppress_output: true).strip
262
+ info("Docker daemon at #{config[:socket]} is reachable, running #{version}.")
263
+ false
264
+ rescue => e
265
+ error("Cannot reach the Docker daemon at #{config[:socket]}. #{e}")
266
+ true
267
+ end
268
+
269
+ # Checks paths the configuration names.
270
+ #
271
+ # A missing TLS file or Dockerfile is worth catching here because docker
272
+ # reports it far from the cause -- a missing client certificate surfaces
273
+ # as a connection error rather than as a missing file.
274
+ #
275
+ # @return [Boolean] whether any named path is missing
276
+ def doctor_files
277
+ %i{tls_cacert tls_cert tls_key dockerfile}.map do |key|
278
+ path = config[key]
279
+ next false if path.nil? || ::File.exist?(::File.expand_path(path))
280
+
281
+ error("#{key} is set to #{path}, which does not exist.")
282
+ true
283
+ end.any?
284
+ end
285
+
286
+ # @param state [Hash] instance state naming the container
287
+ # @return [Boolean] whether state names a container that is gone
288
+ def doctor_container(state)
289
+ return false unless state[:container_id]
290
+ return false if container_exists?(state)
291
+
292
+ error("The state file names container #{state[:container_id]}, which the daemon does " \
293
+ "not have. Run `kitchen destroy` to clear it.")
294
+ true
295
+ end
296
+
174
297
  # The container implementation for this platform.
175
298
  #
176
299
  # @return [Kitchen::Docker::Container] a Windows or Linux container
@@ -38,12 +38,14 @@ module Kitchen
38
38
  default_config :env_variables, nil
39
39
  default_config :interactive, false
40
40
  default_config :privileged, false
41
+ default_config :sudo_command, nil
41
42
  default_config :tls, false
42
43
  default_config :tls_cacert, nil
43
44
  default_config :tls_cert, nil
44
45
  default_config :tls_key, nil
45
46
  default_config :tls_verify, false
46
47
  default_config :tty, false
48
+ default_config :use_sudo, false
47
49
  default_config :working_dir, nil
48
50
 
49
51
  default_config :socket do |transport|
@@ -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)
@@ -334,11 +334,80 @@ describe Kitchen::Docker::Helpers::ContainerHelper do
334
334
  end
335
335
 
336
336
  describe "#copy_file_to_container" do
337
+ let(:state) { { container_id: "abc", platform: "ubuntu-24.04" } }
338
+
339
+ # Records every docker subcommand, and answers the copy check with
340
+ # `landed`. `docker cp` itself prints nothing on success, so the probe is
341
+ # the only call whose output matters.
342
+ def copier(landed:)
343
+ helper.tap do |h|
344
+ @commands = []
345
+ allow(h).to receive(:replace_env_variables) { |_s, path| path }
346
+ allow(h).to receive(:docker_command) do |cmd, _opts = {}|
347
+ @commands << cmd
348
+ cmd.include?(described_class::COPIED_MARKER) && landed ? "#{described_class::COPIED_MARKER}\n" : ""
349
+ end
350
+ end
351
+ end
352
+
337
353
  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")
354
+ copier(landed: true).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb")
355
+ expect(@commands.first).to eq "cp /local/f.rb abc:/tmp/f.rb"
356
+ end
357
+
358
+ it "says nothing when the file arrived" do
359
+ expect { copier(landed: true).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb") }
360
+ .not_to raise_error
361
+ end
362
+
363
+ # From #387. `docker cp` writes to the container's filesystem layer, which
364
+ # a tmpfs or volume mounted over the destination then hides -- and it exits
365
+ # 0, so nothing about the copy says it did not happen. Left unchecked the
366
+ # first sign is the next command failing with "No such file or directory",
367
+ # which names neither the copy nor the mount.
368
+ context "when docker exits 0 but wrote nothing, as it does into a mount" do
369
+ it "fails at the copy rather than somewhere later" do
370
+ expect { copier(landed: false).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb") }
371
+ .to raise_error(RuntimeError, %r{Failed to copy file /local/f\.rb})
372
+ end
373
+
374
+ it "names the mount as the cause" do
375
+ expect { copier(landed: false).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb") }
376
+ .to raise_error(RuntimeError, /cannot write into a mount/)
377
+ end
378
+
379
+ it "names the settings that move the destination off the mount" do
380
+ expect { copier(landed: false).copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb") }
381
+ .to raise_error(RuntimeError, /temp_dir.*root_path/m)
382
+ end
383
+ end
384
+
385
+ it "checks the path docker copies into, not the directory it was given" do
386
+ copier(landed: true).copy_file_to_container(state, "/local/f.rb", "/tmp")
387
+ # The probe is handed both parts and picks between them inside the
388
+ # container, since only there is it known whether /tmp is a directory.
389
+ expect(@commands.last).to include("/tmp f.rb")
390
+ end
391
+
392
+ it "does not print the probe's marker to the console" do
393
+ h = copier(landed: true)
394
+ allow(h).to receive(:logger).and_return(double(debug?: false, debug: nil))
395
+ opts = []
396
+ allow(h).to receive(:docker_command) { |_cmd, o = {}| opts << o; "" }
397
+ begin
398
+ h.copy_file_to_container(state, "/local/f.rb", "/tmp/f.rb")
399
+ rescue RuntimeError
400
+ nil
401
+ end
402
+ expect(opts.last).to eq(suppress_output: true)
403
+ end
404
+
405
+ # Windows containers have no tmpfs, and `docker cp` against them is a
406
+ # different code path in Docker, so they keep the behaviour they had.
407
+ it "does not probe a Windows container" do
408
+ h = copier(landed: false)
409
+ h.copy_file_to_container({ container_id: "abc", platform: "windows-2022" }, "C:\\f.rb", "C:\\Temp")
410
+ expect(@commands.length).to eq 1
342
411
  end
343
412
  end
344
413
  end
data/spec/docker_spec.rb CHANGED
@@ -62,6 +62,159 @@ describe Kitchen::Driver::Docker do
62
62
  end # /context with a hash of strings with spaces
63
63
  end # /describe #config_to_options
64
64
 
65
+ # `kitchen package`, `kitchen doctor`, and `kitchen list --live` each ask the
66
+ # driver a question. Driver::Base answers all three with a shrug, and this
67
+ # driver used to inherit that: package produced nothing, doctor said nothing,
68
+ # and every instance listed as "unknown". Docker can answer all three.
69
+ def driver(config = {}, instance_name: "default-ubuntu-2404")
70
+ described_class.new(config).tap do |d|
71
+ allow(d).to receive(:instance).and_return(instance_double("Kitchen::Instance", name: instance_name))
72
+ allow(d).to receive(:logger).and_return(double(debug?: false, debug: nil))
73
+ %i{info error debug banner}.each { |level| allow(d).to receive(level) }
74
+ end
75
+ end
76
+
77
+ describe "#status" do
78
+ let(:state) { { container_id: "abc123abc123" } }
79
+
80
+ def status_of(exists:, running:, state: { container_id: "abc123abc123" })
81
+ d = driver
82
+ allow(d).to receive(:container_exists?).and_return(exists)
83
+ allow(d).to receive(:container_running?).and_return(running)
84
+ d.status(state)
85
+ end
86
+
87
+ it "reports a running container as live" do
88
+ expect(status_of(exists: true, running: true))
89
+ .to include(live: true, state: "running", source: "driver")
90
+ end
91
+
92
+ it "distinguishes a stopped container from a missing one" do
93
+ expect(status_of(exists: true, running: false)).to include(live: false, state: "stopped")
94
+ expect(status_of(exists: false, running: false)).to include(live: false, state: "gone")
95
+ end
96
+
97
+ it "reports an instance with no container as not created" do
98
+ expect(status_of(exists: false, running: false, state: {}))
99
+ .to include(live: false, state: "not created")
100
+ end
101
+
102
+ it "names the container so `kitchen list --live` can show it" do
103
+ expect(status_of(exists: true, running: true)[:resource_id]).to eq "abc123abc123"
104
+ end
105
+
106
+ it "does not ask docker about an instance that has no container" do
107
+ d = driver
108
+ expect(d).not_to receive(:container_exists?)
109
+ d.status({})
110
+ end
111
+
112
+ it "stamps when it looked" do
113
+ expect(status_of(exists: true, running: true)[:checked_at])
114
+ .to match(/\A\d{4}-\d{2}-\d{2}T[\d:]+Z\z/)
115
+ end
116
+ end
117
+
118
+ describe "#package" do
119
+ let(:state) { { container_id: "abc123abc123" } }
120
+ let(:digest) { "sha256:#{"a" * 64}" }
121
+
122
+ it "commits the container to the configured image name" do
123
+ d = driver({ package_name: "myapp:v1" })
124
+ allow(d).to receive(:container_exists?).and_return(true)
125
+ expect(d).to receive(:docker_command)
126
+ .with("commit abc123abc123 myapp:v1", hash_including(:suppress_output))
127
+ .and_return("#{digest}\n")
128
+ d.package(state)
129
+ end
130
+
131
+ it "names the image after the instance by default" do
132
+ d = driver
133
+ expect(d.send(:config)[:package_name]).to eq "default-ubuntu-2404:latest"
134
+ end
135
+
136
+ it "escapes a package name that would otherwise split" do
137
+ d = driver({ package_name: "my app:v1" })
138
+ allow(d).to receive(:container_exists?).and_return(true)
139
+ expect(d).to receive(:docker_command)
140
+ .with(%q{commit abc123abc123 my\ app:v1}, hash_including(:suppress_output))
141
+ .and_return("#{digest}\n")
142
+ d.package(state)
143
+ end
144
+
145
+ it "refuses to package an instance that was never created" do
146
+ expect { driver.package({}) }
147
+ .to raise_error(Kitchen::ActionFailed, /has not been created/)
148
+ end
149
+
150
+ it "does not run docker for an instance that was never created" do
151
+ d = driver
152
+ expect(d).not_to receive(:docker_command)
153
+ expect { d.package({}) }.to raise_error(Kitchen::ActionFailed)
154
+ end
155
+
156
+ # `docker commit` on a container that is gone says only "Error response
157
+ # from daemon: No such container: <64 hex characters>", which names neither
158
+ # the instance nor what to do about it.
159
+ it "names the instance when the container is gone" do
160
+ d = driver
161
+ allow(d).to receive(:container_exists?).and_return(false)
162
+ expect { d.package(state) }
163
+ .to raise_error(Kitchen::ActionFailed, /default-ubuntu-2404.*kitchen destroy/m)
164
+ end
165
+ end
166
+
167
+ describe "#doctor" do
168
+ let(:state) { {} }
169
+
170
+ def doctor_with(config = {}, daemon: "29.7.2", state: {})
171
+ d = driver(config)
172
+ allow(d).to receive(:container_exists?).and_return(true)
173
+ allow(d).to receive(:docker_command) do
174
+ raise Kitchen::ShellOut::ShellCommandFailed, "cannot connect" if daemon.nil?
175
+
176
+ "#{daemon}\n"
177
+ end
178
+ d.doctor(state)
179
+ end
180
+
181
+ it "reports no problem when the daemon answers" do
182
+ expect(doctor_with).to be false
183
+ end
184
+
185
+ it "reports a problem when the daemon cannot be reached" do
186
+ expect(doctor_with(daemon: nil)).to be true
187
+ end
188
+
189
+ it "reports a TLS file that is not there" do
190
+ expect(doctor_with({ tls_cert: "/nope/cert.pem" })).to be true
191
+ end
192
+
193
+ it "reports a dockerfile that is not there" do
194
+ expect(doctor_with({ dockerfile: "/nope/Dockerfile" })).to be true
195
+ end
196
+
197
+ it "accepts paths that do exist" do
198
+ expect(doctor_with({ dockerfile: __FILE__ })).to be false
199
+ end
200
+
201
+ it "reports a state file naming a container the daemon does not have" do
202
+ d = driver
203
+ allow(d).to receive(:docker_command).and_return("29.7.2\n")
204
+ allow(d).to receive(:container_exists?).and_return(false)
205
+ expect(d.doctor(container_id: "abc123abc123")).to be true
206
+ end
207
+
208
+ it "keeps checking after the first problem, so the whole list is reported" do
209
+ # `kitchen doctor` exists to tell you everything that is wrong at once.
210
+ d = driver({ tls_cert: "/nope/cert.pem", dockerfile: "/nope/Dockerfile" })
211
+ allow(d).to receive(:docker_command).and_raise(Kitchen::ShellOut::ShellCommandFailed, "nope")
212
+ allow(d).to receive(:container_exists?).and_return(false)
213
+ expect(d).to receive(:error).at_least(4).times
214
+ d.doctor(container_id: "abc123abc123")
215
+ end
216
+ end
217
+
65
218
  describe "socket default config logic" do
66
219
  def resolve_socket
67
220
  socket = "unix:///var/run/docker.sock"
@@ -178,4 +178,49 @@ describe Kitchen::Docker::Helpers::ImageHelper do
178
178
  h.remove_image(state)
179
179
  end
180
180
  end
181
+
182
+ describe "#image_exists?" do
183
+ let(:state) { { image_id: "sha256:abc" } }
184
+
185
+ def asking(&answer)
186
+ helper.tap do |h|
187
+ @opts = nil
188
+ allow(h).to receive(:logger).and_return(double(debug?: false, debug: nil))
189
+ allow(h).to receive(:docker_command) { |_cmd, opts = {}| @opts = opts; answer.call }
190
+ end
191
+ end
192
+
193
+ it "is true when docker knows the image" do
194
+ expect(asking { "[{}]" }.image_exists?(state)).to be true
195
+ end
196
+
197
+ it "is false when docker does not" do
198
+ expect(asking { raise Kitchen::ShellOut::ShellCommandFailed, "no such image" }
199
+ .image_exists?(state)).to be false
200
+ end
201
+
202
+ it "does not ask about an image that state does not name" do
203
+ h = helper
204
+ expect(h).not_to receive(:docker_command)
205
+ expect(h.image_exists?({})).to be false
206
+ end
207
+
208
+ # `kitchen destroy` with remove_images set printed the image's whole
209
+ # inspect JSON -- config, every layer digest, metadata -- between removing
210
+ # the container and removing the image. Only whether the command succeeded
211
+ # is used here, as in every other predicate in this file, all of which
212
+ # already silence their output.
213
+ it "does not print the inspect output" do
214
+ asking { "[{}]" }.image_exists?(state)
215
+ expect(@opts).to eq(suppress_output: true)
216
+ end
217
+
218
+ it "still prints it under -l debug" do
219
+ h = helper
220
+ allow(h).to receive(:logger).and_return(double(debug?: true, debug: nil))
221
+ allow(h).to receive(:docker_command) { |_cmd, opts = {}| @opts = opts; "[{}]" }
222
+ h.image_exists?(state)
223
+ expect(@opts).to eq(suppress_output: false)
224
+ end
225
+ end
181
226
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-docker
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.4
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Porter
@@ -16,7 +16,7 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 1.0.0
19
+ version: '3.0'
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
22
  version: '5.0'
@@ -26,7 +26,7 @@ dependencies:
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: 1.0.0
29
+ version: '3.0'
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '5.0'