kitchen-dokken 2.23.3 → 2.23.5

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.
@@ -19,11 +19,14 @@ require "kitchen"
19
19
  require "net/scp"
20
20
  require "tmpdir" unless defined?(Dir.mktmpdir)
21
21
  require "digest/sha1" unless defined?(Digest::SHA1)
22
+ require "open3" unless defined?(Open3)
23
+ require "shellwords" unless defined?(Shellwords)
22
24
  require_relative "../helpers"
23
25
 
24
26
  include Dokken::Helpers
25
27
 
26
28
  module Kitchen
29
+ # @see Kitchen::Transport::Dokken
27
30
  module Transport
28
31
  # Wrapped exception for any internally raised errors.
29
32
  #
@@ -33,6 +36,11 @@ module Kitchen
33
36
  # A Transport which uses Docker tricks to execute commands and
34
37
  # transfer files.
35
38
  #
39
+ # Commands run through `docker exec` against the runner container. File
40
+ # transfer only happens when the daemon cannot read the host filesystem;
41
+ # then the files go over ssh into the data container, which shares its
42
+ # volumes with the runner.
43
+ #
36
44
  # @author Sean OMeara <sean@sean.io>
37
45
  class Dokken < Kitchen::Transport::Base
38
46
  kitchen_transport_api_version 2
@@ -47,7 +55,7 @@ module Kitchen
47
55
  default_config :write_timeout, 3600
48
56
  default_config :login_command, "docker"
49
57
  default_config :host_ip_override do |transport|
50
- if running_inside_docker_desktop?
58
+ if transport.running_inside_docker_desktop?
51
59
  "host.docker.internal"
52
60
  elsif transport.docker_for_mac_or_win?
53
61
  "localhost"
@@ -57,6 +65,10 @@ module Kitchen
57
65
  end
58
66
 
59
67
  # (see Base#connection)
68
+ #
69
+ # @param state [Hash] mutable instance state
70
+ # @yieldparam connection [Connection] the connection, if a block is given
71
+ # @return [Connection] a connection to the runner container
60
72
  def connection(state, &block)
61
73
  options = connection_options(config.to_hash.merge(state))
62
74
 
@@ -67,12 +79,26 @@ module Kitchen
67
79
  end
68
80
  end
69
81
 
82
+ # A connection to one runner container.
83
+ #
70
84
  # @author Sean OMeara <sean@sean.io>
71
- class Connection < Kitchen::Transport::Dokken::Connection
85
+ class Connection < Kitchen::Transport::Base::Connection
86
+ # Where rsync is expected to live. Kept as a constant so the
87
+ # availability check and the command line cannot drift apart.
88
+ RSYNC_PATH = "/usr/bin/rsync".freeze
89
+
90
+ # The docker-api connection this transport talks to.
91
+ #
92
+ # @return [::Docker::Connection] a memoised connection
72
93
  def docker_connection
73
94
  @docker_connection ||= ::Docker::Connection.new(options[:docker_host_url], options[:docker_host_options])
74
95
  end
75
96
 
97
+ # Run a command inside the runner container.
98
+ #
99
+ # @param command [String, nil] the command to run; nil is a no-op
100
+ # @return [void]
101
+ # @raise [Kitchen::Transport::DockerExecFailed] on a non-zero exit
76
102
  def execute(command)
77
103
  return if command.nil?
78
104
 
@@ -87,119 +113,349 @@ module Kitchen
87
113
  raise Transport::DockerExecFailed.new("Docker Exec (#{@exit_code}) for command: [#{command}]", @exit_code) if @exit_code != 0
88
114
  end
89
115
 
116
+ # Copy the kitchen sandbox into the data container.
117
+ #
118
+ # @param locals [Array<String>] local paths to copy
119
+ # @param remote [String] the destination path inside the container
120
+ # @return [void]
121
+ # @raise [Kitchen::UserError] if docker_host_url is not tcp:// or unix://
122
+ # @raise [Kitchen::Transport::TransportFailed] if the copy fails
90
123
  def upload(locals, remote)
124
+ ssh_ip, ssh_port = ssh_endpoint
125
+
126
+ debug "ssh_ip : #{ssh_ip}"
127
+ debug "ssh_port : #{ssh_port}"
128
+
129
+ upload_files(locals, remote, ssh_ip, ssh_port, write_insecure_key)
130
+ end
131
+
132
+ private
133
+
134
+ # Work out which address and port the data container's sshd can
135
+ # actually be reached on from where kitchen is running.
136
+ #
137
+ # @return [Array(String, String)] the address and port to ssh to
138
+ # @raise [Kitchen::UserError] if docker_host_url is not tcp:// or unix://
139
+ # @api private
140
+ def ssh_endpoint
91
141
  if options[:host_ip_override]
92
142
  # Allow connecting to any ip/hostname to support sibling containers
93
- ssh_ip = options[:host_ip_override]
94
- ssh_port = options[:data_container][:NetworkSettings][:Ports][:"22/tcp"][0][:HostPort]
95
-
143
+ [options[:host_ip_override], published_ssh_port]
96
144
  elsif /unix:/.match?(options[:docker_host_url])
97
- if options[:data_container][:NetworkSettings][:Ports][:"22/tcp"][0][:HostIp] == "0.0.0.0"
98
- ssh_ip = options[:data_container][:NetworkSettings][:IPAddress]
99
- ssh_port = "22"
100
- else
101
- # we should read the proper mapped ip, since this allows us to upload the files
102
- ssh_ip = options[:data_container][:NetworkSettings][:Ports][:"22/tcp"][0][:HostIp]
103
- ssh_port = options[:data_container][:NetworkSettings][:Ports][:"22/tcp"][0][:HostPort]
104
- end
105
-
145
+ unix_ssh_endpoint
106
146
  elsif /tcp:/.match?(options[:docker_host_url])
107
- name = options[:data_container][:Name]
147
+ tcp_ssh_endpoint
148
+ else
149
+ raise Kitchen::UserError, "docker_host_url must be tcp:// or unix://"
150
+ end
151
+ end
152
+
153
+ # The host port the data container's sshd is published on.
154
+ #
155
+ # @return [String] the published port
156
+ # @api private
157
+ def published_ssh_port
158
+ ssh_port_binding[:HostPort]
159
+ end
108
160
 
109
- # DOCKER_HOST
110
- docker_host_url_ip = options[:docker_host_url].split("tcp://")[1].split(":")[0]
161
+ # The first host binding for the data container's ssh port.
162
+ #
163
+ # @return [Hash] the `22/tcp` port binding
164
+ # @api private
165
+ def ssh_port_binding
166
+ options[:data_container][:NetworkSettings][:Ports][:"22/tcp"][0]
167
+ end
111
168
 
112
- # mapped IP of data container
113
- candidate_ip = ::Docker::Container.all.find do |x|
114
- x.info["Names"][0].eql?(name)
115
- end.info["NetworkSettings"]["Networks"]["dokken"]["IPAddress"]
169
+ # Pick an endpoint for a daemon reached over a unix socket.
170
+ #
171
+ # When sshd is published on every interface we can talk to the
172
+ # container's own address directly; otherwise we have to go through
173
+ # the specific host mapping the daemon set up.
174
+ #
175
+ # @return [Array(String, String)] the address and port to ssh to
176
+ # @api private
177
+ def unix_ssh_endpoint
178
+ if ssh_port_binding[:HostIp] == "0.0.0.0"
179
+ [data_container_ip, "22"]
180
+ else
181
+ # we should read the proper mapped ip, since this allows us to upload the files
182
+ [ssh_port_binding[:HostIp], ssh_port_binding[:HostPort]]
183
+ end
184
+ end
116
185
 
117
- # mapped port
118
- candidate_ssh_port = options[:data_container][:NetworkSettings][:Ports][:"22/tcp"][0][:HostPort]
186
+ # The data container's own address on the docker network.
187
+ #
188
+ # NetworkSettings.IPAddress is only populated for the default bridge.
189
+ # The data container is handed a NetworkingConfig endpoint whenever
190
+ # network_mode names a user-defined network -- which dokken's own
191
+ # default does -- and its address then lives under
192
+ # NetworkSettings.Networks.<name>, leaving the legacy field empty.
193
+ # Uploading to that empty value produced `root@:/opt/kitchen` and an
194
+ # unresolvable hostname.
195
+ #
196
+ # @return [String] the container's address
197
+ # @raise [Kitchen::Transport::TransportFailed] if the daemon reported none
198
+ # @api private
199
+ def data_container_ip
200
+ settings = options[:data_container][:NetworkSettings]
201
+
202
+ legacy = settings[:IPAddress].to_s
203
+ return legacy unless legacy.empty?
204
+
205
+ (settings[:Networks] || {}).each_value do |network|
206
+ address = network[:IPAddress].to_s
207
+ return address unless address.empty?
208
+ end
119
209
 
