Merge pull request #1432 from shin-/plugins

Plugins API
This commit is contained in:
Joffrey F 2017-02-13 13:20:53 -08:00 committed by GitHub
commit 35f37a0936
15 changed files with 638 additions and 13 deletions

View File

@ -5,3 +5,4 @@ include README.rst
include LICENSE
recursive-include tests *.py
recursive-include tests/unit/testdata *
recursive-include tests/integration/testdata *

View File

@ -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'] = {}

214
docker/api/plugin.py Normal file
View File

@ -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

View File

@ -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 <plugins>` for full details.
"""
return PluginCollection(client=self)
@property
def services(self):
"""

175
docker/models/plugins.py Normal file
View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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
-----------------

View File

@ -19,6 +19,7 @@ Client reference
.. autoattribute:: images
.. autoattribute:: networks
.. autoattribute:: nodes
.. autoattribute:: plugins
.. autoattribute:: services
.. autoattribute:: swarm
.. autoattribute:: volumes

View File

@ -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

37
docs/plugins.rst Normal file
View File

@ -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

View File

@ -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']

View File

@ -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)

View File

@ -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"
}
]
}