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.
@@ -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,313 @@ 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]
108
-
109
- # DOCKER_HOST
110
- docker_host_url_ip = options[:docker_host_url].split("tcp://")[1].split(":")[0]
111
-
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"]
116
-
117
- # mapped port
118
- candidate_ssh_port = options[:data_container][:NetworkSettings][:Ports][:"22/tcp"][0][:HostPort]
119
-
120
- debug "candidate_ip - #{candidate_ip}"
121
- debug "candidate_ssh_port - #{candidate_ssh_port}"
122
-
123
- if port_open?(candidate_ip, candidate_ssh_port)
124
- debug "candidate_ip - #{candidate_ip}/#{candidate_ssh_port} open"
125
- ssh_ip = candidate_ip
126
- ssh_port = candidate_ssh_port
127
-
128
- elsif port_open?(candidate_ip, "22")
129
- ssh_ip = candidate_ip
130
- ssh_port = "22"
131
- debug "candidate_ip - #{candidate_ip}/22 open"
132
- else
133
- ssh_ip = docker_host_url_ip
134
- ssh_port = candidate_ssh_port
135
- end
147
+ tcp_ssh_endpoint
136
148
  else
137
149
  raise Kitchen::UserError, "docker_host_url must be tcp:// or unix://"
138
150
  end
151
+ end
139
152
 
140
- debug "ssh_ip : #{ssh_ip}"
141
- debug "ssh_port : #{ssh_port}"
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
160
+
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
142
168
 
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
185
+
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
209
+
210
+ raise Kitchen::Transport::TransportFailed,
211
+ "The data container has no address on any docker network: #{settings[:Networks].inspect}"
212
+ end
213
+
214
+ # Pick an endpoint for a daemon reached over tcp.
215
+ #
216
+ # The container's address on the dokken network is preferred, since it
217
+ # avoids a round trip through the host, but it is only usable when
218
+ # kitchen shares a route with the daemon. Fall back to the docker host
219
+ # itself when neither candidate port answers.
220
+ #
221
+ # @return [Array(String, String)] the address and port to ssh to
222
+ # @api private
223
+ def tcp_ssh_endpoint
224
+ name = options[:data_container][:Name]
225
+
226
+ # DOCKER_HOST
227
+ docker_host_url_ip = options[:docker_host_url].split("tcp://")[1].split(":")[0]
228
+
229
+ # mapped IP of data container
230
+ candidate_ip = ::Docker::Container.all.find do |x|
231
+ x.info["Names"][0].eql?(name)
232
+ end.info["NetworkSettings"]["Networks"]["dokken"]["IPAddress"]
233
+
234
+ # mapped port
235
+ candidate_ssh_port = published_ssh_port
236
+
237
+ debug "candidate_ip - #{candidate_ip}"
238
+ debug "candidate_ssh_port - #{candidate_ssh_port}"
239
+
240
+ if port_open?(candidate_ip, candidate_ssh_port)
241
+ debug "candidate_ip - #{candidate_ip}/#{candidate_ssh_port} open"
242
+ [candidate_ip, candidate_ssh_port]
243
+ elsif port_open?(candidate_ip, "22")
244
+ debug "candidate_ip - #{candidate_ip}/22 open"
245
+ [candidate_ip, "22"]
246
+ else
247
+ [docker_host_url_ip, candidate_ssh_port]
248
+ end
249
+ end
250
+
251
+ # Write the built-in insecure private key somewhere ssh will accept it.
252
+ #
253
+ # ssh refuses to use a key file other users can read, so the file is
254
+ # written 0600 under a per-uid directory.
255
+ #
256
+ # @return [String] the directory holding the `id_rsa` file
257
+ # @api private
258
+ def write_insecure_key
143
259
  tmpdir = Dir.tmpdir + "/dokken/"
144
260
  FileUtils.mkdir_p tmpdir.to_s, mode: 0o777
145
261
  tmpdir += Process.uid.to_s
146
262
  FileUtils.mkdir_p tmpdir.to_s
147
263
  File.write("#{tmpdir}/id_rsa", insecure_ssh_private_key)
148
264
  FileUtils.chmod(0o600, "#{tmpdir}/id_rsa")
265
+ tmpdir
266
+ end
149
267
 
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
268
+ # Copy files into the data container, preferring rsync.
269
+ #
270
+ # @param locals [Array<String>] local paths to copy
271
+ # @param remote [String] the destination path inside the container
272
+ # @param ssh_ip [String] the address to ssh to
273
+ # @param ssh_port [String] the port to ssh to
274
+ # @param key_dir [String] directory holding the `id_rsa` file
275
+ # @return [void]
276
+ # @api private
277
+ def upload_files(locals, remote, ssh_ip, ssh_port, key_dir)
278
+ if rsync_available?
279
+ upload_via_rsync(locals, remote, ssh_ip, ssh_port, key_dir)
280
+ else
281
+ debug "Rsync is not installed at #{RSYNC_PATH}. Falling back to SCP."
282
+ upload_via_scp(locals, remote, ssh_ip, ssh_port, key_dir)
176
283
  end
177
284
  end
178
285
 