210
+ raise Kitchen::Transport::TransportFailed,
211
+ "The data container has no address on any docker network: #{settings[:Networks].inspect}"
212
+ end
213
+
214
+ # Every address the data container has, best candidate first.
215
+ #
216
+ # A user-defined network comes before the default bridge. That order
217
+ # matters when kitchen is itself a container: a sibling on the dokken
218
+ # network can reach the container there but has no route to its
219
+ # bridge address, and preferring the bridge would send every upload
220
+ # to the docker host instead.
221
+ #
222
+ # The legacy top-level `IPAddress` is the default bridge's, so it
223
+ # sorts with the bridge and is deduplicated against it.
224
+ #
225
+ # @return [Array<String>] addresses, most specific network first
226
+ # @api private
227
+ def data_container_addresses
228
+ settings = options[:data_container][:NetworkSettings]
229
+ networks = settings[:Networks] || {}
230
+
231
+ user_defined, default_bridge = networks.partition { |name, _| name.to_s != "bridge" }
232
+
233
+ addresses = (user_defined + default_bridge).map { |_, network| network[:IPAddress].to_s }
234
+ addresses << settings[:IPAddress].to_s
235
+
236
+ addresses.reject(&:empty?).uniq
237
+ end
238
+
239
+ # Pick an endpoint for a daemon reached over tcp.
240
+ #
241
+ # The container's address on the dokken network is preferred, since it
242
+ # avoids a round trip through the host, but it is only usable when
243
+ # kitchen shares a route with the daemon. Fall back to the docker host
244
+ # itself when neither candidate port answers.
245
+ #
246
+ # @return [Array(String, String)] the address and port to ssh to
247
+ # @api private
248
+ def tcp_ssh_endpoint
249
+ # DOCKER_HOST
250
+ docker_host_url_ip = options[:docker_host_url].split("tcp://")[1].split(":")[0]
251
+
252
+ # mapped port
253
+ candidate_ssh_port = published_ssh_port
254
+
255
+ # The addresses come from the state the driver recorded after it
256
+ # started the container. This used to ask the daemon instead --
257
+ # Docker::Container.all, find ours by name, then read
258
+ # Networks["dokken"]["IPAddress"] -- which was wrong twice over.
259
+ #
260
+ # Container.all lists only *running* containers, so a data container
261
+ # that had exited made `find` return nil and the chained `.info`
262
+ # raise `undefined method 'info' for nil`. And the dokken network is
263
+ # only attached when network_mode is left at its default:
264
+ # start_data_container adds the endpoint
265
+ # "unless %w{host bridge}.include?" and names it after network_mode,
266
+ # so `bridge`, `host` and every custom network name produced
267
+ # `undefined method '[]' for nil` on a remote daemon.
268
+ data_container_addresses.each do |candidate_ip|
120
269
  debug "candidate_ip - #{candidate_ip}"
121
270
  debug "candidate_ssh_port - #{candidate_ssh_port}"
122
271
 
123
272
  if port_open?(candidate_ip, candidate_ssh_port)
124
273
  debug "candidate_ip - #{candidate_ip}/#{candidate_ssh_port} open"
125
- ssh_ip = candidate_ip
126
- ssh_port = candidate_ssh_port
127
-
274
+ return [candidate_ip, candidate_ssh_port]
128
275
  elsif port_open?(candidate_ip, "22")
129
- ssh_ip = candidate_ip
130
- ssh_port = "22"
131
276
  debug "candidate_ip - #{candidate_ip}/22 open"
132
- else
133
- ssh_ip = docker_host_url_ip
134
- ssh_port = candidate_ssh_port
277
+ return [candidate_ip, "22"]
135
278
  end
136
- else
137
- raise Kitchen::UserError, "docker_host_url must be tcp:// or unix://"
138
279
  end
139
280
 
140
- debug "ssh_ip : #{ssh_ip}"
141
- debug "ssh_port : #{ssh_port}"
281
+ # Nothing answered, or -- with host networking -- the container has
282
+ # no address of its own to try. The docker host is the endpoint.
283
+ debug "no data container address answered; falling back to the docker host"
284
+ [docker_host_url_ip, candidate_ssh_port]
285
+ end
142
286
 
287
+ # Write the built-in insecure private key somewhere ssh will accept it.
288
+ #
289
+ # ssh refuses to use a key file other users can read, so the file is
290
+ # written 0600 under a per-uid directory.
291
+ #
292
+ # @return [String] the directory holding the `id_rsa` file
293
+ # @api private
294
+ def write_insecure_key
143
295
  tmpdir = Dir.tmpdir + "/dokken/"
