kitchen-dokken 2.23.2 → 2.23.4

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.
@@ -20,6 +20,7 @@ require "json" unless defined?(JSON)
20
20
  require "kitchen"
21
21
  require "tmpdir" unless defined?(Dir.mktmpdir)
22
22
  require "docker"
23
+ require "shellwords" unless defined?(Shellwords)
23
24
  require "base64" unless defined?(Base64)
24
25
  require_relative "../helpers"
25
26
 
@@ -29,9 +30,16 @@ include Dokken::Helpers
29
30
  Excon.defaults[:ssl_verify_peer] = false
30
31
 
31
32
  module Kitchen
33
+ # @see Kitchen::Driver::Dokken
32
34
  module Driver
33
35
  # Dokken driver for Kitchen.
34
36
  #
37
+ # Creates three containers per instance: a *runner*, which the converge
38
+ # actually happens in; a *chef* volume container, whose /opt/chef is
39
+ # mounted into the runner instead of installing chef; and -- only when the
40
+ # docker daemon cannot read the local filesystem -- a *data* container that
41
+ # serves the kitchen sandbox over ssh.
42
+ #
35
43
  # @author Sean OMeara <sean@sean.io>
36
44
  class Dokken < Kitchen::Driver::Base
37
45
  default_config :api_retries, 20
@@ -81,9 +89,12 @@ module Kitchen
81
89
  default_config :write_timeout, 3600
82
90
  default_config :user_ns_mode, nil
83
91
  default_config :creds_file, nil
84
- default_config :docker_config_creds, false
92
+ default_config :docker_config_creds, true
85
93
 
86
94
  # (see Base#create)
95
+ #
96
+ # @param state [Hash] mutable instance state
97
+ # @return [void]
87
98
  def create(state)
88
99
  # Authenticate the private registry
89
100
  authenticate!
@@ -116,6 +127,10 @@ module Kitchen
116
127
  save_misc_state state
117
128
  end
118
129
 
130
+ # (see Base#destroy)
131
+ #
132
+ # The chef volume container and the dokken network are deliberately left
133
+ # behind: both are shared by every instance using the same chef version.
119
134
  def destroy(_state)
120
135
  if remote_docker_host? || running_inside_docker?
121
136
  stop_data_container
@@ -130,6 +145,10 @@ module Kitchen
130
145
 
131
146
  private
132
147
 
148
+ # Attach DNS settings to a network endpoint configuration, in place.
149
+ #
150
+ # @param endpoint_config [Hash] the EndpointsConfig entry to extend
151
+ # @return [void]
133
152
  def add_dns_config(endpoint_config)
134
153
  return unless self[:dns] || self[:dns_search]
135
154
 
@@ -138,16 +157,30 @@ module Kitchen
138
157
  endpoint_config["DNSConfig"]["Search"] = self[:dns_search] if self[:dns_search]
139
158
  end
140
159
 
160
+ # A Hash that compares equal to any Hash containing all of its pairs.
161
+ #
162
+ # The daemon echoes back more volumes than we asked for, so a plain
163
+ # equality check against the requested set would never match.
141
164
  class PartialHash < Hash
165
+ # Whether `other` contains every pair this hash holds.
166
+ #
167
+ # @param other [Object] the value to compare against
168
+ # @return [Boolean] true when `other` is a superset hash
142
169
  def ==(other)
143
170
  other.is_a?(Hash) && all? { |key, val| other.key?(key) && other[key] == val }
144
171
  end
145
172
  end
146
173
 
174
+ # How many times to retry a retryable docker API call.
175
+ #
176
+ # @return [Integer] the retry count
147
177
  def api_retries
148
178
  config[:api_retries]
149
179
  end
150
180
 
181
+ # The docker-api connection this driver talks to.
182
+ #
183
+ # @return [::Docker::Connection] a memoised connection
151
184
  def docker_connection
152
185
  opts = ::Docker.options
153
186
  opts[:read_timeout] = config[:read_timeout]
@@ -155,6 +188,9 @@ module Kitchen
155
188
  @docker_connection ||= ::Docker::Connection.new(config[:docker_host_url], opts)
156
189
  end
157
190
 
191
+ # Remove this instance's work image, if nothing else still needs it.
192
+ #
193
+ # @return [void]
158
194
  def delete_work_image
159
195
  return unless ::Docker::Image.exist?(work_image, { "platform" => oci_platform(config[:platform]) }, docker_connection)
160
196
 
