Skip to content

Commit 75e2e8a

Browse files
authored
Merge pull request #1879 from docker/mtsmfm-master
Add support for detachKeys configuration
2 parents 2e8f1f7 + e304f91 commit 75e2e8a

File tree

11 files changed

+313
-104
lines changed

11 files changed

+313
-104
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ integration-dind-py2: build
5454
-H tcp://0.0.0.0:2375 --experimental
5555
docker run -t --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\
5656
--link=dpy-dind-py2:docker docker-sdk-python py.test tests/integration
57-
docker rm -vf dpy-dind-py3
57+
docker rm -vf dpy-dind-py2
5858

5959
.PHONY: integration-dind-py3
6060
integration-dind-py3: build-py3

docker/api/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
)
3333
from ..tls import TLSConfig
3434
from ..transport import SSLAdapter, UnixAdapter
35-
from ..utils import utils, check_resource, update_headers
35+
from ..utils import utils, check_resource, update_headers, config
3636
from ..utils.socket import frames_iter, socket_raw_iter
3737
from ..utils.json_stream import json_stream
3838
try:
@@ -106,6 +106,7 @@ def __init__(self, base_url=None, version=None,
106106
self.headers['User-Agent'] = user_agent
107107

108108
self._auth_configs = auth.load_config()
109+
self._general_configs = config.load_general_config()
109110

110111
base_url = utils.parse_host(
111112
base_url, IS_WINDOWS_PLATFORM, tls=bool(tls)

docker/api/container.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def attach_socket(self, container, params=None, ws=False):
6666
container (str): The container to attach to.
6767
params (dict): Dictionary of request parameters (e.g. ``stdout``,
6868
``stderr``, ``stream``).
69+
For ``detachKeys``, ~/.docker/config.json is used by default.
6970
ws (bool): Use websockets instead of raw HTTP.
7071
7172
Raises:
@@ -79,6 +80,11 @@ def attach_socket(self, container, params=None, ws=False):
7980
'stream': 1
8081
}
8182

83+
if 'detachKeys' not in params \
84+
and 'detachKeys' in self._general_configs:
85+
86+
params['detachKeys'] = self._general_configs['detachKeys']
87+
8288
if ws:
8389
return self._attach_websocket(container, params)
8490

docker/api/exec_api.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class ExecApiMixin(object):
99
@utils.check_resource('container')
1010
def exec_create(self, container, cmd, stdout=True, stderr=True,
1111
stdin=False, tty=False, privileged=False, user='',
12-
environment=None, workdir=None):
12+
environment=None, workdir=None, detach_keys=None):
1313
"""
1414
Sets up an exec instance in a running container.
1515
@@ -27,6 +27,11 @@ def exec_create(self, container, cmd, stdout=True, stderr=True,
2727
the following format ``["PASSWORD=xxx"]`` or
2828
``{"PASSWORD": "xxx"}``.
2929
workdir (str): Path to working directory for this exec session
30+
detach_keys (str): Override the key sequence for detaching
31+
a container. Format is a single character `[a-Z]`
32+
or `ctrl-<value>` where `<value>` is one of:
33+
`a-z`, `@`, `^`, `[`, `,` or `_`.
34+
~/.docker/config.json is used by default.
3035
3136
Returns:
3237
(dict): A dictionary with an exec ``Id`` key.
@@ -74,6 +79,11 @@ def exec_create(self, container, cmd, stdout=True, stderr=True,
7479
)
7580
data['WorkingDir'] = workdir
7681

82+
if detach_keys:
83+
data['detachKeys'] = detach_keys
84+
elif 'detachKeys' in self._general_configs:
85+
data['detachKeys'] = self._general_configs['detachKeys']
86+
7787
url = self._url('/containers/{0}/exec', container)
7888
res = self._post_json(url, data=data)
7989
return self._result(res, True)

docker/auth.py

Lines changed: 4 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import base64
22
import json
33
import logging
4-
import os
54

65
import dockerpycreds
76
import six
87

98
from . import errors
10-
from .constants import IS_WINDOWS_PLATFORM
9+
from .utils import config
1110

1211
INDEX_NAME = 'docker.io'
1312
INDEX_URL = 'https://index.{0}/v1/'.format(INDEX_NAME)
14-
DOCKER_CONFIG_FILENAME = os.path.join('.docker', 'config.json')
15-
LEGACY_DOCKER_CONFIG_FILENAME = '.dockercfg'
1613
TOKEN_USERNAME = '<token>'
1714

1815
log = logging.getLogger(__name__)
@@ -105,10 +102,10 @@ def resolve_authconfig(authconfig, registry=None):
105102
log.debug("Found {0}".format(repr(registry)))
106103
return authconfig[registry]
107104

108-
for key, config in six.iteritems(authconfig):
105+
for key, conf in six.iteritems(authconfig):
109106
if resolve_index_name(key) == registry:
110107
log.debug("Found {0}".format(repr(key)))
111-
return config
108+
return conf
112109

113110
log.debug("No entry found")
114111
return None
@@ -223,44 +220,6 @@ def parse_auth(entries, raise_on_error=False):
223220
return conf
224221

225222

226-
def find_config_file(config_path=None):
227-
paths = list(filter(None, [
228-
config_path, # 1
229-
config_path_from_environment(), # 2
230-
os.path.join(home_dir(), DOCKER_CONFIG_FILENAME), # 3
231-
os.path.join(home_dir(), LEGACY_DOCKER_CONFIG_FILENAME), # 4
232-
]))
233-
234-
log.debug("Trying paths: {0}".format(repr(paths)))
235-
236-
for path in paths:
237-
if os.path.exists(path):
238-
log.debug("Found file at path: {0}".format(path))
239-
return path
240-
241-
log.debug("No config file found")
242-
243-
return None
244-
245-
246-
def config_path_from_environment():
247-
config_dir = os.environ.get('DOCKER_CONFIG')
248-
if not config_dir:
249-
return None
250-
return os.path.join(config_dir, os.path.basename(DOCKER_CONFIG_FILENAME))
251-
252-
253-
def home_dir():
254-
"""
255-
Get the user's home directory, using the same logic as the Docker Engine
256-
client - use %USERPROFILE% on Windows, $HOME/getuid on POSIX.
257-
"""
258-
if IS_WINDOWS_PLATFORM:
259-
return os.environ.get('USERPROFILE', '')
260-
else:
261-
return os.path.expanduser('~')
262-
263-
264223
def load_config(config_path=None):
265224
"""
266225
Loads authentication data from a Docker configuration file in the given
@@ -269,7 +228,7 @@ def load_config(config_path=None):
269228
explicit config_path parameter > DOCKER_CONFIG environment variable >
270229
~/.docker/config.json > ~/.dockercfg
271230
"""
272-
config_file = find_config_file(config_path)
231+
config_file = config.find_config_file(config_path)
273232

274233
if not config_file:
275234
return {}

docker/utils/config.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import json
2+
import logging
3+
import os
4+
5+
from ..constants import IS_WINDOWS_PLATFORM
6+
7+
DOCKER_CONFIG_FILENAME = os.path.join('.docker', 'config.json')
8+
LEGACY_DOCKER_CONFIG_FILENAME = '.dockercfg'
9+
10+
log = logging.getLogger(__name__)
11+
12+
13+
def find_config_file(config_path=None):
14+
paths = list(filter(None, [
15+
config_path, # 1
16+
config_path_from_environment(), # 2
17+
os.path.join(home_dir(), DOCKER_CONFIG_FILENAME), # 3
18+
os.path.join(home_dir(), LEGACY_DOCKER_CONFIG_FILENAME), # 4
19+
]))
20+
21+
log.debug("Trying paths: {0}".format(repr(paths)))
22+
23+
for path in paths:
24+
if os.path.exists(path):
25+
log.debug("Found file at path: {0}".format(path))
26+
return path
27+
28+
log.debug("No config file found")
29+
30+
return None
31+
32+
33+
def config_path_from_environment():
34+
config_dir = os.environ.get('DOCKER_CONFIG')
35+
if not config_dir:
36+
return None
37+
return os.path.join(config_dir, os.path.basename(DOCKER_CONFIG_FILENAME))
38+
39+
40+
def home_dir():
41+
"""
42+
Get the user's home directory, using the same logic as the Docker Engine
43+
client - use %USERPROFILE% on Windows, $HOME/getuid on POSIX.
44+
"""
45+
if IS_WINDOWS_PLATFORM:
46+
return os.environ.get('USERPROFILE', '')
47+
else:
48+
return os.path.expanduser('~')
49+
50+
51+
def load_general_config(config_path=None):
52+
config_file = find_config_file(config_path)
53+
54+
if not config_file:
55+
return {}
56+
57+
try:
58+
with open(config_file) as f:
59+
return json.load(f)
60+
except Exception as e:
61+
log.debug(e)
62+
pass
63+
64+
log.debug("All parsing attempts failed - returning empty config")
65+
return {}

tests/helpers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import tarfile
66
import tempfile
77
import time
8+
import re
9+
import six
10+
import socket
811

912
import docker
1013
import pytest
@@ -102,3 +105,29 @@ def force_leave_swarm(client):
102105

103106
def swarm_listen_addr():
104107
return '0.0.0.0:{0}'.format(random.randrange(10000, 25000))
108+
109+
110+
def assert_cat_socket_detached_with_keys(sock, inputs):
111+
if six.PY3:
112+
sock = sock._sock
113+
114+
for i in inputs:
115+
sock.send(i)
116+
time.sleep(0.5)
117+
118+
# If we're using a Unix socket, the sock.send call will fail with a
119+
# BrokenPipeError ; INET sockets will just stop receiving / sending data
120+
# but will not raise an error
121+
if sock.family == getattr(socket, 'AF_UNIX', -1):
122+
with pytest.raises(socket.error):
123+
sock.send(b'make sure the socket is closed\n')
124+
else:
125+
sock.send(b"make sure the socket is closed\n")
126+
assert sock.recv(32) == b''
127+
128+
129+
def ctrl_with(char):
130+
if re.match('[a-z]', char):
131+
return chr(ord(char) - ord('a') + 1).encode('ascii')
132+
else:
133+
raise(Exception('char must be [a-z]'))

tests/integration/api_container_test.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import re
23
import signal
34
import tempfile
45
from datetime import datetime
@@ -15,8 +16,9 @@
1516

1617
from .base import BUSYBOX, BaseAPIIntegrationTest
1718
from .. import helpers
18-
from ..helpers import requires_api_version
19-
import re
19+
from ..helpers import (
20+
requires_api_version, ctrl_with, assert_cat_socket_detached_with_keys
21+
)
2022

2123

2224
class ListContainersTest(BaseAPIIntegrationTest):
@@ -1223,6 +1225,57 @@ def test_attach_no_stream(self):
12231225
output = self.client.attach(container, stream=False, logs=True)
12241226
assert output == 'hello\n'.encode(encoding='ascii')
12251227

1228+
def test_detach_with_default(self):
1229+
container = self.client.create_container(
1230+
BUSYBOX, 'cat',
1231+
detach=True, stdin_open=True, tty=True
1232+
)
1233+
self.tmp_containers.append(container)
1234+
self.client.start(container)
1235+
1236+
sock = self.client.attach_socket(
1237+
container,
1238+
{'stdin': True, 'stream': True}
1239+
)
1240+
1241+
assert_cat_socket_detached_with_keys(
1242+
sock, [ctrl_with('p'), ctrl_with('q')]
1243+
)
1244+
1245+
def test_detach_with_config_file(self):
1246+
self.client._general_configs['detachKeys'] = 'ctrl-p'
1247+
1248+
container = self.client.create_container(
1249+
BUSYBOX, 'cat',
1250+
detach=True, stdin_open=True, tty=True
1251+
)
1252+
self.tmp_containers.append(container)
1253+
self.client.start(container)
1254+
1255+
sock = self.client.attach_socket(
1256+
container,
1257+
{'stdin': True, 'stream': True}
1258+
)
1259+
1260+
assert_cat_socket_detached_with_keys(sock, [ctrl_with('p')])
1261+
1262+
def test_detach_with_arg(self):
1263+
self.client._general_configs['detachKeys'] = 'ctrl-p'
1264+
1265+
container = self.client.create_container(
1266+
BUSYBOX, 'cat',
1267+
detach=True, stdin_open=True, tty=True
1268+
)
1269+
self.tmp_containers.append(container)
1270+
self.client.start(container)
1271+
1272+
sock = self.client.attach_socket(
1273+
container,
1274+
{'stdin': True, 'stream': True, 'detachKeys': 'ctrl-x'}
1275+
)
1276+
1277+
assert_cat_socket_detached_with_keys(sock, [ctrl_with('x')])
1278+
12261279

12271280
class PauseTest(BaseAPIIntegrationTest):
12281281
def test_pause_unpause(self):

0 commit comments

Comments
 (0)