Merge pull request #1449 from shin-/secrets-api

Implement secrets API
This commit is contained in:
Joffrey F 2017-02-15 18:08:25 -08:00 committed by GitHub
commit afcbeb5e4f
13 changed files with 363 additions and 8 deletions

View File

@ -15,6 +15,7 @@ from .exec_api import ExecApiMixin
from .image import ImageApiMixin from .image import ImageApiMixin
from .network import NetworkApiMixin from .network import NetworkApiMixin
from .plugin import PluginApiMixin from .plugin import PluginApiMixin
from .secret import SecretApiMixin
from .service import ServiceApiMixin from .service import ServiceApiMixin
from .swarm import SwarmApiMixin from .swarm import SwarmApiMixin
from .volume import VolumeApiMixin from .volume import VolumeApiMixin
@ -48,6 +49,7 @@ class APIClient(
ImageApiMixin, ImageApiMixin,
NetworkApiMixin, NetworkApiMixin,
PluginApiMixin, PluginApiMixin,
SecretApiMixin,
ServiceApiMixin, ServiceApiMixin,
SwarmApiMixin, SwarmApiMixin,
VolumeApiMixin): VolumeApiMixin):

91
docker/api/secret.py Normal file
View File

@ -0,0 +1,91 @@
import base64
import six
from .. import utils
class SecretApiMixin(object):
@utils.minimum_version('1.25')
def create_secret(self, name, data, labels=None):
"""
Create a secret
Args:
name (string): Name of the secret
data (bytes): Secret data to be stored
labels (dict): A mapping of labels to assign to the secret
Returns (dict): ID of the newly created secret
"""
if not isinstance(data, bytes):
data = data.encode('utf-8')
data = base64.b64encode(data)
if six.PY3:
data = data.decode('ascii')
body = {
'Data': data,
'Name': name,
'Labels': labels
}
url = self._url('/secrets/create')
return self._result(
self._post_json(url, data=body), True
)
@utils.minimum_version('1.25')
@utils.check_resource
def inspect_secret(self, id):
"""
Retrieve secret metadata
Args:
id (string): Full ID of the secret to remove
Returns (dict): A dictionary of metadata
Raises:
:py:class:`docker.errors.NotFound`
if no secret with that ID exists
"""
url = self._url('/secrets/{0}', id)
return self._result(self._get(url), True)
@utils.minimum_version('1.25')
@utils.check_resource
def remove_secret(self, id):
"""
Remove a secret
Args:
id (string): Full ID of the secret to remove
Returns (boolean): True if successful
Raises:
:py:class:`docker.errors.NotFound`
if no secret with that ID exists
"""
url = self._url('/secrets/{0}', id)
res = self._delete(url)
self._raise_for_status(res)
return True
@utils.minimum_version('1.25')
def secrets(self, filters=None):
"""
List secrets
Args:
filters (dict): A map of filters to process on the secrets
list. Available filters: ``names``
Returns (list): A list of secrets
"""
url = self._url('/secrets')
params = {}
if filters:
params['filters'] = utils.convert_filters(filters)
return self._result(self._get(url, params=params), True)

View File

@ -4,6 +4,7 @@ from .models.images import ImageCollection
from .models.networks import NetworkCollection from .models.networks import NetworkCollection
from .models.nodes import NodeCollection from .models.nodes import NodeCollection
from .models.plugins import PluginCollection from .models.plugins import PluginCollection
from .models.secrets import SecretCollection
from .models.services import ServiceCollection from .models.services import ServiceCollection
from .models.swarm import Swarm from .models.swarm import Swarm
from .models.volumes import VolumeCollection from .models.volumes import VolumeCollection
@ -118,6 +119,13 @@ class DockerClient(object):
""" """
return PluginCollection(client=self) return PluginCollection(client=self)
def secrets(self):
"""
An object for managing secrets on the server. See the
:doc:`secrets documentation <secrets>` for full details.
"""
return SecretCollection(client=self)
@property @property
def services(self): def services(self):
""" """

69
docker/models/secrets.py Normal file
View File

