kitchen-docker 3.3.1 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48b3e240f091225e997ef1b3ef22576733a2874ea3584e2d362f84af9bf48d70
4
- data.tar.gz: d1f6e04ee6c62a9bdcd95f7020ce63b7de932161486383b2cde9d0541f871eab
3
+ metadata.gz: f64096aa934f3502dd1427249a5d15db1371855534522ecb20b4ea4e928f7a5b
4
+ data.tar.gz: 013667f1b55015399185c490cae0c3cd5e899e9fb8678af3fcb42d74c5c9e5d8
5
5
  SHA512:
6
- metadata.gz: 2aab821262b74c69f39e6189549d87e9bbb265e9a9169d318702d86f0504c719e777a8363726e9996727b31c7b3ab989c5c1b917d641915b39268952114c57c9
7
- data.tar.gz: f02acc355ec6699a2a23557d39599b1c98be400e906b9bfc20ee8dfa330c4d3eb98cbde1f8ccb38bcbab5ff8cd166ccf9babac0864a44fa1d7fbb8eaad78edaf
6
+ metadata.gz: 414439ff16eeffb9f0288925cd6579da33f60968efe4ead0fa85d6454d98499f634dfed7bfe90ff2131c2a519c2b5f109fdefd7cb854a917f7ff756f539a7f24
7
+ data.tar.gz: 72446a19d4951a3b1b87b66a3c604f13f67cac6395588e1698c4a9dc4ab7cd8f9f3edaa31c1c5ecc7ed5477a26a0e40639d217290ae48c74fb368daadc555b45
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "3.3.1"
2
+ ".": "3.3.3"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ Future CHANGELOG notes will be in GitHub release notes
6
6
 
