Module: Kitchen::Docker::Helpers::ContainerHelper

Includes:
Configurable, CliHelper
Included in:
Container, ImageHelper, Kitchen::Driver::Docker
Defined in:
lib/kitchen/docker/helpers/container_helper.rb

Overview

rubocop:disable Metrics/ModuleLength Operations against a running container: exec, copy, inspect, remove.

Constant Summary collapse

COPIED_MARKER =

Printed by the probe in #file_on_container? when the file is there. A marker is echoed rather than the exit status being read, because a non-zero exit from docker exec is raised rather than returned.

"kitchen_docker_copied".freeze

Instance Method Summary collapse

Methods included from CliHelper

#build_copy_command, #build_env_variable_args, #build_exec_command, #build_powershell_command, #build_run_command, #config_to_options, #dev_null, #docker_command, #docker_shell_opts, #docker_sudo_opts, #run_command, #shell_escape

Instance Method Details

#container_env_variables(state) ⇒ Hash

Reads the container's environment.

Parameters:

  • state (Hash)

    instance state naming the container

Returns:

  • (Hash)

    variable names to values



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 264

def container_env_variables(state)
  # Retrieves all environment variables from inside container
  vars = {}

  if state[:platform].include?("windows")
    cmd = build_powershell_command("-Command [System.Environment]::GetEnvironmentVariables() ^| ConvertTo-Json")
    cmd = build_exec_command(state, cmd)
    stdout = docker_command(cmd, suppress_output: !logger.debug?).strip
    vars = ::JSON.parse(stdout)
  else
    cmd = build_exec_command(state, "printenv")
    stdout = docker_command(cmd, suppress_output: !logger.debug?).strip
    # printenv writes NAME=VALUE, and values routinely contain "=" --
    # LS_COLORS and anything -Dkey=value shaped do. Split on the first
    # one only, or the value is truncated at it. Lines with no "=" are
    # continuations of a multi-line value and carry no name.
    stdout.split("\n").each do |line|
      name, value = line.split("=", 2)
      vars[name] = value unless value.nil?
    end
  end

  vars
end

#container_exec(state, command) ⇒ String

Runs a command inside the container.

Parameters:

  • state (Hash)

    instance state naming the container

  • command (String)

    the command to run

Returns:

  • (String)

    the command's combined output

Raises:

  • (RuntimeError)

    if the command fails



149
150
151
152
153
154
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 149

def container_exec(state, command)
  cmd = build_exec_command(state, command)
  docker_command(cmd)
rescue => e
  raise "Failed to execute command on Docker container. #{e}"
end

#container_exists?(state) ⇒ Boolean

Whether the container named in state is present, running or not.

Asked with docker inspect rather than docker top, which answers a different question: top lists processes, so it fails on a container that exists but has stopped. Reading that as "does not exist" made Container#destroy skip removal and leave the container behind, while Test Kitchen deleted the state file and reported success.

Parameters:

  • state (Hash)

    instance state naming the container

Returns:

  • (Boolean)

    whether the container exists in any state



113
114
115
116
117
118
119
120
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 113

def container_exists?(state)
  return false unless state[:container_id]

  !!docker_command("inspect --type=container #{state[:container_id]}",
    suppress_output: !logger.debug?)
rescue
  false
end

#container_ip_address(state) ⇒ String

The container's address on the Docker network.

Read from NetworkSettings.Networks rather than the top-level NetworkSettings.IPAddress. That field was only ever populated for the default bridge, and Docker 29 removed it altogether -- asking for it there fails the whole docker inspect with "map has no entry for key "IPAddress"", which took use_internal_docker_network with it. Networks has been present since Docker 1.9, so reading it works on both.

A container attached to several networks has an address on each; the first is used, which is the only one for the single-network case this option is for.

Parameters:

  • state (Hash)

    instance state naming the container

Returns:

  • (String)

    the container's address on the Docker network

Raises:

  • (Kitchen::ActionFailed)

    if it cannot be determined



340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 340

def container_ip_address(state)
  cmd = "inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'"
  cmd << " #{state[:container_id]}"
  output = docker_command(cmd, suppress_output: !logger.debug?)

  # Picked by parsing rather than by taking the first word, so that a
  # warning docker writes to stderr is not returned as an address.
  address = output.split(/\s+/).find { |token| ip_address?(token) }
  raise ActionFailed, "Docker reports no IP address for the container" if address.nil?

  address
rescue => e
  raise ActionFailed, "Error getting internal IP of Docker container. #{e}"
end

#container_running?(state) ⇒ Boolean

Whether the container named in state is running.

Separate from #container_exists? because the two callers want different questions answered: destroy removes a container in any state, while create has to tell a container it can use from one that has stopped.

Parameters:

  • state (Hash)

    instance state naming the container

Returns:

  • (Boolean)

    whether the container exists and is running



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 131

def container_running?(state)
  return false unless state[:container_id]

  output = docker_command(
    "inspect --type=container --format '{{.State.Running}}' #{state[:container_id]}",
    suppress_output: !logger.debug?
  )
  output.strip == "true"
rescue
  false
end