@ -0,0 +1,69 @@
from ..api import APIClient
from .resource import Model, Collection
class Secret(Model):
"""A secret."""
id_attribute = 'ID'
def __repr__(self):
return "<%s: '%s'>" % (self.__class__.__name__, self.name)
@property
def name(self):
return self.attrs['Spec']['Name']
def remove(self):
"""
Remove this secret.
Raises:
:py:class:`docker.errors.APIError`
If secret failed to remove.
"""
return self.client.api.remove_secret(self.id)
class SecretCollection(Collection):
"""Secrets on the Docker server."""
model = Secret
def create(self, **kwargs):
obj = self.client.api.create_secret(**kwargs)
return self.prepare_model(obj)
create.__doc__ = APIClient.create_secret.__doc__
def get(self, secret_id):
"""
Get a secret.
Args:
secret_id (str): Secret ID.
Returns:
(:py:class:`Secret`): The secret.
Raises:
:py:class:`docker.errors.NotFound`
If the secret does not exist.
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
return self.prepare_model(self.client.api.inspect_secret(secret_id))
def list(self, **kwargs):
"""
List secrets. Similar to the ``docker secret ls`` command.
Args:
filters (dict): Server-side list filtering options.
Returns:
(list of :py:class:`Secret`): The secrets.
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
resp = self.client.api.secrets(**kwargs)
return [self.prepare_model(obj) for obj in resp]

View File

@ -109,6 +109,8 @@ class ServiceCollection(Collection):
the service to. Default: ``None``. the service to. Default: ``None``.
resources (Resources): Resource limits and reservations. resources (Resources): Resource limits and reservations.
restart_policy (RestartPolicy): Restart policy for containers. restart_policy (RestartPolicy): Restart policy for containers.
secrets (list of :py:class:`docker.types.SecretReference`): List
of secrets accessible to containers for this service.
stop_grace_period (int): Amount of time to wait for stop_grace_period (int): Amount of time to wait for
containers to terminate before forcefully killing them. containers to terminate before forcefully killing them.
update_config (UpdateConfig): Specification for the update strategy update_config (UpdateConfig): Specification for the update strategy
@ -179,6 +181,7 @@ CONTAINER_SPEC_KWARGS = [
'labels', 'labels',
'mounts', 'mounts',
'stop_grace_period', 'stop_grace_period',
'secrets',
] ]
# kwargs to copy straight over to TaskTemplate # kwargs to copy straight over to TaskTemplate

View File

@ -4,6 +4,6 @@ from .healthcheck import Healthcheck
from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig
from .services import ( from .services import (
ContainerSpec, DriverConfig, EndpointSpec, Mount, Resources, RestartPolicy, ContainerSpec, DriverConfig, EndpointSpec, Mount, Resources, RestartPolicy,
ServiceMode, TaskTemplate, UpdateConfig SecretReference, ServiceMode, TaskTemplate, UpdateConfig
) )
from .swarm import SwarmSpec, SwarmExternalCA from .swarm import SwarmSpec, SwarmExternalCA

View File

@ -2,7 +2,7 @@ import six
from .. import errors from .. import errors
from ..constants import IS_WINDOWS_PLATFORM from ..constants import IS_WINDOWS_PLATFORM
from ..utils import format_environment, split_command from ..utils import check_resource, format_environment, split_command
class TaskTemplate(dict): class TaskTemplate(dict):
@ -79,9 +79,12 @@ class ContainerSpec(dict):
:py:class:`~docker.types.Mount` class for details. :py:class:`~docker.types.Mount` class for details.
stop_grace_period (int): Amount of time to wait for the container to stop_grace_period (int): Amount of time to wait for the container to
terminate before forcefully killing it. terminate before forcefully killing it.
secrets (list of py:class:`SecretReference`): List of secrets to be
made available inside the containers.
""" """
def __init__(self, image, command=None, args=None, env=None, workdir=None, def __init__(self, image, command=None, args=None, env=None, workdir=None,
user=None, labels=None, mounts=None, stop_grace_period=None): user=None, labels=None, mounts=None, stop_grace_period=None,
secrets=None):
self['Image'] = image self['Image'] = image
if isinstance(command, six.string_types): if isinstance(command, six.string_types):
@ -109,6 +112,11 @@ class ContainerSpec(dict):
if stop_grace_period is not None: if stop_grace_period is not None:
self['StopGracePeriod'] = stop_grace_period self['StopGracePeriod'] = stop_grace_period
if secrets is not None:
if not isinstance(secrets, list):
raise TypeError('secrets must be a list')
self['Secrets'] = secrets
class Mount(dict): class Mount(dict):
""" """
@ -410,3 +418,31 @@ class ServiceMode(dict):
if self.mode != 'replicated': if self.mode != 'replicated':
return None return None
return self['replicated'].get('Replicas') return self['replicated'].get('Replicas')
class SecretReference(dict):
"""
Secret reference to be used as part of a :py:class:`ContainerSpec`.
Describes how a secret is made accessible inside the service's
containers.
Args:
secret_id (string): Secret's ID
secret_name (string): Secret's name as defined at its creation.
filename (string): Name of the file containing the secret. Defaults
to the secret's name if not specified.
uid (string): UID of the secret file's owner. Default: 0
gid (string): GID of the secret file's group. Default: 0
mode (int): File access mode inside the container. Default: 0o444
"""
@check_resource
def __init__(self, secret_id, secret_name, filename=None, uid=None,
gid=None, mode=0o444):
self['SecretName'] = secret_name
self['SecretID'] = secret_id
self['File'] = {
'Name': filename or secret_name,
'UID': uid or '0',
'GID': gid or '0',
'Mode': mode
}