286
+ # Whether rsync is installed where we expect it.
287
+ #
288
+ # This is checked up front rather than inferred from a failed shell
289
+ # invocation: backticks run the command through /bin/sh, which reports
290
+ # a missing binary as exit status 127 and never raises Errno::ENOENT,
291
+ # so an exception-based check could never see it.
292
+ #
293
+ # @return [Boolean] true when rsync can be executed
294
+ # @api private
295
+ def rsync_available?
296
+ File.executable?(RSYNC_PATH)
297
+ end
298
+
299
+ # Build the rsync invocation used to copy the sandbox in.
300
+ #
301
+ # @param locals [Array<String>] local paths to copy
302
+ # @param remote [String] the destination path inside the container
303
+ # @param ssh_ip [String] the address to ssh to
304
+ # @param ssh_port [String] the port to ssh to
305
+ # @param key_dir [String] directory holding the `id_rsa` file
306
+ # @return [String] a shell command line
307
+ # @api private
308
+ def rsync_command(locals, remote, ssh_ip, ssh_port, key_dir)
309
+ ssh_opts = [
310
+ "ssh -2",
311
+ "-i #{key_dir}/id_rsa",
312
+ "-o CheckHostIP=no",
313
+ "-o Compression=no",
314
+ "-o PasswordAuthentication=no",
315
+ "-o StrictHostKeyChecking=no",
316
+ "-o UserKnownHostsFile=/dev/null",
317
+ "-o LogLevel=ERROR",
318
+ "-p #{ssh_port}",
319
+ ].join(" ")
320
+
321
+ "#{RSYNC_PATH} -a -e '#{ssh_opts}' #{locals.join(" ")} root@#{ssh_ip}:#{remote}"
322
+ end
323
+
324
+ # Copy files in with rsync.
325
+ #
326
+ # A non-zero exit is raised rather than ignored: silently continuing
327
+ # leaves the converge running against a container with no cookbooks in
328
+ # it, which then fails much further along with a confusing error.
329
+ #
330
+ # @param locals [Array<String>] local paths to copy
331
+ # @param remote [String] the destination path inside the container
332
+ # @param ssh_ip [String] the address to ssh to
333
+ # @param ssh_port [String] the port to ssh to
334
+ # @param key_dir [String] directory holding the `id_rsa` file
335
+ # @return [void]
336
+ # @raise [Kitchen::Transport::TransportFailed] if rsync exits non-zero
337
+ # @api private
338
+ def upload_via_rsync(locals, remote, ssh_ip, ssh_port, key_dir)
339
+ cmd = rsync_command(locals, remote, ssh_ip, ssh_port, key_dir)
340
+ debug "rsync_cmd :#{cmd}:"
341
+
342
+ output, status = Open3.capture2e(cmd)
343
+ return if status.success?
344
+
345
+ raise Kitchen::Transport::TransportFailed.new(
346
+ "rsync exited #{status.exitstatus} while uploading to #{ssh_ip}:#{remote}: #{output.strip}",
347
+ status.exitstatus
348
+ )
349
+ end
350
+
351
+ # Copy files in with scp, for hosts without rsync.
352
+ #
353
+ # @param locals [Array<String>] local paths to copy
354
+ # @param remote [String] the destination path inside the container
355
+ # @param ssh_ip [String] the address to ssh to
356
+ # @param ssh_port [String] the port to ssh to
357
+ # @param key_dir [String] directory holding the `id_rsa` file
358
+ # @return [void]
359
+ # @api private
360
+ def upload_via_scp(locals, remote, ssh_ip, ssh_port, key_dir)
361
+ locals.each do |local|
362
+ Net::SCP.upload!(ssh_ip,
363
+ "root",
364
+ local,
365
+ remote,
366
+ recursive: true,
367
+ ssh: { port: ssh_port, keys: ["#{key_dir}/id_rsa"] })
368
+ end
369
+ end
370
+
371
+ public
372
+
373
+ # Build the command `kitchen login` runs to drop the user into the
374
+ # runner container.
375
+ #
376
+ # `tput` reports the terminal size with a trailing newline; that has to
377
+ # be stripped, or bash reads the embedded newline in COLUMNS as the
378
+ # start of another command line.
379
+ #
380
+ # @return [Kitchen::LoginCommand] the command to exec
179
381
  def login_command
180
382
  @runner = options[:instance_name].to_s
181
- cols = `tput cols`
182
- lines = `tput lines`
383
+ cols = `tput cols`.strip
384
+ lines = `tput lines`.strip
183
385
  args = ["exec", "-e", "COLUMNS=#{cols}", "-e", "LINES=#{lines}", "-it", @runner, "/bin/bash", "-login", "-i"]
184
386
  LoginCommand.new(options[:login_command], args)
185
387
  end
186
388
 
187
389
  private
188
390
 
391
+ # The runner container's name.
392
+ #
393
+ # @return [String] the container name
394
+ # @api private
189
395
  def instance_name
190
396
  options[:instance_name]
191
397
  end
192
398
 
399
+ # The locally built image the runner was created from.
400
+ #
401
+ # @return [String] an image reference
402
+ # @api private
193
403
  def work_image
194
404
  return "#{image_prefix}/#{instance_name}" unless image_prefix.nil?
195
405
 
196
406
  instance_name
197
407
  end
198
408
 
409
+ # The configured image name prefix, if any.
410
+ #
411
+ # @return [String, nil] the prefix
412
+ # @api private
199
413
  def image_prefix
200
414
  options[:image_prefix]
201
415
  end
202
416
 
417
+ # Retry a docker API call through the errors that retrying can fix.
418
+ #
419
+ # @yield the API call to attempt
420
+ # @return [Object] the block's value
421
+ # @raise [::Docker::Error::DockerError] if every attempt failed
422
+ # @api private
203
423
  def with_retries
204
424
  tries = 20
205
425
  begin
@@ -258,6 +478,7 @@ module Kitchen
258
478
  @connection.close
259
479
  end
260
480
 
481
+ @connection_options = options
261
482
  @connection = Kitchen::Transport::Dokken::Connection.new(options, &block)
262
483
  end
263
484
 
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.2
4
+ version: 2.23.4
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