#copy_file_to_container(state, local_file, remote_file) ⇒ String

Copies a local file into the container.

The copy is checked afterwards, because docker cp cannot write into a mount and does not say so. A destination under a tmpfs or a volume is written to the container's own filesystem layer, which the mount then hides, and docker exits 0 with no output -- so the copy looks like it worked and the file is simply not there.

Left unchecked, that surfaces later and somewhere else. Running with tmpfs: /tmp, which is how the Docker documentation suggests running systemd, the first sign is the next command failing with /bin/bash: /tmp/docker-<uuid>.sh: No such file or directory (#387), which names neither the copy nor the mount.

Parameters:

  • state (Hash)

    instance state naming the container

  • local_file (String)

    source path

  • remote_file (String)

    destination path inside the container

Returns:

  • (String)

    the command's combined output

Raises:

  • (RuntimeError)

    if the copy fails, or if it silently wrote nothing



198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 198

def copy_file_to_container(state, local_file, remote_file)
  debug("Copying local file #{local_file} to #{remote_file} on container")

  remote_file = replace_env_variables(state, remote_file)

  cmd = build_copy_command(local_file, "#{state[:container_id]}:#{remote_file}")
  output = docker_command(cmd)
  verify_file_copied(state, local_file, remote_file)
  output
rescue => e
  raise "Failed to copy file #{local_file} to container. #{e}"
end

#create_dir_on_container(state, path) ⇒ String

Creates a directory inside the container, on Linux or Windows.

Parameters:

  • state (Hash)

    instance state naming the container

  • path (String)

    the directory to create; environment variable references are expanded first

Returns:

  • (String)

    the command's combined output

Raises:

  • (RuntimeError)

    if the directory cannot be created



163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 163

def create_dir_on_container(state, path)
  path = replace_env_variables(state, path)
  cmd = "mkdir -p #{path}"

  if state[:platform].include?("windows")
    psh = "-Command if(-not (Test-Path '#{path}')) { New-Item -Path '#{path}' -Force }"
    cmd = build_powershell_command(psh)
  end

  cmd = build_exec_command(state, cmd)
  docker_command(cmd)
rescue => e
  raise "Failed to create directory #{path} on container. #{e}"
end

#dockerfile_path(file) ⇒ String

The path to pass to docker build -f.

With a build context the path has to be relative to it; without one docker reads the Dockerfile from stdin and the absolute path is fine.

Parameters:

  • file (File)

    the temp Dockerfile

Returns:

  • (String)

    the path to use



98
99
100
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 98

def dockerfile_path(file)
  config[:build_context] ? Pathname.new(file.path).relative_path_from(Pathname.pwd).to_s : file.path
end

#dockerfile_proxy_configString

Dockerfile ENV lines carrying the configured proxy settings.

Each is emitted in both lower and upper case, because different tools inside the image read different spellings.

Returns:

  • (String)

    the ENV lines, empty when no proxy is configured



382
383
384
385
386
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 382

def dockerfile_proxy_config
  %i{http_proxy https_proxy no_proxy}.map do |proxy_type|
    proxy_env_vars(proxy_type)
  end.join
end

#dockerfile_templateString

Renders the configured Dockerfile through ERB.

Returns:

  • (String)

    the rendered Dockerfile



74
75
76
77
78
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 74

def dockerfile_template
  template = IO.read(File.expand_path(config[:dockerfile]))
  context = Kitchen::Docker::ERBContext.new(config.to_hash)
  ERB.new(template).result(context.get_binding)
end

#file_on_container?(state, remote_file, basename) ⇒ Boolean

Whether a docker cp destination now holds the file that was copied.

docker cp SRC CONTAINER:DEST copies into DEST when DEST is a directory and to DEST otherwise. Which of those happened is only known inside the container, so the choice is made there, in the one command, rather than by asking twice from here.

The two paths are passed as arguments to sh rather than interpolated into the script, so that nothing in either is read as shell syntax.

Parameters:

  • state (Hash)

    instance state naming the container

  • remote_file (String)

    the destination that was copied to

  • basename (String)

    the source's file name

Returns:

  • (Boolean)

    whether the file is there



248
249
250
251
252
253
254
255
256
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 248

def file_on_container?(state, remote_file, basename)
  script = 'p="$1"; if [ -d "$p" ]; then p="$p/$2"; fi; ' \
           "if [ -e \"$p\" ]; then echo #{COPIED_MARKER}; fi"
  probe = "/bin/sh -c #{Shellwords.escape(script)} sh " \
          "#{Shellwords.escape(remote_file)} #{Shellwords.escape(basename)}"

  output = docker_command(build_exec_command(state, probe), suppress_output: !logger.debug?)
  output.include?(COPIED_MARKER)
end

#ip_address?(token) ⇒ Boolean

Returns whether it parses as an IPv4 or IPv6 address.

Parameters:

  • token (String)

    a candidate address

Returns:

  • (Boolean)

    whether it parses as an IPv4 or IPv6 address



357
358
359
360
361
362
363
364
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 357

def ip_address?(token)
  return false if token.nil? || token.empty?

  IPAddr.new(token)
  true
rescue IPAddr::Error
  false
end

#parse_container_id(output) ⇒ String

Pulls the container id out of docker run output.

Docker prints ids in short (12) or full (64) hex form, on a line of their own. The id is looked for line by line rather than by taking the whole output, because Kitchen::Docker::Helpers::CliHelper#run_command returns stdout and stderr together and some daemons write warnings to stderr on a run that otherwise succeeds. Rootless Docker emits "WARNING: IPv4 forwarding is disabled. Networking will not work." on every run; setting run_options to --net=host produces "WARNING: Published ports are discarded when using host network mode", since the driver always publishes port 22 for Linux containers. Treating the whole output as the id failed those runs after the container had been created, leaving it running and untracked.

Scanning forward is deterministic: run_command concatenates stdout before stderr, so the id always precedes anything a warning adds.

Parameters:

  • output (String)

    the command output, stdout and stderr together

Returns:

  • (String)

    the container id

Raises:

  • (Kitchen::ActionFailed)

    if no id could be found



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

def parse_container_id(output)
  container_id = output.to_s.lines.map(&:strip).find do |line|
    line.match?(/\A[0-9a-f]{12}(?:[0-9a-f]{52})?\z/)
  end

  raise ActionFailed, "Could not parse Docker run output for container ID" unless container_id

  container_id
end

#proxy_env_vars(proxy_type) ⇒ String

ENV lines for one proxy setting, in both spellings.

Parameters:

  • proxy_type (Symbol)

    :http_proxy, :https_proxy, or :no_proxy

Returns:

  • (String)

    two ENV lines, or empty when that proxy is unset



393
394
395
396
397
398
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 393

def proxy_env_vars(proxy_type)
  return "" unless config[proxy_type]

  value = config[proxy_type]
  "ENV #{proxy_type}=#{value}\nENV #{proxy_type.upcase}=#{value}\n"
end

#remote_socket?Boolean

Returns whether the configured socket is a TCP one, meaning the daemon is not on this machine.

Returns:

  • (Boolean)

    whether the configured socket is a TCP one, meaning the daemon is not on this machine



82
83
84
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 82

def remote_socket?
  config[:socket] ? socket_uri.scheme == "tcp" : false
end

#remove_container(state) ⇒ void

This method returns an undefined value.

Stops and removes the container.

Parameters:

  • state (Hash)

    instance state naming the container



370
371
372
373
374
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 370

def remove_container(state)
  container_id = state[:container_id]
  docker_command("stop -t 0 #{container_id}")
  docker_command("rm #{container_id}")
end

#replace_env_variables(state, str) ⇒ String

Expands a container-side environment variable reference in a path.

Handles both $env:TEMP and $TEMP forms. The value has to be read from inside the container, since the workstation's environment is unrelated.

Parameters:

  • state (Hash)

    instance state naming the container

  • str (String)

    the string to expand

Returns:

  • (String)

    the expanded string



298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 298

def replace_env_variables(state, str)
  if str.include?("$env:")
    key = str[/\$env:(.*?)(\\|$)/, 1]
    value = container_env_variables(state)[key].to_s.strip
    str = str.gsub("$env:#{key}", value)
  elsif str.include?("$")
    key = str[%r{\$(.*?)(/|$)}, 1]
    value = container_env_variables(state)[key].to_s.strip
    str = str.gsub("$#{key}", value)
  end

  str
end

#run_container(state, transport_port = nil) ⇒ String

Runs the container and returns its id.

Parameters:

  • state (Hash)

    instance state naming the image

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

    container port to publish, if any

Returns:

  • (String)

    the new container's id



317
318
319
320
321
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 317

def run_container(state, transport_port = nil)
  cmd = build_run_command(state[:image_id], transport_port)
  output = docker_command(cmd)
  parse_container_id(output)
end

#socket_uriURI

Returns the configured Docker socket.

Returns:

  • (URI)

    the configured Docker socket



87
88
89
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 87

def socket_uri
  URI.parse(config[:socket])
end

#verify_file_copied(state, local_file, remote_file) ⇒ void

This method returns an undefined value.

Checks that a copied file arrived, and says why if it did not.

Only Linux containers are checked. tmpfs mounts are a Linux container feature, and docker cp against a Windows container is a different code path in Docker that this cannot be tried against, so those keep the behaviour they have always had.

Parameters:

  • state (Hash)

    instance state naming the container

  • local_file (String)

    the source that was copied

  • remote_file (String)

    the destination it was copied to

Raises:

  • (Kitchen::ActionFailed)

    if the file is not there



223
224
225
226
227
228
229
230
231
232
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 223

def verify_file_copied(state, local_file, remote_file)
  return if state[:platform].to_s.include?("windows")
  return if file_on_container?(state, remote_file, ::File.basename(local_file))

  raise ActionFailed,
    "docker reported no error copying it to #{remote_file}, but the file is " \
    "not there. `docker cp` cannot write into a mount -- if #{remote_file} is " \
    "a tmpfs or a volume, set the transport's temp_dir and the provisioner's " \
    "root_path to a path that is not."
end