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.
- checksums.yaml +4 -4
- data/Gemfile +10 -1
- data/Rakefile +18 -0
- data/lib/kitchen/driver/dokken.rb +370 -36
- data/lib/kitchen/driver/dokken_version.rb +1 -1
- data/lib/kitchen/helpers.rb +260 -30
- data/lib/kitchen/provisioner/dokken.rb +53 -4
- data/lib/kitchen/transport/dokken.rb +294 -73
- metadata +2 -2
|
@@ -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,
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
797
|
+
repo, separator, tag = image.rpartition(":")
|
|
557
798
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
613
|
-
args["Env"]
|
|
614
|
-
|
|
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 #{
|
|
860
|
+
debug "driver - create_container args #{create_args}"
|
|
617
861
|
with_retries do
|
|
618
|
-
@container = create_container_for_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
|
-
|
|
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(
|
|
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
|