Kaydet (Commit) e7ad5361 authored tarafından Joffrey F's avatar Joffrey F

Merge pull request #764 from docker/744-volume-api-support

Volume API support
......@@ -4,3 +4,4 @@ from .container import ContainerApiMixin
from .daemon import DaemonApiMixin
from .exec_api import ExecApiMixin
from .image import ImageApiMixin
from .volume import VolumeApiMixin
......@@ -4,8 +4,8 @@ import re
from .. import constants
from .. import errors
from ..auth import auth
from ..utils import utils
from .. import auth
from .. import utils
log = logging.getLogger(__name__)
......
......@@ -2,11 +2,11 @@ import six
import warnings
from .. import errors
from ..utils import utils, check_resource
from .. import utils
class ContainerApiMixin(object):
@check_resource
@utils.check_resource
def attach(self, container, stdout=True, stderr=True,
stream=False, logs=False):
params = {
......@@ -20,7 +20,7 @@ class ContainerApiMixin(object):
return self._get_result(container, stream, response)
@check_resource
@utils.check_resource
def attach_socket(self, container, params=None, ws=False):
if params is None:
params = {
......@@ -36,7 +36,7 @@ class ContainerApiMixin(object):
return self._get_raw_response_socket(self.post(
u, None, params=self._attach_params(params), stream=True))
@check_resource
@utils.check_resource
def commit(self, container, repository=None, tag=None, message=None,
author=None, conf=None):
params = {
......@@ -73,7 +73,7 @@ class ContainerApiMixin(object):
x['Id'] = x['Id'][:12]
return res
@check_resource
@utils.check_resource
def copy(self, container, resource):
res = self._post_json(
self._url("/containers/{0}/copy".format(container)),
......@@ -131,13 +131,13 @@ class ContainerApiMixin(object):
kwargs['version'] = self._version
return utils.create_host_config(*args, **kwargs)
@check_resource
@utils.check_resource
def diff(self, container):
return self._result(
self._get(self._url("/containers/{0}/changes", container)), True
)
@check_resource
@utils.check_resource
def export(self, container):
res = self._get(
self._url("/containers/{0}/export", container), stream=True
......@@ -145,13 +145,13 @@ class ContainerApiMixin(object):
self._raise_for_status(res)
return res.raw
@check_resource
@utils.check_resource
def inspect_container(self, container):
return self._result(
self._get(self._url("/containers/{0}/json", container)), True
)
@check_resource
@utils.check_resource
def kill(self, container, signal=None):
url = self._url("/containers/{0}/kill", container)
params = {}
......@@ -161,7 +161,7 @@ class ContainerApiMixin(object):
self._raise_for_status(res)
@check_resource
@utils.check_resource
def logs(self, container, stdout=True, stderr=True, stream=False,
timestamps=False, tail='all'):
if utils.compare_version('1.11', self._version) >= 0:
......@@ -185,13 +185,13 @@ class ContainerApiMixin(object):
logs=True
)
@check_resource
@utils.check_resource
def pause(self, container):
url = self._url('/containers/{0}/pause', container)
res = self._post(url)
self._raise_for_status(res)
@check_resource
@utils.check_resource
def port(self, container, private_port):
res = self._get(self._url("/containers/{0}/json", container))
self._raise_for_status(res)
......@@ -211,7 +211,7 @@ class ContainerApiMixin(object):
return h_ports
@check_resource
@utils.check_resource
def remove_container(self, container, v=False, link=False, force=False):
params = {'v': v, 'link': link, 'force': force}
res = self._delete(
......@@ -219,32 +219,29 @@ class ContainerApiMixin(object):
)
self._raise_for_status(res)
@check_resource
@utils.minimum_version('1.17')
@utils.check_resource
def rename(self, container, name):
if utils.compare_version('1.17', self._version) < 0:
raise errors.InvalidVersion(
'rename was only introduced in API version 1.17'
)
url = self._url("/containers/{0}/rename", container)
params = {'name': name}
res = self._post(url, params=params)
self._raise_for_status(res)
@check_resource
@utils.check_resource
def resize(self, container, height, width):
params = {'h': height, 'w': width}
url = self._url("/containers/{0}/resize", container)
res = self._post(url, params=params)
self._raise_for_status(res)
@check_resource
@utils.check_resource
def restart(self, container, timeout=10):
params = {'t': timeout}
url = self._url("/containers/{0}/restart", container)
res = self._post(url, params=params)
self._raise_for_status(res)
@check_resource
@utils.check_resource
def start(self, container, binds=None, port_bindings=None, lxc_conf=None,
publish_all_ports=None, links=None, privileged=None,
dns=None, dns_search=None, volumes_from=None, network_mode=None,
......@@ -312,16 +309,13 @@ class ContainerApiMixin(object):
res = self._post_json(url, data=start_config)
self._raise_for_status(res)
@check_resource
@utils.minimum_version('1.17')
@utils.check_resource
def stats(self, container, decode=None):
if utils.compare_version('1.17', self._version) < 0:
raise errors.InvalidVersion(
'Stats retrieval is not supported in API < 1.17!')
url = self._url("/containers/{0}/stats", container)
return self._stream_helper(self._get(url, stream=True), decode=decode)
@check_resource
@utils.check_resource
def stop(self, container, timeout=10):
params = {'t': timeout}
url = self._url("/containers/{0}/stop", container)
......@@ -330,18 +324,18 @@ class ContainerApiMixin(object):
timeout=(timeout + (self.timeout or 0)))
self._raise_for_status(res)
@check_resource
@utils.check_resource
def top(self, container):
u = self._url("/containers/{0}/top", container)
return self._result(self._get(u), True)
@check_resource
@utils.check_resource
def unpause(self, container):
url = self._url('/containers/{0}/unpause', container)
res = self._post(url)
self._raise_for_status(res)
@check_resource
@utils.check_resource
def wait(self, container, timeout=None):
url = self._url("/containers/{0}/wait", container)
res = self._post(url, timeout=timeout)
......
......@@ -3,15 +3,14 @@ import shlex
import six
from .. import errors
from ..utils import utils, check_resource
from .. import utils
class ExecApiMixin(object):
@check_resource
@utils.minimum_version('1.15')
@utils.check_resource
def exec_create(self, container, cmd, stdout=True, stderr=True, tty=False,
privileged=False, user=''):
if utils.compare_version('1.15', self._version) < 0:
raise errors.InvalidVersion('Exec is not supported in API < 1.15')
if privileged and utils.compare_version('1.19', self._version) < 0:
raise errors.InvalidVersion(
'Privileged exec is not supported in API < 1.19'
......@@ -38,19 +37,15 @@ class ExecApiMixin(object):
res = self._post_json(url, data=data)
return self._result(res, True)
@utils.minimum_version('1.16')
def exec_inspect(self, exec_id):
if utils.compare_version('1.16', self._version) < 0:
raise errors.InvalidVersion(
'exec_inspect is not supported in API < 1.16'
)
if isinstance(exec_id, dict):
exec_id = exec_id.get('Id')
res = self._get(self._url("/exec/{0}/json", exec_id))
return self._result(res, True)
@utils.minimum_version('1.15')
def exec_resize(self, exec_id, height=None, width=None):
if utils.compare_version('1.15', self._version) < 0:
raise errors.InvalidVersion('Exec is not supported in API < 1.15')
if isinstance(exec_id, dict):
exec_id = exec_id.get('Id')
......@@ -59,9 +54,8 @@ class ExecApiMixin(object):
res = self._post(url, params=params)
self._raise_for_status(res)
@utils.minimum_version('1.15')
def exec_start(self, exec_id, detach=False, tty=False, stream=False):
if utils.compare_version('1.15', self._version) < 0:
raise errors.InvalidVersion('Exec is not supported in API < 1.15')
if isinstance(exec_id, dict):
exec_id = exec_id.get('Id')
......
......@@ -4,7 +4,7 @@ import warnings
from ..auth import auth
from ..constants import INSECURE_REGISTRY_DEPRECATION_WARNING
from ..utils import utils, check_resource
from .. import utils
from .. import errors
log = logging.getLogger(__name__)
......@@ -12,13 +12,13 @@ log = logging.getLogger(__name__)
class ImageApiMixin(object):
@check_resource
@utils.check_resource
def get_image(self, image):
res = self._get(self._url("/images/{0}/get", image), stream=True)
self._raise_for_status(res)
return res.raw
@check_resource
@utils.check_resource
def history(self, image):
res = self._get(self._url("/images/{0}/history", image))
return self._result(res, True)
......@@ -124,7 +124,7 @@ class ImageApiMixin(object):
return self._result(
self._post(u, data=None, params=params))
@check_resource
@utils.check_resource
def insert(self, image, url, path):
if utils.compare_version('1.12', self._version) >= 0:
raise errors.DeprecatedMethod(
......@@ -137,7 +137,7 @@ class ImageApiMixin(object):
}
return self._result(self._post(api_url, params=params))
@check_resource
@utils.check_resource
def inspect_image(self, image):
return self._result(
self._get(self._url("/images/{0}/json", image)), True
......@@ -246,7 +246,7 @@ class ImageApiMixin(object):
return self._result(response)
@check_resource
@utils.check_resource
def remove_image(self, image, force=False, noprune=False):
params = {'force': force, 'noprune': noprune}
res = self._delete(self._url("/images/{0}", image), params=params)
......@@ -258,7 +258,7 @@ class ImageApiMixin(object):
True
)
@check_resource
@utils.check_resource
def tag(self, image, repository, tag=None, force=False):
params = {
'tag': tag,
......
from .. import utils
class VolumeApiMixin(object):
@utils.minimum_version('1.21')
def volumes(self, filters=None):
params = {
'filter': utils.convert_filters(filters) if filters else None
}
url = self._url('/volumes')
return self._result(self._get(url, params=params), True)
@utils.minimum_version('1.21')
def create_volume(self, name, driver=None, driver_opts=None):
url = self._url('/volumes')
if driver_opts is not None and not isinstance(driver_opts, dict):
raise TypeError('driver_opts must be a dictionary')
data = {
'Name': name,
'Driver': driver,
'DriverOpts': driver_opts,
}
return self._result(self._post_json(url, data=data), True)
@utils.minimum_version('1.21')
def inspect_volume(self, name):
url = self._url('/volumes/{0}', name)
return self._result(self._get(url), True)
@utils.minimum_version('1.21')
def remove_volume(self, name):
url = self._url('/volumes/{0}', name)
resp = self._delete(url)
self._raise_for_status(resp)
return True
......@@ -38,7 +38,8 @@ class Client(
api.ContainerApiMixin,
api.DaemonApiMixin,
api.ExecApiMixin,
api.ImageApiMixin):
api.ImageApiMixin,
api.VolumeApiMixin):
def __init__(self, base_url=None, version=None,
timeout=constants.DEFAULT_TIMEOUT_SECONDS, tls=False):
super(Client, self).__init__()
......
......@@ -2,8 +2,9 @@ from .utils import (
compare_version, convert_port_bindings, convert_volume_binds,
mkbuildcontext, tar, exclude_paths, parse_repository_tag, parse_host,
kwargs_from_env, convert_filters, create_host_config,
create_container_config, parse_bytes, ping_registry, parse_env_file
create_container_config, parse_bytes, ping_registry, parse_env_file,
version_lt, version_gte
) # flake8: noqa
from .types import Ulimit, LogConfig # flake8: noqa
from .decorators import check_resource #flake8: noqa
from .decorators import check_resource, minimum_version #flake8: noqa
import functools
from .. import errors
from . import utils
def check_resource(f):
......@@ -19,3 +20,18 @@ def check_resource(f):
)
return f(self, resource_id, *args, **kwargs)
return wrapped
def minimum_version(version):
def decorator(f):
@functools.wraps(f)
def wrapper(self, *args, **kwargs):
if utils.version_lt(self._version, version):
raise errors.InvalidVersion(
'{0} is not available for version < {1}'.format(
f.__name__, version
)
)
return f(self, *args, **kwargs)
return wrapper
return decorator
......@@ -164,6 +164,14 @@ def compare_version(v1, v2):
return 1
def version_lt(v1, v2):
return compare_version(v1, v2) > 0
def version_gte(v1, v2):
return not version_lt(v1, v2)
def ping_registry(url):
warnings.warn(
'The `ping_registry` method is deprecated and will be removed.',
......
......@@ -255,6 +255,29 @@ The utility can be used as follows:
You can now use this with 'environment' for `create_container`.
## create_volume
Create and register a named volume
**Params**:
* name (str): Name of the volume
* driver (str): Name of the driver used to create the volume
* driver_opts (dict): Driver options as a key-value dictionary
**Returns** (dict): The created volume reference object
```python
>>> from docker import Client
>>> cli = Client()
>>> volume = cli.create_volume(
name='foobar', driver='local', driver_opts={'foo': 'bar', 'baz': 'false'}
)
>>> print(volume)
{u'Mountpoint': u'/var/lib/docker/volumes/foobar/_data', u'Driver': u'local', u'Name': u'foobar'}
```
## diff
Inspect changes on a container's filesystem
......@@ -526,6 +549,21 @@ Identical to the `docker inspect` command, but only for images
**Returns** (dict): Nearly the same output as `docker inspect`, just as a
single dict
## inspect_volume
Retrieve volume info by name.
**Params**:
* name (str): volume name
**Returns** (dict): Volume information dictionary
```python
>>> cli.inspect_volume('foobar')
{u'Mountpoint': u'/var/lib/docker/volumes/foobar/_data', u'Driver': u'local', u'Name': u'foobar'}
```
## kill
Kill a container or send a signal to a container
......@@ -695,6 +733,17 @@ Remove an image. Similar to the `docker rmi` command.
* force (bool): Force removal of the image
* noprune (bool): Do not delete untagged parents
## remove_volume
Remove a volume. Similar to the `docker volume rm` command.
**Params**:
* name (str): The volume's name
**Returns** (bool): True on successful removal. Failure will raise a
`docker.errors.APIError` exception.
## rename
Rename a container. Similar to the `docker rename` command.
......@@ -851,6 +900,7 @@ Unpauses all processes within a container.
* container (str): The container to unpause
## version
Nearly identical to the `docker version` command.
**Returns** (dict): The server version information
......@@ -870,6 +920,23 @@ Nearly identical to the `docker version` command.
}
```
## volumes
List volumes currently registered by the docker daemon. Similar to the `docker volume ls` command.
**Params**
* filters (dict): Server-side list filtering options.
**Returns** (dict): Dictionary with list of volume objects as value of the `Volumes` key.
```python
>>> cli.volumes()
{u'Volumes': [
{u'Mountpoint': u'/var/lib/docker/volumes/foobar/_data', u'Driver': u'local', u'Name': u'foobar'},
{u'Mountpoint': u'/var/lib/docker/volumes/baz/_data', u'Driver': u'local', u'Name': u'baz'}
]}
```
## wait
Identical to the `docker wait` command. Block until a container stops, then
......@@ -893,6 +960,5 @@ exception will be raised.
TODO:
* load_image
* resize
-->
import sys
import unittest
import pytest
import six
import docker
class BaseTestCase(unittest.TestCase):
def assertIn(self, object, collection):
if six.PY2 and sys.version_info[1] <= 6:
return self.assertTrue(object in collection)
return super(BaseTestCase, self).assertIn(object, collection)
def requires_api_version(version):
return pytest.mark.skipif(
docker.utils.version_lt(
docker.constants.DEFAULT_DOCKER_API_VERSION, version
),
reason="API version is too low (< {0})".format(version)
)
......@@ -13,8 +13,9 @@
# limitations under the License.
from . import fake_stat
from docker import constants
CURRENT_VERSION = 'v1.19'
CURRENT_VERSION = 'v{0}'.format(constants.DEFAULT_DOCKER_API_VERSION)
FAKE_CONTAINER_ID = '3cc2351ab11b'
FAKE_IMAGE_ID = 'e9aa60c60128'
......@@ -26,6 +27,7 @@ FAKE_TAG_NAME = 'tag'
FAKE_FILE_NAME = 'file'
FAKE_URL = 'myurl'
FAKE_PATH = '/path'
FAKE_VOLUME_NAME = 'perfectcherryblossom'
# Each method is prefixed with HTTP method (get, post...)
# for clarity and readability
......@@ -380,6 +382,38 @@ def get_fake_stats():
response = fake_stat.OBJ
return status_code, response
def get_fake_volume_list():
status_code = 200
response = {
'Volumes': [
{
'Name': 'perfectcherryblossom',
'Driver': 'local',
'Mountpoint': '/var/lib/docker/volumes/perfectcherryblossom'
}, {
'Name': 'subterraneananimism',
'Driver': 'local',
'Mountpoint': '/var/lib/docker/volumes/subterraneananimism'
}
]
}
return status_code, response
def get_fake_volume():
status_code = 200
response = {
'Name': 'perfectcherryblossom',
'Driver': 'local',
'Mountpoint': '/var/lib/docker/volumes/perfectcherryblossom'
}
return status_code, response
def fake_remove_volume():
return 204, None
# Maps real api url to fake response callback
prefix = 'http+docker://localunixsocket'
fake_responses = {
......@@ -463,5 +497,17 @@ fake_responses = {
'{1}/{0}/build'.format(CURRENT_VERSION, prefix):
post_fake_build_container,
'{1}/{0}/events'.format(CURRENT_VERSION, prefix):
get_fake_events
get_fake_events,
('{1}/{0}/volumes'.format(CURRENT_VERSION, prefix), 'GET'):
get_fake_volume_list,
('{1}/{0}/volumes'.format(CURRENT_VERSION, prefix), 'POST'):
get_fake_volume,
('{1}/{0}/volumes/{2}'.format(
CURRENT_VERSION, prefix, FAKE_VOLUME_NAME
), 'GET'):
get_fake_volume,
('{1}/{0}/volumes/{2}'.format(
CURRENT_VERSION, prefix, FAKE_VOLUME_NAME
), 'DELETE'):
fake_remove_volume,
}
......@@ -27,17 +27,18 @@ import time
import unittest
import warnings
import docker
from docker.utils import kwargs_from_env
import pytest
import six
from six.moves import BaseHTTPServer
from six.moves import socketserver
from .test import Cleanup
import docker
from docker.errors import APIError
from docker.utils import kwargs_from_env
from .base import requires_api_version
from .test import Cleanup
import pytest
# FIXME: missing tests for
# export; history; insert; port; push; tag; get; load; stats
......@@ -77,6 +78,7 @@ class BaseTestCase(unittest.TestCase):
tmp_imgs = []
tmp_containers = []
tmp_folders = []
tmp_volumes = []
def setUp(self):
if six.PY2:
......@@ -86,6 +88,7 @@ class BaseTestCase(unittest.TestCase):
self.tmp_imgs = []
self.tmp_containers = []
self.tmp_folders = []
self.tmp_volumes = []
def tearDown(self):
for img in self.tmp_imgs:
......@@ -101,6 +104,13 @@ class BaseTestCase(unittest.TestCase):
pass
for folder in self.tmp_folders:
shutil.rmtree(folder)
for volume in self.tmp_volumes:
try:
self.client.remove_volume(volume)
except docker.errors.APIError:
pass
self.client.close()
#########################
......@@ -223,6 +233,8 @@ class TestCreateContainerWithBinds(BaseTestCase):
if six.PY3:
logs = logs.decode('utf-8')
self.assertIn(filename, logs)
# FIXME: format changes in API version >= 1.20
inspect_data = self.client.inspect_container(container_id)
self.assertIn('Volumes', inspect_data)
self.assertIn(mount_dest, inspect_data['Volumes'])
......@@ -265,6 +277,8 @@ class TestCreateContainerWithRoBinds(BaseTestCase):
if six.PY3:
logs = logs.decode('utf-8')
self.assertIn(filename, logs)
# FIXME: format changes in API version >= 1.20
inspect_data = self.client.inspect_container(container_id)
self.assertIn('Volumes', inspect_data)
self.assertIn(mount_dest, inspect_data['Volumes'])
......@@ -273,6 +287,7 @@ class TestCreateContainerWithRoBinds(BaseTestCase):
self.assertFalse(inspect_data['VolumesRW'][mount_dest])
@requires_api_version('1.20')
class CreateContainerWithGroupAddTest(BaseTestCase):
def test_group_id_ints(self):
container = self.client.create_container(
......@@ -1370,9 +1385,63 @@ class TestImportFromURL(ImportTestCase):
#################
# BUILDER TESTS #
# VOLUMES TESTS #
#################
@requires_api_version('1.21')
class TestVolumes(BaseTestCase):
def test_create_volume(self):
name = 'perfectcherryblossom'
self.tmp_volumes.append(name)
result = self.client.create_volume(name)
self.assertIn('Name', result)
self.assertEqual(result['Name'], name)
self.assertIn('Driver', result)
self.assertEqual(result['Driver'], 'local')
def test_create_volume_invalid_driver(self):
driver_name = 'invalid.driver'
with pytest.raises(docker.errors.NotFound):
self.client.create_volume('perfectcherryblossom', driver_name)
def test_list_volumes(self):
name = 'imperishablenight'
self.tmp_volumes.append(name)
volume_info = self.client.create_volume(name)
result = self.client.volumes()
self.assertIn('Volumes', result)
volumes = result['Volumes']
self.assertIn(volume_info, volumes)
def test_inspect_volume(self):
name = 'embodimentofscarletdevil'
self.tmp_volumes.append(name)
volume_info = self.client.create_volume(name)
result = self.client.inspect_volume(name)
self.assertEqual(volume_info, result)
def test_inspect_nonexistent_volume(self):
name = 'embodimentofscarletdevil'
with pytest.raises(docker.errors.NotFound):
self.client.inspect_volume(name)
def test_remove_volume(self):
name = 'shootthebullet'
self.tmp_volumes.append(name)
self.client.create_volume(name)
result = self.client.remove_volume(name)
self.assertTrue(result)
def test_remove_nonexistent_volume(self):
name = 'shootthebullet'
with pytest.raises(docker.errors.NotFound):
self.client.remove_volume(name)
#################
# BUILDER TESTS #
#################
class TestBuild(BaseTestCase):
def runTest(self):
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment