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 .network import NetworkApiMixin
from .plugin import PluginApiMixin
from .secret import SecretApiMixin
from .service import ServiceApiMixin
from .swarm import SwarmApiMixin
from .volume import VolumeApiMixin
@ -48,6 +49,7 @@ class APIClient(
ImageApiMixin,
NetworkApiMixin,
PluginApiMixin,
SecretApiMixin,
ServiceApiMixin,
SwarmApiMixin,
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.nodes import NodeCollection
from .models.plugins import PluginCollection
from .models.secrets import SecretCollection
from .models.services import ServiceCollection
from .models.swarm import Swarm
from .models.volumes import VolumeCollection
@ -118,6 +119,13 @@ class DockerClient(object):
"""
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
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``.
resources (Resources): Resource limits and reservations.
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
containers to terminate before forcefully killing them.
update_config (UpdateConfig): Specification for the update strategy
@ -179,6 +181,7 @@ CONTAINER_SPEC_KWARGS = [
'labels',
'mounts',
'stop_grace_period',
'secrets',
]
# 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 .services import (
ContainerSpec, DriverConfig, EndpointSpec, Mount, Resources, RestartPolicy,
ServiceMode, TaskTemplate, UpdateConfig
SecretReference, ServiceMode, TaskTemplate, UpdateConfig
)
from .swarm import SwarmSpec, SwarmExternalCA

View File

@ -2,7 +2,7 @@ import six
from .. import errors
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):
@ -79,9 +79,12 @@ class ContainerSpec(dict):
:py:class:`~docker.types.Mount` class for details.
stop_grace_period (int): Amount of time to wait for the container to
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,
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
if isinstance(command, six.string_types):
@ -109,6 +112,11 @@ class ContainerSpec(dict):
if stop_grace_period is not None:
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):
"""
@ -410,3 +418,31 @@ class ServiceMode(dict):
if self.mode != 'replicated':
return None
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'))
if not resource_id:
raise errors.NullResource(
'image or container param is undefined'
'Resource ID was not provided'
)
return f(self, resource_id, *args, **kwargs)
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 time
import docker
@ -24,6 +27,21 @@ class ServiceTest(BaseAPIIntegrationTest):
def get_service_name(self):
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):
if name:
name = 'dockerpytest_{0}'.format(name)
@ -317,3 +335,55 @@ class ServiceTest(BaseAPIIntegrationTest):
new_index = svc_info['Version']['Index']
assert new_index > version_index
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_networks = []
self.tmp_plugins = []
self.tmp_secrets = []
def tearDown(self):
client = docker.from_env(version=TEST_API_VERSION)
@ -52,6 +53,12 @@ class BaseIntegrationTest(unittest.TestCase):
except docker.errors.APIError:
pass
for secret in self.tmp_secrets:
try:
client.api.remove_secret(secret)
except docker.errors.APIError:
pass
for folder in self.tmp_folders:
shutil.rmtree(folder)

View File

@ -45,7 +45,7 @@ class StartContainerTest(BaseAPIClientTest):
self.assertEqual(
str(excinfo.value),
'image or container param is undefined',
'Resource ID was not provided',
)
with pytest.raises(ValueError) as excinfo:
@ -53,7 +53,7 @@ class StartContainerTest(BaseAPIClientTest):
self.assertEqual(
str(excinfo.value),
'image or container param is undefined',
'Resource ID was not provided',
)
def test_start_container_regression_573(self):
@ -1559,7 +1559,7 @@ class ContainerTest(BaseAPIClientTest):
self.client.inspect_container(arg)
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):

View File

@ -204,7 +204,7 @@ class ImageTest(BaseAPIClientTest):
self.client.inspect_image(arg)
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):