diff options
author | Joffrey F <f.joffrey@gmail.com> | 2018-01-30 18:38:35 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-01-30 18:38:35 -0800 |
commit | 75e2e8ad816ac35e1c4928f6b3f9e40841ca493c (patch) | |
tree | af96522c00d38e29850ecd8090f427e48030df94 | |
parent | 2e8f1f798a3d6748735481ac519978a8b18a793c (diff) | |
parent | e304f91b4636b59a056ff795d91895c725c6255f (diff) | |
download | docker-py-75e2e8ad816ac35e1c4928f6b3f9e40841ca493c.tar.gz |
Merge pull request #1879 from docker/mtsmfm-master
Add support for detachKeys configuration
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | docker/api/client.py | 3 | ||||
-rw-r--r-- | docker/api/container.py | 6 | ||||
-rw-r--r-- | docker/api/exec_api.py | 12 | ||||
-rw-r--r-- | docker/auth.py | 49 | ||||
-rw-r--r-- | docker/utils/config.py | 65 | ||||
-rw-r--r-- | tests/helpers.py | 29 | ||||
-rw-r--r-- | tests/integration/api_container_test.py | 57 | ||||
-rw-r--r-- | tests/integration/api_exec_test.py | 57 | ||||
-rw-r--r-- | tests/unit/auth_test.py | 53 | ||||
-rw-r--r-- | tests/unit/utils_config_test.py | 84 |
11 files changed, 313 insertions, 104 deletions
@@ -54,7 +54,7 @@ integration-dind-py2: build -H tcp://0.0.0.0:2375 --experimental docker run -t --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ --link=dpy-dind-py2:docker docker-sdk-python py.test tests/integration - docker rm -vf dpy-dind-py3 + docker rm -vf dpy-dind-py2 .PHONY: integration-dind-py3 integration-dind-py3: build-py3 diff --git a/docker/api/client.py b/docker/api/client.py index f0a86d4..10640e1 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -32,7 +32,7 @@ from ..errors import ( ) from ..tls import TLSConfig from ..transport import SSLAdapter, UnixAdapter -from ..utils import utils, check_resource, update_headers +from ..utils import utils, check_resource, update_headers, config from ..utils.socket import frames_iter, socket_raw_iter from ..utils.json_stream import json_stream try: @@ -106,6 +106,7 @@ class APIClient( self.headers['User-Agent'] = user_agent self._auth_configs = auth.load_config() + self._general_configs = config.load_general_config() base_url = utils.parse_host( base_url, IS_WINDOWS_PLATFORM, tls=bool(tls) diff --git a/docker/api/container.py b/docker/api/container.py index 49230c7..260fbe9 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -66,6 +66,7 @@ class ContainerApiMixin(object): container (str): The container to attach to. params (dict): Dictionary of request parameters (e.g. ``stdout``, ``stderr``, ``stream``). + For ``detachKeys``, ~/.docker/config.json is used by default. ws (bool): Use websockets instead of raw HTTP. Raises: @@ -79,6 +80,11 @@ class ContainerApiMixin(object): 'stream': 1 } + if 'detachKeys' not in params \ + and 'detachKeys' in self._general_configs: + + params['detachKeys'] = self._general_configs['detachKeys'] + if ws: return self._attach_websocket(container, params) diff --git a/docker/api/exec_api.py b/docker/api/exec_api.py index 029c984..d607461 100644 --- a/docker/api/exec_api.py +++ b/docker/api/exec_api.py @@ -9,7 +9,7 @@ class ExecApiMixin(object): @utils.check_resource('container') def exec_create(self, container, cmd, stdout=True, stderr=True, stdin=False, tty=False, privileged=False, user='', - environment=None, workdir=None): + environment=None, workdir=None, detach_keys=None): """ Sets up an exec instance in a running container. @@ -27,6 +27,11 @@ class ExecApiMixin(object): the following format ``["PASSWORD=xxx"]`` or ``{"PASSWORD": "xxx"}``. workdir (str): Path to working directory for this exec session + detach_keys (str): Override the key sequence for detaching + a container. Format is a single character `[a-Z]` + or `ctrl-<value>` where `<value>` is one of: + `a-z`, `@`, `^`, `[`, `,` or `_`. + ~/.docker/config.json is used by default. Returns: (dict): A dictionary with an exec ``Id`` key. @@ -74,6 +79,11 @@ class ExecApiMixin(object): ) data['WorkingDir'] = workdir + if detach_keys: + data['detachKeys'] = detach_keys + elif 'detachKeys' in self._general_configs: + data['detachKeys'] = self._general_configs['detachKeys'] + url = self._url('/containers/{0}/exec', container) res = self._post_json(url, data=data) return self._result(res, True) diff --git a/docker/auth.py b/docker/auth.py index c0cae5d..79f63cc 100644 --- a/docker/auth.py +++ b/docker/auth.py @@ -1,18 +1,15 @@ import base64 import json import logging -import os import dockerpycreds import six from . import errors -from .constants import IS_WINDOWS_PLATFORM +from .utils import config INDEX_NAME = 'docker.io' INDEX_URL = 'https://index.{0}/v1/'.format(INDEX_NAME) -DOCKER_CONFIG_FILENAME = os.path.join('.docker', 'config.json') -LEGACY_DOCKER_CONFIG_FILENAME = '.dockercfg' TOKEN_USERNAME = '<token>' log = logging.getLogger(__name__) @@ -105,10 +102,10 @@ def resolve_authconfig(authconfig, registry=None): log.debug("Found {0}".format(repr(registry))) return authconfig[registry] - for key, config in six.iteritems(authconfig): + for key, conf in six.iteritems(authconfig): if resolve_index_name(key) == registry: log.debug("Found {0}".format(repr(key))) - return config + return conf log.debug("No entry found") return None @@ -223,44 +220,6 @@ def parse_auth(entries, raise_on_error=False): return conf -def find_config_file(config_path=None): - paths = list(filter(None, [ - config_path, # 1 - config_path_from_environment(), # 2 - os.path.join(home_dir(), DOCKER_CONFIG_FILENAME), # 3 - os.path.join(home_dir(), LEGACY_DOCKER_CONFIG_FILENAME), # 4 - ])) - - log.debug("Trying paths: {0}".format(repr(paths))) - - for path in paths: - if os.path.exists(path): - log.debug("Found file at path: {0}".format(path)) - return path - - log.debug("No config file found") - - return None - - -def config_path_from_environment(): - config_dir = os.environ.get('DOCKER_CONFIG') - if not config_dir: - return None - return os.path.join(config_dir, os.path.basename(DOCKER_CONFIG_FILENAME)) - - -def home_dir(): - """ - Get the user's home directory, using the same logic as the Docker Engine - client - use %USERPROFILE% on Windows, $HOME/getuid on POSIX. - """ - if IS_WINDOWS_PLATFORM: - return os.environ.get('USERPROFILE', '') - else: - return os.path.expanduser('~') - - def load_config(config_path=None): """ Loads authentication data from a Docker configuration file in the given @@ -269,7 +228,7 @@ def load_config(config_path=None): explicit config_path parameter > DOCKER_CONFIG environment variable > ~/.docker/config.json > ~/.dockercfg """ - config_file = find_config_file(config_path) + config_file = config.find_config_file(config_path) if not config_file: return {} diff --git a/docker/utils/config.py b/docker/utils/config.py new file mode 100644 index 0000000..8417261 --- /dev/null +++ b/docker/utils/config.py @@ -0,0 +1,65 @@ +import json +import logging +import os + +from ..constants import IS_WINDOWS_PLATFORM + +DOCKER_CONFIG_FILENAME = os.path.join('.docker', 'config.json') +LEGACY_DOCKER_CONFIG_FILENAME = '.dockercfg' + +log = logging.getLogger(__name__) + + +def find_config_file(config_path=None): + paths = list(filter(None, [ + config_path, # 1 + config_path_from_environment(), # 2 + os.path.join(home_dir(), DOCKER_CONFIG_FILENAME), # 3 + os.path.join(home_dir(), LEGACY_DOCKER_CONFIG_FILENAME), # 4 + ])) + + log.debug("Trying paths: {0}".format(repr(paths))) + + for path in paths: + if os.path.exists(path): + log.debug("Found file at path: {0}".format(path)) + return path + + log.debug("No config file found") + + return None + + +def config_path_from_environment(): + config_dir = os.environ.get('DOCKER_CONFIG') + if not config_dir: + return None + return os.path.join(config_dir, os.path.basename(DOCKER_CONFIG_FILENAME)) + + +def home_dir(): + """ + Get the user's home directory, using the same logic as the Docker Engine + client - use %USERPROFILE% on Windows, $HOME/getuid on POSIX. + """ + if IS_WINDOWS_PLATFORM: + return os.environ.get('USERPROFILE', '') + else: + return os.path.expanduser('~') + + +def load_general_config(config_path=None): + config_file = find_config_file(config_path) + + if not config_file: + return {} + + try: + with open(config_file) as f: + return json.load(f) + except Exception as e: + log.debug(e) + pass + + log.debug("All parsing attempts failed - returning empty config") + return {} diff --git a/tests/helpers.py b/tests/helpers.py index 124ae2d..c4ea364 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,6 +5,9 @@ import random import tarfile import tempfile import time +import re +import six +import socket import docker import pytest @@ -102,3 +105,29 @@ def force_leave_swarm(client): def swarm_listen_addr(): return '0.0.0.0:{0}'.format(random.randrange(10000, 25000)) + + +def assert_cat_socket_detached_with_keys(sock, inputs): + if six.PY3: + sock = sock._sock + + for i in inputs: + sock.send(i) + time.sleep(0.5) + + # If we're using a Unix socket, the sock.send call will fail with a + # BrokenPipeError ; INET sockets will just stop receiving / sending data + # but will not raise an error + if sock.family == getattr(socket, 'AF_UNIX', -1): + with pytest.raises(socket.error): + sock.send(b'make sure the socket is closed\n') + else: + sock.send(b"make sure the socket is closed\n") + assert sock.recv(32) == b'' + + +def ctrl_with(char): + if re.match('[a-z]', char): + return chr(ord(char) - ord('a') + 1).encode('ascii') + else: + raise(Exception('char must be [a-z]')) diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index 4585c44..f48e78e 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -1,4 +1,5 @@ import os +import re import signal import tempfile from datetime import datetime @@ -15,8 +16,9 @@ import six from .base import BUSYBOX, BaseAPIIntegrationTest from .. import helpers -from ..helpers import requires_api_version -import re +from ..helpers import ( + requires_api_version, ctrl_with, assert_cat_socket_detached_with_keys +) class ListContainersTest(BaseAPIIntegrationTest): @@ -1223,6 +1225,57 @@ class AttachContainerTest(BaseAPIIntegrationTest): output = self.client.attach(container, stream=False, logs=True) assert output == 'hello\n'.encode(encoding='ascii') + def test_detach_with_default(self): + container = self.client.create_container( + BUSYBOX, 'cat', + detach=True, stdin_open=True, tty=True + ) + self.tmp_containers.append(container) + self.client.start(container) + + sock = self.client.attach_socket( + container, + {'stdin': True, 'stream': True} + ) + + assert_cat_socket_detached_with_keys( + sock, [ctrl_with('p'), ctrl_with('q')] + ) + + def test_detach_with_config_file(self): + self.client._general_configs['detachKeys'] = 'ctrl-p' + + container = self.client.create_container( + BUSYBOX, 'cat', + detach=True, stdin_open=True, tty=True + ) + self.tmp_containers.append(container) + self.client.start(container) + + sock = self.client.attach_socket( + container, + {'stdin': True, 'stream': True} + ) + + assert_cat_socket_detached_with_keys(sock, [ctrl_with('p')]) + + def test_detach_with_arg(self): + self.client._general_configs['detachKeys'] = 'ctrl-p' + + container = self.client.create_container( + BUSYBOX, 'cat', + detach=True, stdin_open=True, tty=True + ) + self.tmp_containers.append(container) + self.client.start(container) + + sock = self.client.attach_socket( + container, + {'stdin': True, 'stream': True, 'detachKeys': 'ctrl-x'} + ) + + assert_cat_socket_detached_with_keys(sock, [ctrl_with('x')]) + class PauseTest(BaseAPIIntegrationTest): def test_pause_unpause(self): diff --git a/tests/integration/api_exec_test.py b/tests/integration/api_exec_test.py index cd97c68..1a5a4e5 100644 --- a/tests/integration/api_exec_test.py +++ b/tests/integration/api_exec_test.py @@ -2,7 +2,9 @@ from docker.utils.socket import next_frame_size from docker.utils.socket import read_exactly from .base import BaseAPIIntegrationTest, BUSYBOX -from ..helpers import requires_api_version +from ..helpers import ( + requires_api_version, ctrl_with, assert_cat_socket_detached_with_keys +) class ExecTest(BaseAPIIntegrationTest): @@ -148,3 +150,56 @@ class ExecTest(BaseAPIIntegrationTest): res = self.client.exec_create(container, 'pwd', workdir='/var/www') exec_log = self.client.exec_start(res) assert exec_log == b'/var/www\n' + + def test_detach_with_default(self): + container = self.client.create_container( + BUSYBOX, 'cat', detach=True, stdin_open=True + ) + id = container['Id'] + self.client.start(id) + self.tmp_containers.append(id) + + exec_id = self.client.exec_create( + id, 'cat', stdin=True, tty=True, stdout=True + ) + sock = self.client.exec_start(exec_id, tty=True, socket=True) + self.addCleanup(sock.close) + + assert_cat_socket_detached_with_keys( + sock, [ctrl_with('p'), ctrl_with('q')] + ) + + def test_detach_with_config_file(self): + self.client._general_configs['detachKeys'] = 'ctrl-p' + container = self.client.create_container( + BUSYBOX, 'cat', detach=True, stdin_open=True + ) + id = container['Id'] + self.client.start(id) + self.tmp_containers.append(id) + + exec_id = self.client.exec_create( + id, 'cat', stdin=True, tty=True, stdout=True + ) + sock = self.client.exec_start(exec_id, tty=True, socket=True) + self.addCleanup(sock.close) + + assert_cat_socket_detached_with_keys(sock, [ctrl_with('p')]) + + def test_detach_with_arg(self): + self.client._general_configs['detachKeys'] = 'ctrl-p' + container = self.client.create_container( + BUSYBOX, 'cat', detach=True, stdin_open=True + ) + id = container['Id'] + self.client.start(id) + self.tmp_containers.append(id) + + exec_id = self.client.exec_create( + id, 'cat', + stdin=True, tty=True, detach_keys='ctrl-x', stdout=True + ) + sock = self.client.exec_start(exec_id, tty=True, socket=True) + self.addCleanup(sock.close) + + assert_cat_socket_detached_with_keys(sock, [ctrl_with('x')]) diff --git a/tests/unit/auth_test.py b/tests/unit/auth_test.py index 1506ccb..e3356d3 100644 --- a/tests/unit/auth_test.py +++ b/tests/unit/auth_test.py @@ -9,9 +9,6 @@ import shutil import tempfile import unittest -from py.test import ensuretemp -from pytest import mark - from docker import auth, errors import pytest @@ -263,56 +260,6 @@ class CredStoreTest(unittest.TestCase): ) == 'truesecret' -class FindConfigFileTest(unittest.TestCase): - def tmpdir(self, name): - tmpdir = ensuretemp(name) - self.addCleanup(tmpdir.remove) - return tmpdir - - def test_find_config_fallback(self): - tmpdir = self.tmpdir('test_find_config_fallback') - - with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}): - assert auth.find_config_file() is None - - def test_find_config_from_explicit_path(self): - tmpdir = self.tmpdir('test_find_config_from_explicit_path') - config_path = tmpdir.ensure('my-config-file.json') - - assert auth.find_config_file(str(config_path)) == str(config_path) - - def test_find_config_from_environment(self): - tmpdir = self.tmpdir('test_find_config_from_environment') - config_path = tmpdir.ensure('config.json') - - with mock.patch.dict(os.environ, {'DOCKER_CONFIG': str(tmpdir)}): - assert auth.find_config_file() == str(config_path) - - @mark.skipif("sys.platform == 'win32'") - def test_find_config_from_home_posix(self): - tmpdir = self.tmpdir('test_find_config_from_home_posix') - config_path = tmpdir.ensure('.docker', 'config.json') - - with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}): - assert auth.find_config_file() == str(config_path) - - @mark.skipif("sys.platform == 'win32'") - def test_find_config_from_home_legacy_name(self): - tmpdir = self.tmpdir('test_find_config_from_home_legacy_name') - config_path = tmpdir.ensure('.dockercfg') - - with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}): - assert auth.find_config_file() == str(config_path) - - @mark.skipif("sys.platform != 'win32'") - def test_find_config_from_home_windows(self): - tmpdir = self.tmpdir('test_find_config_from_home_windows') - config_path = tmpdir.ensure('.docker', 'config.json') - - with mock.patch.dict(os.environ, {'USERPROFILE': str(tmpdir)}): - assert auth.find_config_file() == str(config_path) - - class LoadConfigTest(unittest.TestCase): def test_load_config_no_file(self): folder = tempfile.mkdtemp() diff --git a/tests/unit/utils_config_test.py b/tests/unit/utils_config_test.py new file mode 100644 index 0000000..45f75ff --- /dev/null +++ b/tests/unit/utils_config_test.py @@ -0,0 +1,84 @@ +import os +import unittest +import shutil +import tempfile +import json + +from py.test import ensuretemp +from pytest import mark +from docker.utils import config + +try: + from unittest import mock +except ImportError: + import mock + + +class FindConfigFileTest(unittest.TestCase): + def tmpdir(self, name): + tmpdir = ensuretemp(name) + self.addCleanup(tmpdir.remove) + return tmpdir + + def test_find_config_fallback(self): + tmpdir = self.tmpdir('test_find_config_fallback') + + with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}): + assert config.find_config_file() is None + + def test_find_config_from_explicit_path(self): + tmpdir = self.tmpdir('test_find_config_from_explicit_path') + config_path = tmpdir.ensure('my-config-file.json') + + assert config.find_config_file(str(config_path)) == str(config_path) + + def test_find_config_from_environment(self): + tmpdir = self.tmpdir('test_find_config_from_environment') + config_path = tmpdir.ensure('config.json') + + with mock.patch.dict(os.environ, {'DOCKER_CONFIG': str(tmpdir)}): + assert config.find_config_file() == str(config_path) + + @mark.skipif("sys.platform == 'win32'") + def test_find_config_from_home_posix(self): + tmpdir = self.tmpdir('test_find_config_from_home_posix') + config_path = tmpdir.ensure('.docker', 'config.json') + + with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}): + assert config.find_config_file() == str(config_path) + + @mark.skipif("sys.platform == 'win32'") + def test_find_config_from_home_legacy_name(self): + tmpdir = self.tmpdir('test_find_config_from_home_legacy_name') + config_path = tmpdir.ensure('.dockercfg') + + with mock.patch.dict(os.environ, {'HOME': str(tmpdir)}): + assert config.find_config_file() == str(config_path) + + @mark.skipif("sys.platform != 'win32'") + def test_find_config_from_home_windows(self): + tmpdir = self.tmpdir('test_find_config_from_home_windows') + config_path = tmpdir.ensure('.docker', 'config.json') + + with mock.patch.dict(os.environ, {'USERPROFILE': str(tmpdir)}): + assert config.find_config_file() == str(config_path) + + +class LoadConfigTest(unittest.TestCase): + def test_load_config_no_file(self): + folder = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, folder) + cfg = config.load_general_config(folder) + self.assertTrue(cfg is not None) + + def test_load_config(self): + folder = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, folder) + dockercfg_path = os.path.join(folder, '.dockercfg') + cfg = { + 'detachKeys': 'ctrl-q, ctrl-u, ctrl-i' + } + with open(dockercfg_path, 'w') as f: + json.dump(cfg, f) + + self.assertEqual(config.load_general_config(dockercfg_path), cfg) |