From b1301637cf2669205b048df80f7d21c2ac3c4d68 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 26 Oct 2017 16:13:05 -0700 Subject: [PATCH] Add support for configs management Signed-off-by: Joffrey F --- docker/api/client.py | 2 + docker/api/config.py | 91 +++++++++++++ docker/client.py | 9 ++ docker/models/configs.py | 69 ++++++++++ docs/api.rst | 10 ++ docs/client.rst | 1 + docs/configs.rst | 30 +++++ docs/index.rst | 1 + tests/integration/api_config_test.py | 69 ++++++++++ tests/integration/api_service_test.py | 178 +++++++++++++++++++++++++- tests/integration/base.py | 7 + 11 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 docker/api/config.py create mode 100644 docker/models/configs.py create mode 100644 docs/configs.rst create mode 100644 tests/integration/api_config_test.py diff --git a/docker/api/client.py b/docker/api/client.py index 1de10c77c..cbe74b916 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -9,6 +9,7 @@ import websocket from .build import BuildApiMixin +from .config import ConfigApiMixin from .container import ContainerApiMixin from .daemon import DaemonApiMixin from .exec_api import ExecApiMixin @@ -43,6 +44,7 @@ class APIClient( requests.Session, BuildApiMixin, + ConfigApiMixin, ContainerApiMixin, DaemonApiMixin, ExecApiMixin, diff --git a/docker/api/config.py b/docker/api/config.py new file mode 100644 index 000000000..b46b09c7c --- /dev/null +++ b/docker/api/config.py @@ -0,0 +1,91 @@ +import base64 + +import six + +from .. import utils + + +class ConfigApiMixin(object): + @utils.minimum_version('1.25') + def create_config(self, name, data, labels=None): + """ + Create a config + + Args: + name (string): Name of the config + data (bytes): Config data to be stored + labels (dict): A mapping of labels to assign to the config + + Returns (dict): ID of the newly created config + """ + 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('/configs/create') + return self._result( + self._post_json(url, data=body), True + ) + + @utils.minimum_version('1.25') + @utils.check_resource('id') + def inspect_config(self, id): + """ + Retrieve config metadata + + Args: + id (string): Full ID of the config to remove + + Returns (dict): A dictionary of metadata + + Raises: + :py:class:`docker.errors.NotFound` + if no config with that ID exists + """ + url = self._url('/configs/{0}', id) + return self._result(self._get(url), True) + + @utils.minimum_version('1.25') + @utils.check_resource('id') + def remove_config(self, id): + """ + Remove a config + + Args: + id (string): Full ID of the config to remove + + Returns (boolean): True if successful + + Raises: + :py:class:`docker.errors.NotFound` + if no config with that ID exists + """ + url = self._url('/configs/{0}', id) + res = self._delete(url) + self._raise_for_status(res) + return True + + @utils.minimum_version('1.25') + def configs(self, filters=None): + """ + List configs + + Args: + filters (dict): A map of filters to process on the configs + list. Available filters: ``names`` + + Returns (list): A list of configs + """ + url = self._url('/configs') + params = {} + if filters: + params['filters'] = utils.convert_filters(filters) + return self._result(self._get(url, params=params), True) diff --git a/docker/client.py b/docker/client.py index ee361bb96..29968c1f0 100644 --- a/docker/client.py +++ b/docker/client.py @@ -1,5 +1,6 @@ from .api.client import APIClient from .constants import DEFAULT_TIMEOUT_SECONDS +from .models.configs import ConfigCollection from .models.containers import ContainerCollection from .models.images import ImageCollection from .models.networks import NetworkCollection @@ -80,6 +81,14 @@ def from_env(cls, **kwargs): **kwargs_from_env(**kwargs)) # Resources + @property + def configs(self): + """ + An object for managing configs on the server. See the + :doc:`configs documentation ` for full details. + """ + return ConfigCollection(client=self) + @property def containers(self): """ diff --git a/docker/models/configs.py b/docker/models/configs.py new file mode 100644 index 000000000..7f23f6500 --- /dev/null +++ b/docker/models/configs.py @@ -0,0 +1,69 @@ +from ..api import APIClient +from .resource import Model, Collection + + +class Config(Model): + """A config.""" + 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 config. + + Raises: + :py:class:`docker.errors.APIError` + If config failed to remove. + """ + return self.client.api.remove_config(self.id) + + +class ConfigCollection(Collection): + """Configs on the Docker server.""" + model = Config + + def create(self, **kwargs): + obj = self.client.api.create_config(**kwargs) + return self.prepare_model(obj) + create.__doc__ = APIClient.create_config.__doc__ + + def get(self, config_id): + """ + Get a config. + + Args: + config_id (str): Config ID. + + Returns: + (:py:class:`Config`): The config. + + Raises: + :py:class:`docker.errors.NotFound` + If the config does not exist. + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + return self.prepare_model(self.client.api.inspect_config(config_id)) + + def list(self, **kwargs): + """ + List configs. Similar to the ``docker config ls`` command. + + Args: + filters (dict): Server-side list filtering options. + + Returns: + (list of :py:class:`Config`): The configs. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + resp = self.client.api.configs(**kwargs) + return [self.prepare_model(obj) for obj in resp] diff --git a/docs/api.rst b/docs/api.rst index 2fce0a77a..18993ad34 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -9,6 +9,16 @@ It's possible to use :py:class:`APIClient` directly. Some basic things (e.g. run .. autoclass:: docker.api.client.APIClient +Configs +------- + +.. py:module:: docker.api.config + +.. rst-class:: hide-signature +.. autoclass:: ConfigApiMixin + :members: + :undoc-members: + Containers ---------- diff --git a/docs/client.rst b/docs/client.rst index ac7a256a0..43d7c63be 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -15,6 +15,7 @@ Client reference .. autoclass:: DockerClient() + .. autoattribute:: configs .. autoattribute:: containers .. autoattribute:: images .. autoattribute:: networks diff --git a/docs/configs.rst b/docs/configs.rst new file mode 100644 index 000000000..d907ad421 --- /dev/null +++ b/docs/configs.rst @@ -0,0 +1,30 @@ +Configs +======= + +.. py:module:: docker.models.configs + +Manage configs on the server. + +Methods available on ``client.configs``: + +.. rst-class:: hide-signature +.. py:class:: ConfigCollection + + .. automethod:: create + .. automethod:: get + .. automethod:: list + + +Config objects +-------------- + +.. autoclass:: Config() + + .. autoattribute:: id + .. autoattribute:: name + .. py:attribute:: attrs + + The raw representation of this object from the server. + + .. automethod:: reload + .. automethod:: remove diff --git a/docs/index.rst b/docs/index.rst index 9113bffcc..39426b681 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -80,6 +80,7 @@ That's just a taste of what you can do with the Docker SDK for Python. For more, :maxdepth: 2 client + configs containers images networks diff --git a/tests/integration/api_config_test.py b/tests/integration/api_config_test.py new file mode 100644 index 000000000..fb6002a76 --- /dev/null +++ b/tests/integration/api_config_test.py @@ -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.30') +class ConfigAPITest(BaseAPIIntegrationTest): + def setUp(self): + super(ConfigAPITest, self).setUp() + self.init_swarm() + + def tearDown(self): + super(ConfigAPITest, self).tearDown() + force_leave_swarm(self.client) + + def test_create_config(self): + config_id = self.client.create_config( + 'favorite_character', 'sakuya izayoi' + ) + self.tmp_configs.append(config_id) + assert 'ID' in config_id + data = self.client.inspect_config(config_id) + assert data['Spec']['Name'] == 'favorite_character' + + def test_create_config_unicode_data(self): + config_id = self.client.create_config( + 'favorite_character', u'いざよいさくや' + ) + self.tmp_configs.append(config_id) + assert 'ID' in config_id + data = self.client.inspect_config(config_id) + assert data['Spec']['Name'] == 'favorite_character' + + def test_inspect_config(self): + config_name = 'favorite_character' + config_id = self.client.create_config( + config_name, 'sakuya izayoi' + ) + self.tmp_configs.append(config_id) + data = self.client.inspect_config(config_id) + assert data['Spec']['Name'] == config_name + assert 'ID' in data + assert 'Version' in data + + def test_remove_config(self): + config_name = 'favorite_character' + config_id = self.client.create_config( + config_name, 'sakuya izayoi' + ) + self.tmp_configs.append(config_id) + + assert self.client.remove_config(config_id) + with pytest.raises(docker.errors.NotFound): + self.client.inspect_config(config_id) + + def test_list_configs(self): + config_name = 'favorite_character' + config_id = self.client.create_config( + config_name, 'sakuya izayoi' + ) + self.tmp_configs.append(config_id) + + data = self.client.configs(filters={'name': ['favorite_character']}) + assert len(data) == 1 + assert data[0]['ID'] == config_id['ID'] diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index c966916eb..56c3e683c 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -473,7 +473,7 @@ def test_create_service_with_unicode_secret(self): 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) + secret_ref = docker.types.ConfigReference(secret_id, secret_name) container_spec = docker.types.ContainerSpec( 'busybox', ['sleep', '999'], secrets=[secret_ref] ) @@ -481,8 +481,8 @@ def test_create_service_with_unicode_secret(self): 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 'Configs' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + secrets = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Configs'] assert secrets[0] == secret_ref container = self.get_service_container(name) @@ -493,3 +493,175 @@ def test_create_service_with_unicode_secret(self): container_secret = self.client.exec_start(exec_id) container_secret = container_secret.decode('utf-8') assert container_secret == secret_data + + @requires_api_version('1.25') + def test_create_service_with_config(self): + config_name = 'favorite_touhou' + config_data = b'phantasmagoria of flower view' + config_id = self.client.create_config(config_name, config_data) + self.tmp_configs.append(config_id) + config_ref = docker.types.ConfigReference(config_id, config_name) + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], configs=[config_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 'Configs' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + configs = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Configs'] + assert configs[0] == config_ref + + container = self.get_service_container(name) + assert container is not None + exec_id = self.client.exec_create( + container, 'cat /run/configs/{0}'.format(config_name) + ) + assert self.client.exec_start(exec_id) == config_data + + @requires_api_version('1.25') + def test_create_service_with_unicode_config(self): + config_name = 'favorite_touhou' + config_data = u'東方花映塚' + config_id = self.client.create_config(config_name, config_data) + self.tmp_configs.append(config_id) + config_ref = docker.types.ConfigReference(config_id, config_name) + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], configs=[config_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 'Configs' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + configs = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Configs'] + assert configs[0] == config_ref + + container = self.get_service_container(name) + assert container is not None + exec_id = self.client.exec_create( + container, 'cat /run/configs/{0}'.format(config_name) + ) + container_config = self.client.exec_start(exec_id) + container_config = container_config.decode('utf-8') + assert container_config == config_data + + @requires_api_version('1.25') + def test_create_service_with_hosts(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], hosts={ + 'foobar': '127.0.0.1', + 'baz': '8.8.8.8', + } + ) + 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 'Hosts' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + hosts = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Hosts'] + assert len(hosts) == 2 + assert 'foobar:127.0.0.1' in hosts + assert 'baz:8.8.8.8' in hosts + + @requires_api_version('1.25') + def test_create_service_with_hostname(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], hostname='foobar.baz.com' + ) + 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 'Hostname' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert ( + svc_info['Spec']['TaskTemplate']['ContainerSpec']['Hostname'] == + 'foobar.baz.com' + ) + + @requires_api_version('1.25') + def test_create_service_with_groups(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], groups=['shrinemaidens', 'youkais'] + ) + 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 'Groups' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + groups = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Groups'] + assert len(groups) == 2 + assert 'shrinemaidens' in groups + assert 'youkais' in groups + + @requires_api_version('1.25') + def test_create_service_with_dns_config(self): + dns_config = docker.types.DNSConfig( + nameservers=['8.8.8.8', '8.8.4.4'], + search=['local'], options=['debug'] + ) + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], dns_config=dns_config + ) + 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 'DNSConfig' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert ( + dns_config == + svc_info['Spec']['TaskTemplate']['ContainerSpec']['DNSConfig'] + ) + + @requires_api_version('1.25') + def test_create_service_with_healthcheck(self): + second = 1000000000 + hc = docker.types.Healthcheck( + test='true', retries=3, timeout=1 * second, + start_period=3 * second, interval=second / 2, + ) + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], healthcheck=hc + ) + 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 ( + 'Healthcheck' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + ) + assert ( + hc == + svc_info['Spec']['TaskTemplate']['ContainerSpec']['Healthcheck'] + ) + + @requires_api_version('1.28') + def test_create_service_with_readonly(self): + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], read_only=True + ) + 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 ( + 'ReadOnly' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + ) + assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['ReadOnly'] + + @requires_api_version('1.28') + def test_create_service_with_stop_signal(self): + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], stop_signal='SIGINT' + ) + 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 ( + 'StopSignal' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + ) + assert ( + svc_info['Spec']['TaskTemplate']['ContainerSpec']['StopSignal'] == + 'SIGINT' + ) diff --git a/tests/integration/base.py b/tests/integration/base.py index 0c0cd0656..701e7fc29 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -29,6 +29,7 @@ def setUp(self): self.tmp_networks = [] self.tmp_plugins = [] self.tmp_secrets = [] + self.tmp_configs = [] def tearDown(self): client = docker.from_env(version=TEST_API_VERSION) @@ -59,6 +60,12 @@ def tearDown(self): except docker.errors.APIError: pass + for config in self.tmp_configs: + try: + client.api.remove_config(config) + except docker.errors.APIError: + pass + for folder in self.tmp_folders: shutil.rmtree(folder)