144
296
  FileUtils.mkdir_p tmpdir.to_s, mode: 0o777
145
297
  tmpdir += Process.uid.to_s
146
298
  FileUtils.mkdir_p tmpdir.to_s
147
299
  File.write("#{tmpdir}/id_rsa", insecure_ssh_private_key)
148
300
  FileUtils.chmod(0o600, "#{tmpdir}/id_rsa")
301
+ tmpdir
302
+ end
149
303
 
150
- begin
151
- rsync_cmd = "/usr/bin/rsync -a -e"
152
- rsync_cmd << " '"
153
- rsync_cmd << "ssh -2"
154
- rsync_cmd << " -i #{tmpdir}/id_rsa"
155
- rsync_cmd << " -o CheckHostIP=no"
156
- rsync_cmd << " -o Compression=no"
157
- rsync_cmd << " -o PasswordAuthentication=no"
158
- rsync_cmd << " -o StrictHostKeyChecking=no"
159
- rsync_cmd << " -o UserKnownHostsFile=/dev/null"
160
- rsync_cmd << " -o LogLevel=ERROR"
161
- rsync_cmd << " -p #{ssh_port}"
162
- rsync_cmd << "'"
163
- rsync_cmd << " #{locals.join(" ")} root@#{ssh_ip}:#{remote}"
164
- debug "rsync_cmd :#{rsync_cmd}:"
165
- `#{rsync_cmd}`
166
- rescue Errno::ENOENT
167
- debug "Rsync is not installed. Falling back to SCP."
168
- locals.each do |local|
169
- Net::SCP.upload!(ssh_ip,
170
- "root",
171
- local,
172
- remote,
173
- recursive: true,
174
- ssh: { port: ssh_port, keys: ["#{tmpdir}/id_rsa"] })
175
- end
304
+ # Copy files into the data container, preferring rsync.
305
+ #
306
+ # @param locals [Array<String>] local paths to copy
307
+ # @param remote [String] the destination path inside the container
308
+ # @param ssh_ip [String] the address to ssh to
309
+ # @param ssh_port [String] the port to ssh to
310
+ # @param key_dir [String] directory holding the `id_rsa` file
311
+ # @return [void]
312
+ # @api private
313
+ def upload_files(locals, remote, ssh_ip, ssh_port, key_dir)
314
+ if rsync_available?
315
+ upload_via_rsync(locals, remote, ssh_ip, ssh_port, key_dir)
316
+ else
317
+ debug "Rsync is not installed at #{RSYNC_PATH}. Falling back to SCP."
318
+ upload_via_scp(locals, remote, ssh_ip, ssh_port, key_dir)
319
+ end
320
+ end
321
+
322
+ # Whether rsync is installed where we expect it.
323
+ #
324
+ # This is checked up front rather than inferred from a failed shell
325
+ # invocation: backticks run the command through /bin/sh, which reports
326
+ # a missing binary as exit status 127 and never raises Errno::ENOENT,
327
+ # so an exception-based check could never see it.
328
+ #
329
+ # @return [Boolean] true when rsync can be executed
330
+ # @api private
331
+ def rsync_available?
332
+ File.executable?(RSYNC_PATH)
333
+ end
334
+
335
+ # Build the rsync invocation used to copy the sandbox in.
336
+ #
337
+ # @param locals [Array<String>] local paths to copy
338
+ # @param remote [String] the destination path inside the container
339
+ # @param ssh_ip [String] the address to ssh to
340
+ # @param ssh_port [String] the port to ssh to
341
+ # @param key_dir [String] directory holding the `id_rsa` file
342
+ # @return [String] a shell command line
343
+ # @api private
344
+ def rsync_command(locals, remote, ssh_ip, ssh_port, key_dir)
345
+ ssh_opts = [
346
+ "ssh -2",
347
+ "-i #{key_dir}/id_rsa",
348
+ "-o CheckHostIP=no",
349
+ "-o Compression=no",
350
+ "-o PasswordAuthentication=no",
351
+ "-o StrictHostKeyChecking=no",
352
+ "-o UserKnownHostsFile=/dev/null",
353
+ "-o LogLevel=ERROR",
354
+ "-p #{ssh_port}",
355
+ ].join(" ")
356
+
357
+ "#{RSYNC_PATH} -a -e '#{ssh_opts}' #{locals.join(" ")} root@#{ssh_ip}:#{remote}"
358
+ end
359
+
360
+ # Copy files in with rsync.
361
+ #
362
+ # A non-zero exit is raised rather than ignored: silently continuing
363
+ # leaves the converge running against a container with no cookbooks in
364
+ # it, which then fails much further along with a confusing error.
365
+ #
366
+ # @param locals [Array<String>] local paths to copy
367
+ # @param remote [String] the destination path inside the container
368
+ # @param ssh_ip [String] the address to ssh to
369
+ # @param ssh_port [String] the port to ssh to
370
+ # @param key_dir [String] directory holding the `id_rsa` file
371
+ # @return [void]
372
+ # @raise [Kitchen::Transport::TransportFailed] if rsync exits non-zero
373
+ # @api private
374
+ def upload_via_rsync(locals, remote, ssh_ip, ssh_port, key_dir)
375
+ cmd = rsync_command(locals, remote, ssh_ip, ssh_port, key_dir)
376
+ debug "rsync_cmd :#{cmd}:"
377
+
378
+ output, status = Open3.capture2e(cmd)
379
+ return if status.success?
380
+
381
+ raise Kitchen::Transport::TransportFailed.new(
382
+ "rsync exited #{status.exitstatus} while uploading to #{ssh_ip}:#{remote}: #{output.strip}",
383
+ status.exitstatus
384
+ )
385
+ end
386
+
387
+ # Copy files in with scp, for hosts without rsync.
388
+ #
389
+ # @param locals [Array<String>] local paths to copy
390
+ # @param remote [String] the destination path inside the container
391
+ # @param ssh_ip [String] the address to ssh to
392
+ # @param ssh_port [String] the port to ssh to
393
+ # @param key_dir [String] directory holding the `id_rsa` file
394
+ # @return [void]
395
+ # @api private
396
+ def upload_via_scp(locals, remote, ssh_ip, ssh_port, key_dir)
397
+ locals.each do |local|
398
+ Net::SCP.upload!(ssh_ip,
399
+ "root",
400
+ local,
401
+ remote,
402
+ recursive: true,
403
+ ssh: { port: ssh_port, keys: ["#{key_dir}/id_rsa"] })
176
404
  end
