diff --git a/MANIFEST.in b/MANIFEST.in index ee6cdbbd..41b3fa9f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ include README.rst include LICENSE recursive-include tests *.py recursive-include tests/unit/testdata * +recursive-include tests/integration/testdata * diff --git a/docker/api/client.py b/docker/api/client.py index be63181e..0098d44a 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -14,6 +14,7 @@ from .daemon import DaemonApiMixin from .exec_api import ExecApiMixin from .image import ImageApiMixin from .network import NetworkApiMixin +from .plugin import PluginApiMixin from .service import ServiceApiMixin from .swarm import SwarmApiMixin from .volume import VolumeApiMixin @@ -46,6 +47,7 @@ class APIClient( ExecApiMixin, ImageApiMixin, NetworkApiMixin, + PluginApiMixin, ServiceApiMixin, SwarmApiMixin, VolumeApiMixin): @@ -225,10 +227,12 @@ class APIClient( # Go <1.1 can't unserialize null to a string # so we do this disgusting thing here. data2 = {} - if data is not None: + if data is not None and isinstance(data, dict): for k, v in six.iteritems(data): if v is not None: data2[k] = v + elif data is not None: + data2 = data if 'headers' not in kwargs: kwargs['headers'] = {} diff --git a/docker/api/plugin.py b/docker/api/plugin.py new file mode 100644 index 00000000..772d2633 --- /dev/null +++ b/docker/api/plugin.py @@ -0,0 +1,214 @@ +import six + +from .. import auth, utils + + +class PluginApiMixin(object): + @utils.minimum_version('1.25') + @utils.check_resource + def configure_plugin(self, name, options): + """ + Configure a plugin. + + Args: + name (string): The name of the plugin. The ``:latest`` tag is + optional, and is the default if omitted. + options (dict): A key-value mapping of options + + Returns: + ``True`` if successful + """ + url = self._url('/plugins/{0}/set', name) + data = options + if isinstance(data, dict): + data = ['{0}={1}'.format(k, v) for k, v in six.iteritems(data)] + res = self._post_json(url, data=data) + self._raise_for_status(res) + return True + + @utils.minimum_version('1.25') + def create_plugin(self, name, plugin_data_dir, gzip=False): + """ + Create a new plugin. + + Args: + name (string): The name of the plugin. The ``:latest`` tag is + optional, and is the default if omitted. + plugin_data_dir (string): Path to the plugin data directory. + Plugin data directory must contain the ``config.json`` + manifest file and the ``rootfs`` directory. + gzip (bool): Compress the context using gzip. Default: False + + Returns: + ``True`` if successful + """ + url = self._url('/plugins/create') + + with utils.create_archive(root=plugin_data_dir, gzip=gzip) as archv: + res = self._post(url, params={'name': name}, data=archv) + self._raise_for_status(res) + return True + + @utils.minimum_version('1.25') + def disable_plugin(self, name): + """ + Disable an installed plugin. + + Args: + name (string): The name of the plugin. The ``:latest`` tag is + optional, and is the default if omitted. + + Returns: + ``True`` if successful + """ + url = self._url('/plugins/{0}/disable', name) + res = self._post(url) + self._raise_for_status(res) + return True + + @utils.minimum_version('1.25') + def enable_plugin(self, name, timeout=0): + """ + Enable an installed plugin. + + Args: + name (string): The name of the plugin. The ``:latest`` tag is + optional, and is the default if omitted. + timeout (int): Operation timeout (in seconds). Default: 0 + + Returns: + ``True`` if successful + """ + url = self._url('/plugins/{0}/enable', name) + params = {'timeout': timeout} + res = self._post(url, params=params) + self._raise_for_status(res) + return True + + @utils.minimum_version('1.25') + def inspect_plugin(self, name): + """ + Retrieve plugin metadata. + + Args: + name (string): The name of the plugin. The ``:latest`` tag is + optional, and is the default if omitted. + + Returns: + A dict containing plugin info + """ + url = self._url('/plugins/{0}/json', name) + return self._result(self._get(url), True) + + @utils.minimum_version('1.25') + def pull_plugin(self, remote, privileges, name=None): + """ + Pull and install a plugin. After the plugin is installed, it can be + enabled using :py:meth:`~enable_plugin`. + + Args: + remote (string): Remote reference for the plugin to install. + The ``:latest`` tag is optional, and is the default if + omitted. + privileges (list): A list of privileges the user consents to + grant to the plugin. Can be retrieved using + :py:meth:`~plugin_privileges`. + name (string): Local name for the pulled plugin. The + ``:latest`` tag is optional, and is the default if omitted. + + Returns: + An iterable object streaming the decoded API logs + """ + url = self._url('/plugins/pull') + params = { + 'remote': remote, + } + if name: + params['name'] = name + + headers = {} + registry, repo_name = auth.resolve_repository_name(remote) + header = auth.get_config_header(self, registry) + if header: + headers['X-Registry-Auth'] = header + response = self._post_json( + url, params=params, headers=headers, data=privileges, + stream=True + ) + self._raise_for_status(response) + return self._stream_helper(response, decode=True) + + @utils.minimum_version('1.25') + def plugins(self): + """ + Retrieve a list of installed plugins. + + Returns: + A list of dicts, one per plugin + """ + url = self._url('/plugins') + return self._result(self._get(url), True) + + @utils.minimum_version('1.25') + def plugin_privileges(self, name): + """ + Retrieve list of privileges to be granted to a plugin. + + Args: + name (string): Name of the remote plugin to examine. The + ``:latest`` tag is optional, and is the default if omitted. + + Returns: + A list of dictionaries representing the plugin's + permissions + + """ + params = { + 'remote': name, + } + + url = self._url('/plugins/privileges') + return self._result(self._get(url, params=params), True) + + @utils.minimum_version('1.25') + @utils.check_resource + def push_plugin(self, name): + """ + Push a plugin to the registry. + + Args: + name (string): Name of the plugin to upload. The ``:latest`` + tag is optional, and is the default if omitted. + + Returns: + ``True`` if successful + """ + url = self._url('/plugins/{0}/pull', name) + + headers = {} + registry, repo_name = auth.resolve_repository_name(name) + header = auth.get_config_header(self, registry) + if header: + headers['X-Registry-Auth'] = header + res = self._post(url, headers=headers) + self._raise_for_status(res) + return self._stream_helper(res, decode=True) + + @utils.minimum_version('1.25') + def remove_plugin(self, name, force=False): + """ + Remove an installed plugin. + + Args: + name (string): Name of the plugin to remove. The ``:latest`` + tag is optional, and is the default if omitted. + force (bool): Disable the plugin before removing. This may + result in issues if the plugin is in use by a container. + + Returns: + ``True`` if successful + """ + url = self._url('/plugins/{0}', name) + res = self._delete(url, params={'force': force}) + self._raise_for_status(res) + return True diff --git a/docker/client.py b/docker/client.py index 171175d3..127f8dd0 100644 --- a/docker/client.py +++ b/docker/client.py @@ -3,6 +3,7 @@ from .models.containers import ContainerCollection from .models.images import ImageCollection from .models.networks import NetworkCollection from .models.nodes import NodeCollection +from .models.plugins import PluginCollection from .models.services import ServiceCollection from .models.swarm import Swarm from .models.volumes import VolumeCollection @@ -109,6 +110,14 @@ class DockerClient(object): """ return NodeCollection(client=self) + @property + def plugins(self): + """ + An object for managing plugins on the server. See the + :doc:`plugins documentation ` for full details. + """ + return PluginCollection(client=self) + @property def services(self): """ diff --git a/docker/models/plugins.py b/docker/models/plugins.py new file mode 100644 index 00000000..8b6ede95 --- /dev/null +++ b/docker/models/plugins.py @@ -0,0 +1,175 @@ +from .resource import Collection, Model + + +class Plugin(Model): + """ + A plugin on the server. + """ + def __repr__(self): + return "<%s: '%s'>" % (self.__class__.__name__, self.name) + + @property + def name(self): + """ + The plugin's name. + """ + return self.attrs.get('Name') + + @property + def enabled(self): + """ + Whether the plugin is enabled. + """ + return self.attrs.get('Enabled') + + @property + def settings(self): + """ + A dictionary representing the plugin's configuration. + """ + return self.attrs.get('Settings') + + def configure(self, options): + """ + Update the plugin's settings. + + Args: + options (dict): A key-value mapping of options. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + self.client.api.configure_plugin(self.name, options) + self.reload() + + def disable(self): + """ + Disable the plugin. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + + self.client.api.disable_plugin(self.name) + self.reload() + + def enable(self, timeout=0): + """ + Enable the plugin. + + Args: + timeout (int): Timeout in seconds. Default: 0 + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + self.client.api.enable_plugin(self.name, timeout) + self.reload() + + def push(self): + """ + Push the plugin to a remote registry. + + Returns: + A dict iterator streaming the status of the upload. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + return self.client.api.push_plugin(self.name) + + def remove(self, force=False): + """ + Remove the plugin from the server. + + Args: + force (bool): Remove even if the plugin is enabled. + Default: False + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + return self.client.api.remove_plugin(self.name, force=force) + + +class PluginCollection(Collection): + model = Plugin + + def create(self, name, plugin_data_dir, gzip=False): + """ + Create a new plugin. + + Args: + name (string): The name of the plugin. The ``:latest`` tag is + optional, and is the default if omitted. + plugin_data_dir (string): Path to the plugin data directory. + Plugin data directory must contain the ``config.json`` + manifest file and the ``rootfs`` directory. + gzip (bool): Compress the context using gzip. Default: False + + Returns: + (:py:class:`Plugin`): The newly created plugin. + """ + self.client.api.create_plugin(name, plugin_data_dir, gzip) + return self.get(name) + + def get(self, name): + """ + Gets a plugin. + + Args: + name (str): The name of the plugin. + + Returns: + (:py:class:`Plugin`): The plugin. + + Raises: + :py:class:`docker.errors.NotFound` If the plugin does not + exist. + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + return self.prepare_model(self.client.api.inspect_plugin(name)) + + def install(self, remote_name, local_name=None): + """ + Pull and install a plugin. + + Args: + remote_name (string): Remote reference for the plugin to + install. The ``:latest`` tag is optional, and is the + default if omitted. + local_name (string): Local name for the pulled plugin. + The ``:latest`` tag is optional, and is the default if + omitted. Optional. + + Returns: + (:py:class:`Plugin`): The installed plugin + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + privileges = self.client.api.plugin_privileges(remote_name) + it = self.client.api.pull_plugin(remote_name, privileges, local_name) + for data in it: + pass + return self.get(local_name or remote_name) + + def list(self): + """ + List plugins installed on the server. + + Returns: + (list of :py:class:`Plugin`): The plugins. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + resp = self.client.api.plugins() + return [self.prepare_model(r) for r in resp] diff --git a/docker/utils/__init__.py b/docker/utils/__init__.py index 747743cc..8f8eb270 100644 --- a/docker/utils/__init__.py +++ b/docker/utils/__init__.py @@ -6,7 +6,7 @@ from .utils import ( create_host_config, parse_bytes, ping_registry, parse_env_file, version_lt, version_gte, decode_json_header, split_command, create_ipam_config, create_ipam_pool, parse_devices, normalize_links, convert_service_networks, - format_environment + format_environment, create_archive ) from .decorators import check_resource, minimum_version, update_headers diff --git a/docker/utils/utils.py b/docker/utils/utils.py index 8026c4df..01eb16c3 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -80,16 +80,35 @@ def decode_json_header(header): def tar(path, exclude=None, dockerfile=None, fileobj=None, gzip=False): - if not fileobj: - fileobj = tempfile.NamedTemporaryFile() - t = tarfile.open(mode='w:gz' if gzip else 'w', fileobj=fileobj) - root = os.path.abspath(path) exclude = exclude or [] - for path in sorted(exclude_paths(root, exclude, dockerfile=dockerfile)): - i = t.gettarinfo(os.path.join(root, path), arcname=path) + return create_archive( + files=sorted(exclude_paths(root, exclude, dockerfile=dockerfile)), + root=root, fileobj=fileobj, gzip=gzip + ) + +def build_file_list(root): + files = [] + for dirname, dirnames, fnames in os.walk(root): + for filename in fnames + dirnames: + longpath = os.path.join(dirname, filename) + files.append( + longpath.replace(root, '', 1).lstrip('/') + ) + + return files + + +def create_archive(root, files=None, fileobj=None, gzip=False): + if not fileobj: + fileobj = tempfile.NamedTemporaryFile() + t = tarfile.open(mode='w:gz' if gzip else 'w', fileobj=fileobj) + if files is None: + files = build_file_list(root) + for path in files: + i = t.gettarinfo(os.path.join(root, path), arcname=path) if i is None: # This happens when we encounter a socket file. We can safely # ignore it and proceed. @@ -102,13 +121,11 @@ def tar(path, exclude=None, dockerfile=None, fileobj=None, gzip=False): try: # We open the file object in binary mode for Windows support. - f = open(os.path.join(root, path), 'rb') + with open(os.path.join(root, path), 'rb') as f: + t.addfile(i, f) except IOError: # When we encounter a directory the file object is set to None. - f = None - - t.addfile(i, f) - + t.addfile(i, None) t.close() fileobj.seek(0) return fileobj diff --git a/docs/api.rst b/docs/api.rst index b5c1e929..52d12aed 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -87,6 +87,17 @@ Services :members: :undoc-members: +Plugins +------- + +.. py:module:: docker.api.plugin + +.. rst-class:: hide-signature +.. autoclass:: PluginApiMixin + :members: + :undoc-members: + + The Docker daemon ----------------- diff --git a/docs/client.rst b/docs/client.rst index 63bce2c8..5096bcc4 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -19,6 +19,7 @@ Client reference .. autoattribute:: images .. autoattribute:: networks .. autoattribute:: nodes + .. autoattribute:: plugins .. autoattribute:: services .. autoattribute:: swarm .. autoattribute:: volumes diff --git a/docs/index.rst b/docs/index.rst index b297fc08..70f570ea 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -84,6 +84,7 @@ That's just a taste of what you can do with the Docker SDK for Python. For more, images networks nodes + plugins services swarm volumes diff --git a/docs/plugins.rst b/docs/plugins.rst new file mode 100644 index 00000000..a171b2bd --- /dev/null +++ b/docs/plugins.rst @@ -0,0 +1,37 @@ +Plugins +======= + +.. py:module:: docker.models.plugins + +Manage plugins on the server. + +Methods available on ``client.plugins``: + +.. rst-class:: hide-signature +.. py:class:: PluginCollection + + .. automethod:: get + .. automethod:: install + .. automethod:: list + + +Plugin objects +-------------- + +.. autoclass:: Plugin() + + .. autoattribute:: id + .. autoattribute:: short_id + .. autoattribute:: name + .. autoattribute:: enabled + .. autoattribute:: settings + .. py:attribute:: attrs + + The raw representation of this object from the server. + + .. automethod:: configure + .. automethod:: disable + .. automethod:: enable + .. automethod:: reload + .. automethod:: push + .. automethod:: remove diff --git a/tests/integration/api_plugin_test.py b/tests/integration/api_plugin_test.py new file mode 100644 index 00000000..e90a1088 --- /dev/null +++ b/tests/integration/api_plugin_test.py @@ -0,0 +1,135 @@ +import os + +import docker +import pytest + +from .base import BaseAPIIntegrationTest, TEST_API_VERSION +from ..helpers import requires_api_version + +SSHFS = 'vieux/sshfs:latest' + + +@requires_api_version('1.25') +class PluginTest(BaseAPIIntegrationTest): + @classmethod + def teardown_class(cls): + c = docker.APIClient( + version=TEST_API_VERSION, timeout=60, + **docker.utils.kwargs_from_env() + ) + try: + c.remove_plugin(SSHFS, force=True) + except docker.errors.APIError: + pass + + def teardown_method(self, method): + try: + self.client.disable_plugin(SSHFS) + except docker.errors.APIError: + pass + + for p in self.tmp_plugins: + try: + self.client.remove_plugin(p, force=True) + except docker.errors.APIError: + pass + + def ensure_plugin_installed(self, plugin_name): + try: + return self.client.inspect_plugin(plugin_name) + except docker.errors.NotFound: + prv = self.client.plugin_privileges(plugin_name) + for d in self.client.pull_plugin(plugin_name, prv): + pass + return self.client.inspect_plugin(plugin_name) + + def test_enable_plugin(self): + pl_data = self.ensure_plugin_installed(SSHFS) + assert pl_data['Enabled'] is False + assert self.client.enable_plugin(SSHFS) + pl_data = self.client.inspect_plugin(SSHFS) + assert pl_data['Enabled'] is True + with pytest.raises(docker.errors.APIError): + self.client.enable_plugin(SSHFS) + + def test_disable_plugin(self): + pl_data = self.ensure_plugin_installed(SSHFS) + assert pl_data['Enabled'] is False + assert self.client.enable_plugin(SSHFS) + pl_data = self.client.inspect_plugin(SSHFS) + assert pl_data['Enabled'] is True + self.client.disable_plugin(SSHFS) + pl_data = self.client.inspect_plugin(SSHFS) + assert pl_data['Enabled'] is False + with pytest.raises(docker.errors.APIError): + self.client.disable_plugin(SSHFS) + + def test_inspect_plugin(self): + self.ensure_plugin_installed(SSHFS) + data = self.client.inspect_plugin(SSHFS) + assert 'Config' in data + assert 'Name' in data + assert data['Name'] == SSHFS + + def test_plugin_privileges(self): + prv = self.client.plugin_privileges(SSHFS) + assert isinstance(prv, list) + for item in prv: + assert 'Name' in item + assert 'Value' in item + assert 'Description' in item + + def test_list_plugins(self): + self.ensure_plugin_installed(SSHFS) + data = self.client.plugins() + assert len(data) > 0 + plugin = [p for p in data if p['Name'] == SSHFS][0] + assert 'Config' in plugin + + def test_configure_plugin(self): + pl_data = self.ensure_plugin_installed(SSHFS) + assert pl_data['Enabled'] is False + self.client.configure_plugin(SSHFS, { + 'DEBUG': '1' + }) + pl_data = self.client.inspect_plugin(SSHFS) + assert 'Env' in pl_data['Settings'] + assert 'DEBUG=1' in pl_data['Settings']['Env'] + + self.client.configure_plugin(SSHFS, ['DEBUG=0']) + pl_data = self.client.inspect_plugin(SSHFS) + assert 'DEBUG=0' in pl_data['Settings']['Env'] + + def test_remove_plugin(self): + pl_data = self.ensure_plugin_installed(SSHFS) + assert pl_data['Enabled'] is False + assert self.client.remove_plugin(SSHFS) is True + + def test_force_remove_plugin(self): + self.ensure_plugin_installed(SSHFS) + self.client.enable_plugin(SSHFS) + assert self.client.inspect_plugin(SSHFS)['Enabled'] is True + assert self.client.remove_plugin(SSHFS, force=True) is True + + def test_install_plugin(self): + try: + self.client.remove_plugin(SSHFS, force=True) + except docker.errors.APIError: + pass + + prv = self.client.plugin_privileges(SSHFS) + logs = [d for d in self.client.pull_plugin(SSHFS, prv)] + assert filter(lambda x: x['status'] == 'Download complete', logs) + assert self.client.inspect_plugin(SSHFS) + assert self.client.enable_plugin(SSHFS) + + def test_create_plugin(self): + plugin_data_dir = os.path.join( + os.path.dirname(__file__), 'testdata/dummy-plugin' + ) + assert self.client.create_plugin( + 'docker-sdk-py/dummy', plugin_data_dir + ) + self.tmp_plugins.append('docker-sdk-py/dummy') + data = self.client.inspect_plugin('docker-sdk-py/dummy') + assert data['Config']['Entrypoint'] == ['/dummy'] diff --git a/tests/integration/base.py b/tests/integration/base.py index 7da3aa75..6f00a46a 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -27,6 +27,7 @@ class BaseIntegrationTest(unittest.TestCase): self.tmp_folders = [] self.tmp_volumes = [] self.tmp_networks = [] + self.tmp_plugins = [] def tearDown(self): client = docker.from_env(version=TEST_API_VERSION) diff --git a/tests/integration/testdata/dummy-plugin/config.json b/tests/integration/testdata/dummy-plugin/config.json new file mode 100644 index 00000000..53b4e7aa --- /dev/null +++ b/tests/integration/testdata/dummy-plugin/config.json @@ -0,0 +1,19 @@ +{ + "description": "Dummy test plugin for docker python SDK", + "documentation": "https://github.com/docker/docker-py", + "entrypoint": ["/dummy"], + "network": { + "type": "host" + }, + "interface" : { + "types": ["docker.volumedriver/1.0"], + "socket": "dummy.sock" + }, + "env": [ + { + "name":"DEBUG", + "settable":["value"], + "value":"0" + } + ] +} diff --git a/tests/integration/testdata/dummy-plugin/rootfs/dummy/file.txt b/tests/integration/testdata/dummy-plugin/rootfs/dummy/file.txt new file mode 100644 index 00000000..e69de29b