View File

@ -16,7 +16,7 @@ def check_resource(f):
resource_id = resource_id.get('Id', resource_id.get('ID')) resource_id = resource_id.get('Id', resource_id.get('ID'))
if not resource_id: if not resource_id:
raise errors.NullResource( raise errors.NullResource(
'image or container param is undefined' 'Resource ID was not provided'
) )
return f(self, resource_id, *args, **kwargs) return f(self, resource_id, *args, **kwargs)
return wrapped return wrapped

View File

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
import docker
import pytest
from ..helpers import force_leave_swarm, requires_api_version
from .base import BaseAPIIntegrationTest
@requires_api_version('1.25')
class SecretAPITest(BaseAPIIntegrationTest):
def setUp(self):
super(SecretAPITest, self).setUp()
self.init_swarm()
def tearDown(self):
super(SecretAPITest, self).tearDown()
force_leave_swarm(self.client)
def test_create_secret(self):
secret_id = self.client.create_secret(
'favorite_character', 'sakuya izayoi'
)
self.tmp_secrets.append(secret_id)
assert 'ID' in secret_id
data = self.client.inspect_secret(secret_id)
assert data['Spec']['Name'] == 'favorite_character'
def test_create_secret_unicode_data(self):
secret_id = self.client.create_secret(
'favorite_character', u'いざよいさくや'
)
self.tmp_secrets.append(secret_id)
assert 'ID' in secret_id
data = self.client.inspect_secret(secret_id)
assert data['Spec']['Name'] == 'favorite_character'
def test_inspect_secret(self):
secret_name = 'favorite_character'
secret_id = self.client.create_secret(
secret_name, 'sakuya izayoi'
)
self.tmp_secrets.append(secret_id)
data = self.client.inspect_secret(secret_id)
assert data['Spec']['Name'] == secret_name
assert 'ID' in data
assert 'Version' in data
def test_remove_secret(self):
secret_name = 'favorite_character'
secret_id = self.client.create_secret(
secret_name, 'sakuya izayoi'
)
self.tmp_secrets.append(secret_id)
assert self.client.remove_secret(secret_id)
with pytest.raises(docker.errors.NotFound):
self.client.inspect_secret(secret_id)
def test_list_secrets(self):
secret_name = 'favorite_character'
secret_id = self.client.create_secret(
secret_name, 'sakuya izayoi'
)
self.tmp_secrets.append(secret_id)
data = self.client.secrets(filters={'names': ['favorite_character']})
assert len(data) == 1
assert data[0]['ID'] == secret_id['ID']

View File