@@ -167,6 +203,11 @@ module Kitchen
167
203
  end
168
204
  end
169
205
 
206
+ # Build the per-instance image the runner container is created from.
207
+ #
208
+ # @param state [Hash] mutable instance state
209
+ # @return [void]
210
+ # @raise [RuntimeError] if the daemon rejects the build
170
211
  def build_work_image(state)
171
212
  info("Building work image..")
172
213
  return if ::Docker::Image.exist?(work_image, { "platform" => oci_platform(config[:platform]) }, docker_connection)
@@ -183,7 +224,7 @@ module Kitchen
183
224
  # credit to https://github.com/someara/kitchen-dokken/issues/95#issue-224697526
184
225
  rescue Docker::Error::UnexpectedResponseError => e
185
226
  msg = "work_image build failed: "
186
- msg += JSON.parse(e.to_s.split("\r\n").last)["error"].to_s
227
+ msg += build_error_detail(e)
187
228
  msg += ". The common scenarios are incorrect intermediate "
188
229
  msg += "instructions such as not including `-y` on an `apt-get` "
189
230
  msg += "or similar. The other common scenario is a transient "
@@ -197,6 +238,24 @@ module Kitchen
197
238
  state[:work_image] = work_image
198
239
  end
199
240
 
241
+ # Pull the human-readable reason out of a failed build response.
242
+ #
243
+ # The daemon usually answers with a JSON document carrying an "error"
244
+ # key, but a proxy or a plain-text 500 does not -- and a JSON::ParserError
245
+ # raised from inside the rescue clause would bury the real failure.
246
+ #
247
+ # @param error [Exception] the error docker-api raised
248
+ # @return [String] the daemon's explanation, or the raw response
249
+ def build_error_detail(error)
250
+ last_line = error.to_s.split("\r\n").last.to_s
251
+ JSON.parse(last_line)["error"].to_s
252
+ rescue JSON::ParserError, TypeError
253
+ last_line
254
+ end
255
+
256
+ # The Dockerfile for the work image.
257
+ #
258
+ # @return [String] the Dockerfile contents
200
259
  def work_image_dockerfile
201
260
  from = registry_image_path(platform_image)
202
261
  debug("driver - Building work image from #{from}")
@@ -210,6 +269,10 @@ module Kitchen
210
269
  dockerfile_contents.join("\n")
211
270
  end
212
271
 
272
+ # Record the values the transport, provisioner and verifier read later.
273
+ #
274
+ # @param state [Hash] mutable instance state
275
+ # @return [void]
213
276
  def save_misc_state(state)
214
277
  state[:platform_image] = platform_image
215
278
  state[:instance_name] = instance_name
@@ -217,47 +280,81 @@ module Kitchen
217
280
  state[:image_prefix] = image_prefix
218
281
  end
219
282
 
283
+ # Delete the shared chef volume container.
284
+ #
285
+ # Not called during {#destroy}; it exists for callers that really do want
286
+ # to reclaim the shared container.
287
+ #
288
+ # @return [void]
220
289
  def delete_chef_container
221
290
  debug "driver - deleting container #{chef_container_name}"
222
291
  delete_container chef_container_name
223
292
  end
224
293
 
294
+ # Delete this instance's data container.
295
+ #
296
+ # @return [void]
225
297
  def delete_data_container
226
298
  debug "driver - deleting container #{data_container_name}"
227
299
  delete_container data_container_name
228
300
  end
229
301
 
302
+ # Delete this instance's runner container.
303
+ #
304
+ # @return [void]
230
305
  def delete_runner_container
231
306
  debug "driver - deleting container #{runner_container_name}"
232
307
  delete_container runner_container_name
233
308
  end
234
309
 
310
+ # The configured image name prefix, if any.
311
+ #
312
+ # @return [String, nil] the prefix
235
313
  def image_prefix
236
314
  config[:image_prefix]
237
315
  end
238
316
 
317
+ # The kitchen platform name, e.g. `almalinux-9`.
318
+ #
319
+ # @return [String] the platform name
239
320
  def instance_platform_name
240
321
  instance.platform.name
241
322
  end
242
323
 
324
+ # Stop this instance's runner container.
325
+ #
326
+ # @return [void]
243
327
  def stop_runner_container
244
328
  debug "driver - stopping container #{runner_container_name}"
245
329
  stop_container runner_container_name
246
330
  end
247
331
 
332
+ # Stop this instance's data container.
333
+ #
334
+ # @return [void]
248
335
  def stop_data_container
