Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
64 changes: 46 additions & 18 deletions docker/api/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,8 @@ def kill(self, container, signal=None):

@utils.check_resource('container')
def logs(self, container, stdout=True, stderr=True, stream=False,
timestamps=False, tail='all', since=None, follow=None):
timestamps=False, tail='all', since=None, follow=None,
until=None):
"""
Get logs from a container. Similar to the ``docker logs`` command.

Expand All @@ -805,6 +806,8 @@ def logs(self, container, stdout=True, stderr=True, stream=False,
since (datetime or int): Show logs since a given datetime or
integer epoch (in seconds)
follow (bool): Follow log output
until (datetime or int): Show logs that occurred before the given
datetime or integer epoch (in seconds)

Returns:
(generator or str)
Expand All @@ -827,21 +830,35 @@ def logs(self, container, stdout=True, stderr=True, stream=False,
params['tail'] = tail

if since is not None:
if utils.compare_version('1.19', self._version) < 0:
if utils.version_lt(self._version, '1.19'):
raise errors.InvalidVersion(
'since is not supported in API < 1.19'
'since is not supported for API version < 1.19'
)
if isinstance(since, datetime):
params['since'] = utils.datetime_to_timestamp(since)
elif (isinstance(since, int) and since > 0):
params['since'] = since
else:
if isinstance(since, datetime):
params['since'] = utils.datetime_to_timestamp(since)
elif (isinstance(since, int) and since > 0):
params['since'] = since
else:
raise errors.InvalidArgument(
'since value should be datetime or positive int, '
'not {}'.
format(type(since))
)
raise errors.InvalidArgument(
'since value should be datetime or positive int, '
'not {}'.format(type(since))
)

if until is not None:
if utils.version_lt(self._version, '1.35'):
raise errors.InvalidVersion(
'until is not supported for API version < 1.35'
)
if isinstance(until, datetime):
params['until'] = utils.datetime_to_timestamp(until)
elif (isinstance(until, int) and until > 0):
params['until'] = until
else:
raise errors.InvalidArgument(
'until value should be datetime or positive int, '
'not {}'.format(type(until))
)

url = self._url("/containers/{0}/logs", container)
res = self._get(url, params=params, stream=stream)
return self._get_result(container, stream, res)
Expand Down Expand Up @@ -1241,7 +1258,7 @@ def update_container(
return self._result(res, True)

@utils.check_resource('container')
def wait(self, container, timeout=None):
def wait(self, container, timeout=None, condition=None):
"""
Block until a container stops, then return its exit code. Similar to
the ``docker wait`` command.
Expand All @@ -1250,10 +1267,13 @@ def wait(self, container, timeout=None):
container (str or dict): The container to wait on. If a dict, the
``Id`` key is used.
timeout (int): Request timeout
condition (str): Wait until a container state reaches the given
condition, either ``not-running`` (default), ``next-exit``,
or ``removed``

Returns:
(int): The exit code of the container. Returns ``-1`` if the API
responds without a ``StatusCode`` attribute.
(int or dict): The exit code of the container. Returns the full API
response if no ``StatusCode`` field is included.

Raises:
:py:class:`requests.exceptions.ReadTimeout`
Expand All @@ -1262,9 +1282,17 @@ def wait(self, container, timeout=None):
If the server returns an error.
"""
url = self._url("/containers/{0}/wait", container)
res = self._post(url, timeout=timeout)
params = {}
if condition is not None:
if utils.version_lt(self._version, '1.30'):
raise errors.InvalidVersion(
'wait condition is not supported for API version < 1.30'
)
params['condition'] = condition

res = self._post(url, timeout=timeout, params=params)
self._raise_for_status(res)
json_ = res.json()
if 'StatusCode' in json_:
return json_['StatusCode']
return -1
return json_
10 changes: 9 additions & 1 deletion docker/api/exec_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
environment=None, workdir=None):
"""
Sets up an exec instance in a running container.