177
405
  end
178
406
 
407
+ public
408
+
409
+ # Build the command `kitchen login` runs to drop the user into the
410
+ # runner container.
411
+ #
412
+ # `tput` reports the terminal size with a trailing newline; that has to
413
+ # be stripped, or bash reads the embedded newline in COLUMNS as the
414
+ # start of another command line.
415
+ #
416
+ # @return [Kitchen::LoginCommand] the command to exec
179
417
  def login_command
180
418
  @runner = options[:instance_name].to_s
181
- cols = `tput cols`
182
- lines = `tput lines`
419
+ cols = `tput cols`.strip
420
+ lines = `tput lines`.strip
183
421
  args = ["exec", "-e", "COLUMNS=#{cols}", "-e", "LINES=#{lines}", "-it", @runner, "/bin/bash", "-login", "-i"]
184
422
  LoginCommand.new(options[:login_command], args)
185
423
  end
186
424
 
187
425
  private
188
426
 
427
+ # The runner container's name.
428
+ #
429
+ # @return [String] the container name
430
+ # @api private
189
431
  def instance_name
190
432
  options[:instance_name]
191
433
  end
192
434
 
435
+ # The locally built image the runner was created from.
436
+ #
437
+ # @return [String] an image reference
438
+ # @api private
193
439
  def work_image
194
440
  return "#{image_prefix}/#{instance_name}" unless image_prefix.nil?
195
441
 
196
442
  instance_name
197
443
  end
198
444
 
445
+ # The configured image name prefix, if any.
446
+ #
447
+ # @return [String, nil] the prefix
448
+ # @api private
199
449
  def image_prefix
200
450
  options[:image_prefix]
201
451
  end
202
452
 
453
+ # Retry a docker API call through the errors that retrying can fix.
454
+ #
455
+ # @yield the API call to attempt
456
+ # @return [Object] the block's value
457
+ # @raise [::Docker::Error::DockerError] if every attempt failed
458
+ # @api private
203
459
  def with_retries
204
460
  tries = 20
205
461
  begin
@@ -258,6 +514,7 @@ module Kitchen
258
514
  @connection.close
259
515
  end
260
516
 
517
+ @connection_options = options
261
518
  @connection = Kitchen::Transport::Dokken::Connection.new(options, &block)
262
519
  end
263
520
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-dokken
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.23.3
4
+ version: 2.23.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean OMeara
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: docker-api