249
336
  debug "driver - stopping container #{data_container_name}"
250
337
  stop_container data_container_name
251
338
  end
252
339
 
340
+ # The name of the per-instance image the runner is created from.
341
+ #
342
+ # @return [String] a lowercase image name
253
343
  def work_image
254
344
  [image_prefix, instance_name].compact.join("/").downcase
255
345
  end
256
346
 
347
+ # The `Tmpfs` value for the runner container.
348
+ #
349
+ # @return [Hash, nil] a Docker API Tmpfs hash
257
350
  def dokken_tmpfs
258
351
  coerce_tmpfs(config[:tmpfs])
259
352
  end
260
353
 
354
+ # Normalise a `tmpfs:` setting into a Docker API Tmpfs hash.
355
+ #
356
+ # @param v [Hash, Array<String>, nil] the configured tmpfs mounts
357
+ # @return [Hash, nil] a Tmpfs hash, or nil to omit the key
261
358
  def coerce_tmpfs(v)
262
359
  case v
263
360
  when Hash, nil
@@ -270,6 +367,9 @@ module Kitchen
270
367
  end
271
368
  end
272
369
 
370
+ # The containers whose volumes the runner mounts.
371
+ #
372
+ # @return [Array<String>] container names
273
373
  def dokken_volumes_from
274
374
  ret = []
275
375
  ret << chef_container_name
@@ -277,6 +377,14 @@ module Kitchen
277
377
  ret
278
378
  end
279
379
 
380
+ # Split a `volumes:` setting into anonymous volumes and bind mounts.
381
+ #
382
+ # Entries containing a colon are bind mounts and are moved into `binds`;
383
+ # what is left becomes the `Volumes` hash. `binds` is mutated in place.
384
+ #
385
+ # @param v [Hash, Array<String>, nil] the configured volumes
386
+ # @param binds [Array] the bind list to append to
387
+ # @return [Hash, nil] a Volumes hash, or nil to omit the key
280
388
  def coerce_volumes(v, binds)
281
389
  case v
282
390
  when PartialHash, nil
@@ -297,8 +405,17 @@ module Kitchen
297
405
  end
298
406
  end
299
407
 
408
+ # Work out the runner container's `Volumes` and `Binds` values.
409
+ #
410
+ # The sandboxes are only bind-mounted when the daemon can read the host
411
+ # filesystem; otherwise the transport ships them into the data container.
412
+ #
413
+ # @return [Array(Hash, Array<String>)] the volumes and binds
300
414
  def calc_volumes_binds
301
- volumes = Array.new(Array(config[:volumes]))
415
+ # Array() on a Hash yields its pairs, which would destroy an explicit
416
+ # `volumes:` mapping before coerce_volumes ever sees it.
417
+ configured_volumes = config[:volumes]
418
+ volumes = configured_volumes.is_a?(Hash) ? configured_volumes : Array.new(Array(configured_volumes))
302
419
  binds = Array.new(Array(config[:binds]))
303
420
 
304
421
  # Binds is mutated in-place, volumes *may* be.
@@ -312,6 +429,10 @@ module Kitchen
312
429
  [volumes, binds_ret.flatten]
313
430
  end
314
431
 
432
+ # Create and start the container the converge actually runs in.
433
+ #
434
+ # @param state [Hash] mutable instance state
435
+ # @return [void]
315
436
  def start_runner_container(state)
316
437
  debug "driver - starting #{runner_container_name}"
317
438
 
@@ -375,6 +496,10 @@ module Kitchen
375
496
  state[:runner_container] = runner_container.json
376
497
  end
377
498
 
499
+ # Create and start the container that serves the sandboxes over ssh.
500
+ #
501
+ # @param state [Hash] mutable instance state
502
+ # @return [void]
378
503
  def start_data_container(state)
379
504
  debug "driver - creating #{data_container_name}"
