kitchen-docker 3.3.0 → 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.
data/Rakefile CHANGED
@@ -16,4 +16,22 @@ rescue LoadError
16
16
  puts "cookstyle/chefstyle is not available. (sudo) gem install cookstyle to do style checking."
17
17
  end
18
18
 
19
+ begin
20
+ require "yard"
21
+
22
+ # Options and the file list live in .yardopts so that a bare `yard` from the
23
+ # command line produces exactly what `rake doc` does.
24
+ YARD::Rake::YardocTask.new(:doc)
25
+
26
+ desc "List anything in lib/ that is still undocumented"
27
+ task :doc_coverage do
28
+ sh "yard stats --list-undoc"
29
+ end
30
+ rescue LoadError
31
+ desc "Generate YARD documentation (not installed)"
32
+ task :doc do
33
+ abort "YARD is not installed. Run: bundle install"
34
+ end
35
+ end
36
+
19
37
  task default: %i{style test}
@@ -22,15 +22,28 @@ require_relative "../helpers/dockerfile_helper"
22
22
  module Kitchen
23
23
  module Docker
24
24
  class Container
25
+ # A Linux container, reached over SSH.
26
+ #
27
+ # The generated image installs and runs an SSH server, and Test Kitchen
28
+ # connects to a published port with a generated key. That is why the
29
+ # Dockerfile here is so much larger than the Windows one.
25
30
  class Linux < Kitchen::Docker::Container
26
31
  include Kitchen::Docker::Helpers::DockerfileHelper
27
32
 
33
+ # Serializes SSH key generation across concurrently running instances,
34
+ # which share one key path.
28
35
  MUTEX_FOR_SSH_KEYS = Mutex.new
29
36
 
37
+ # @param config [Hash] the driver configuration
30
38
  def initialize(config)
31
39
  super
32
40
  end
33
41
 
42
+ # Builds the image, runs the container, and publishes its SSH port.
43
+ #
44
+ # @param state [Hash] mutable instance state; gains +ssh_key+, +image_id+,
45
+ # +container_id+, +hostname+, and +port+
46
+ # @return [void]
34
47
  def create(state)
35
48
  super
36
49
 
@@ -44,6 +57,15 @@ module Kitchen
44
57
  state[:port] = container_ssh_port(state)
45
58
  end
46
59
 
60
+ # Runs a command in the container by uploading it as a shell script.
61
+ #
62
+ # The command is written to a temp file and executed with bash rather than
63
+ # passed on the command line, which keeps long converge scripts clear of
64
+ # argument-length and quoting limits.
65
+ #
66
+ # @param command [String] the shell code to run
67
+ # @return [String] the command's combined output
68
+ # @raise [RuntimeError] if the command fails
47
69
  def execute(command)
48
70
  # Create temp script file and upload files to container
49
71
  debug("Executing command on Linux container (Platform: #{@config[:platform]})")
@@ -58,9 +80,6 @@ module Kitchen
58
80
  debug("Uploading temp file #{temp_file} to #{remote_path} on container")
59
81
  upload(temp_file, remote_path)
60
82
 
61
- debug("Deleting temp file from local filesystem")
62
- ::File.delete(temp_file)
63
-
64
83
  # Replace any environment variables used in the path and execute script file
65
84
  debug("Executing temp script #{remote_path}/#{filename} on container")
66
85
  remote_path = replace_env_variables(@config, remote_path)
@@ -68,10 +87,23 @@ module Kitchen
68
87
  container_exec(@config, "/bin/bash #{remote_path}/#{filename}")
69
88
  rescue => e
70
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
71
97
  end
72
98
 
73
99
  protected
74
100
 
101
+ # Generates the SSH keypair used to log into Linux containers.
102
+ #
103
+ # Guarded by {MUTEX_FOR_SSH_KEYS} because concurrent instances share one
104
+ # key path and would otherwise write the file while another reads it.
105
+ #
106
+ # @return [void]
75
107
  def generate_keys
76
108
  MUTEX_FOR_SSH_KEYS.synchronize do
77
109
  if !File.exist?(@config[:public_key]) || !File.exist?(@config[:private_key])
@@ -90,13 +122,40 @@ module Kitchen
90
122
  end
91
123
  end
92
124
 
125
+ # Pulls the published port out of `docker port` output.
126
+ #
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"+
138
+ # @return [Integer] the host-side port
139
+ # @raise [Kitchen::ActionFailed] if no port could be found
93
140
  def parse_container_ssh_port(output)
94
- _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
+
95
147
  port.to_i
96
- rescue => e
97
- raise ActionFailed, "Could not parse Docker port output for container SSH port. #{e}"
98
148
  end