7
7
  * Docs: document the last four options and split contributor docs ([#463](https://github.com/test-kitchen/kitchen-docker/pull/463)) ([4818bce](https://github.com/test-kitchen/kitchen-docker/commit/4818bce))
8
8
 
9
+ ## [3.3.3](https://github.com/test-kitchen/kitchen-docker/compare/v3.3.2...v3.3.3) (2026-08-23)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * detect containers that exist but have stopped ([#476](https://github.com/test-kitchen/kitchen-docker/issues/476)) ([c6c7038](https://github.com/test-kitchen/kitchen-docker/commit/c6c70382f9e7be4a4c27f5f225f72465a3b392a6))
15
+ * detect that an image is in use before removing it ([#477](https://github.com/test-kitchen/kitchen-docker/issues/477)) ([4c5f255](https://github.com/test-kitchen/kitchen-docker/commit/4c5f255bdcbbb58c9f8b4e225627642d6ac79fed))
16
+ * remove the staged script when a command fails ([#481](https://github.com/test-kitchen/kitchen-docker/issues/481)) ([44de85e](https://github.com/test-kitchen/kitchen-docker/commit/44de85e9ac552b317bf116248d80c3d9c1a81dd0))
17
+ * report why a temp file could not be written ([#480](https://github.com/test-kitchen/kitchen-docker/issues/480)) ([70565e5](https://github.com/test-kitchen/kitchen-docker/commit/70565e56d0755791596920a55bd4f5897a1dc455))
18
+
19
+ ## [3.3.2](https://github.com/test-kitchen/kitchen-docker/compare/v3.3.1...v3.3.2) (2026-08-23)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * escape configured values interpolated into docker command lines ([#471](https://github.com/test-kitchen/kitchen-docker/issues/471)) ([994b0ca](https://github.com/test-kitchen/kitchen-docker/commit/994b0ca9d89bab0501b3946ca45fd177b24bf2bd))
25
+ * split printenv output on the first equals sign only ([#473](https://github.com/test-kitchen/kitchen-docker/issues/473)) ([9b0d0d7](https://github.com/test-kitchen/kitchen-docker/commit/9b0d0d784efe4f65c1c6585e19440e90e322b3a1))
26
+ * tolerate stderr warnings when parsing docker run container id (rebase of [#452](https://github.com/test-kitchen/kitchen-docker/issues/452)) ([#475](https://github.com/test-kitchen/kitchen-docker/issues/475)) ([a8ad6f6](https://github.com/test-kitchen/kitchen-docker/commit/a8ad6f6e05e927f3966a72ee6029b9f135246565))
27
+
9
28
  ## [3.3.1](https://github.com/test-kitchen/kitchen-docker/compare/v3.3.0...v3.3.1) (2026-08-23)
10
29
 
11
30
 
@@ -80,9 +80,6 @@ module Kitchen
80
80
  debug("Uploading temp file #{temp_file} to #{remote_path} on container")
81
81
  upload(temp_file, remote_path)
82
82
 
83
- debug("Deleting temp file from local filesystem")
84
- ::File.delete(temp_file)
85
-
86
83
  # Replace any environment variables used in the path and execute script file
87
84
  debug("Executing temp script #{remote_path}/#{filename} on container")
88
85
  remote_path = replace_env_variables(@config, remote_path)
@@ -90,6 +87,13 @@ module Kitchen
90
87
  container_exec(@config, "/bin/bash #{remote_path}/#{filename}")
91
88
  rescue => e
92
89
  raise "Failed to execute command on Linux container. #{e}"
90
+ ensure
91
+ # Removed here rather than after the upload, so that a failure part
92
+ # way through does not leave the script behind in .kitchen/temp.
93
+ if temp_file && ::File.exist?(temp_file)
94
+ debug("Deleting temp file from local filesystem")
95
+ ::File.delete(temp_file)
96
+ end
93
97
  end
94
98
 
95
99
  protected
@@ -120,14 +124,27 @@ module Kitchen
120
124
 
121
125
  # Pulls the published port out of `docker port` output.
122
126
  #
123
- # @param output [String] e.g. +"0.0.0.0:32768"+
127
+ # One line is printed per published binding, and the host part varies:
128
+ #
129
+ # 0.0.0.0:32768
130
+ # [::]:32768
131
+ #
132
+ # The port is taken from the end of the line rather than by splitting on
133
+ # ":" from the left, because an IPv6 host contains colons of its own --
134
+ # splitting left-to-right returned "" for those, and "".to_i is 0, so a
135
+ # daemon publishing on IPv6 handed Test Kitchen port 0 to connect to.
136
+ #
137
+ # @param output [String] e.g. +"0.0.0.0:32768\n[::]:32768\n"+
124
138
  # @return [Integer] the host-side port
125
- # @raise [Kitchen::ActionFailed] if the output cannot be parsed
139
+ # @raise [Kitchen::ActionFailed] if no port could be found
126
140
  def parse_container_ssh_port(output)
127
- _host, port = output.split(":")
141
+ port = output.lines.filter_map { |line| line[/:(\d+)\s*\z/, 1] }.first
142
+
143
+ if port.nil?
144
+ raise ActionFailed, "Could not parse Docker port output for container SSH port: #{output.inspect}"
145
+ end
146
+
128
147
  port.to_i
129
- rescue => e
130
- raise ActionFailed, "Could not parse Docker port output for container SSH port. #{e}"
131
148
  end
132
149
 
133
150
  # The port Test Kitchen should connect to for SSH.
@@ -66,9 +66,6 @@ module Kitchen
66
66
  debug("Uploading temp file #{temp_file} to #{remote_path} on container")
67
67
  upload(temp_file, remote_path)
68
68
 
69
- debug("Deleting temp file from local filesystem")
70
- ::File.delete(temp_file)
71
-
72
69
  # Replace any environment variables used in the path and execute script file
73
70
  debug("Executing temp script #{remote_path}\\#{filename} on container")
74
71
  remote_path = replace_env_variables(@config, remote_path)
@@ -77,6 +74,13 @@ module Kitchen
77
74
  container_exec(@config, cmd)
78
75
  rescue => e
79
76
  raise "Failed to execute command on Windows container. #{e}"
77
+ ensure
78
+ # Removed here rather than after the upload, so that a failure part
79
+ # way through does not leave the script behind in .kitchen/temp.
80
+ if temp_file && ::File.exist?(temp_file)
81
+ debug("Deleting temp file from local filesystem")
82
+ ::File.delete(temp_file)
83
+ end
80
84
  end
81
85
 
82
86
  protected
@@ -42,13 +42,23 @@ module Kitchen
42
42
  # container was removed behind Test Kitchen's back and silently creating a
43
43
  # new one would hide that.
44
44
  #
45
+ # A container that exists but has stopped is also an error, and a separate
46
+ # one: it is still there to be cleaned up, so the message points at
47
+ # `kitchen destroy` rather than claiming the container is gone.
48
+ #
45
49
  # @param state [Hash] mutable instance state; gains +username+
46
50
  # @return [void]
47
- # @raise [Kitchen::ActionFailed] if state names a container that is gone
51
+ # @raise [Kitchen::ActionFailed] if state names a container that is gone,
52
+ # or one that exists but is not running
48
53
  def create(state)
49
54
  if container_exists?(state)
55
+ unless container_running?(state)
56
+ raise ActionFailed, "Container ID #{state[:container_id]} was found in the kitchen state data, " \
57
+ "but the container is not running. Run `kitchen destroy` to remove it."
58
+ end
59
+
50
60
  info("Container ID #{state[:container_id]} already exists.")
51
- elsif !container_exists?(state) && state[:container_id]
61
+ elsif state[:container_id]
52
62
  raise ActionFailed, "Container ID #{state[:container_id]} was found in the kitchen state data, " \
53
63
  "but the container does not exist."
54
64
  end
@@ -18,6 +18,6 @@ module Kitchen
18
18
  # Everything belonging to the kitchen-docker plugin.
19
19
  module Docker
20
20
  # The version of the kitchen-docker gem.
21
- DOCKER_VERSION = "3.3.1".freeze
21
+ DOCKER_VERSION = "3.3.3".freeze
22
22
  end
23
23
  end
@@ -15,6 +15,7 @@ require "kitchen"
15
15
  require "kitchen/configurable"
16
16
  require "kitchen/logging"
17
17
  require "kitchen/shell_out"
18
+ require "shellwords" unless defined?(Shellwords)
18
19
 
19
20
  module Kitchen
20
21
  module Docker
@@ -27,6 +28,27 @@ module Kitchen
27
28
  include Logging
28
29
  include ShellOut
29
30
 
31
+ # Escapes a configured value for the shell.
32
+ #
33
+ # Docker command lines are assembled as strings and handed to a shell, so
34
+ # a value that is a single datum -- a path, a name, a port mapping -- has
35
+ # to be escaped, or a space inside it is read as an argument separator and
36
+ # the rest of the value is taken as the image name.
37
+ #
38
+ # Values that are deliberately shell fragments are *not* escaped:
39
+ # +run_command+, +run_options+, +build_options+, and the command handed to
40
+ # `docker exec` are all documented as accepting flags and arguments.
41
+ #
42
+ # Escaping is a no-op for ordinary values -- +db:db+ and +8.8.8.8+ come
43
+ # back unchanged -- so this only alters command lines that were already
44
+ # broken.
45
+ #
46
+ # @param value [#to_s] the configured value
47
+ # @return [String] the value, safe to interpolate into a command line
48
+ def shell_escape(value)
49
+ Shellwords.escape(value.to_s)
50
+ end
51
+
30
52
  # rubocop:disable Metrics/AbcSize
31
53
 
32
54
  # Runs a docker CLI command with the configured connection flags.
@@ -36,12 +58,12 @@ module Kitchen
36
58
  # @return [String] the command's combined stdout and stderr
37
59
  def docker_command(cmd, options = {})
38
60
  docker = config[:binary].dup
39
- docker << " -H #{config[:socket]}" if config[:socket]
61
+ docker << " -H #{shell_escape(config[:socket])}" if config[:socket]
40
62
  docker << " --tls" if config[:tls]
41
63
  docker << " --tlsverify" if config[:tls_verify]
42
- docker << " --tlscacert=#{config[:tls_cacert]}" if config[:tls_cacert]
43
- docker << " --tlscert=#{config[:tls_cert]}" if config[:tls_cert]
44
- docker << " --tlskey=#{config[:tls_key]}" if config[:tls_key]
64
+ docker << " --tlscacert=#{shell_escape(config[:tls_cacert])}" if config[:tls_cacert]
65
+ docker << " --tlscert=#{shell_escape(config[:tls_cert])}" if config[:tls_cert]
66
+ docker << " --tlskey=#{shell_escape(config[:tls_key])}" if config[:tls_key]
45
67
  logger.debug("docker_command: #{docker} #{cmd} shell_opts: #{docker_shell_opts(options)}")
46
68
  run_command("#{docker} #{cmd}", docker_shell_opts(options))
47
69
  end
@@ -91,32 +113,33 @@ module Kitchen
91
113
  cmd << " -t" if config[:tty]
92
114
  cmd << build_env_variable_args(config[:env_variables]) if config[:env_variables]
93
115
  cmd << " -p #{transport_port}" unless transport_port.nil?
94
- Array(config[:forward]).each { |port| cmd << " -p #{port}" }
95
- Array(config[:dns]).each { |dns| cmd << " --dns #{dns}" }
96
- Array(config[:add_host]).each { |host, ip| cmd << " --add-host=#{host}:#{ip}" }
97
- Array(config[:volume]).each { |volume| cmd << " -v #{volume}" }
98
- Array(config[:volumes_from]).each { |container| cmd << " --volumes-from #{container}" }
99
- Array(config[:links]).each { |link| cmd << " --link #{link}" }
100
- Array(config[:devices]).each { |device| cmd << " --device #{device}" }
101
- Array(config[:mount]).each { |mount| cmd << " --mount #{mount}" }
102
- Array(config[:tmpfs]).each { |tmpfs| cmd << " --tmpfs #{tmpfs}" }
103
- cmd << " --name #{config[:instance_name]}" if config[:instance_name]
116
+ Array(config[:forward]).each { |port| cmd << " -p #{shell_escape(port)}" }
117
+ Array(config[:dns]).each { |dns| cmd << " --dns #{shell_escape(dns)}" }
118
+ Array(config[:add_host]).each { |host, ip| cmd << " --add-host=#{shell_escape("#{host}:#{ip}")}" }
119
+ Array(config[:volume]).each { |volume| cmd << " -v #{shell_escape(volume)}" }
120
+ Array(config[:volumes_from]).each { |container| cmd << " --volumes-from #{shell_escape(container)}" }
121
+ Array(config[:links]).each { |link| cmd << " --link #{shell_escape(link)}" }
122
+ Array(config[:devices]).each { |device| cmd << " --device #{shell_escape(device)}" }
123
+ Array(config[:mount]).each { |mount| cmd << " --mount #{shell_escape(mount)}" }
124
+ Array(config[:tmpfs]).each { |tmpfs| cmd << " --tmpfs #{shell_escape(tmpfs)}" }
125
+ cmd << " --name #{shell_escape(config[:instance_name])}" if config[:instance_name]
104
126
  cmd << " -P" if config[:publish_all]
105
- cmd << " -h #{config[:hostname]}" if config[:hostname]
106
- cmd << " -m #{config[:memory]}" if config[:memory]
107
- cmd << " -c #{config[:cpu]}" if config[:cpu]
108
- cmd << " --gpus #{config[:gpus]}" if config[:gpus]
109
- cmd << " -e http_proxy=#{config[:http_proxy]}" if config[:http_proxy]
110
- cmd << " -e https_proxy=#{config[:https_proxy]}" if config[:https_proxy]
127
+ cmd << " -h #{shell_escape(config[:hostname])}" if config[:hostname]
128
+ cmd << " -m #{shell_escape(config[:memory])}" if config[:memory]
129
+ cmd << " -c #{shell_escape(config[:cpu])}" if config[:cpu]
130
+ cmd << " --gpus #{shell_escape(config[:gpus])}" if config[:gpus]
131
+ cmd << " -e http_proxy=#{shell_escape(config[:http_proxy])}" if config[:http_proxy]
132
+ cmd << " -e https_proxy=#{shell_escape(config[:https_proxy])}" if config[:https_proxy]
111
133
  cmd << " --privileged" if config[:privileged]
112
- cmd << " --isolation #{config[:isolation]}" if config[:isolation]
113
- Array(config[:cap_add]).each { |cap| cmd << " --cap-add=#{cap}" } if config[:cap_add]
114
- Array(config[:cap_drop]).each { |cap| cmd << " --cap-drop=#{cap}" } if config[:cap_drop]
115
- Array(config[:security_opt]).each { |opt| cmd << " --security-opt=#{opt}" } if config[:security_opt]
116
- cmd << " --platform=#{config[:docker_platform]}" if config[:docker_platform]
134
+ cmd << " --isolation #{shell_escape(config[:isolation])}" if config[:isolation]
135
+ Array(config[:cap_add]).each { |cap| cmd << " --cap-add=#{shell_escape(cap)}" } if config[:cap_add]
136
+ Array(config[:cap_drop]).each { |cap| cmd << " --cap-drop=#{shell_escape(cap)}" } if config[:cap_drop]
137
+ Array(config[:security_opt]).each { |opt| cmd << " --security-opt=#{shell_escape(opt)}" } if config[:security_opt]
138
+ cmd << " --platform=#{shell_escape(config[:docker_platform])}" if config[:docker_platform]
117
139
  extra_run_options = config_to_options(config[:run_options])
118
140
  cmd << " #{extra_run_options}" unless extra_run_options.empty?
119
- cmd << " #{image_id} #{config[:run_command]}"
141
+ # run_command is a command line, not a single value, so it stays raw.
142
+ cmd << " #{shell_escape(image_id)} #{config[:run_command]}"
120
143
  logger.debug("build_run_command: #{cmd}")
121
144
  cmd
122
145
  end
@@ -136,9 +159,10 @@ module Kitchen
136
159
  cmd << " --privileged" if config[:privileged]
137
160
  cmd << " -t" if config[:tty]
138
161
  cmd << " -i" if config[:interactive]
139
- cmd << " -u #{config[:username]}" if config[:username]
140
- cmd << " -w #{config[:working_dir]}" if config[:working_dir]
141
- cmd << " #{state[:container_id]}"
162
+ cmd << " -u #{shell_escape(config[:username])}" if config[:username]
163
+ cmd << " -w #{shell_escape(config[:working_dir])}" if config[:working_dir]
164
+ cmd << " #{shell_escape(state[:container_id])}"
165
+ # command is a command line, not a single value, so it stays raw.
142
166
  cmd << " #{command}"
143
167
  logger.debug("build_exec_command: #{cmd}")
144
168
  cmd
@@ -154,7 +178,7 @@ module Kitchen
154
178
  def build_copy_command(local_file, remote_file, opts = {})
155
179
  cmd = "cp"
156
180
  cmd << " -a" if opts[:archive]
157
- cmd << " #{local_file} #{remote_file}"
181
+ cmd << " #{shell_escape(local_file)} #{shell_escape(remote_file)}"
158
182
  cmd
159
183
  end
160
184
 
@@ -179,7 +203,7 @@ module Kitchen
179
203
 
180
204
  args = ""
181
205
  vars.each do |k, v|
182
- args << " -e #{k.to_s.strip}=\"#{v.to_s.strip}\""
206
+ args << " -e #{shell_escape("#{k.to_s.strip}=#{v.to_s.strip}")}"
183
207
  end
184
208
 
185
209
  args
@@ -34,19 +34,31 @@ module Kitchen
34
34
 
35
35
  # Pulls the container id out of `docker run` output.
36
36
  #
37
- # Docker prints ids in short (12) or full (64) hex form; anything else
38
- # means the output was not an id at all.
37
+ # Docker prints ids in short (12) or full (64) hex form, on a line of
38
+ # their own. The id is looked for line by line rather than by taking the
39
+ # whole output, because {CliHelper#run_command} returns stdout and stderr
40
+ # together and some daemons write warnings to stderr on a run that
41
+ # otherwise succeeds. Rootless Docker emits "WARNING: IPv4 forwarding is
42
+ # disabled. Networking will not work." on every run; setting
43
+ # +run_options+ to +--net=host+ produces "WARNING: Published ports are
44
+ # discarded when using host network mode", since the driver always
45
+ # publishes port 22 for Linux containers. Treating the whole output as
46
+ # the id failed those runs *after* the container had been created,
47
+ # leaving it running and untracked.
39
48
  #
40
- # @param output [String] the command output
49
+ # Scanning forward is deterministic: +run_command+ concatenates stdout
50
+ # before stderr, so the id always precedes anything a warning adds.
51
+ #
52
+ # @param output [String] the command output, stdout and stderr together
41
53
  # @return [String] the container id
42
- # @raise [Kitchen::ActionFailed] if no id could be parsed
54
+ # @raise [Kitchen::ActionFailed] if no id could be found
43
55
  def parse_container_id(output)
44
- container_id = output.chomp
45
-
46
- unless [12, 64].include?(container_id.size)
47
- raise ActionFailed, "Could not parse Docker run output for container ID"
56
+ container_id = output.to_s.lines.map(&:strip).find do |line|
57
+ line.match?(/\A[0-9a-f]{12}(?:[0-9a-f]{52})?\z/)
48
58
  end
49
59
 
60
+ raise ActionFailed, "Could not parse Docker run output for container ID" unless container_id
61
+
50
62
  container_id
51
63
  end
52
64
 
@@ -81,10 +93,45 @@ module Kitchen
81
93
  config[:build_context] ? Pathname.new(file.path).relative_path_from(Pathname.pwd).to_s : file.path
82
94
  end
83
95
 
96
+ # Whether the container named in state is present, running or not.
97
+ #
98
+ # Asked with `docker inspect` rather than `docker top`, which answers a
99
+ # different question: `top` lists processes, so it fails on a container
100
+ # that exists but has stopped. Reading that as "does not exist" made
101
+ # {Kitchen::Docker::Container#destroy} skip removal and leave the
102
+ # container behind, while Test Kitchen deleted the state file and
103
+ # reported success.
104
+ #
84
105
  # @param state [Hash] instance state naming the container
85
- # @return [Boolean] whether the container is present and running
106
+ # @return [Boolean] whether the container exists in any state
86
107
  def container_exists?(state)
87
- state[:container_id] && !!docker_command("top #{state[:container_id]}") rescue false
108
+ return false unless state[:container_id]
109
+
110
+ !!docker_command("inspect --type=container #{state[:container_id]}",
111
+ suppress_output: !logger.debug?)
112
+ rescue
113
+ false
114
+ end
115
+
116
+ # Whether the container named in state is running.
117
+ #
118
+ # Separate from {#container_exists?} because the two callers want
119
+ # different questions answered: destroy removes a container in any
120
+ # state, while create has to tell a container it can use from one that
121
+ # has stopped.
122
+ #
123
+ # @param state [Hash] instance state naming the container
124
+ # @return [Boolean] whether the container exists and is running
125
+ def container_running?(state)
126
+ return false unless state[:container_id]
127
+
128
+ output = docker_command(
129
+ "inspect --type=container --format '{{.State.Running}}' #{state[:container_id]}",
130
+ suppress_output: !logger.debug?
131
+ )
132
+ output.strip == "true"
133
+ rescue
134
+ false
88
135
  end
89
136
 
90
137
  # Runs a command inside the container.
@@ -159,7 +206,14 @@ module Kitchen
159
206
  else
160
207
  cmd = build_exec_command(state, "printenv")
161
208
  stdout = docker_command(cmd, suppress_output: !logger.debug?).strip
162
- stdout.split("\n").each { |line| vars[line.split("=")[0]] = line.split("=")[1] }
209
+ # printenv writes NAME=VALUE, and values routinely contain "=" --
210
+ # LS_COLORS and anything -Dkey=value shaped do. Split on the first
211
+ # one only, or the value is truncated at it. Lines with no "=" are
212
+ # continuations of a multi-line value and carry no name.
213
+ stdout.split("\n").each do |line|
214
+ name, value = line.split("=", 2)
215
+ vars[name] = value unless value.nil?
216
+ end
163
217
  end
164
218
 
165
219
  vars
@@ -21,26 +21,32 @@ module Kitchen
21
21
  module FileHelper
22
22
  # Writes a temp file, creating its parent directory if needed.
23
23
  #
24
+ # Written with +File.write+, which opens, writes and closes in one call.
25
+ # The previous implementation assigned the open file back over the +file+
26
+ # parameter and closed it in an +ensure+, so when opening failed -- a
27
+ # read-only directory, a parent that is not a directory, a full disk --
28
+ # +file+ was still the path String and the ensure raised
29
+ # "undefined method 'close' for an instance of String", replacing the
30
+ # real error with a Ruby one. Its rescue did not help either: it caught
31
+ # +IOError+, while opening a file fails with +Errno+ classes, which are
32
+ # +SystemCallError+ and not +IOError+.
33
+ #
24
34
  # @param file [String] path to write
25
35
  # @param contents [String] what to write
26
36
  # @return [void]
27
- # @raise [RuntimeError] if the write fails
37
+ # @raise [RuntimeError] if the file cannot be written, naming the path
38
+ # and the underlying cause
28
39
  def create_temp_file(file, contents)
29
40
  debug("[Docker] Creating temp file #{file}")
30
41
  debug("[Docker] --- Start Temp File Contents ---")
31
42
  debug(contents)
32
43
  debug("[Docker] --- End Temp File Contents ---")
33
44
 
34
- begin
35
- path = ::File.dirname(file)
36
- ::FileUtils.mkdir_p(path) unless ::Dir.exist?(path)
37
- file = ::File.open(file, "w")
38
- file.write(contents)
39
- rescue IOError => e
40
- raise "Failed to write temp file. Error Details: #{e}"
41
- ensure
42
- file.close unless file.nil?
43
- end
45
+ path = ::File.dirname(file)
46
+ ::FileUtils.mkdir_p(path) unless ::Dir.exist?(path)
47
+ ::File.write(file, contents)
48
+ rescue SystemCallError, IOError => e
49
+ raise "Failed to write temp file #{file}. Error Details: #{e}"
44
50
  end
45
51
  end
46
52
  end
@@ -68,10 +68,28 @@ module Kitchen
68
68
  end
69
69
  end
70
70
 
71
+ # Whether any container was created from the image.
72
+ #
73
+ # Asked with a filter rather than by searching `docker ps -a` output for
74
+ # the id. That output abbreviates the IMAGE column to twelve characters,
75
+ # while state carries the full +sha256:+ digest, so the substring never
76
+ # matched and the answer was always false -- which defeated the guard
77
+ # entirely and let {#remove_image} run `docker rmi` against an image a
78
+ # container was still using.
79
+ #
71
80
  # @param state [Hash] instance state naming the image
72
81
  # @return [Boolean] whether any container references it
73
82
  def image_in_use?(state)
74
- docker_command("ps -a", suppress_output: !logger.debug?).include?(state[:image_id])
83
+ return false unless state[:image_id]
84
+
85
+ output = docker_command("ps -a -q --filter ancestor=#{state[:image_id]}",
86
+ suppress_output: !logger.debug?)
87
+
88
+ # Matched line by line rather than by emptiness, so a warning docker
89
+ # writes to stderr is not mistaken for a container id.
90
+ output.lines.map(&:strip).any? do |line|
91
+ line.match?(/\A[0-9a-f]{12}(?:[0-9a-f]{52})?\z/)
92
+ end
75
93
  end
76
94
 
77
95
  # Builds the image from the given Dockerfile.
@@ -104,17 +104,14 @@ describe Kitchen::Docker::Helpers::CliHelper do
104
104
  # configured value containing a space has to survive shell splitting as a
105
105
  # single argument. These are the cases where it does not.
106
106
  it "keeps a volume path containing a space as one argument" do
107
- pending "BUG: build_run_command interpolates volume unquoted, so a path with a space becomes two arguments"
108
107
  expect(run_argv(volume: "/host dir:/data")).to include_consecutive("-v", "/host dir:/data")
109
108
  end
110
109
 
111
110
  it "keeps a hostname containing a space as one argument" do
112
- pending "BUG: build_run_command interpolates hostname unquoted"
113
111
  expect(run_argv(hostname: "two words")).to include_consecutive("-h", "two words")
114
112
  end
115
113
 
116
114
  it "keeps a mount specification containing a space as one argument" do
117
- pending "BUG: build_run_command interpolates mount unquoted"
118
115
  expect(run_argv(mount: "type=bind,source=/my dir,destination=/d"))
119
116
  .to include_consecutive("--mount", "type=bind,source=/my dir,destination=/d")
120
117
  end
@@ -176,10 +173,22 @@ describe Kitchen::Docker::Helpers::CliHelper do
176
173
  end
177
174
 
178
175
  it "keeps a value containing a double quote intact" do
179
- pending "BUG: values are wrapped in double quotes, so an embedded double quote ends the quoting early"
180
176
  expect(argv(helper.build_env_variable_args("MSG" => 'say "hi"')))
181
177
  .to eq ["-e", 'MSG=say "hi"']
182
178
  end
179
+
180
+ it "keeps a value containing a dollar sign out of the shell's reach" do
181
+ # An unescaped $HOME would be expanded by the shell running the docker
182
+ # command, so the container would receive the workstation's home
183
+ # directory instead of the literal string.
184
+ expect(argv(helper.build_env_variable_args("P" => "$HOME/x")))
185
+ .to eq ["-e", "P=$HOME/x"]
186
+ end
187
+
188
+ it "keeps a name containing a space as one argument" do
189
+ expect(argv(helper.build_env_variable_args("ODD NAME" => "v")))
190
+ .to eq ["-e", "ODD NAME=v"]
191
+ end
183
192
  end
184
193
 
185
194
  describe "#build_copy_command" do
@@ -194,7 +203,6 @@ describe Kitchen::Docker::Helpers::CliHelper do
194
203
  end
195
204
 
196
205
  it "keeps a local path containing a space as one argument" do
197
- pending "BUG: build_copy_command interpolates both paths unquoted, so uploads from a path with a space fail"
198
206
  expect(argv(helper.build_copy_command("/Users/me/My Cookbooks/f.rb", "abc:/tmp/f.rb")))
199
207
  .to eq ["cp", "/Users/me/My Cookbooks/f.rb", "abc:/tmp/f.rb"]
200
208
  end
@@ -30,18 +30,64 @@ describe Kitchen::Docker::Helpers::ContainerHelper do
30
30
  .to raise_error(Kitchen::ActionFailed, /Could not parse Docker run output/)
31
31
  end
32
32
 
33
+ it "fails loudly on empty output" do
34
+ expect { helper.parse_container_id("") }
35
+ .to raise_error(Kitchen::ActionFailed, /Could not parse Docker run output/)
36
+ end
37
+
38
+ # Cases from #452. Rootless Docker emits this on every `docker run`, which
39
+ # makes it the most common way to reach this code path.
40
+ context "when docker wrote a warning to stderr" do
41
+ let(:warning) { "WARNING: IPv4 forwarding is disabled. Networking will not work." }
42
+
43
+ it "finds an id the warning follows" do
44
+ expect(helper.parse_container_id("#{DockerOutput::RUN_CONTAINER_ID}\n#{warning}\n"))
45
+ .to eq DockerOutput::RUN_CONTAINER_ID
46
+ end
47
+
48
+ it "finds an id the warning precedes" do
49
+ expect(helper.parse_container_id("#{warning}\n#{DockerOutput::RUN_CONTAINER_ID}\n"))
50
+ .to eq DockerOutput::RUN_CONTAINER_ID
51
+ end
52
+
53
+ it "finds a short id" do
54
+ expect(helper.parse_container_id("abcdef123456\n#{warning}\n")).to eq "abcdef123456"
55
+ end
56
+
57
+ it "still fails when the warning is all there is" do
58
+ expect { helper.parse_container_id("#{warning}\nno container id here") }
59
+ .to raise_error(Kitchen::ActionFailed, /Could not parse Docker run output/)
60
+ end
61
+ end
62
+
63
+ it "takes the id docker printed, not a later hex line" do
64
+ # run_command concatenates stdout before stderr, so the id always comes
65
+ # first. Scanning backwards would prefer a bare hex line that a warning
66
+ # happened to leave on stderr.
67
+ expect(helper.parse_container_id("#{DockerOutput::RUN_CONTAINER_ID}\ndeadbeefcafe\n"))
68
+ .to eq DockerOutput::RUN_CONTAINER_ID
69
+ end
70
+
71
+ it "does not mistake a hex-looking word inside a message for an id" do
72
+ expect { helper.parse_container_id("WARNING: layer abcdef123456 was skipped\n") }
73
+ .to raise_error(Kitchen::ActionFailed, /Could not parse Docker run output/)
74
+ end
75
+
76
+ it "tolerates surrounding whitespace on the id line" do
77
+ expect(helper.parse_container_id(" #{DockerOutput::RUN_CONTAINER_ID} \n"))
78
+ .to eq DockerOutput::RUN_CONTAINER_ID
79
+ end
80
+
33
81
  # run_command returns stdout + stderr, and the parser requires the whole
34
82
  # combined string to be exactly the id. Anything docker writes to stderr on
35
83
  # a successful `run` therefore fails container creation, with an error that
36
84
  # blames parsing rather than naming the warning.
37
85
  it "finds the id when docker pulled the image and logged progress to stderr" do
38
- pending "BUG: parse_container_id requires the entire output to be the id, so any stderr output fails the run"
39
86
  expect(helper.parse_container_id(DockerOutput::RUN_WITH_PULL_ON_STDERR))
40
87
  .to eq DockerOutput::RUN_CONTAINER_ID
41
88
  end
42
89
 
43
90
  it "finds the id when the kernel lacks swap accounting" do
44
- pending "BUG: the 'No swap limit support' warning docker emits on many Linux hosts is concatenated onto the id"
45
91
  expect(helper.parse_container_id(DockerOutput::RUN_WITH_SWAP_WARNING))
46
92
  .to eq DockerOutput::RUN_CONTAINER_ID
47
93
  end
@@ -110,11 +156,26 @@ describe Kitchen::Docker::Helpers::ContainerHelper do
110
156
  # rather than the first silently truncates them, and the truncated value
111
157
  # then flows into replace_env_variables and into upload paths.
112
158
  it "keeps a value containing an equals sign intact" do
113
- pending "BUG: values are split on every '=' rather than the first; use split(\"=\", 2)"
114
159
  expect(with_printenv("LS_COLORS=rs=0:di=01;34\n"))
115
160
  .to eq("LS_COLORS" => "rs=0:di=01;34")
116
161
  end
117
162
 
163
+ it "keeps a value that is itself a key=value list" do
164
+ expect(with_printenv("JAVA_OPTS=-Dfoo=bar -Dbaz=qux\n"))
165
+ .to eq("JAVA_OPTS" => "-Dfoo=bar -Dbaz=qux")
166
+ end
167
+
168
+ it "keeps a variable with an empty value" do
169
+ expect(with_printenv("EMPTY=\n")).to eq("EMPTY" => "")
170
+ end
171
+
172
+ it "ignores a continuation line of a multi-line value" do
173
+ # printenv prints an embedded newline literally, so the second line has no
174
+ # name. Recording it would put a nil-valued entry under a bogus key.
175
+ expect(with_printenv("CERT=-----BEGIN-----\nMIIBIjANBg\n"))
176
+ .to eq("CERT" => "-----BEGIN-----")
177
+ end
178
+
118
179
  it "parses the JSON a Windows container returns" do
119
180
  h = helper
120
181
  allow(h).to receive(:docker_command).and_return('{"TEMP":"C:\\\\Users\\\\ADMINI~1\\\\AppData\\\\Local\\\\Temp"}')
@@ -156,6 +217,59 @@ describe Kitchen::Docker::Helpers::ContainerHelper do
156
217
  end
157
218
  end
158
219
 
220
+ describe "#container_exists? and #container_running?" do
221
+ # `docker inspect` answers for a container in any state; `docker top` only
222
+ # for a running one. The two predicates have to disagree on a stopped
223
+ # container, or destroy cannot clean it up.
224
+ def helper_answering(inspect_ok:, running: "false")
225
+ h = helper
226
+ allow(h).to receive(:docker_command) do |cmd, _opts = {}|
227
+ raise Kitchen::ShellOut::ShellCommandFailed, "No such container" unless inspect_ok
228
+
229
+ cmd.include?("{{.State.Running}}") ? "#{running}\n" : "[{...}]"
230
+ end
231
+ h
232
+ end
233
+
234
+ let(:state) { { container_id: "abc123abc123" } }
235
+
236
+ it "reports a running container as existing and running" do
237
+ h = helper_answering(inspect_ok: true, running: "true")
238
+ expect(h.container_exists?(state)).to be true
239
+ expect(h.container_running?(state)).to be true
240
+ end
241
+
242
+ it "reports a stopped container as existing but not running" do
243
+ h = helper_answering(inspect_ok: true, running: "false")
244
+ expect(h.container_exists?(state)).to be true
245
+ expect(h.container_running?(state)).to be false
246
+ end
247
+
248
+ it "reports a container docker does not know as neither" do
249
+ h = helper_answering(inspect_ok: false)
250
+ expect(h.container_exists?(state)).to be false
251
+ expect(h.container_running?(state)).to be false
252
+ end
253
+
254
+ it "reports no container when state names none" do
255
+ h = helper
256
+ expect(h).not_to receive(:docker_command)
257
+ expect(h.container_exists?({})).to be false
258
+ expect(h.container_running?({})).to be false
259
+ end
260
+
261
+ it "asks docker about a container, not about its processes" do
262
+ # `docker top` was the original implementation and is the bug: it fails
263
+ # on a stopped container, which read as the container not existing.
264
+ h = helper
265
+ seen = []
266
+ allow(h).to receive(:docker_command) { |cmd, _opts = {}| seen << cmd; "[{...}]" }
267
+ h.container_exists?(state)
268
+ expect(seen.join).to include("inspect --type=container")
269
+ expect(seen.join).not_to include("top ")
270
+ end
271
+ end
272
+
159
273
  describe "#remove_container" do
160
274
  it "stops the container before removing it" do
161
275
  # `docker rm` refuses to remove a running container, so the order matters.
@@ -0,0 +1,115 @@
1
+ #
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ #
14
+
15
+ require "spec_helper"
16
+
17
+ describe Kitchen::Docker::Container do
18
+ subject(:container) { described_class.new(config) }
19
+
20
+ let(:config) { { username: "kitchen", remove_images: false } }
21
+ let(:state) { { container_id: "abc123abc123" } }
22
+
23
+ # A container that exists but has stopped is the case these two guard. It
24
+ # happens whenever the host reboots, the container's PID 1 exits, or someone
25
+ # runs `docker stop` -- so it is not an exotic state to be in.
26
+ def stub_container(exists:, running:)
27
+ allow(container).to receive(:container_exists?).and_return(exists)
28
+ allow(container).to receive(:container_running?).and_return(running)
29
+ end
30
+
31
+ describe "#destroy" do
32
+ it "removes a running container" do
33
+ stub_container(exists: true, running: true)
34
+ expect(container).to receive(:remove_container).with(state)
35
+ container.destroy(state)
36
+ end
37
+
38
+ it "removes a container that exists but has stopped" do
39
+ # Test Kitchen deletes the state file once destroy returns, so a stopped
40
+ # container skipped here is orphaned for good: nothing records its id any
41
+ # more, and `kitchen list` reports the instance as not created.
42
+ stub_container(exists: true, running: false)
43
+ expect(container).to receive(:remove_container).with(state)
44
+ container.destroy(state)
45
+ end
46
+
47
+ it "removes nothing when the container is already gone" do
48
+ stub_container(exists: false, running: false)
49
+ expect(container).not_to receive(:remove_container)
50
+ container.destroy(state)
51
+ end
52
+
53
+ context "with remove_images set" do
54
+ let(:config) { { username: "kitchen", remove_images: true } }
55
+ let(:state) { { container_id: "abc123abc123", image_id: "sha256:abc" } }
56
+
57
+ it "removes the image after the container" do
58
+ stub_container(exists: true, running: true)
59
+ allow(container).to receive(:image_exists?).and_return(true)
60
+ order = []
61
+ allow(container).to receive(:remove_container) { order << :container }
62
+ allow(container).to receive(:remove_image) { order << :image }
63
+
64
+ container.destroy(state)
65
+
66
+ expect(order).to eq %i{container image}
67
+ end
68
+ end
69
+ end
70
+
71
+ describe "#create" do
72
+ it "accepts a running container it has seen before" do
73
+ stub_container(exists: true, running: true)
74
+ expect { container.create(state) }.not_to raise_error
75
+ end
76
+
77
+ it "records the login user" do
78
+ stub_container(exists: true, running: true)
79
+ new_state = state.dup
80
+ container.create(new_state)
81
+ expect(new_state[:username]).to eq "kitchen"
82
+ end
83
+
84
+ it "refuses a container that exists but is not running, and says how to clear it" do
85
+ # Reporting this as "the container does not exist" left no way forward:
86
+ # the container is there, so the message has to point at destroy.
87
+ stub_container(exists: true, running: false)
88
+ expect { container.create(state) }
89
+ .to raise_error(Kitchen::ActionFailed, /is not running.*kitchen destroy/m)
90
+ end
91
+
92
+ it "refuses a container that state names but docker does not have" do
93
+ stub_container(exists: false, running: false)
94
+ expect { container.create(state) }
95
+ .to raise_error(Kitchen::ActionFailed, /does not exist/)
96
+ end
97
+
98
+ it "is happy to start from nothing" do
99
+ stub_container(exists: false, running: false)
100
+ expect { container.create({}) }.not_to raise_error
101
+ end
102
+
103
+ it "asks docker about the container only as often as it needs to" do
104
+ # The previous implementation called container_exists? twice on the path
105
+ # that raises, which is two `docker inspect` round trips for one answer.
106
+ stub_container(exists: false, running: false)
107
+ expect(container).to receive(:container_exists?).once.and_return(false)
108
+ begin
109
+ container.create(state)
110
+ rescue Kitchen::ActionFailed
111
+ nil
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,110 @@
1
+ #
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ #
14
+
15
+ require "spec_helper"
16
+ require "tmpdir"
17
+
18
+ describe Kitchen::Docker::Helpers::FileHelper do
19
+ around do |example|
20
+ Dir.mktmpdir { |dir| @dir = dir; example.run }
21
+ end
22
+
23
+ describe "#create_temp_file" do
24
+ it "writes the contents" do
25
+ path = File.join(@dir, "docker-abc.sh")
26
+ helper.create_temp_file(path, "echo hi\n")
27
+ expect(File.read(path)).to eq "echo hi\n"
28
+ end
29
+
30
+ it "creates the parent directory" do
31
+ # Container#execute writes to ./.kitchen/temp, which does not exist on a
32
+ # fresh checkout.
33
+ path = File.join(@dir, ".kitchen", "temp", "docker-abc.sh")
34
+ helper.create_temp_file(path, "echo hi\n")
35
+ expect(File.read(path)).to eq "echo hi\n"
36
+ end
37
+
38
+ it "replaces the contents of an existing file" do
39
+ path = File.join(@dir, "docker-abc.sh")
40
+ File.write(path, "old and longer content")
41
+ helper.create_temp_file(path, "new\n")
42
+ expect(File.read(path)).to eq "new\n"
43
+ end
44
+
45
+ it "leaves no open handle behind" do
46
+ path = File.join(@dir, "docker-abc.sh")
47
+ before = ObjectSpace.each_object(File).count { |f| !f.closed? }
48
+ helper.create_temp_file(path, "echo hi\n")
49
+ expect(ObjectSpace.each_object(File).count { |f| !f.closed? }).to eq before
50
+ end
51
+
52
+ # Every one of these used to raise "undefined method 'close' for an instance
53
+ # of String": the open failed, `file` was still the path, and the ensure
54
+ # tried to close it. The real cause never reached the user, who saw a Ruby
55
+ # NoMethodError reported as a Docker failure.
56
+ context "when the file cannot be written" do
57
+ it "reports a parent that is not a directory" do
58
+ # ".kitchen" existing as a file rather than a directory. The exact errno
59
+ # differs by platform -- macOS reports EEXIST from mkdir here, Linux
60
+ # ENOTDIR -- so the assertion is that the underlying cause reaches the
61
+ # user at all, which is what used to be lost.
62
+ blocker = File.join(@dir, "kitchen")
63
+ File.write(blocker, "not a directory")
64
+ path = File.join(blocker, "temp", "docker-abc.sh")
65
+
66
+ expect { helper.create_temp_file(path, "echo hi") }
67
+ .to raise_error(RuntimeError, /Failed to write temp file.*Error Details: \S+.*#{Regexp.escape(blocker)}/m)
68
+ end
69
+
70
+ it "reports a directory it may not write to" do
71
+ readonly = File.join(@dir, "readonly")
72
+ Dir.mkdir(readonly)
73
+ File.chmod(0o500, readonly)
74
+
75
+ begin
76
+ expect { helper.create_temp_file(File.join(readonly, "docker-abc.sh"), "echo hi") }
77
+ .to raise_error(RuntimeError, /Failed to write temp file.*Permission denied/m)
78
+ ensure
79
+ File.chmod(0o700, readonly)
80
+ end
81
+ end
82
+
83
+ it "reports a target that is itself a directory" do
84
+ target = File.join(@dir, "isadir")
85
+ Dir.mkdir(target)
86
+
87
+ expect { helper.create_temp_file(target, "echo hi") }
88
+ .to raise_error(RuntimeError, /Failed to write temp file/)
89
+ end
90
+
91
+ it "names the path it could not write" do
92
+ # The old message said only "Failed to write temp file", which did not
93
+ # say which one.
94
+ path = File.join(@dir, "isadir")
95
+ Dir.mkdir(path)
96
+
97
+ expect { helper.create_temp_file(path, "echo hi") }
98
+ .to raise_error(RuntimeError, /#{Regexp.escape(path)}/)
99
+ end
100
+
101
+ it "never raises NoMethodError" do
102
+ blocker = File.join(@dir, "kitchen")
103
+ File.write(blocker, "not a directory")
104
+
105
+ expect { helper.create_temp_file(File.join(blocker, "x.sh"), "echo hi") }
106
+ .not_to raise_error(NoMethodError)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -122,6 +122,45 @@ describe Kitchen::Docker::Helpers::ImageHelper do
122
122
  end
123
123
  end
124
124
 
125
+ describe "#image_in_use?" do
126
+ let(:image_id) { "sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" }
127
+
128
+ def helper_seeing(output)
129
+ h = helper
130
+ @asked = nil
131
+ allow(h).to receive(:docker_command) { |cmd, _opts = {}| @asked = cmd; output }
132
+ h
133
+ end
134
+
135
+ it "reports the image as in use when a container references it" do
136
+ expect(helper_seeing("331a7cd151e4\n").image_in_use?(image_id: image_id)).to be true
137
+ end
138
+
139
+ it "reports the image as free when nothing references it" do
140
+ expect(helper_seeing("").image_in_use?(image_id: image_id)).to be false
141
+ end
142
+
143
+ it "reports the image as free when state names no image" do
144
+ h = helper
145
+ expect(h).not_to receive(:docker_command)
146
+ expect(h.image_in_use?({})).to be false
147
+ end
148
+
149
+ it "asks docker to filter, rather than searching ps output for the id" do
150
+ # `docker ps -a` abbreviates the IMAGE column to twelve characters, so
151
+ # searching it for the full sha256 digest never matched and the guard
152
+ # was always false.
153
+ h = helper_seeing("331a7cd151e4\n")
154
+ h.image_in_use?(image_id: image_id)
155
+ expect(@asked).to eq "ps -a -q --filter ancestor=#{image_id}"
156
+ end
157
+
158
+ it "does not mistake a warning on stderr for a container" do
159
+ expect(helper_seeing("WARNING: something happened\n").image_in_use?(image_id: image_id))
160
+ .to be false
161
+ end
162
+ end
163
+
125
164
  describe "#remove_image" do
126
165
  let(:state) { { image_id: "sha256:abc" } }
127
166
 
@@ -54,15 +54,28 @@ describe Kitchen::Docker::Container::Linux do
54
54
  # rescue clause cannot fire. Test Kitchen is then handed port 0 and fails
55
55
  # far away from here, complaining that SSH was refused.
56
56
  it "reads the published port from an IPv6-only daemon" do
57
- pending "BUG: IPv6 output parses to port 0 rather than the published port"
58
57
  expect(port(DockerOutput::PORT_IPV6_ONLY)).to eq DockerOutput::PUBLISHED_PORT
59
58
  end
60
59
 
61
60
  it "refuses to report a port it could not parse" do
62
- pending "BUG: unparseable output silently yields port 0 instead of raising"
63
61
  expect { port("") }.to raise_error(Kitchen::ActionFailed)
64
62
  end
65
63
 
64
+ it "reads the published port when docker names the host" do
65
+ expect(port("localhost:52239\n")).to eq DockerOutput::PUBLISHED_PORT
66
+ end
67
+
68
+ it "refuses to report a port from output with no port in it" do
69
+ expect { port("no public port '22/tcp' published\n") }
70
+ .to raise_error(Kitchen::ActionFailed, /Could not parse Docker port output/)
71
+ end
72
+
73
+ it "names the output it could not parse" do
74
+ # The previous message said only that parsing failed, which left nobody
75
+ # any way to tell what docker had actually printed.
76
+ expect { port("surprising\n") }.to raise_error(Kitchen::ActionFailed, /surprising/)
77
+ end
78
+
66
79
  it "never returns a port that cannot be connected to" do
67
80
  # The invariant behind both pending examples above: whatever this returns
68
81
  # has to be usable. Zero is not.
@@ -155,6 +168,51 @@ describe Kitchen::Docker::Container::Linux do
155
168
  end
156
169
  end
157
170
 
171
+ describe "#execute" do
172
+ # The command is staged as a script under .kitchen/temp and uploaded. That
173
+ # local copy has to go whether or not the rest of the run works, or a failing
174
+ # converge leaves a file behind on every attempt.
175
+ def container_in(dir, &upload)
176
+ c = container
177
+ allow(c).to receive(:create_dir_on_container)
178
+ allow(c).to receive(:replace_env_variables) { |_cfg, path| path }
179
+ allow(c).to receive(:container_exec).and_return("ok")
180
+ allow(c).to receive(:upload, &(upload || ->(*) { nil }))
181
+ allow(Dir).to receive(:pwd).and_return(dir)
182
+ c
183
+ end
184
+
185
+ around do |example|
186
+ Dir.chdir(@tmpdir) { example.run }
187
+ end
188
+
189
+ it "removes the staged script once the command has run" do
190
+ c = container_in(@tmpdir)
191
+ c.execute("echo hi")
192
+ expect(Dir.glob(".kitchen/temp/docker-*.sh")).to be_empty
193
+ end
194
+
195
+ it "removes the staged script when the upload fails" do
196
+ c = container_in(@tmpdir) { raise "upload exploded" }
197
+ expect { c.execute("echo hi") }.to raise_error(/Failed to execute command/)
198
+ expect(Dir.glob(".kitchen/temp/docker-*.sh")).to be_empty
199
+ end
200
+
201
+ it "removes the staged script when the command itself fails" do
202
+ c = container_in(@tmpdir)
203
+ allow(c).to receive(:container_exec).and_raise("command exploded")
204
+ expect { c.execute("echo hi") }.to raise_error(/Failed to execute command/)
205
+ expect(Dir.glob(".kitchen/temp/docker-*.sh")).to be_empty
206
+ end
207
+
208
+ it "uploads the command as the contents of the script" do
209
+ uploaded = nil
210
+ c = container_in(@tmpdir) { |local, _remote| uploaded = ::File.read(local) }
211
+ c.execute("echo hi")
212
+ expect(uploaded).to eq "echo hi"
213
+ end
214
+ end
215
+
158
216
  describe "#generate_keys" do
159
217
  it "writes a usable key pair when none exists" do
160
218
  private_key = File.join(@tmpdir, "generated")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-docker
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.1
4
+ version: 3.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Porter
@@ -74,9 +74,11 @@ files:
74
74
  - renovate.json
75
75
  - spec/cli_helper_spec.rb
76
76
  - spec/container_helper_spec.rb
77
+ - spec/container_spec.rb
77
78
  - spec/docker_spec.rb
78
79
  - spec/dockerfile_helper_spec.rb
79
80
  - spec/erb_context_spec.rb
81
+ - spec/file_helper_spec.rb
80
82
  - spec/image_helper_spec.rb
81
83
  - spec/inspec_helper_spec.rb
82
84
  - spec/linux_container_spec.rb