440 lines
13 KiB
Python
440 lines
13 KiB
Python
"""Functions for accessing docker via the docker cli."""
|
|
from __future__ import (absolute_import, division, print_function)
|
|
__metaclass__ = type
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
|
|
from . import types as t
|
|
|
|
from .io import (
|
|
open_binary_file,
|
|
read_text_file,
|
|
)
|
|
|
|
from .util import (
|
|
ApplicationError,
|
|
common_environment,
|
|
display,
|
|
find_executable,
|
|
SubprocessError,
|
|
)
|
|
|
|
from .http import (
|
|
urlparse,
|
|
)
|
|
|
|
from .util_common import (
|
|
run_command,
|
|
)
|
|
|
|
from .config import (
|
|
EnvironmentConfig,
|
|
)
|
|
|
|
BUFFER_SIZE = 256 * 256
|
|
|
|
|
|
def docker_available():
|
|
"""
|
|
:rtype: bool
|
|
"""
|
|
return find_executable('docker', required=False)
|
|
|
|
|
|
def get_docker_hostname(): # type: () -> str
|
|
"""Return the hostname of the Docker service."""
|
|
try:
|
|
return get_docker_hostname.hostname
|
|
except AttributeError:
|
|
pass
|
|
|
|
docker_host = os.environ.get('DOCKER_HOST')
|
|
|
|
if docker_host and docker_host.startswith('tcp://'):
|
|
try:
|
|
hostname = urlparse(docker_host)[1].split(':')[0]
|
|
display.info('Detected Docker host: %s' % hostname, verbosity=1)
|
|
except ValueError:
|
|
hostname = 'localhost'
|
|
display.warning('Could not parse DOCKER_HOST environment variable "%s", falling back to localhost.' % docker_host)
|
|
else:
|
|
hostname = 'localhost'
|
|
display.info('Assuming Docker is available on localhost.', verbosity=1)
|
|
|
|
get_docker_hostname.hostname = hostname
|
|
|
|
return hostname
|
|
|
|
|
|
def get_docker_container_id():
|
|
"""
|
|
:rtype: str | None
|
|
"""
|
|
try:
|
|
return get_docker_container_id.container_id
|
|
except AttributeError:
|
|
pass
|
|
|
|
path = '/proc/self/cpuset'
|
|
container_id = None
|
|
|
|
if os.path.exists(path):
|
|
# File content varies based on the environment:
|
|
# No Container: /
|
|
# Docker: /docker/c86f3732b5ba3d28bb83b6e14af767ab96abbc52de31313dcb1176a62d91a507
|
|
# Azure Pipelines (Docker): /azpl_job/0f2edfed602dd6ec9f2e42c867f4d5ee640ebf4c058e6d3196d4393bb8fd0891
|
|
# Podman: /../../../../../..
|
|
contents = read_text_file(path)
|
|
|
|
cgroup_path, cgroup_name = os.path.split(contents.strip())
|
|
|
|
if cgroup_path in ('/docker', '/azpl_job'):
|
|
container_id = cgroup_name
|
|
|
|
get_docker_container_id.container_id = container_id
|
|
|
|
if container_id:
|
|
display.info('Detected execution in Docker container: %s' % container_id, verbosity=1)
|
|
|
|
return container_id
|
|
|
|
|
|
def get_docker_container_ip(args, container_id):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type container_id: str
|
|
:rtype: str
|
|
"""
|
|
results = docker_inspect(args, container_id)
|
|
network_settings = results[0]['NetworkSettings']
|
|
networks = network_settings.get('Networks')
|
|
|
|
if networks:
|
|
network_name = get_docker_preferred_network_name(args) or 'bridge'
|
|
ipaddress = networks[network_name]['IPAddress']
|
|
else:
|
|
# podman doesn't provide Networks, fall back to using IPAddress
|
|
ipaddress = network_settings['IPAddress']
|
|
|
|
if not ipaddress:
|
|
raise ApplicationError('Cannot retrieve IP address for container: %s' % container_id)
|
|
|
|
return ipaddress
|
|
|
|
|
|
def get_docker_network_name(args, container_id): # type: (EnvironmentConfig, str) -> str
|
|
"""
|
|
Return the network name of the specified container.
|
|
Raises an exception if zero or more than one network is found.
|
|
"""
|
|
networks = get_docker_networks(args, container_id)
|
|
|
|
if not networks:
|
|
raise ApplicationError('No network found for Docker container: %s.' % container_id)
|
|
|
|
if len(networks) > 1:
|
|
raise ApplicationError('Found multiple networks for Docker container %s instead of only one: %s' % (container_id, ', '.join(networks)))
|
|
|
|
return networks[0]
|
|
|
|
|
|
def get_docker_preferred_network_name(args): # type: (EnvironmentConfig) -> str
|
|
"""
|
|
Return the preferred network name for use with Docker. The selection logic is:
|
|
- the network selected by the user with `--docker-network`
|
|
- the network of the currently running docker container (if any)
|
|
- the default docker network (returns None)
|
|
"""
|
|
network = None
|
|
|
|
if args.docker_network:
|
|
network = args.docker_network
|
|
else:
|
|
current_container_id = get_docker_container_id()
|
|
|
|
if current_container_id:
|
|
# Make sure any additional containers we launch use the same network as the current container we're running in.
|
|
# This is needed when ansible-test is running in a container that is not connected to Docker's default network.
|
|
network = get_docker_network_name(args, current_container_id)
|
|
|
|
return network
|
|
|
|
|
|
def is_docker_user_defined_network(network): # type: (str) -> bool
|
|
"""Return True if the network being used is a user-defined network."""
|
|
return network and network != 'bridge'
|
|
|
|
|
|
def get_docker_networks(args, container_id):
|
|
"""
|
|
:param args: EnvironmentConfig
|
|
:param container_id: str
|
|
:rtype: list[str]
|
|
"""
|
|
results = docker_inspect(args, container_id)
|
|
# podman doesn't return Networks- just silently return None if it's missing...
|
|
networks = results[0]['NetworkSettings'].get('Networks')
|
|
if networks is None:
|
|
return None
|
|
return sorted(networks)
|
|
|
|
|
|
def docker_pull(args, image):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type image: str
|
|
"""
|
|
if ('@' in image or ':' in image) and docker_images(args, image):
|
|
display.info('Skipping docker pull of existing image with tag or digest: %s' % image, verbosity=2)
|
|
return
|
|
|
|
if not args.docker_pull:
|
|
display.warning('Skipping docker pull for "%s". Image may be out-of-date.' % image)
|
|
return
|
|
|
|
for _iteration in range(1, 10):
|
|
try:
|
|
docker_command(args, ['pull', image])
|
|
return
|
|
except SubprocessError:
|
|
display.warning('Failed to pull docker image "%s". Waiting a few seconds before trying again.' % image)
|
|
time.sleep(3)
|
|
|
|
raise ApplicationError('Failed to pull docker image "%s".' % image)
|
|
|
|
|
|
def docker_put(args, container_id, src, dst):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type container_id: str
|
|
:type src: str
|
|
:type dst: str
|
|
"""
|
|
# avoid 'docker cp' due to a bug which causes 'docker rm' to fail
|
|
with open_binary_file(src) as src_fd:
|
|
docker_exec(args, container_id, ['dd', 'of=%s' % dst, 'bs=%s' % BUFFER_SIZE],
|
|
options=['-i'], stdin=src_fd, capture=True)
|
|
|
|
|
|
def docker_get(args, container_id, src, dst):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type container_id: str
|
|
:type src: str
|
|
:type dst: str
|
|
"""
|
|
# avoid 'docker cp' due to a bug which causes 'docker rm' to fail
|
|
with open_binary_file(dst, 'wb') as dst_fd:
|
|
docker_exec(args, container_id, ['dd', 'if=%s' % src, 'bs=%s' % BUFFER_SIZE],
|
|
options=['-i'], stdout=dst_fd, capture=True)
|
|
|
|
|
|
def docker_run(args, image, options, cmd=None, create_only=False):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type image: str
|
|
:type options: list[str] | None
|
|
:type cmd: list[str] | None
|
|
:type create_only[bool] | False
|
|
:rtype: str | None, str | None
|
|
"""
|
|
if not options:
|
|
options = []
|
|
|
|
if not cmd:
|
|
cmd = []
|
|
|
|
if create_only:
|
|
command = 'create'
|
|
else:
|
|
command = 'run'
|
|
|
|
network = get_docker_preferred_network_name(args)
|
|
|
|
if is_docker_user_defined_network(network):
|
|
# Only when the network is not the default bridge network.
|
|
# Using this with the default bridge network results in an error when using --link: links are only supported for user-defined networks
|
|
options.extend(['--network', network])
|
|
|
|
for _iteration in range(1, 3):
|
|
try:
|
|
return docker_command(args, [command] + options + [image] + cmd, capture=True)
|
|
except SubprocessError as ex:
|
|
display.error(ex)
|
|
display.warning('Failed to run docker image "%s". Waiting a few seconds before trying again.' % image)
|
|
time.sleep(3)
|
|
|
|
raise ApplicationError('Failed to run docker image "%s".' % image)
|
|
|
|
|
|
def docker_start(args, container_id, options): # type: (EnvironmentConfig, str, t.List[str]) -> (t.Optional[str], t.Optional[str])
|
|
"""
|
|
Start a docker container by name or ID
|
|
"""
|
|
if not options:
|
|
options = []
|
|
|
|
for _iteration in range(1, 3):
|
|
try:
|
|
return docker_command(args, ['start'] + options + [container_id], capture=True)
|
|
except SubprocessError as ex:
|
|
display.error(ex)
|
|
display.warning('Failed to start docker container "%s". Waiting a few seconds before trying again.' % container_id)
|
|
time.sleep(3)
|
|
|
|
raise ApplicationError('Failed to run docker container "%s".' % container_id)
|
|
|
|
|
|
def docker_images(args, image):
|
|
"""
|
|
:param args: CommonConfig
|
|
:param image: str
|
|
:rtype: list[dict[str, any]]
|
|
"""
|
|
try:
|
|
stdout, _dummy = docker_command(args, ['images', image, '--format', '{{json .}}'], capture=True, always=True)
|
|
except SubprocessError as ex:
|
|
if 'no such image' in ex.stderr:
|
|
return [] # podman does not handle this gracefully, exits 125
|
|
|
|
if 'function "json" not defined' in ex.stderr:
|
|
# podman > 2 && < 2.2.0 breaks with --format {{json .}}, and requires --format json
|
|
# So we try this as a fallback. If it fails again, we just raise the exception and bail.
|
|
stdout, _dummy = docker_command(args, ['images', image, '--format', 'json'], capture=True, always=True)
|
|
else:
|
|
raise ex
|
|
|
|
if stdout.startswith('['):
|
|
# modern podman outputs a pretty-printed json list. Just load the whole thing.
|
|
return json.loads(stdout)
|
|
|
|
# docker outputs one json object per line (jsonl)
|
|
return [json.loads(line) for line in stdout.splitlines()]
|
|
|
|
|
|
def docker_rm(args, container_id):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type container_id: str
|
|
"""
|
|
try:
|
|
docker_command(args, ['rm', '-f', container_id], capture=True)
|
|
except SubprocessError as ex:
|
|
if 'no such container' in ex.stderr:
|
|
pass # podman does not handle this gracefully, exits 1
|
|
else:
|
|
raise ex
|
|
|
|
|
|
def docker_inspect(args, container_id):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type container_id: str
|
|
:rtype: list[dict]
|
|
"""
|
|
if args.explain:
|
|
return []
|
|
|
|
try:
|
|
stdout = docker_command(args, ['inspect', container_id], capture=True)[0]
|
|
return json.loads(stdout)
|
|
except SubprocessError as ex:
|
|
if 'no such image' in ex.stderr:
|
|
return [] # podman does not handle this gracefully, exits 125
|
|
try:
|
|
return json.loads(ex.stdout)
|
|
except Exception:
|
|
raise ex
|
|
|
|
|
|
def docker_network_disconnect(args, container_id, network):
|
|
"""
|
|
:param args: EnvironmentConfig
|
|
:param container_id: str
|
|
:param network: str
|
|
"""
|
|
docker_command(args, ['network', 'disconnect', network, container_id], capture=True)
|
|
|
|
|
|
def docker_network_inspect(args, network):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type network: str
|
|
:rtype: list[dict]
|
|
"""
|
|
if args.explain:
|
|
return []
|
|
|
|
try:
|
|
stdout = docker_command(args, ['network', 'inspect', network], capture=True)[0]
|
|
return json.loads(stdout)
|
|
except SubprocessError as ex:
|
|
try:
|
|
return json.loads(ex.stdout)
|
|
except Exception:
|
|
raise ex
|
|
|
|
|
|
def docker_exec(args, container_id, cmd, options=None, capture=False, stdin=None, stdout=None, data=None):
|
|
"""
|
|
:type args: EnvironmentConfig
|
|
:type container_id: str
|
|
:type cmd: list[str]
|
|
:type options: list[str] | None
|
|
:type capture: bool
|
|
:type stdin: BinaryIO | None
|
|
:type stdout: BinaryIO | None
|
|
:type data: str | None
|
|
:rtype: str | None, str | None
|
|
"""
|
|
if not options:
|
|
options = []
|
|
|
|
if data:
|
|
options.append('-i')
|
|
|
|
return docker_command(args, ['exec'] + options + [container_id] + cmd, capture=capture, stdin=stdin, stdout=stdout, data=data)
|
|
|
|
|
|
def docker_info(args):
|
|
"""
|
|
:param args: CommonConfig
|
|
:rtype: dict[str, any]
|
|
"""
|
|
stdout, _dummy = docker_command(args, ['info', '--format', '{{json .}}'], capture=True, always=True)
|
|
return json.loads(stdout)
|
|
|
|
|
|
def docker_version(args):
|
|
"""
|
|
:param args: CommonConfig
|
|
:rtype: dict[str, any]
|
|
"""
|
|
stdout, _dummy = docker_command(args, ['version', '--format', '{{json .}}'], capture=True, always=True)
|
|
return json.loads(stdout)
|
|
|
|
|
|
def docker_command(args, cmd, capture=False, stdin=None, stdout=None, always=False, data=None):
|
|
"""
|
|
:type args: CommonConfig
|
|
:type cmd: list[str]
|
|
:type capture: bool
|
|
:type stdin: file | None
|
|
:type stdout: file | None
|
|
:type always: bool
|
|
:type data: str | None
|
|
:rtype: str | None, str | None
|
|
"""
|
|
env = docker_environment()
|
|
return run_command(args, ['docker'] + cmd, env=env, capture=capture, stdin=stdin, stdout=stdout, always=always, data=data)
|
|
|
|
|
|
def docker_environment():
|
|
"""
|
|
:rtype: dict[str, str]
|
|
"""
|
|
env = common_environment()
|
|
env.update(dict((key, os.environ[key]) for key in os.environ if key.startswith('DOCKER_')))
|
|
return env
|