Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docker connection plugin: fix config docs and update to use config system #297

Merged
merged 25 commits into from
Mar 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelogs/fragments/297-docker-connection-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
bugfixes:
- "docker connection plugin - fix option handling to be compatible with ansible-core 2.13 (https://github.com/ansible-collections/community.docker/pull/297, https://github.com/ansible-collections/community.docker/issues/307)."
minor_changes:
- "docker connection plugin - the plugin supports new ways to define the timeout. These are the ``ANSIBLE_DOCKER_TIMEOUT`` environment variable, the ``timeout`` setting in the ``docker_connection`` section of ``ansible.cfg``, and the ``ansible_docker_timeout`` variable (https://github.com/ansible-collections/community.docker/pull/297)."
162 changes: 112 additions & 50 deletions plugins/connection/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,59 @@
R(community.docker.docker_api,ansible_collections.community.docker.docker_api_connection)
connection plugin.
options:
remote_addr:
description:
- The name of the container you want to access.
default: inventory_hostname
bcoca marked this conversation as resolved.
Show resolved Hide resolved
vars:
- name: inventory_hostname
- name: ansible_host
- name: ansible_docker_host
remote_user:
description:
- The user to execute as inside the container
- The user to execute as inside the container.
- If Docker is too old to allow this (< 1.7), the one set by Docker itself will be used.
vars:
- name: ansible_user
- name: ansible_docker_user
ini:
- section: defaults
key: remote_user
env:
- name: ANSIBLE_REMOTE_USER
cli:
- name: user
keyword:
- name: remote_user
docker_extra_args:
description:
- Extra arguments to pass to the docker command line
- Extra arguments to pass to the docker command line.
default: ''
remote_addr:
vars:
- name: ansible_docker_extra_args
ini:
- section: docker_connection
key: extra_cli_args
container_timeout:
default: 10
description:
- The name of the container you want to access.
default: inventory_hostname
- Controls how long we can wait to access reading output from the container once execution started.
env:
- name: ANSIBLE_TIMEOUT
- name: ANSIBLE_DOCKER_TIMEOUT
bcoca marked this conversation as resolved.
Show resolved Hide resolved
version_added: 2.2.0
ini:
- key: timeout
section: defaults
- key: timeout
section: docker_connection
bcoca marked this conversation as resolved.
Show resolved Hide resolved
version_added: 2.2.0
vars:
- name: ansible_host
- name: ansible_docker_host
- name: ansible_docker_timeout
bcoca marked this conversation as resolved.
Show resolved Hide resolved
version_added: 2.2.0
cli:
- name: timeout
type: integer
'''

import fcntl
Expand All @@ -47,7 +83,6 @@
import subprocess
import re

import ansible.constants as C
from ansible.compat import selectors
from ansible.errors import AnsibleError, AnsibleFileNotFound
from ansible.module_utils.six.moves import shlex_quote
Expand Down Expand Up @@ -79,6 +114,9 @@ def __init__(self, play_context, new_stdin, *args, **kwargs):
# configured to be connected to by root and they are not running as
# root.

self._docker_args = []
self._container_user_cache = {}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not cache user as it is not guaranteed to be the same for all calls in the task

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean that someone could recreate the container during the playbook/role run?


# Windows uses Powershell modules
if getattr(self._shell, "_IS_WINDOWS", False):
self.module_implementation_preferences = ('.ps1', '.exe', '')
Expand All @@ -91,44 +129,20 @@ def __init__(self, play_context, new_stdin, *args, **kwargs):
except ValueError:
raise AnsibleError("docker command not found in PATH")

docker_version = self._get_docker_version()
if docker_version == u'dev':
self.docker_version = self._get_docker_version()
bcoca marked this conversation as resolved.
Show resolved Hide resolved
if self.docker_version == u'dev':
display.warning(u'Docker version number is "dev". Will assume latest version.')
if docker_version != u'dev' and LooseVersion(docker_version) < LooseVersion(u'1.3'):
if self.docker_version != u'dev' and LooseVersion(self.docker_version) < LooseVersion(u'1.3'):
raise AnsibleError('docker connection type requires docker 1.3 or higher')

# The remote user we will request from docker (if supported)
self.remote_user = None
# The actual user which will execute commands in docker (if known)
self.actual_user = None

if self._play_context.remote_user is not None:
if docker_version == u'dev' or LooseVersion(docker_version) >= LooseVersion(u'1.7'):
# Support for specifying the exec user was added in docker 1.7
self.remote_user = self._play_context.remote_user
self.actual_user = self.remote_user
else:
self.actual_user = self._get_docker_remote_user()

if self.actual_user != self._play_context.remote_user:
display.warning(u'docker {0} does not support remote_user, using container default: {1}'
.format(docker_version, self.actual_user or u'?'))
elif self._display.verbosity > 2:
# Since we're not setting the actual_user, look it up so we have it for logging later
# Only do this if display verbosity is high enough that we'll need the value
# This saves overhead from calling into docker when we don't need to
self.actual_user = self._get_docker_remote_user()

@staticmethod
def _sanitize_version(version):
version = re.sub(u'[^0-9a-zA-Z.]', u'', version)
version = re.sub(u'^v', u'', version)
return version

def _old_docker_version(self):
cmd_args = []
if self._play_context.docker_extra_args:
cmd_args += self._play_context.docker_extra_args.split(' ')
cmd_args = self._docker_args

old_version_subcommand = ['version']

Expand All @@ -140,9 +154,7 @@ def _old_docker_version(self):

def _new_docker_version(self):
# no result yet, must be newer Docker version
cmd_args = []
if self._play_context.docker_extra_args:
cmd_args += self._play_context.docker_extra_args.split(' ')
cmd_args = self._docker_args

new_version_subcommand = ['version', '--format', "'{{.Server.Version}}'"]

Expand All @@ -167,18 +179,24 @@ def _get_docker_version(self):

def _get_docker_remote_user(self):
""" Get the default user configured in the docker container """
p = subprocess.Popen([self.docker_cmd, 'inspect', '--format', '{{.Config.User}}', self._play_context.remote_addr],
container = self.get_option('remote_addr')
if container in self._container_user_cache:
return self._container_user_cache[container]
p = subprocess.Popen([self.docker_cmd, 'inspect', '--format', '{{.Config.User}}', container],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)

out, err = p.communicate()
out = to_text(out, errors='surrogate_or_strict')

if p.returncode != 0:
display.warning(u'unable to retrieve default user from docker container: %s %s' % (out, to_text(err)))
self._container_user_cache[container] = None
return None

# The default exec user is root, unless it was changed in the Dockerfile with USER
return out.strip() or u'root'
user = out.strip() or u'root'
self._container_user_cache[container] = user
return user

def _build_exec_cmd(self, cmd):
""" Build the local docker exec command to run cmd on remote_host
Expand All @@ -189,35 +207,77 @@ def _build_exec_cmd(self, cmd):

local_cmd = [self.docker_cmd]

if self._play_context.docker_extra_args:
local_cmd += self._play_context.docker_extra_args.split(' ')
if self._docker_args:
local_cmd += self._docker_args

local_cmd += [b'exec']

if self.remote_user is not None:
local_cmd += [b'-u', self.remote_user]

# -i is needed to keep stdin open which allows pipelining to work
local_cmd += [b'-i', self._play_context.remote_addr] + cmd
local_cmd += [b'-i', self.get_option('remote_addr')] + cmd

return local_cmd

def _set_conn_data(self):

''' initialize for the connection, cannot do only in init since all data is not ready at that point '''

# TODO: this is mostly for backwards compatibility, play_context is used as fallback for older versions
# docker arguments
del self._docker_args[:]
bcoca marked this conversation as resolved.
Show resolved Hide resolved
extra_args = self.get_option('docker_extra_args') or self._play_context.docker_extra_args
if extra_args:
self._docker_args += extra_args.split(' ')

self.remote_user = self.get_option('remote_user')
if self.remote_user is None and self._play_context.remote_user is not None:
self.remote_user = self._play_context.remote_user
# The actual user which will execute commands in docker (if known)
self.actual_user = None

if self.remote_user is not None:
if self.docker_version == u'dev' or LooseVersion(self.docker_version) >= LooseVersion(u'1.7'):
# Support for specifying the exec user was added in docker 1.7
self.actual_user = self.remote_user
else:
self.remote_user = None
self.actual_user = self._get_docker_remote_user()
if self.actual_user != self.get_option('remote_user'):
display.warning(u'docker {0} does not support remote_user, using container default: {1}'
.format(self.docker_version, self.actual_user or u'?'))
elif self._display.verbosity > 2:
# Since we're not setting the actual_user, look it up so we have it for logging later
# Only do this if display verbosity is high enough that we'll need the value
# This saves overhead from calling into docker when we don't need to
self.actual_user = self._get_docker_remote_user()

# timeout, use unless default and pc is different, backwards compat
self.timeout = self.get_option('container_timeout')
if self.timeout == 10 and self.timeout != self._play_context.timeout:
self.timeout = self._play_context.timeout

def _connect(self, port=None):
""" Connect to the container. Nothing to do """
super(Connection, self)._connect()
if not self._connected:
self._set_conn_data()
display.vvv(u"ESTABLISH DOCKER CONNECTION FOR USER: {0}".format(
self.actual_user or u'?'), host=self._play_context.remote_addr
self.actual_user or u'?'), host=self.get_option('remote_addr')
)
self._connected = True

def exec_command(self, cmd, in_data=None, sudoable=False):
""" Run a command on the docker host """

self._set_conn_data()

super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable)

local_cmd = self._build_exec_cmd([self._play_context.executable, '-c', cmd])

display.vvv(u"EXEC {0}".format(to_text(local_cmd)), host=self._play_context.remote_addr)
display.vvv(u"EXEC {0}".format(to_text(local_cmd)), host=self.get_option('remote_addr'))
display.debug("opening command with Popen()")

local_cmd = [to_bytes(i, errors='surrogate_or_strict') for i in local_cmd]
Expand All @@ -240,7 +300,7 @@ def exec_command(self, cmd, in_data=None, sudoable=False):
become_output = b''
try:
while not self.become.check_success(become_output) and not self.become.check_password_prompt(become_output):
events = selector.select(self._play_context.timeout)
events = selector.select(self.timeout)
if not events:
stdout, stderr = p.communicate()
raise AnsibleError('timeout waiting for privilege escalation password prompt:\n' + to_native(become_output))
Expand Down Expand Up @@ -291,8 +351,9 @@ def _prefix_login_path(self, remote_path):

def put_file(self, in_path, out_path):
""" Transfer a file from local to docker container """
self._set_conn_data()
super(Connection, self).put_file(in_path, out_path)
display.vvv("PUT %s TO %s" % (in_path, out_path), host=self._play_context.remote_addr)
display.vvv("PUT %s TO %s" % (in_path, out_path), host=self.get_option('remote_addr'))

out_path = self._prefix_login_path(out_path)
if not os.path.exists(to_bytes(in_path, errors='surrogate_or_strict')):
Expand Down Expand Up @@ -324,15 +385,16 @@ def put_file(self, in_path, out_path):

def fetch_file(self, in_path, out_path):
""" Fetch a file from container to local. """
self._set_conn_data()
super(Connection, self).fetch_file(in_path, out_path)
display.vvv("FETCH %s TO %s" % (in_path, out_path), host=self._play_context.remote_addr)
display.vvv("FETCH %s TO %s" % (in_path, out_path), host=self.get_option('remote_addr'))

in_path = self._prefix_login_path(in_path)
# out_path is the final file path, but docker takes a directory, not a
# file path
out_dir = os.path.dirname(out_path)

args = [self.docker_cmd, "cp", "%s:%s" % (self._play_context.remote_addr, in_path), out_dir]
args = [self.docker_cmd, "cp", "%s:%s" % (self.get_option('remote_addr'), in_path), out_dir]
args = [to_bytes(i, errors='surrogate_or_strict') for i in args]

p = subprocess.Popen(args, stdin=subprocess.PIPE,
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/plugins/connection/test_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from ansible.errors import AnsibleError
from ansible.playbook.play_context import PlayContext
from ansible_collections.community.docker.plugins.connection.docker import Connection as DockerConnection
from ansible.plugins.loader import connection_loader


class TestDockerConnectionClass(unittest.TestCase):
Expand All @@ -37,6 +38,16 @@ def setUp(self):
'[sudo via ansible, key=ouzmdnewuhucvuaabtjmweasarviygqq] password: '
)
self.in_stream = StringIO()
self.mock_get_bin_path = mock.patch(
'ansible_collections.community.docker.plugins.connection.docker.get_bin_path', return_value='docker')
self.mock_get_bin_path.start()
with mock.patch(
'ansible_collections.community.docker.plugins.connection.docker.Connection._old_docker_version',
return_value=('false', 'garbage', '', 1)):
with mock.patch(
'ansible_collections.community.docker.plugins.connection.docker.Connection._new_docker_version',
return_value=(['docker', 'version'], '20.10.0', '', 0)):
dc = connection_loader.get('community.docker.docker', self.play_context, self.in_stream)

def tearDown(self):
pass
Expand Down