380
505
  config = {
@@ -408,6 +533,12 @@ module Kitchen
408
533
  state[:data_container] = data_container.json
409
534
  end
410
535
 
536
+ # Create the shared `dokken` network, unless it already exists.
537
+ #
538
+ # Several instances converge in parallel and all race to create the one
539
+ # shared network, so this is both file-locked and forgiving of a lost race.
540
+ #
541
+ # @return [void]
411
542
  def make_dokken_network
412
543
  return unless self[:network_mode] == "dokken"
413
544
 
@@ -416,17 +547,28 @@ module Kitchen
416
547
  rescue ::Docker::Error::NotFoundError
417
548
  begin
418
549
  with_retries { ::Docker::Network.create("dokken", network_settings) }
419
- rescue ::Docker::Error => e
550
+ rescue ::Docker::Error::DockerError => e
420
551
  debug "driver - error :#{e}:"
421
552
  end
422
553
  end
423
554
  end
424
555
 
556
+ # Build the data image, unless it is already present.
557
+ #
558
+ # @return [void]
425
559
  def make_data_image
426
560
  debug "driver - calling create_data_image"
427
561
  create_data_image(config[:docker_registry])
428
562
  end
429
563
 
564
+ # Create the volume container /opt/chef is mounted into the runner from.
565
+ #
566
+ # The container is shared by every instance on the same chef version and
567
+ # platform, so creation is guarded by a file lock.
568
+ #
569
+ # @param state [Hash] mutable instance state
570
+ # @return [void]
571
+ # @raise [RuntimeError] if the container could not be created
430
572
  def create_chef_container(state)
431
573
  with_file_lock("#{home_dir}/.dokken-#{chef_container_name}.lock") do
432
574
  with_retries do
@@ -454,12 +596,20 @@ module Kitchen
454
596
  # has to be built for the same architecture as the runner.
455
597
  chef_container = create_container(config, platform: self[:platform])
456
598
  state[:chef_container] = chef_container.json
457
- rescue ::Docker::Error, StandardError => e
599
+ # Both constants have to be spelled out: bare `StandardError` inside
600
+ # module Kitchen resolves to Kitchen::StandardError, and Docker::Error
601
+ # is a namespace module that no exception class includes.
602
+ rescue ::Docker::Error::DockerError, ::StandardError => e
458
603
  raise "driver - #{chef_container_name} failed to create #{e}"
459
604
  end
460
605
  end
461
606
  end
462
607
 
608
+ # Run a block holding an exclusive lock on a file.
609
+ #
610
+ # @param path [String] the lock file to create and hold
611
+ # @yield with the lock held
612
+ # @return [void]
463
613
  def with_file_lock(path)
464
614
  File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
465
615
  f.flock(File::LOCK_EX)
@@ -467,6 +617,9 @@ module Kitchen
467
617
  end
468
618
  end
469
619
 
620
+ # Log the docker client in, when a creds_file has been configured.
621
+ #
622
+ # @return [void]
470
623
  def authenticate!
471
624
  # No need to authenticate if the credentials are empty
472
625
  return if docker_creds.empty?
@@ -474,6 +627,9 @@ module Kitchen
474
627
  ::Docker.authenticate! docker_creds
475
628
  end
476
629
 
630
+ # The credentials read from the configured creds_file.
631
+ #
632
+ # @return [Hash] the credentials, or an empty hash
477
633
  def docker_creds
478
634
  @docker_creds ||= if config[:creds_file]
479
635
  JSON.parse(IO.read(config[:creds_file]))
@@ -482,6 +638,12 @@ module Kitchen
482
638
  end
483
639
  end
484
640
 
641
+ # The credentials found in ~/.docker/config.json, keyed by registry.
642
+ #
643
+ # `auths` entries resolve to a credentials hash; `credHelpers` entries
644
+ # resolve to a proc that shells out to the helper only if it is needed.
645
+ #
646
+ # @return [Hash{String => Hash, Proc}] credentials by registry key
485
647
  def docker_config_creds
486
648
  return @docker_config_creds if @docker_config_creds
487
649
 
@@ -493,7 +655,8 @@ module Kitchen
493
655
  config["auths"].each do |k, v|
494
656
  next if v["auth"].nil?
495
657
 
496
- username, password = Base64.decode64(v["auth"]).split(":")
658
+ # Split once: a colon is legal inside a registry password.
659
+ username, password = Base64.decode64(v["auth"]).split(":", 2)
497
660
  @docker_config_creds[k] = { serveraddress: k, username:, password: }
498
661
  end
499
662
  end
@@ -513,55 +676,129 @@ module Kitchen
513
676
  @docker_config_creds
514
677
  end
515
678
 
679
+ # The `auths` key `docker login` itself writes for Docker Hub.
680
+ DOCKER_HUB_REGISTRY_KEY = "https://index.docker.io/v1/".freeze
681
+
682
+ # Registry hosts that all refer to Docker Hub. Docker writes the legacy
683
+ # v1 URL above, but hand-written and tool-generated configs use any of
684
+ # these. Listed in preference order.
685
+ DOCKER_HUB_HOSTS = %w{index.docker.io docker.io registry-1.docker.io}.freeze
686
+
687
+ # An explicit "no credentials" value. Returning nil here instead would let
688
+ # docker-api fall back to the process-global ::Docker.creds, which
689
+ # authenticate! populates from creds_file and never clears -- so another
690
+ # instance's private registry password would be sent to this registry,
691
+ # which is the very leak this scoping exists to prevent.
692
+ NO_DOCKER_CREDS = {}.freeze
693
+
694
+ # Return the registry host an image reference points at, or nil when the
695
+ # reference resolves to Docker Hub. The leading path component only names
696
+ # a registry when it contains a "." or a ":", or is exactly "localhost" --
697
+ # the same heuristic Docker uses to tell "quay.io/org/image" apart from
698
+ # the Hub shorthand "dokken/almalinux-8".
699
+ #
700
+ # @param image [String] an image reference
701
+ # @return [String, nil] the registry host, or nil for Docker Hub
702
+ def image_registry_host(image)
703
+ first, remainder = image.split("/", 2)
704
+ return nil if remainder.nil?
705
+ return nil unless first.include?(".") || first.include?(":") || first == "localhost"
706
+
707
+ first
708
+ end
709
+
710
+ # Look up the ~/.docker/config.json entry that applies to an image's
711
+ # registry. Returns nil when that registry has no entry so the pull is
712
+ # attempted anonymously, rather than offering it another registry's
713
+ # credentials.
714
+ # Pick the config.json key that holds the Docker Hub credentials. A
715
+ # config may spell Hub several ways, so prefer the canonical key and then
716
+ # a fixed alias order rather than whichever happens to be listed first.
717
+ # @return [String, nil] the config.json key holding Docker Hub credentials
718
+ def docker_hub_creds_key
719
+ keys = docker_config_creds.keys.select { |k| DOCKER_HUB_HOSTS.include?(parse_registry_host(k)) }
720
+ return if keys.empty?
721
+
722
+ keys.find { |k| k == DOCKER_HUB_REGISTRY_KEY } ||
723
+ keys.min_by { |k| DOCKER_HUB_HOSTS.index(parse_registry_host(k)) }
724
+ end
725
+
726
+ # The ~/.docker/config.json credentials that apply to an image.
727
+ #
728
+ # @param image [String] an image reference
729
+ # @return [Hash, nil] the credentials, or nil when none apply
730
+ def docker_config_creds_for_image(image)
731
+ host = image_registry_host(image)
732
+
733
+ key = if host.nil? || DOCKER_HUB_HOSTS.include?(host)
734
+ docker_hub_creds_key
735
+ else
736
+ docker_config_creds.keys.find { |k| parse_registry_host(k) == host }
737
+ end
738
+ return if key.nil?
739
+
740
+ c = docker_config_creds[key]
741
+ c.respond_to?(:call) ? c.call : c
742
+ end
743
+
744
+ # The credentials to send when pulling an image.
745
+ #
746
+ # @param image [String] an image reference
747
+ # @return [Hash] credentials, or {NO_DOCKER_CREDS} to pull anonymously
516
748
  def docker_creds_for_image(image)
517
749
  return docker_creds if config[:creds_file]
750
+ return NO_DOCKER_CREDS unless config[:docker_config_creds]
518
751
 
519
- image_registry = image.split("/").first
520
-
521
- # NOTE: Try to use DockerHub auth if exact registry match isn't found
522
- default_registry = "https://index.docker.io/v1/"
523
- if docker_config_creds.key?(image_registry)
524
- c = docker_config_creds[image_registry]
525
- c.respond_to?(:call) ? c.call : c
526
- elsif docker_config_creds.key?(default_registry)
527
- c = docker_config_creds[default_registry]
528
- c.respond_to?(:call) ? c.call : c
529
- end
752
+ docker_config_creds_for_image(image) || NO_DOCKER_CREDS
530
753
  end
531
754
 
755
+ # Pull the platform image the work image is built from.
756
+ #
757
+ # @return [void]
532
758
  def pull_platform_image
533
759
  debug "driver - pulling #{short_image_path(platform_image)}"
534
760
  config[:pull_platform_image] ? pull_image(platform_image) : pull_if_missing(platform_image)
535
761
  end
536
762
 
763
+ # Pull the chef/cinc image the volume container is built from.
764
+ #
765
+ # @return [void]
537
766
  def pull_chef_image
538
767
  debug "driver - pulling #{short_image_path(chef_image)}"
539
768
  config[:pull_chef_image] ? pull_image(chef_image) : pull_if_missing(chef_image)
540
769
  end
541
770
 
771
+ # Force-remove an image.
772
+ #
773
+ # @param name [String] an image reference
774
+ # @return [void]
542
775
  def delete_image(name)
543
776
  with_retries { @image = ::Docker::Image.get(name, { "platform" => oci_platform(config[:platform]) }, docker_connection) }
544
777
  with_retries { @image.remove(force: true) }
545
- rescue ::Docker::Error
778
+ rescue ::Docker::Error::DockerError
546
779
  puts "Image #{name} not found. Nothing to delete."
547
780
  end
548
781
 
782
+ # Whether the daemon knows about a container.
783
+ #
784
+ # @param name [String] the container name
785
+ # @return [Boolean] true when the container exists
549
786
  def container_exist?(name)
550
787
  true if ::Docker::Container.get(name, {}, docker_connection)
551
- rescue StandardError, ::Docker::Error::NotFoundError
788
+ rescue ::StandardError
552
789
  false
553
790
  end
554
791
 
792
+ # Split an image reference into its repo and tag.
793
+ #
794
+ # @param image [String] the docker image path to parse
795
+ # @return [Array(String, String)] the repo and tag
555
796
  def parse_image_name(image)
556
- parts = image.split(":")
797
+ repo, separator, tag = image.rpartition(":")
557
798
 
558
- if parts.size > 2
559
- tag = parts.pop
560
- repo = parts.join(":")
561
- else
562
- tag = parts[1] || "latest"
563
- repo = parts[0]
564
- end
799
+ # A colon before a slash delimits a registry port, not a tag:
800
+ # "localhost:5000/almalinux" is an untagged image on a local registry.
801
+ return [image, "latest"] if separator.empty? || tag.include?("/")
565
802
 
566
803
  [repo, tag]
567
804
  end
@@ -605,27 +842,45 @@ module Kitchen
605
842
  end
606
843
  end
607
844
 
845
+ # Fetch a container by name, creating it if it does not exist.
846
+ #
847
+ # @param args [Hash] the `/containers/create` body
848
+ # @param platform [String, nil] an OCI platform to pin the container to
849
+ # @return [::Docker::Container] the container
850
+ # @raise [RuntimeError] if the container could not be created
608
851
  def create_container(args, platform: nil)
609
852
  with_retries { @container = ::Docker::Container.get(args["name"], {}, docker_connection) }
610
853
  rescue
611
854
  with_retries do
612
- args["Env"] = [] if args["Env"].nil?
613
- args["Env"] << "TEST_KITCHEN=1"
614
- args["Env"] << "CI=#{ENV["CI"]}" if ENV.include? "CI"
855
+ # Merge rather than append: start_runner_container passes config[:env]
856
+ # straight through, so mutating args["Env"] would edit the driver's
857
+ # own configuration -- and stamp it again on every retry.
858
+ create_args = args.merge("Env" => Array(args["Env"]) + container_env)
615
859
  info "Creating container #{args["name"]}"
616
- debug "driver - create_container args #{args}"
860
+ debug "driver - create_container args #{create_args}"
617
861
  with_retries do
618
- @container = create_container_for_platform(args.clone, platform)
862
+ @container = create_container_for_platform(create_args, platform)
619
863
  rescue ::Docker::Error::ConflictError
620
864
  debug "driver - rescue ConflictError: #{args["name"]}"
621
865
  with_retries { @container = ::Docker::Container.get(args["name"], {}, docker_connection) }
622
866
  end
623
- rescue ::Docker::Error => e
867
+ rescue ::Docker::Error::DockerError => e
624
868
  debug "driver - error :#{e}:"
625
869
  raise "driver - failed to create_container #{args["name"]}"
626
870
  end
627
871
  end
628
872
 
873
+ # Environment variables kitchen-dokken stamps onto every container it
874
+ # creates, so that recipes and tests can tell they are running under
875
+ # Test Kitchen.
876
+ #
877
+ # @return [Array<String>] `KEY=value` strings
878
+ def container_env
879
+ env = ["TEST_KITCHEN=1"]
880
+ env << "CI=#{ENV["CI"]}" if ENV.include? "CI"
881
+ env
882
+ end
883
+
629
884
  # Create a container, optionally pinned to an OCI platform.
630
885
  #
631
886
  # /containers/create takes `platform` as a *query* parameter, but
@@ -634,6 +889,10 @@ module Kitchen
634
889
  # is therefore accepted and silently ignored by the daemon, which is why
635
890
  # the platform config had no effect on container creation. Post directly
636
891
  # when a platform is wanted; otherwise use the gem as before.
892
+ #
893
+ # @param args [Hash] the `/containers/create` body
894
+ # @param platform [String, nil] an OCI platform such as `linux/arm64/v8`
895
+ # @return [::Docker::Container] the created container
637
896
  def create_container_for_platform(args, platform)
638
897
  return ::Docker::Container.create(args, docker_connection) if platform.to_s.empty?
639
898
 
@@ -643,8 +902,13 @@ module Kitchen
643
902
  ::Docker::Container.get(args["name"], {}, docker_connection)
644
903
  end
645
904
 
905
+ # Create a container if needed, then start it and wait for it to run.
906
+ #
907
+ # @param args [Hash] the `/containers/create` body
908
+ # @param platform [String, nil] an OCI platform to pin the container to
909
+ # @return [::Docker::Container] the running container
646
910
  def run_container(args, platform: nil)
647
- create_container(args, platform: platform)
911
+ @container = create_container(args, platform: platform)
648
912
  with_retries do
649
913
  @container.start
650
914
  @container = ::Docker::Container.get(args["name"], {}, docker_connection)
@@ -653,10 +917,17 @@ module Kitchen
653
917
  @container
654
918
  end
655
919
 
920
+ # The current container's `State` payload.
921
+ #
922
+ # @return [Hash] the state, or an empty hash if there is no container
656
923
  def container_state
657
924
  @container ? @container.info["State"] : {}
658
925
  end
659
926
 
927
+ # Stop a container and wait for it to leave the running state.
928
+ #
929
+ # @param name [String] the container name
930
+ # @return [void]
660
931
  def stop_container(name)
661
932
  with_retries { @container = ::Docker::Container.get(name, {}, docker_connection) }
662
933
  with_retries do
@@ -667,6 +938,10 @@ module Kitchen
667
938
  debug "Container #{name} not found. Nothing to stop."
668
939
  end
669
940
 
941
+ # Force-remove a container along with its anonymous volumes.
942
+ #
943
+ # @param name [String] the container name
944
+ # @return [void]
670
945
  def delete_container(name)
671
946
  with_retries { @container = ::Docker::Container.get(name, {}, docker_connection) }
672
947
  with_retries { @container.delete(force: true, v: true) }
@@ -674,6 +949,14 @@ module Kitchen
674
949
  debug "Container #{name} not found. Nothing to delete."
675
950
  end
676
951
 
952
+ # Poll a container until it reaches the wanted running state.
953
+ #
954
+ # Gives up after a bounded number of polls rather than blocking a converge
955
+ # forever, and stops early once the container has actually finished.
956
+ #
957
+ # @param name [String] the container name
958
+ # @param v [Boolean] the running state to wait for
959
+ # @return [void]
677
960
  def wait_running_state(name, v)
678
961
  @container = ::Docker::Container.get(name, {}, docker_connection)
679
962
  i = 0
@@ -687,29 +970,51 @@ module Kitchen
687
970
  end
688
971
  end
689
972
 
973
+ # The name of the shared chef volume container.
974
+ #
975
+ # @return [String] the container name
690
976
  def chef_container_name
691
977
  prefix = instance.provisioner[:product_name] == "cinc" ? "cinc" : "chef"
692
- config[:platform] != "" ? "#{prefix}-#{chef_version}-" + config[:platform].tr("/", "-") : "#{prefix}-#{chef_version}"
978
+ # `platform: ~` in a kitchen.yml yields nil rather than the "" default.
979
+ platform = config[:platform].to_s
980
+ return "#{prefix}-#{chef_version}" if platform.empty?
981
+
982
+ "#{prefix}-#{chef_version}-#{platform.tr("/", "-")}"
693
983
  end
694
984
 
985
+ # The chef/cinc image the volume container is built from.
986
+ #
987
+ # @return [String] an image reference
695
988
  def chef_image
696
989
  "#{config[:chef_image]}:#{chef_version}"
697
990
  end
698
991
 
992
+ # The chef version to use, as an image tag.
993
+ #
994
+ # @return [String] a tag
699
995
  def chef_version
700
996
  return "latest" if config[:chef_version] == "stable"
701
997
 
702
998
  config[:chef_version]
703
999
  end
704
1000
 
1001
+ # The name of this instance's data container.
1002
+ #
1003
+ # @return [String] the container name
705
1004
  def data_container_name
706
1005
  "#{instance_name}-data"
707
1006
  end
708
1007
 
1008
+ # The image the data container is built from.
1009
+ #
1010
+ # @return [String] an image reference
709
1011
  def data_image
710
1012
  config[:data_image]
711
1013
  end
712
1014
 
1015
+ # The `PortBindings` value for the data container.
1016
+ #
1017
+ # @return [Hash] a Docker API PortBindings hash
713
1018
  def data_port_bindings
714
1019
  return port_bindings unless config[:data_ssh_port]
715
1020
 
@@ -731,15 +1036,25 @@ module Kitchen
731
1036
  end
732
1037
  end
733
1038
 
1039
+ # The base image for the platform under test.
1040
+ #
1041
+ # @return [String] an image reference
734
1042
  def platform_image
735
1043
  config[:image] || platform_image_from_name
736
1044
  end
737
1045
 
1046
+ # Derive an image reference from the kitchen platform name.
1047
+ #
1048
+ # @return [String] an image reference
738
1049
  def platform_image_from_name
739
1050
  platform, release = instance.platform.name.split("-")
740
1051
  release ? [platform, release].join(":") : platform
741
1052
  end
742
1053
 
1054
+ # Pull an image only when the daemon does not already have it.
1055
+ #
1056
+ # @param image [String] an image reference
1057
+ # @return [void]
743
1058
  def pull_if_missing(image)
744
1059
  return if ::Docker::Image.exist?(registry_image_path(image), { "platform" => oci_platform(config[:platform]) }, docker_connection)
745
1060
 
@@ -747,10 +1062,18 @@ module Kitchen
747
1062
  end
748
1063
 
749
1064
  # https://github.com/docker/docker/blob/4fcb9ac40ce33c4d6e08d5669af6be5e076e2574/registry/auth.go#L231
1065
+ # Reduce a config.json registry key to a bare host.
1066
+ #
1067
+ # @param val [String] a registry key, possibly a URL
1068
+ # @return [String] the host
750
1069
  def parse_registry_host(val)
751
1070
  val.sub(%r{https?://}, "").split("/").first
752
1071
  end
753
1072
 
1073
+ # Pull an image from its registry.
1074
+ #
1075
+ # @param image [String] an image reference
1076
+ # @return [Boolean] true when the pull changed what is on disk
754
1077
  def pull_image(image)
755
1078
  path = registry_image_path(image)
756
1079
  with_retries do
@@ -758,7 +1081,7 @@ module Kitchen
758
1081
  original_image = Docker::Image.get(path, { "platform" => oci_platform(config[:platform]) }, docker_connection)
759
1082
  end
760
1083
 
761
- new_image = Docker::Image.create({ "fromImage" => path, "platform" => config[:platform] }, docker_creds_for_image(image), docker_connection)
1084
+ new_image = Docker::Image.create({ "fromImage" => path, "platform" => config[:platform] }, docker_creds_for_image(path), docker_connection)
762
1085
 
763
1086
  !(original_image&.id&.start_with?(new_image.id))
764
1087
  end
@@ -769,6 +1092,9 @@ module Kitchen
769
1092
  # matters: the daemon will not match a filter of
770
1093
  # {"os":"linux","architecture":"amd64"} against a linux/amd64/v2 image,
771
1094
  # so dropping it makes every image lookup for a variant image miss.
1095
+ #
1096
+ # @param platform [String, nil] an "os/arch[/variant]" string
1097
+ # @return [String, nil] a JSON OCI platform spec, or the input unchanged
772
1098
  def oci_platform(platform)
773
1099
  return platform if platform.nil? || !platform.include?("/")
774
1100
 
@@ -778,10 +1104,18 @@ module Kitchen
778
1104
  spec.to_json
779
1105
  end
780
1106
 
1107
+ # The name of this instance's runner container.
1108
+ #
1109
+ # @return [String] the container name
781
1110
  def runner_container_name
782
1111
  instance_name.to_s
783
1112
  end
784
1113
 
1114
+ # Retry a docker API call through the errors that retrying can fix.
1115
+ #
1116
+ # @yield the API call to attempt
1117
+ # @return [Object] the block's value
1118
+ # @raise [::Docker::Error::DockerError] if every attempt failed
785
1119
  def with_retries
786
1120
  tries = api_retries
787
1121
  begin