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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +19 -0
- data/lib/kitchen/docker/container/linux.rb +25 -8
- data/lib/kitchen/docker/container/windows.rb +7 -3
- data/lib/kitchen/docker/container.rb +12 -2
- data/lib/kitchen/docker/docker_version.rb +1 -1
- data/lib/kitchen/docker/helpers/cli_helper.rb +55 -31
- data/lib/kitchen/docker/helpers/container_helper.rb +65 -11
- data/lib/kitchen/docker/helpers/file_helper.rb +17 -11
- data/lib/kitchen/docker/helpers/image_helper.rb +19 -1
- data/spec/cli_helper_spec.rb +13 -5
- data/spec/container_helper_spec.rb +117 -3
- data/spec/container_spec.rb +115 -0
- data/spec/file_helper_spec.rb +110 -0
- data/spec/image_helper_spec.rb +39 -0
- data/spec/linux_container_spec.rb +60 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f64096aa934f3502dd1427249a5d15db1371855534522ecb20b4ea4e928f7a5b
|
|
4
|
+
data.tar.gz: 013667f1b55015399185c490cae0c3cd5e899e9fb8678af3fcb42d74c5c9e5d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 414439ff16eeffb9f0288925cd6579da33f60968efe4ead0fa85d6454d98499f634dfed7bfe90ff2131c2a519c2b5f109fdefd7cb854a917f7ff756f539a7f24
|
|
7
|
+
data.tar.gz: 72446a19d4951a3b1b87b66a3c604f13f67cac6395588e1698c4a9dc4ab7cd8f9f3edaa31c1c5ecc7ed5477a26a0e40639d217290ae48c74fb368daadc555b45
|
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
|
-
#
|
|
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
|
|
139
|
+
# @raise [Kitchen::ActionFailed] if no port could be found
|
|
126
140
|
def parse_container_ssh_port(output)
|
|
127
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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}
|
|
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
|
|
38
|
-
#
|
|
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
|
-
#
|
|
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
|
|
54
|
+
# @raise [Kitchen::ActionFailed] if no id could be found
|
|
43
55
|
def parse_container_id(output)
|
|
44
|
-
container_id = output.
|
|
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
|
|
106
|
+
# @return [Boolean] whether the container exists in any state
|
|
86
107
|
def container_exists?(state)
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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.
|
data/spec/cli_helper_spec.rb
CHANGED
|
@@ -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
|
data/spec/image_helper_spec.rb
CHANGED
|
@@ -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.
|
|
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
|