Module: Kitchen::Docker::Helpers::CliHelper

Includes:
Configurable, Logging, ShellOut
Included in:
Container, ContainerHelper, ImageHelper, Kitchen::Driver::Docker
Defined in:
lib/kitchen/docker/helpers/cli_helper.rb

Overview

rubocop:disable Metrics/ModuleLength Builds and runs docker CLI command lines.

Instance Method Summary collapse

Instance Method Details

#build_copy_command(local_file, remote_file, opts = {}) ⇒ String

Builds a docker cp command line.

Parameters:

  • local_file (String)

    source path

  • remote_file (String)

    destination, as container:path

  • opts (Hash) (defaults to: {})

    :archive to preserve ownership and mode

Returns:

  • (String)

    the docker subcommand and its arguments



206
207
208
209
210
211
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 206

def build_copy_command(local_file, remote_file, opts = {})
  cmd = "cp"
  cmd << " -a" if opts[:archive]
  cmd << " #{shell_escape(local_file)} #{shell_escape(remote_file)}"
  cmd
end

#build_env_variable_args(vars) ⇒ String

Turns a hash of environment variables into -e flags.

Parameters:

  • vars (Hash)

    variable names to values

Returns:

  • (String)

    the flags, each preceded by a space

Raises:

  • (Kitchen::ActionFailed)

    if given something other than a Hash



229
230
231
232
233
234
235
236
237
238
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 229

def build_env_variable_args(vars)
  raise ActionFailed, "Environment variables are not of a Hash type" unless vars.is_a?(Hash)

  args = ""
  vars.each do |k, v|
    args << " -e #{shell_escape("#{k.to_s.strip}=#{v.to_s.strip}")}"
  end

  args
end

#build_exec_command(state, command) ⇒ String

Builds a docker exec command line from the configuration.

Parameters:

  • state (Hash)

    instance state naming the container

  • command (String)

    the command to run inside it

Returns:

  • (String)

    the docker subcommand and its arguments



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 183

def build_exec_command(state, command)
  cmd = "exec"
  cmd << " -d" if config[:detach]
  cmd << build_env_variable_args(config[:env_variables]) if config[:env_variables]
  cmd << " --privileged" if config[:privileged]
  cmd << " -t" if config[:tty]
  cmd << " -i" if config[:interactive]
  cmd << " -u #{shell_escape(config[:username])}" if config[:username]
  cmd << " -w #{shell_escape(config[:working_dir])}" if config[:working_dir]
  cmd << " #{shell_escape(state[:container_id])}"
  # command is a command line, not a single value, so it stays raw.
  cmd << " #{command}"
  logger.debug("build_exec_command: #{cmd}")
  cmd
end

#build_powershell_command(args) ⇒ String

Wraps PowerShell code so it can be run through docker exec.

Parameters:

  • args (String)

    the PowerShell arguments

Returns:

  • (String)

    the full powershell invocation



217
218
219
220
221
222
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 217

def build_powershell_command(args)
  cmd = "powershell -ExecutionPolicy Bypass -NoLogo "
  cmd << args
  logger.debug("build_powershell_command: #{cmd}")
  cmd
end

#build_run_command(image_id, transport_port = nil) ⇒ String

Builds the docker run command line from the configuration.

Parameters:

  • image_id (String)

    the image to run

  • transport_port (Integer, nil) (defaults to: nil)

    container port to publish, if any

Returns:

  • (String)

    the docker subcommand and its arguments



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 138