99
149
 
150
+ # The port Test Kitchen should connect to for SSH.
151
+ #
152
+ # On the internal Docker network the container is reached directly, so the
153
+ # unmapped port 22 is correct; otherwise Docker's published mapping is
154
+ # looked up.
155
+ #
156
+ # @param state [Hash] instance state naming the container
157
+ # @return [Integer] the port to connect on
158
+ # @raise [Kitchen::ActionFailed] if no SSH port is mapped
100
159
  def container_ssh_port(state)
101
160
  return 22 if @config[:use_internal_docker_network]
102
161
 
@@ -106,6 +165,14 @@ module Kitchen
106
165
  raise ActionFailed, "Docker reports container has no ssh port mapped. #{e}"
107
166
  end
108
167
 
168
+ # Builds the Dockerfile for a Linux container.
169
+ #
170
+ # A configured +dockerfile+ is used as-is after ERB rendering. Otherwise
171
+ # one is generated: the base image, proxy settings, the platform's own
172
+ # package setup, any +provision_command+ entries, and the generated public
173
+ # key appended to the login user's authorized_keys.
174
+ #
175
+ # @return [String] the Dockerfile contents
109
176
  def dockerfile
110
177
  return dockerfile_template if @config[:dockerfile]
111
178
 
@@ -18,11 +18,25 @@ require_relative "../container"
18
18
  module Kitchen
19
19
  module Docker
20
20
  class Container
21
+ # A Windows container, driven through `docker exec`.
22
+ #
23
+ # There is no SSH server and no key to inject: commands are uploaded as
24
+ # PowerShell scripts and executed in the container directly, so no port
25
+ # is published.
21
26
  class Windows < Kitchen::Docker::Container
27
+ # @param config [Hash] the driver configuration
22
28
  def initialize(config)
23
29
  super
24
30
  end
25
31
 
32
+ # Builds the image and runs the container.
33
+ #
34
+ # No port is published: Windows containers are driven through `docker exec`
35
+ # rather than SSH, so there is nothing to map.
36
+ #
37
+ # @param state [Hash] mutable instance state; gains +username+, +image_id+,
38
+ # +container_id+, and +hostname+
39
+ # @return [void]
26
40
  def create(state)
27
41
  super
28
42
 
@@ -33,6 +47,11 @@ module Kitchen
33
47
  state[:hostname] = hostname(state)
34
48
  end
35
49
 
50
+ # Runs a command in the container by uploading it as a PowerShell script.
51
+ #
52
+ # @param command [String] the PowerShell code to run
53
+ # @return [String] the command's combined output
54
+ # @raise [RuntimeError] if the command fails
36
55
  def execute(command)
37
56
  # Create temp script file and upload files to container
38
57
  debug("Executing command on Windows container")
@@ -47,9 +66,6 @@ module Kitchen
47
66
  debug("Uploading temp file #{temp_file} to #{remote_path} on container")
48
67
  upload(temp_file, remote_path)
49
68
 
50
- debug("Deleting temp file from local filesystem")
51
- ::File.delete(temp_file)
52
-
53
69
  # Replace any environment variables used in the path and execute script file
54
70
  debug("Executing temp script #{remote_path}\\#{filename} on container")
55
71
  remote_path = replace_env_variables(@config, remote_path)
@@ -58,10 +74,25 @@ module Kitchen
58
74
  container_exec(@config, cmd)
59
75
  rescue => e
60
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
61
84
  end
62
85
 
63
86
  protected
64
87
 
88
+ # Builds the Dockerfile for a Windows container.
89
+ #
90
+ # Much shorter than the Linux equivalent: there is no SSH server and no
91
+ # key to inject, so only the base image, proxy settings, and any
92
+ # +provision_command+ entries are emitted.
93
+ #
94
+ # @return [String] the Dockerfile contents
95
+ # @raise [Kitchen::ActionFailed] if the platform is not +windows+
65
96
  def dockerfile
66
97
  raise ActionFailed, "Unknown platform '#{@config[:platform]}'" unless @config[:platform] == "windows"
67
98
  return dockerfile_template if @config[:dockerfile]
@@ -18,20 +18,47 @@ require_relative "helpers/image_helper"
18
18
 
19
19
  module Kitchen
20
20
  module Docker
21
+ # Base class for the container the instance under test runs in.
22
+ #
23
+ # Holds the behaviour that is the same on every platform -- checking
24
+ # whether the container exists, removing it, working out its address, and
25
+ # copying files in. {Linux} and {Windows} add how the image is built and
26
+ # how commands are run, which share almost nothing.
21
27
  class Container
