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.
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.
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.
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.
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.
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.
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.
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.
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_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.
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.
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.
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.
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.
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.
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.
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.
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_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.
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 |