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 execis raised rather than returned. "kitchen_docker_copied".freeze
Instance Method Summary collapse
-
#container_env_variables(state) ⇒ Hash
Reads the container's environment.
-
#container_exec(state, command) ⇒ String
Runs a command inside the container.
-
#container_exists?(state) ⇒ Boolean
Whether the container named in state is present, running or not.
-
#container_ip_address(state) ⇒ String
The container's address on the Docker network.
-
#container_running?(state) ⇒ Boolean
Whether the container named in state is running.
-
#copy_file_to_container(state, local_file, remote_file) ⇒ String
Copies a local file into the container.
-
#create_dir_on_container(state, path) ⇒ String
Creates a directory inside the container, on Linux or Windows.
-
#dockerfile_path(file) ⇒ String
The path to pass to
docker build -f. -
#dockerfile_proxy_config ⇒ String
Dockerfile ENV lines carrying the configured proxy settings.
-
#dockerfile_template ⇒ String
Renders the configured Dockerfile through ERB.
-
#file_on_container?(state, remote_file, basename) ⇒ Boolean
Whether a
docker cpdestination now holds the file that was copied. -
#ip_address?(token) ⇒ Boolean
Whether it parses as an IPv4 or IPv6 address.
-
#parse_container_id(output) ⇒ String
Pulls the container id out of
docker runoutput. -
#proxy_env_vars(proxy_type) ⇒ String
ENV lines for one proxy setting, in both spellings.
-
#remote_socket? ⇒ Boolean
Whether the configured socket is a TCP one, meaning the daemon is not on this machine.
-
#remove_container(state) ⇒ void
Stops and removes the container.
-
#replace_env_variables(state, str) ⇒ String
Expands a container-side environment variable reference in a path.
-
#run_container(state, transport_port = nil) ⇒ String
Runs the container and returns its id.
-
#socket_uri ⇒ URI
The configured Docker socket.
-
#verify_file_copied(state, local_file, remote_file) ⇒ void
Checks that a copied file arrived, and says why if it did not.
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.
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 275 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.
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.
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.
351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 351 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.
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.
209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 209 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.
The path is escaped for the shell, because it comes from the
transport's temp_dir and a docker exec command line is assembled
as one string. A directory with a space in it was torn in two before
docker ever saw it, and mkdir -p obligingly created both halves --
neither of them the directory that was asked for. Every upload that
followed then went to a path that did not exist.
The PowerShell branch already quotes the path itself, and its argument is reassembled by PowerShell rather than split by a shell, so it is left as it is.
174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 174 def create_dir_on_container(state, path) path = replace_env_variables(state, path) cmd = "mkdir -p #{Shellwords.escape(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.
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_config ⇒ String
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.
393 394 395 396 397 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 393 def dockerfile_proxy_config %i{http_proxy https_proxy no_proxy}.map do |proxy_type| proxy_env_vars(proxy_type) end.join end |
#dockerfile_template ⇒ String
Renders the configured Dockerfile through ERB.
74 75 76 77 78 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 74 def dockerfile_template template = IO.read(File.(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.
259 260 261 262 263 264 265 266 267 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 259 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.
368 369 370 371 372 373 374 375 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 368 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.
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.
404 405 406 407 408 409 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 404 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.
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.
381 382 383 384 385 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 381 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.
309 310 311 312 313 314 315 316 317 318 319 320 321 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 309 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.
328 329 330 331 332 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 328 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_uri ⇒ URI
Returns 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.
234 235 236 237 238 239 240 241 242 243 |
# File 'lib/kitchen/docker/helpers/container_helper.rb', line 234 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 |