22
28
  include Kitchen::Docker::Helpers::CliHelper
23
29
  include Kitchen::Docker::Helpers::ContainerHelper
24
30
  include Kitchen::Docker::Helpers::FileHelper
25
31
  include Kitchen::Docker::Helpers::ImageHelper
26
32
 
33
+ # @param config [Hash] the driver or transport configuration
27
34
  def initialize(config)
28
35
  @config = config
29
36
  end
30
37
 
38
+ # Checks the container named in state and records the login user.
39
+ #
40
+ # A state file naming a container that no longer exists is an error rather
41
+ # than something to build over, because the stale id usually means the
42
+ # container was removed behind Test Kitchen's back and silently creating a
43
+ # new one would hide that.
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
+ #
49
+ # @param state [Hash] mutable instance state; gains +username+
50
+ # @return [void]
51
+ # @raise [Kitchen::ActionFailed] if state names a container that is gone,
52
+ # or one that exists but is not running
31
53
  def create(state)
32
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
+
33
60
  info("Container ID #{state[:container_id]} already exists.")
34
- elsif !container_exists?(state) && state[:container_id]
61
+ elsif state[:container_id]
35
62
  raise ActionFailed, "Container ID #{state[:container_id]} was found in the kitchen state data, " \
36
63
  "but the container does not exist."
37
64
  end
@@ -39,6 +66,10 @@ module Kitchen
39
66
  state[:username] = @config[:username]
40
67
  end
41
68
 
69
+ # Removes the container, and its image when +remove_images+ is set.
70
+ #
71
+ # @param state [Hash] instance state naming the container
72
+ # @return [void]
42
73
  def destroy(state)
43
74
  info("[Docker] Destroying Docker container #{state[:container_id]}") if state[:container_id]
44
75
  remove_container(state) if container_exists?(state)
@@ -48,6 +79,14 @@ module Kitchen
48
79
  end
49
80
  end
50
81
 
82
+ # Works out the address Test Kitchen should connect to.
83
+ #
84
+ # A remote Docker socket means the container is reachable at the socket's
85
+ # own host; +use_internal_docker_network+ means its container IP; anything
86
+ # else is a published port on localhost.
87
+ #
88
+ # @param state [Hash] instance state naming the container
89
+ # @return [String] a hostname or IP address
51
90
  def hostname(state)
52
91
  hostname = "localhost"
53
92
 
@@ -60,6 +99,11 @@ module Kitchen
60
99
  hostname
61
100
  end
62
101
 
102
+ # Copies local files into the container.
103
+ #
104
+ # @param locals [String, Array<String>] one path or several
105
+ # @param remote [String] destination path inside the container
106
+ # @return [Array<String>] the files copied
63
107
  def upload(locals, remote)
64
108
  files = locals
65
109
  files = Array(locals) unless locals.is_a?(Array)
@@ -13,9 +13,11 @@
13
13
  # See the License for the specific language governing permissions and
14
14
  # limitations under the License.
15
15
 
16
+ # Test Kitchen's top-level namespace.
16
17
  module Kitchen
18
+ # Everything belonging to the kitchen-docker plugin.
17
19
  module Docker
18
- # Version string for Docker Kitchen driver
19
- DOCKER_VERSION = "3.3.0".freeze
20
+ # The version of the kitchen-docker gem.
21
+ DOCKER_VERSION = "3.3.3".freeze
20
22
  end
21
23
  end
@@ -17,13 +17,22 @@ require "erb" unless defined?(Erb)
17
17
 
18
18
  module Kitchen
19
19
  module Docker
20
+ # Evaluation context for a user-supplied Dockerfile template.
21
+ #
22
+ # Each configuration key becomes an instance variable, so a template can
23
+ # refer to +@image+, +@username+, and the rest.
20
24
  class ERBContext
25
+ # Exposes each config key to the template as an instance variable, so a
26
+ # custom Dockerfile can refer to +@image+, +@username+, and the rest.
27
+ #
28
+ # @param config [Hash] the configuration to expose
21
29
  def initialize(config = {})
22
30
  config.each do |key, value|
23
31
  instance_variable_set("@" + key.to_s, value)
24
32
  end
25
33
  end
26
34
 
35
+ # @return [Binding] a binding for ERB to evaluate the template in
27
36
  def get_binding
28
37
  binding
29
38
  end
@@ -15,32 +15,71 @@ 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
22
+ # Mixins shared by the driver, transport, and container classes.
21
23
  module Helpers