@ -1,4 +1,7 @@
# -*- coding: utf-8 -*-
import random import random
import time
import docker import docker
@ -24,6 +27,21 @@ class ServiceTest(BaseAPIIntegrationTest):
def get_service_name(self): def get_service_name(self):
return 'dockerpytest_{0:x}'.format(random.getrandbits(64)) return 'dockerpytest_{0:x}'.format(random.getrandbits(64))
def get_service_container(self, service_name, attempts=20, interval=0.5):
# There is some delay between the service's creation and the creation
# of the service's containers. This method deals with the uncertainty
# when trying to retrieve the container associated with a service.
while True:
containers = self.client.containers(
filters={'name': [service_name]}, quiet=True
)
if len(containers) > 0:
return containers[0]
attempts -= 1
if attempts <= 0:
return None
time.sleep(interval)
def create_simple_service(self, name=None): def create_simple_service(self, name=None):
if name: if name:
name = 'dockerpytest_{0}'.format(name) name = 'dockerpytest_{0}'.format(name)
@ -317,3 +335,55 @@ class ServiceTest(BaseAPIIntegrationTest):
new_index = svc_info['Version']['Index'] new_index = svc_info['Version']['Index']
assert new_index > version_index assert new_index > version_index
assert svc_info['Spec']['TaskTemplate']['ForceUpdate'] == 10 assert svc_info['Spec']['TaskTemplate']['ForceUpdate'] == 10
@requires_api_version('1.25')
def test_create_service_with_secret(self):
secret_name = 'favorite_touhou'
secret_data = b'phantasmagoria of flower view'
secret_id = self.client.create_secret(secret_name, secret_data)
self.tmp_secrets.append(secret_id)
secret_ref = docker.types.SecretReference(secret_id, secret_name)
container_spec = docker.types.ContainerSpec(
'busybox', ['top'], secrets=[secret_ref]
)
task_tmpl = docker.types.TaskTemplate(container_spec)
name = self.get_service_name()
svc_id = self.client.create_service(task_tmpl, name=name)
svc_info = self.client.inspect_service(svc_id)
assert 'Secrets' in svc_info['Spec']['TaskTemplate']['ContainerSpec']
secrets = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Secrets']
assert secrets[0] == secret_ref
container = self.get_service_container(name)
assert container is not None
exec_id = self.client.exec_create(
container, 'cat /run/secrets/{0}'.format(secret_name)
)
assert self.client.exec_start(exec_id) == secret_data
@requires_api_version('1.25')
def test_create_service_with_unicode_secret(self):
secret_name = 'favorite_touhou'
secret_data = u'東方花映塚'
secret_id = self.client.create_secret(secret_name, secret_data)
self.tmp_secrets.append(secret_id)
secret_ref = docker.types.SecretReference(secret_id, secret_name)
container_spec = docker.types.ContainerSpec(
'busybox', ['top'], secrets=[secret_ref]
)
task_tmpl = docker.types.TaskTemplate(container_spec)
name = self.get_service_name()
svc_id = self.client.create_service(task_tmpl, name=name)
svc_info = self.client.inspect_service(svc_id)
assert 'Secrets' in svc_info['Spec']['TaskTemplate']['ContainerSpec']
secrets = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Secrets']
assert secrets[0] == secret_ref
container = self.get_service_container(name)
assert container is not None
exec_id = self.client.exec_create(
container, 'cat /run/secrets/{0}'.format(secret_name)
)
container_secret = self.client.exec_start(exec_id)
container_secret = container_secret.decode('utf-8')
assert container_secret == secret_data

View File

@ -28,6 +28,7 @@ class BaseIntegrationTest(unittest.TestCase):
self.tmp_volumes = [] self.tmp_volumes = []
self.tmp_networks = [] self.tmp_networks = []
self.tmp_plugins = [] self.tmp_plugins = []
self.tmp_secrets = []
def tearDown(self): def tearDown(self):
client = docker.from_env(version=TEST_API_VERSION) client = docker.from_env(version=TEST_API_VERSION)
@ -52,6 +53,12 @@ class BaseIntegrationTest(unittest.TestCase):
except docker.errors.APIError: except docker.errors.APIError:
pass pass
for secret in self.tmp_secrets:
try:
client.api.remove_secret(secret)
except docker.errors.APIError:
pass
for folder in self.tmp_folders: for folder in self.tmp_folders:
shutil.rmtree(folder) shutil.rmtree(folder)

View File

@ -45,7 +45,7 @@ class StartContainerTest(BaseAPIClientTest):
self.assertEqual( self.assertEqual(
str(excinfo.value), str(excinfo.value),
'image or container param is undefined', 'Resource ID was not provided',
) )
with pytest.raises(ValueError) as excinfo: with pytest.raises(ValueError) as excinfo:
@ -53,7 +53,7 @@ class StartContainerTest(BaseAPIClientTest):
self.assertEqual( self.assertEqual(
str(excinfo.value), str(excinfo.value),
'image or container param is undefined', 'Resource ID was not provided',
) )
def test_start_container_regression_573(self): def test_start_container_regression_573(self):
@ -1559,7 +1559,7 @@ class ContainerTest(BaseAPIClientTest):
self.client.inspect_container(arg) self.client.inspect_container(arg)
self.assertEqual( self.assertEqual(
excinfo.value.args[0], 'image or container param is undefined' excinfo.value.args[0], 'Resource ID was not provided'
) )
def test_container_stats(self): def test_container_stats(self):

View File

@ -204,7 +204,7 @@ class ImageTest(BaseAPIClientTest):
self.client.inspect_image(arg) self.client.inspect_image(arg)
self.assertEqual( self.assertEqual(
excinfo.value.args[0], 'image or container param is undefined' excinfo.value.args[0], 'Resource ID was not provided'
) )
def test_insert_image(self): def test_insert_image(self):