Expand All @@ -26,6 +26,7 @@ def exec_create(self, container, cmd, stdout=True, stderr=True,
environment (dict or list): A dictionary or a list of strings in
the following format ``["PASSWORD=xxx"]`` or
``{"PASSWORD": "xxx"}``.
workdir (str): Path to working directory for this exec session

Returns:
(dict): A dictionary with an exec ``Id`` key.
Expand Down Expand Up @@ -66,6 +67,13 @@ def exec_create(self, container, cmd, stdout=True, stderr=True,
'Env': environment,
}

if workdir is not None:
if utils.version_lt(self._version, '1.35'):
raise errors.InvalidVersion(
'workdir is not supported for API version < 1.35'
)
data['WorkingDir'] = workdir

url = self._url('/containers/{0}/exec', container)
res = self._post_json(url, data=data)
return self._result(res, True)
Expand Down
4 changes: 4 additions & 0 deletions docker/api/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ def raise_version_error(param, min_version):
if container_spec.get('Privileges') is not None:
raise_version_error('ContainerSpec.privileges', '1.30')

if utils.version_lt(version, '1.35'):
if container_spec.get('Isolation') is not None:
raise_version_error('ContainerSpec.isolation', '1.35')


def _merge_task_template(current, override):
merged = current.copy()
Expand Down
2 changes: 1 addition & 1 deletion docker/constants.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys
from .version import version

DEFAULT_DOCKER_API_VERSION = '1.30'
DEFAULT_DOCKER_API_VERSION = '1.35'
MINIMUM_DOCKER_API_VERSION = '1.21'
DEFAULT_TIMEOUT_SECONDS = 60
STREAM_HEADER_SIZE_BYTES = 8
Expand Down
9 changes: 7 additions & 2 deletions docker/models/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def diff(self):

def exec_run(self, cmd, stdout=True, stderr=True, stdin=False, tty=False,
privileged=False, user='', detach=False, stream=False,
socket=False, environment=None):
socket=False, environment=None, workdir=None):
"""
Run a command inside this container. Similar to
``docker exec``.
Expand All @@ -147,6 +147,7 @@ def exec_run(self, cmd, stdout=True, stderr=True, stdin=False, tty=False,
environment (dict or list): A dictionary or a list of strings in
the following format ``["PASSWORD=xxx"]`` or
``{"PASSWORD": "xxx"}``.
workdir (str): Path to working directory for this exec session

Returns:
(generator or str):
Expand All @@ -159,7 +160,8 @@ def exec_run(self, cmd, stdout=True, stderr=True, stdin=False, tty=False,
"""
resp = self.client.api.exec_create(
self.id, cmd, stdout=stdout, stderr=stderr, stdin=stdin, tty=tty,
privileged=privileged, user=user, environment=environment
privileged=privileged, user=user, environment=environment,
workdir=workdir
)
return self.client.api.exec_start(
resp['Id'], detach=detach, tty=tty, stream=stream, socket=socket
Expand Down Expand Up @@ -427,6 +429,9 @@ def wait(self, **kwargs):

Args:
timeout (int): Request timeout
condition (str): Wait until a container state reaches the given
condition, either ``not-running`` (default), ``next-exit``,
or ``removed``

Returns:
(int): The exit code of the container. Returns ``-1`` if the API
Expand Down
3 changes: 3 additions & 0 deletions docker/models/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ def create(self, image, command=None, **kwargs):
env (list of str): Environment variables, in the form
``KEY=val``.
hostname (string): Hostname to set on the container.
isolation (string): Isolation technology used by the service's
containers. Only used for Windows containers.
labels (dict): Labels to apply to the service.
log_driver (str): Log driver to use for containers.
log_driver_options (dict): Log driver options.
Expand Down Expand Up @@ -255,6 +257,7 @@ def list(self, **kwargs):
'hostname',
'hosts',
'image',
'isolation',
'labels',
'mounts',
'open_stdin',
Expand Down
9 changes: 7 additions & 2 deletions docker/types/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,21 @@ class ContainerSpec(dict):
healthcheck (Healthcheck): Healthcheck
configuration for this service.
hosts (:py:class:`dict`): A set of host to IP mappings to add to
the container's `hosts` file.
the container's ``hosts`` file.
dns_config (DNSConfig): Specification for DNS
related configurations in resolver configuration file.
configs (:py:class:`list`): List of :py:class:`ConfigReference` that
will be exposed to the service.
privileges (Privileges): Security options for the service's containers.
isolation (string): Isolation technology used by the service's
containers. Only used for Windows containers.
"""
def __init__(self, image, command=None, args=None, hostname=None, env=None,
workdir=None, user=None, labels=None, mounts=None,
stop_grace_period=None, secrets=None, tty=None, groups=None,
open_stdin=None, read_only=None, stop_signal=None,
healthcheck=None, hosts=None, dns_config=None, configs=None,
privileges=None):
privileges=None, isolation=None):
self['Image'] = image

if isinstance(command, six.string_types):
Expand Down Expand Up @@ -178,6 +180,9 @@ def __init__(self, image, command=None, args=None, hostname=None, env=None,
if read_only is not None:
self['ReadOnly'] = read_only

if isolation is not None:
self['Isolation'] = isolation


class Mount(dict):
"""
Expand Down
33 changes: 33 additions & 0 deletions tests/integration/api_container_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import signal
import tempfile
from datetime import datetime

import docker
from docker.constants import IS_WINDOWS_PLATFORM
Expand All @@ -9,6 +10,7 @@

import pytest

import requests
import six

from .base import BUSYBOX, BaseAPIIntegrationTest
Expand Down Expand Up @@ -816,6 +818,21 @@ def test_wait_with_dict_instead_of_id(self):
self.assertIn('ExitCode', inspect['State'])
self.assertEqual(inspect['State']['ExitCode'], exitcode)

@requires_api_version('1.30')
def test_wait_with_condition(self):
ctnr = self.client.create_container(BUSYBOX, 'true')
self.tmp_containers.append(ctnr)
with pytest.raises(requests.exceptions.ConnectionError):
self.client.wait(ctnr, condition='removed', timeout=1)

ctnr = self.client.create_container(
BUSYBOX, ['sleep', '3'],
host_config=self.client.create_host_config(auto_remove=True)
)
self.tmp_containers.append(ctnr)
self.client.start(ctnr)
assert self.client.wait(ctnr, condition='removed', timeout=5) == 0


class LogsTest(BaseAPIIntegrationTest):
def test_logs(self):
Expand Down Expand Up @@ -888,6 +905,22 @@ def test_logs_with_tail_0(self):
logs = self.client.logs(id, tail=0)
self.assertEqual(logs, ''.encode(encoding='ascii'))

@requires_api_version('1.35')
def test_logs_with_until(self):
snippet = 'Shanghai Teahouse (Hong Meiling)'
container = self.client.create_container(
BUSYBOX, 'echo "{0}"'.format(snippet)
)

self.tmp_containers.append(container)
self.client.start(container)
exitcode = self.client.wait(container)
assert exitcode == 0
logs_until_1 = self.client.logs(container, until=1)
assert logs_until_1 == b''
logs_until_now = self.client.logs(container, datetime.now())
assert logs_until_now == (snippet + '\n').encode(encoding='ascii')


class DiffTest(BaseAPIIntegrationTest):
def test_diff(self):
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/api_exec_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,15 @@ def test_exec_command_with_env(self):

exec_log = self.client.exec_start(res)
assert b'X=Y\n' in exec_log

@requires_api_version('1.35')
def test_exec_command_with_workdir(self):
container = self.client.create_container(
BUSYBOX, 'cat', detach=True, stdin_open=True
)
self.tmp_containers.append(container)
self.client.start(container)

res = self.client.exec_create(container, 'pwd', workdir='/var/www')
exec_log = self.client.exec_start(res)
assert exec_log == b'/var/www\n'
1 change: 1 addition & 0 deletions tests/integration/models_services_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def test_update_retains_networks(self):
image="alpine",
command="sleep 300"
)
service.reload()
service.update(
# create argument
name=service.name,
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/api_container_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,8 @@ def test_wait(self):
fake_request.assert_called_with(
'POST',
url_prefix + 'containers/3cc2351ab11b/wait',
timeout=None
timeout=None,
params={}
)

def test_wait_with_dict_instead_of_id(self):
Expand All @@ -1272,7 +1273,8 @@ def test_wait_with_dict_instead_of_id(self):
fake_request.assert_called_with(
'POST',
url_prefix + 'containers/3cc2351ab11b/wait',
timeout=None
timeout=None,
params={}
)

def test_logs(self):
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/models_containers_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,8 @@ def test_exec_run(self):
container.exec_run("echo hello world", privileged=True, stream=True)
client.api.exec_create.assert_called_with(
FAKE_CONTAINER_ID, "echo hello world", stdout=True, stderr=True,
stdin=False, tty=False, privileged=True, user='', environment=None
stdin=False, tty=False, privileged=True, user='', environment=None,
workdir=None
)
client.api.exec_start.assert_called_with(
FAKE_EXEC_ID, detach=False, tty=False, stream=True, socket=False
Expand Down