22
- # rubocop:disable Metrics/ModuleLength, Style/Documentation
24
+ # rubocop:disable Metrics/ModuleLength
25
+ # Builds and runs docker CLI command lines.
23
26
  module CliHelper
24
27
  include Configurable
25
28
  include Logging
26
29
  include ShellOut
27
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
+
28
52
  # rubocop:disable Metrics/AbcSize
53
+
54
+ # Runs a docker CLI command with the configured connection flags.
55
+ #
56
+ # @param cmd [String] the docker subcommand and its arguments
57
+ # @param options [Hash] shell-out options
58
+ # @return [String] the command's combined stdout and stderr
29
59
  def docker_command(cmd, options = {})
30
60
  docker = config[:binary].dup
31
- docker << " -H #{config[:socket]}" if config[:socket]
61
+ docker << " -H #{shell_escape(config[:socket])}" if config[:socket]
32
62
  docker << " --tls" if config[:tls]
33
63
  docker << " --tlsverify" if config[:tls_verify]
34
- docker << " --tlscacert=#{config[:tls_cacert]}" if config[:tls_cacert]
35
- docker << " --tlscert=#{config[:tls_cert]}" if config[:tls_cert]
36
- 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]
37
67
  logger.debug("docker_command: #{docker} #{cmd} shell_opts: #{docker_shell_opts(options)}")
38
68
  run_command("#{docker} #{cmd}", docker_shell_opts(options))
39
69
  end
40
70
  # rubocop:enable Metrics/AbcSize
41
71
 
42
- # Copied from kitchen because we need stderr
43
72
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
73
+
74
+ # Runs a shell command, returning stderr as well as stdout.
75
+ #
76
+ # Test Kitchen's own +run_command+ discards stderr, but docker writes build
77
+ # progress and image ids there, so this reimplements it to keep both.
78
+ #
79
+ # @param cmd [String] the command to run
80
+ # @param options [Hash] shell-out options
81
+ # @return [String] combined stdout and stderr
82
+ # @raise [Kitchen::ShellCommandFailed] if the command exits non-zero
44
83
  def run_command(cmd, options = {})
45
84
  if options.fetch(:use_sudo, false)
46
85
  cmd = "#{options.fetch(:sudo_command, "sudo -E")} #{cmd}"
@@ -62,44 +101,57 @@ module Kitchen
62
101
  # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
63
102
 
64
103
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
104
+
105
+ # Builds the `docker run` command line from the configuration.
106
+ #
107
+ # @param image_id [String] the image to run
108
+ # @param transport_port [Integer, nil] container port to publish, if any
109
+ # @return [String] the docker subcommand and its arguments
65
110
  def build_run_command(image_id, transport_port = nil)
66
111
  cmd = "run -d"
67
112
  cmd << " -i" if config[:interactive]
68
113
  cmd << " -t" if config[:tty]
69
114
  cmd << build_env_variable_args(config[:env_variables]) if config[:env_variables]
70
115
  cmd << " -p #{transport_port}" unless transport_port.nil?
71
- Array(config[:forward]).each { |port| cmd << " -p #{port}" }
72
- Array(config[:dns]).each { |dns| cmd << " --dns #{dns}" }
73
- Array(config[:add_host]).each { |host, ip| cmd << " --add-host=#{host}:#{ip}" }
74
- Array(config[:volume]).each { |volume| cmd << " -v #{volume}" }
75
- Array(config[:volumes_from]).each { |container| cmd << " --volumes-from #{container}" }
76
- Array(config[:links]).each { |link| cmd << " --link #{link}" }
77
- Array(config[:devices]).each { |device| cmd << " --device #{device}" }
78
- Array(config[:mount]).each { |mount| cmd << " --mount #{mount}" }
79
- Array(config[:tmpfs]).each { |tmpfs| cmd << " --tmpfs #{tmpfs}" }
80
- 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]
81
126
  cmd << " -P" if config[:publish_all]
82
- cmd << " -h #{config[:hostname]}" if config[:hostname]
83
- cmd << " -m #{config[:memory]}" if config[:memory]
84
- cmd << " -c #{config[:cpu]}" if config[:cpu]
85
- cmd << " --gpus #{config[:gpus]}" if config[:gpus]
86
- cmd << " -e http_proxy=#{config[:http_proxy]}" if config[:http_proxy]
87
- 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]
88
133
  cmd << " --privileged" if config[:privileged]