def build_run_command(image_id, transport_port = nil)
  cmd = "run -d"
  cmd << " -i" if config[:interactive]
  cmd << " -t" if config[:tty]
  cmd << build_env_variable_args(config[:env_variables]) if config[:env_variables]
  cmd << " -p #{transport_port}" unless transport_port.nil?
  Array(config[:forward]).each { |port| cmd << " -p #{shell_escape(port)}" }
  Array(config[:dns]).each { |dns| cmd << " --dns #{shell_escape(dns)}" }
  Array(config[:add_host]).each { |host, ip| cmd << " --add-host=#{shell_escape("#{host}:#{ip}")}" }
  Array(config[:volume]).each { |volume| cmd << " -v #{shell_escape(volume)}" }
  Array(config[:volumes_from]).each { |container| cmd << " --volumes-from #{shell_escape(container)}" }
  Array(config[:links]).each { |link| cmd << " --link #{shell_escape(link)}" }
  Array(config[:devices]).each { |device| cmd << " --device #{shell_escape(device)}" }
  Array(config[:mount]).each { |mount| cmd << " --mount #{shell_escape(mount)}" }
  Array(config[:tmpfs]).each { |tmpfs| cmd << " --tmpfs #{shell_escape(tmpfs)}" }
  cmd << " --name #{shell_escape(config[:instance_name])}" if config[:instance_name]
  cmd << " -P" if config[:publish_all]
  cmd << " -h #{shell_escape(config[:hostname])}" if config[:hostname]
  cmd << " -m #{shell_escape(config[:memory])}" if config[:memory]
  cmd << " -c #{shell_escape(config[:cpu])}" if config[:cpu]
  cmd << " --gpus #{shell_escape(config[:gpus])}" if config[:gpus]
  cmd << " -e http_proxy=#{shell_escape(config[:http_proxy])}" if config[:http_proxy]
  cmd << " -e https_proxy=#{shell_escape(config[:https_proxy])}" if config[:https_proxy]
  cmd << " --privileged" if config[:privileged]
  cmd << " --isolation #{shell_escape(config[:isolation])}" if config[:isolation]
  Array(config[:cap_add]).each { |cap| cmd << " --cap-add=#{shell_escape(cap)}" } if config[:cap_add]
  Array(config[:cap_drop]).each { |cap| cmd << " --cap-drop=#{shell_escape(cap)}" } if config[:cap_drop]
  Array(config[:security_opt]).each { |opt| cmd << " --security-opt=#{shell_escape(opt)}" } if config[:security_opt]
  cmd << " --platform=#{shell_escape(config[:docker_platform])}" if config[:docker_platform]
  extra_run_options = config_to_options(config[:run_options])
  cmd << " #{extra_run_options}" unless extra_run_options.empty?
  # run_command is a command line, not a single value, so it stays raw.
  cmd << " #{shell_escape(image_id)} #{config[:run_command]}"
  logger.debug("build_run_command: #{cmd}")
  cmd
end

#config_to_options(config) ⇒ String

Convert the config input for :build_options or :run_options in to a command line string for use with Docker.

rubocop:disable Metrics/CyclomaticComplexity

Parameters:

  • config (nil, String, Array, Hash)

    Config data to convert.

Returns:

  • (String)

Since:

  • 2.5.0



272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 272

def config_to_options(config)
  case config
  when nil
    ""
  when String
    config
  when Array
    config.map { |c| config_to_options(c) }.join(" ")
  when Hash
    config.map { |k, v| Array(v).map { |c| "--#{k}=#{Shellwords.escape(c)}" }.join(" ") }.join(" ")
  end
end

#dev_nullString

Returns the platform's null device, NUL on Windows and /dev/null everywhere else.

Returns:

  • (String)

    the platform's null device, NUL on Windows and /dev/null everywhere else



242
243
244
245
246
247
248
249
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 242

def dev_null
  case RbConfig::CONFIG["host_os"]
  when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
    "NUL"
  else
    "/dev/null"
  end
end

#docker_command(cmd, options = {}) ⇒ String

Runs a docker CLI command with the configured connection flags.

Parameters:

  • cmd (String)

    the docker subcommand and its arguments

  • options (Hash) (defaults to: {})

    shell-out options

Returns:

  • (String)

    the command's combined stdout and stderr



59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 59

def docker_command(cmd, options = {})
  docker = config[:binary].dup
  docker << " -H #{shell_escape(config[:socket])}" if config[:socket]
  docker << " --tls" if config[:tls]
  docker << " --tlsverify" if config[:tls_verify]
  docker << " --tlscacert=#{shell_escape(config[:tls_cacert])}" if config[:tls_cacert]
  docker << " --tlscert=#{shell_escape(config[:tls_cert])}" if config[:tls_cert]
  docker << " --tlskey=#{shell_escape(config[:tls_key])}" if config[:tls_key]
  options = docker_sudo_opts(options)
  logger.debug("docker_command: #{docker} #{cmd} shell_opts: #{docker_shell_opts(options)}")
  run_command("#{docker} #{cmd}", docker_shell_opts(options))
end

#docker_shell_opts(options = {}) ⇒ Hash

Normalizes shell-out options for a docker command.

Translates :suppress_output into silencing the live stream, and removes it, since Mixlib::ShellOut would reject the unknown key.

Parameters:

  • options (Hash) (defaults to: {})

    the options to normalize

Returns:

  • (Hash)

    options Mixlib::ShellOut accepts



258
259
260
261
262
263
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 258

def docker_shell_opts(options = {})
  options[:live_stream] = nil if options[:suppress_output]
  options.delete(:suppress_output)

  options
end

#docker_sudo_opts(options = {}) ⇒ Hash

Adds the sudo options #run_command reads, when use_sudo is set.

Sudo is a property of the call rather than of the configuration as far as the shell-out layer is concerned: it reads :use_sudo from the options hash it is handed and knows nothing about config. So a docker command only runs through sudo if these are passed to it.

Without this, use_sudo reached exactly one command -- the docker probe in verify_dependencies -- and every build, run, exec, cp, and rm still ran as the invoking user. On a host where the daemon socket needs root, that made the documented answer to "permission denied while trying to connect to the Docker daemon socket" do nothing at all.

A copy is returned rather than the hash being edited in place, so a caller that reuses its options hash does not accumulate sudo.

Parameters:

  • options (Hash) (defaults to: {})

    shell-out options

Returns:

  • (Hash)

    those options, with sudo added when configured



92
93
94
95
96
97
98
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 92

def docker_sudo_opts(options = {})
  return options unless config[:use_sudo]

  options = options.merge(use_sudo: true)
  options[:sudo_command] = config[:sudo_command] if config[:sudo_command]
  options
end

#run_command(cmd, options = {}) ⇒ String

Runs a shell command, returning stderr as well as stdout.

Test Kitchen's own run_command discards stderr, but docker writes build progress and image ids there, so this reimplements it to keep both.

Parameters:

  • cmd (String)

    the command to run

  • options (Hash) (defaults to: {})

    shell-out options

Returns:

  • (String)

    combined stdout and stderr

Raises:

  • (Kitchen::ShellCommandFailed)

    if the command exits non-zero



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 111

def run_command(cmd, options = {})
  if options.fetch(:use_sudo, false)
    cmd = "#{options.fetch(:sudo_command, "sudo -E")} #{cmd}"
  end
  subject = "[#{options.fetch(:log_subject, "local")} command]"

  debug("#{subject} BEGIN (#{cmd})")
  sh = Mixlib::ShellOut.new(cmd, shell_opts(options))
  sh.run_command
  debug("#{subject} END #{Util.duration(sh.execution_time)}")
  sh.error!
  sh.stdout + sh.stderr
rescue Mixlib::ShellOut::ShellCommandFailed => ex
  raise ShellCommandFailed, ex.message
rescue Exception => error # rubocop:disable Lint/RescueException
  error.extend(Kitchen::Error)
  raise
end

#shell_escape(value) ⇒ String

Escapes a configured value for the shell.

Docker command lines are assembled as strings and handed to a shell, so a value that is a single datum -- a path, a name, a port mapping -- has to be escaped, or a space inside it is read as an argument separator and the rest of the value is taken as the image name.

Values that are deliberately shell fragments are not escaped: run_command, run_options, build_options, and the command handed to docker exec are all documented as accepting flags and arguments.

Escaping is a no-op for ordinary values -- db:db and 8.8.8.8 come back unchanged -- so this only alters command lines that were already broken.

Parameters:

  • value (#to_s)

    the configured value

Returns:

  • (String)

    the value, safe to interpolate into a command line



48
49
50
# File 'lib/kitchen/docker/helpers/cli_helper.rb', line 48

def shell_escape(value)
  Shellwords.escape(value.to_s)
end