89
- cmd << " --isolation #{config[:isolation]}" if config[:isolation]
90
- Array(config[:cap_add]).each { |cap| cmd << " --cap-add=#{cap}" } if config[:cap_add]
91
- Array(config[:cap_drop]).each { |cap| cmd << " --cap-drop=#{cap}" } if config[:cap_drop]
92
- Array(config[:security_opt]).each { |opt| cmd << " --security-opt=#{opt}" } if config[:security_opt]
93
- 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]
94
139
  extra_run_options = config_to_options(config[:run_options])
95
140
  cmd << " #{extra_run_options}" unless extra_run_options.empty?
96
- 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]}"
97
143
  logger.debug("build_run_command: #{cmd}")
98
144
  cmd
99
145
  end
100
146
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
101
147
 
102
148
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
149
+
150
+ # Builds a `docker exec` command line from the configuration.
151
+ #
152
+ # @param state [Hash] instance state naming the container
153
+ # @param command [String] the command to run inside it
154
+ # @return [String] the docker subcommand and its arguments
103
155
  def build_exec_command(state, command)
104
156
  cmd = "exec"
105
157
  cmd << " -d" if config[:detach]
@@ -107,22 +159,33 @@ module Kitchen
107
159
  cmd << " --privileged" if config[:privileged]
108
160
  cmd << " -t" if config[:tty]
109
161
  cmd << " -i" if config[:interactive]
110
- cmd << " -u #{config[:username]}" if config[:username]
111
- cmd << " -w #{config[:working_dir]}" if config[:working_dir]
112
- 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.
113
166
  cmd << " #{command}"
114
167
  logger.debug("build_exec_command: #{cmd}")
115
168
  cmd
116
169
  end
117
170
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
118
171
 
172
+ # Builds a `docker cp` command line.
173
+ #
174
+ # @param local_file [String] source path
175
+ # @param remote_file [String] destination, as +container:path+
176
+ # @param opts [Hash] +:archive+ to preserve ownership and mode
177
+ # @return [String] the docker subcommand and its arguments
119
178
  def build_copy_command(local_file, remote_file, opts = {})
120
179
  cmd = "cp"
121
180
  cmd << " -a" if opts[:archive]
122
- cmd << " #{local_file} #{remote_file}"
181
+ cmd << " #{shell_escape(local_file)} #{shell_escape(remote_file)}"
123
182
  cmd
124
183
  end
125
184
 
185
+ # Wraps PowerShell code so it can be run through `docker exec`.
186
+ #
187
+ # @param args [String] the PowerShell arguments
188
+ # @return [String] the full powershell invocation
126
189
  def build_powershell_command(args)
127
190
  cmd = "powershell -ExecutionPolicy Bypass -NoLogo "
128
191
  cmd << args
@@ -130,17 +193,24 @@ module Kitchen
130
193
  cmd
131
194
  end
132
195
 
196
+ # Turns a hash of environment variables into `-e` flags.
197
+ #
198
+ # @param vars [Hash] variable names to values
199
+ # @return [String] the flags, each preceded by a space
200
+ # @raise [Kitchen::ActionFailed] if given something other than a Hash
133
201
  def build_env_variable_args(vars)
134
202
  raise ActionFailed, "Environment variables are not of a Hash type" unless vars.is_a?(Hash)
135
203
 
136
204
  args = ""
137
205
  vars.each do |k, v|
138
- args << " -e #{k.to_s.strip}=\"#{v.to_s.strip}\""
206
+ args << " -e #{shell_escape("#{k.to_s.strip}=#{v.to_s.strip}")}"
139
207
  end
140
208
 
141
209
  args
142
210
  end
143
211
 
212
+ # @return [String] the platform's null device, +NUL+ on Windows and
213
+ # +/dev/null+ everywhere else
144
214
  def dev_null
145
215
  case RbConfig::CONFIG["host_os"]
146
216
  when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
@@ -150,6 +220,13 @@ module Kitchen
150
220
  end
151
221
  end
152
222
 
223
+ # Normalizes shell-out options for a docker command.
224
+ #
225
+ # Translates +:suppress_output+ into silencing the live stream, and removes
226
+ # it, since Mixlib::ShellOut would reject the unknown key.
227
+ #
228
+ # @param options [Hash] the options to normalize
229
+ # @return [Hash] options Mixlib::ShellOut accepts
153
230
  def docker_shell_opts(options = {})
154
231
  options[:live_stream] = nil if options[:suppress_output]
155
232
  options.delete(:suppress_output)
@@ -178,7 +255,7 @@ module Kitchen
178
255
  end
179
256
  # rubocop:enable Metrics/CyclomaticComplexity
180
257
  end
181
- # rubocop:enable Metrics/ModuleLength, Style/Documentation
258
+ # rubocop:enable Metrics/ModuleLength
182
259
  end
183
260
  end
184
261
  end