From f26dabdbe4ba3aa88ee5ee4dd6037bfdf4fc5074 Mon Sep 17 00:00:00 2001 From: Wenzhi Yu Date: Wed, 31 Aug 2016 16:07:47 +0800 Subject: [PATCH] Add etcd db driver This commit add etcd db driver code and related test cases for etcd database. Note that there still some code are needed to make etcd database backend work appropriately, those code will be added in follow up patches. Part of blueprint etcd-db-driver Change-Id: Ie6b80cb2c3e51808d122241c0f55a1029b8622de --- requirements.txt | 1 + zun/common/singleton.py | 23 ++ zun/conf/database.py | 30 ++- zun/db/api.py | 27 ++- zun/db/etcd/__init__.py | 0 zun/db/etcd/api.py | 265 +++++++++++++++++++++ zun/db/etcd/models.py | 108 +++++++++ zun/db/sqlalchemy/api.py | 4 +- zun/objects/container.py | 4 +- zun/tests/unit/db/test_container.py | 282 ++++++++++++++++++++--- zun/tests/unit/db/utils.py | 5 +- zun/tests/unit/objects/test_container.py | 9 +- 12 files changed, 712 insertions(+), 46 deletions(-) create mode 100644 zun/common/singleton.py create mode 100644 zun/db/etcd/__init__.py create mode 100644 zun/db/etcd/api.py create mode 100644 zun/db/etcd/models.py diff --git a/requirements.txt b/requirements.txt index 056b5cec8..76c754088 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ greenlet>=0.3.2 # MIT jsonpatch>=1.1 # BSD pbr>=1.6 # Apache-2.0 pecan!=1.0.2,!=1.0.3,!=1.0.4,!=1.2,>=1.0.0 # BSD +python-etcd>=0.4.3 # MIT License python-glanceclient>=2.5.0 # Apache-2.0 oslo.i18n>=2.1.0 # Apache-2.0 oslo.log>=3.11.0 # Apache-2.0 diff --git a/zun/common/singleton.py b/zun/common/singleton.py new file mode 100644 index 000000000..1cc593972 --- /dev/null +++ b/zun/common/singleton.py @@ -0,0 +1,23 @@ +# Copyright 2016 IBM, Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super( + Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/zun/conf/database.py b/zun/conf/database.py index eb618b800..b2123e728 100644 --- a/zun/conf/database.py +++ b/zun/conf/database.py @@ -14,16 +14,42 @@ from oslo_config import cfg +from zun.common.i18n import _ + +db_opts = [ + # TODO(yuywz): Change to etcd after all etcd db driver code is landed + cfg.StrOpt('db_type', + default='sql', + help=_('Defines which db type to use for storing container. ' + 'Possible Values: sql, etcd')) +] + sql_opts = [ cfg.StrOpt('mysql_engine', default='InnoDB', - help='MySQL engine to use.') + help=_('MySQL engine to use.')) ] +etcd_opts = [ + cfg.StrOpt('etcd_host', + default='127.0.0.1', + help=_("Host IP address on which etcd service running.")), + cfg.PortOpt('etcd_port', + default=2379, + help=_("Port on which etcd listen client request.")) +] + +etcd_group = cfg.OptGroup(name='etcd', title='Options for etcd connection') + +ALL_OPTS = (db_opts + sql_opts + etcd_opts) + def register_opts(conf): + conf.register_opts(db_opts) conf.register_opts(sql_opts, 'database') + conf.register_group(etcd_group) + conf.register_opts(etcd_opts, etcd_group) def list_opts(): - return {"DEFAULT": sql_opts} + return {"DEFAULT": ALL_OPTS} diff --git a/zun/db/api.py b/zun/db/api.py index 2bb6e187a..a8d30ddbf 100644 --- a/zun/db/api.py +++ b/zun/db/api.py @@ -20,20 +20,30 @@ import abc from oslo_db import api as db_api import six +from zun.common import exception +from zun.common.i18n import _ import zun.conf """Add the database backend mapping here""" +CONF = zun.conf.CONF _BACKEND_MAPPING = {'sqlalchemy': 'zun.db.sqlalchemy.api'} -IMPL = db_api.DBAPI.from_config(zun.conf.CONF, +IMPL = db_api.DBAPI.from_config(CONF, backend_mapping=_BACKEND_MAPPING, lazy=True) def get_instance(): """Return a DB API instance.""" - """Add more judgement for selecting more database backend""" - return IMPL + if CONF.db_type == 'sql': + return IMPL + elif CONF.db_type == 'etcd': + import zun.db.etcd.api as etcd_api + return etcd_api.get_connection() + else: + raise exception.ConfigInvalid( + _("db_type value of %s is invalid, " + "must be sql or etcd") % CONF.db_type) @six.add_metaclass(abc.ABCMeta) @@ -119,24 +129,27 @@ class Connection(object): return dbdriver.get_container_by_name(context, container_name) @classmethod - def destroy_container(self, container_id): + def destroy_container(self, context, container_id): """Destroy a container and all associated interfaces. + :param context: Request context :param container_id: The id or uuid of a container. """ dbdriver = get_instance() - return dbdriver.destroy_container(container_id) + return dbdriver.destroy_container(context, container_id) @classmethod - def update_container(self, container_id, values): + def update_container(self, context, container_id, values): """Update properties of a container. + :context: Request context :param container_id: The id or uuid of a container. + :values: The properties to be updated :returns: A container. :raises: ContainerNotFound """ dbdriver = get_instance() - return dbdriver.update_container(container_id, values) + return dbdriver.update_container(context, container_id, values) @classmethod def destroy_zun_service(self, zun_service_id): diff --git a/zun/db/etcd/__init__.py b/zun/db/etcd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zun/db/etcd/api.py b/zun/db/etcd/api.py new file mode 100644 index 000000000..1d29106cd --- /dev/null +++ b/zun/db/etcd/api.py @@ -0,0 +1,265 @@ +# Copyright 2016 IBM, Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""etcd storage backend.""" + +import json + +import etcd +from oslo_log import log +from oslo_utils import strutils +from oslo_utils import uuidutils +import six + +from zun.common import exception +from zun.common.i18n import _ +from zun.common.i18n import _LE +from zun.common import singleton +import zun.conf +from zun.db.etcd import models + + +LOG = log.getLogger(__name__) + + +def get_connection(): + connection = EtcdAPI(host=zun.conf.CONF.etcd.etcd_host, + port=zun.conf.CONF.etcd.etcd_port) + return connection + + +def clean_all_data(): + conn = get_connection() + conn.clean_all_zun_data() + + +def add_identity_filter(query, value): + """Adds an identity filter to a query. + + Filters results by ID, if supplied value is a valid integer. + Otherwise attempts to filter results by UUID. + + :param query: Initial query to add filter to. + :param value: Value for filtering results by. + :return: Modified query. + """ + if strutils.is_int_like(value): + return query.filter_by(id=value) + elif uuidutils.is_uuid_like(value): + return query.filter_by(uuid=value) + else: + raise exception.InvalidIdentity(identity=value) + + +def translate_etcd_result(etcd_result): + """Translate etcd unicode result to etcd.models.Container.""" + try: + container_data = json.loads(etcd_result.value) + return models.Container(container_data) + except (ValueError, TypeError) as e: + LOG.error(_LE("Error occurred while translating etcd result: %s"), + six.text_type(e)) + raise + + +@six.add_metaclass(singleton.Singleton) +class EtcdAPI(object): + """etcd API.""" + + def __init__(self, host, port): + self.client = etcd.Client(host=host, port=port) + + def clean_all_zun_data(self): + try: + for d in self.client.read('/').children: + if d.key in ('/containers',): + self.client.delete(d.key, recursive=True) + except etcd.EtcdKeyNotFound as e: + LOG.error(_LE('Error occurred while cleaning zun data: %s'), + six.text_type(e)) + raise + + def _add_tenant_filters(self, context, filters): + filters = filters or {} + if context.is_admin and context.all_tenants: + return filters + + if context.project_id: + filters['project_id'] = context.project_id + else: + filters['user_id'] = context.user_id + + return filters + + def _filter_containers(self, containers, filters): + for c in list(containers): + for k, v in six.iteritems(filters): + if c.get(k) != v: + containers.remove(c) + break + + return containers + + def _process_list_result(self, res_list, limit=None, sort_key=None): + sorted_res_list = res_list + if sort_key: + if not hasattr(res_list[0], sort_key): + raise exception.InvalidParameterValue( + err='Container has no attribute: %s' % sort_key) + sorted_res_list = sorted(res_list, key=lambda k: k.get(sort_key)) + + if limit: + sorted_res_list = sorted_res_list[0:limit] + + return sorted_res_list + + def list_container(self, context, filters=None, limit=None, + marker=None, sort_key=None, sort_dir=None): + try: + res = getattr(self.client.read('/containers'), 'children', None) + except etcd.EtcdKeyNotFound as e: + LOG.error(_LE("Error occurred while reading from etcd server: %s"), + six.text_type(e)) + raise + + containers = [] + for c in res: + if c.value is not None: + containers.append(translate_etcd_result(c)) + filters = self._add_tenant_filters(context, filters) + filtered_containers = self._filter_containers( + containers, filters) + return self._process_list_result(filtered_containers, + limit=limit, sort_key=sort_key) + + def create_container(self, container_data): + # ensure defaults are present for new containers + if not container_data.get('uuid'): + container_data['uuid'] = uuidutils.generate_uuid() + + container = models.Container(container_data) + try: + container.save() + except Exception: + raise + + return container + + def get_container_by_id(self, context, container_id): + try: + filters = self._add_tenant_filters( + context, {'id': container_id}) + containers = self.list_container(context, filters=filters) + except etcd.EtcdKeyNotFound: + raise exception.ContainerNotFound(container=container_id) + except Exception as e: + LOG.error(_LE('Error occurred while retrieving container: %s'), + six.text_type(e)) + + if len(containers) == 0: + raise exception.ContainerNotFound(container=container_id) + + return containers[0] + + def get_container_by_uuid(self, context, container_uuid): + try: + res = self.client.read('/containers/' + container_uuid) + container = translate_etcd_result(res) + if container.get('project_id') == context.project_id or \ + container.get('user_id') == context.user_id: + return container + else: + raise exception.ContainerNotFound(container=container_uuid) + except etcd.EtcdKeyNotFound: + raise exception.ContainerNotFound(container=container_uuid) + except Exception as e: + LOG.error(_LE('Error occurred while retrieving container: %s'), + six.text_type(e)) + + def get_container_by_name(self, context, container_name): + try: + filters = self._add_tenant_filters( + context, {'name': container_name}) + containers = self.list_container(context, filters=filters) + except etcd.EtcdKeyNotFound: + raise exception.ContainerNotFound(container=container_name) + except Exception as e: + LOG.error(_LE('Error occurred while retrieving container: %s'), + six.text_type(e)) + + if len(containers) > 1: + raise exception.Conflict('Multiple containers exist with same ' + 'name. Please use the container uuid ' + 'instead.') + elif len(containers) == 0: + raise exception.ContainerNotFound(container=container_name) + + return containers[0] + + def _get_container_by_ident(self, context, container_ident): + try: + if strutils.is_int_like(container_ident): + container = self.get_container_by_id(context, + container_ident) + elif uuidutils.is_uuid_like(container_ident): + container = self.get_container_by_uuid(context, + container_ident) + else: + raise exception.InvalidIdentity(identity=container_ident) + except Exception: + raise + + return container + + def destroy_container(self, context, container_ident): + container = self._get_container_by_ident(context, container_ident) + self.client.delete('/containers/' + container.uuid) + + def update_container(self, context, container_ident, values): + # NOTE(yuywz): Update would fail if any other client + # write '/containers/$CONTAINER_UUID' in the meanwhile + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing Container.") + raise exception.InvalidParameterValue(err=msg) + + try: + target_uuid = self._get_container_by_ident( + context, container_ident).uuid + target = self.client.read('/containers/' + target_uuid) + target_value = json.loads(target.value) + target_value.update(values) + target.value = json.dumps(target_value) + self.client.update(target) + except Exception: + raise + + return translate_etcd_result(target) + + # TODO(yuywz): following method for zun_service will be implemented + # in follow up patch. + def destroy_zun_service(self, zun_service_id): + pass + + def update_zun_service(self, zun_service_id, values): + pass + + def get_zun_service_by_host_and_binary(self, context, host, binary): + pass + + def create_zun_service(self, values): + pass + + def get_zun_service_list(self, context, disabled=None, limit=None, + marker=None, sort_key=None, sort_dir=None): + pass diff --git a/zun/db/etcd/models.py b/zun/db/etcd/models.py new file mode 100644 index 000000000..9bbfd1e0a --- /dev/null +++ b/zun/db/etcd/models.py @@ -0,0 +1,108 @@ +# Copyright 2016 IBM, Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +etcd models +""" + +import etcd +import six + +from zun.common import exception +from zun import objects + + +class Base(object): + + def __setitem__(self, key, value): + setattr(self, key, value) + + def __getitem__(self, key): + return getattr(self, key) + + def get(self, key): + return getattr(self, key) + + def etcd_path(self, sub_path): + return self.path + '/' + sub_path + + def as_dict(self): + d = {} + for f in self._fields: + d[f] = getattr(self, f, None) + + return d + + def update(self, values): + """Make the model object behave like a dict.""" + for k, v in six.iteritems(values): + setattr(self, k, v) + + def save(self, session=None): + import zun.db.etcd.api as db_api + + if session is None: + session = db_api.get_connection() + + try: + session.client.read(self.etcd_path(self.uuid)) + except etcd.EtcdKeyNotFound: + session.client.write(self.etcd_path(self.uuid), self.as_dict()) + return + + raise exception.ContainerAlreadyExists(uuid=self.uuid) + + +class ZunService(Base): + """Represents health status of various zun services""" + + _path = '/zun_service' + + _fields = objects.ZunService.fields.keys() + + def __init__(self, service_data): + self.path = ZunService.path() + for f in ZunService.fields(): + setattr(self, f, None) + self.update(service_data) + + @classmethod + def path(cls): + return cls._path + + @classmethod + def fields(cls): + return cls._fields + + +class Container(Base): + """Represents a container.""" + + _path = '/containers' + + _fields = objects.Container.fields.keys() + + def __init__(self, container_data): + self.path = Container.path() + for f in Container.fields(): + setattr(self, f, None) + self.update(container_data) + + @classmethod + def path(cls): + return cls._path + + @classmethod + def fields(cls): + return cls._fields diff --git a/zun/db/sqlalchemy/api.py b/zun/db/sqlalchemy/api.py index 44c95e34c..3b002f146 100644 --- a/zun/db/sqlalchemy/api.py +++ b/zun/db/sqlalchemy/api.py @@ -183,7 +183,7 @@ class Connection(api.Connection): 'name. Please use the container uuid ' 'instead.') - def destroy_container(self, container_id): + def destroy_container(self, context, container_id): session = get_session() with session.begin(): query = model_query(models.Container, session=session) @@ -192,7 +192,7 @@ class Connection(api.Connection): if count != 1: raise exception.ContainerNotFound(container_id) - def update_container(self, container_id, values): + def update_container(self, context, container_id, values): # NOTE(dtantsur): this can lead to very strange errors if 'uuid' in values: msg = _("Cannot overwrite UUID for an existing Container.") diff --git a/zun/objects/container.py b/zun/objects/container.py index afe4e9573..df75d5c8e 100644 --- a/zun/objects/container.py +++ b/zun/objects/container.py @@ -148,7 +148,7 @@ class Container(base.ZunPersistentObject, base.ZunObject, A context should be set when instantiating the object, e.g.: Container(context) """ - dbapi.Connection.destroy_container(self.uuid) + dbapi.Connection.destroy_container(context, self.uuid) self.obj_reset_changes() @base.remotable @@ -166,7 +166,7 @@ class Container(base.ZunPersistentObject, base.ZunObject, object, e.g.: Container(context) """ updates = self.obj_get_changes() - dbapi.Connection.update_container(self.uuid, updates) + dbapi.Connection.update_container(context, self.uuid, updates) self.obj_reset_changes() diff --git a/zun/tests/unit/db/test_container.py b/zun/tests/unit/db/test_container.py index 31f5d5af1..2e49b7ad0 100644 --- a/zun/tests/unit/db/test_container.py +++ b/zun/tests/unit/db/test_container.py @@ -11,10 +11,17 @@ # under the License. """Tests for manipulating Containers via the DB API""" +import json +import mock + +import etcd +from etcd import Client as etcd_client +from oslo_config import cfg from oslo_utils import uuidutils import six from zun.common import exception +from zun.db import api as dbapi from zun.tests.unit.db import base from zun.tests.unit.db import utils @@ -26,34 +33,35 @@ class DbContainerTestCase(base.DbTestCase): def test_create_container_already_exists(self): utils.create_test_container() - self.assertRaises(exception.ResourceExists, + self.assertRaises(exception.ContainerAlreadyExists, utils.create_test_container) def test_get_container_by_id(self): container = utils.create_test_container() - res = self.dbapi.get_container_by_id(self.context, container.id) + res = dbapi.Connection.get_container_by_id(self.context, container.id) self.assertEqual(container.id, res.id) self.assertEqual(container.uuid, res.uuid) def test_get_container_by_uuid(self): container = utils.create_test_container() - res = self.dbapi.get_container_by_uuid(self.context, - container.uuid) + res = dbapi.Connection.get_container_by_uuid(self.context, + container.uuid) self.assertEqual(container.id, res.id) self.assertEqual(container.uuid, res.uuid) def test_get_container_by_name(self): container = utils.create_test_container() - res = self.dbapi.get_container_by_name(self.context, - container.name) + res = dbapi.Connection.get_container_by_name( + self.context, container.name) self.assertEqual(container.id, res.id) self.assertEqual(container.uuid, res.uuid) def test_get_container_that_does_not_exist(self): self.assertRaises(exception.ContainerNotFound, - self.dbapi.get_container_by_id, self.context, 99) + dbapi.Connection.get_container_by_id, + self.context, 99) self.assertRaises(exception.ContainerNotFound, - self.dbapi.get_container_by_uuid, + dbapi.Connection.get_container_by_uuid, self.context, uuidutils.generate_uuid()) @@ -63,7 +71,7 @@ class DbContainerTestCase(base.DbTestCase): container = utils.create_test_container( uuid=uuidutils.generate_uuid()) uuids.append(six.text_type(container['uuid'])) - res = self.dbapi.list_container(self.context) + res = dbapi.Connection.list_container(self.context) res_uuids = [r.uuid for r in res] self.assertEqual(sorted(uuids), sorted(res_uuids)) @@ -73,12 +81,12 @@ class DbContainerTestCase(base.DbTestCase): container = utils.create_test_container( uuid=uuidutils.generate_uuid()) uuids.append(six.text_type(container.uuid)) - res = self.dbapi.list_container(self.context, sort_key='uuid') + res = dbapi.Connection.list_container(self.context, sort_key='uuid') res_uuids = [r.uuid for r in res] self.assertEqual(sorted(uuids), res_uuids) self.assertRaises(exception.InvalidParameterValue, - self.dbapi.list_container, + dbapi.Connection.list_container, self.context, sort_key='foo') @@ -90,40 +98,40 @@ class DbContainerTestCase(base.DbTestCase): name='container-two', uuid=uuidutils.generate_uuid()) - res = self.dbapi.list_container(self.context, - filters={'name': 'container-one'}) + res = dbapi.Connection.list_container( + self.context, filters={'name': 'container-one'}) self.assertEqual([container1.id], [r.id for r in res]) - res = self.dbapi.list_container(self.context, - filters={'name': 'container-two'}) + res = dbapi.Connection.list_container( + self.context, filters={'name': 'container-two'}) self.assertEqual([container2.id], [r.id for r in res]) - res = self.dbapi.list_container(self.context, - filters={'name': 'bad-container'}) + res = dbapi.Connection.list_container( + self.context, filters={'name': 'bad-container'}) self.assertEqual([], [r.id for r in res]) - res = self.dbapi.list_container( + res = dbapi.Connection.list_container( self.context, filters={'name': container1.name}) self.assertEqual([container1.id], [r.id for r in res]) def test_destroy_container(self): container = utils.create_test_container() - self.dbapi.destroy_container(container.id) + dbapi.Connection.destroy_container(self.context, container.id) self.assertRaises(exception.ContainerNotFound, - self.dbapi.get_container_by_id, + dbapi.Connection.get_container_by_id, self.context, container.id) def test_destroy_container_by_uuid(self): container = utils.create_test_container() - self.dbapi.destroy_container(container.uuid) + dbapi.Connection.destroy_container(self.context, container.uuid) self.assertRaises(exception.ContainerNotFound, - self.dbapi.get_container_by_uuid, + dbapi.Connection.get_container_by_uuid, self.context, container.uuid) def test_destroy_container_that_does_not_exist(self): self.assertRaises(exception.ContainerNotFound, - self.dbapi.destroy_container, + dbapi.Connection.destroy_container, self.context, uuidutils.generate_uuid()) def test_update_container(self): @@ -132,19 +140,237 @@ class DbContainerTestCase(base.DbTestCase): new_image = 'new-image' self.assertNotEqual(old_image, new_image) - res = self.dbapi.update_container(container.id, - {'image': new_image}) + res = dbapi.Connection.update_container(self.context, container.id, + {'image': new_image}) self.assertEqual(new_image, res.image) def test_update_container_not_found(self): container_uuid = uuidutils.generate_uuid() new_image = 'new-image' self.assertRaises(exception.ContainerNotFound, - self.dbapi.update_container, + dbapi.Connection.update_container, self.context, container_uuid, {'image': new_image}) def test_update_container_uuid(self): container = utils.create_test_container() self.assertRaises(exception.InvalidParameterValue, - self.dbapi.update_container, container.id, - {'uuid': ''}) + dbapi.Connection.update_container, self.context, + container.id, {'uuid': ''}) + + +class FakeEtcdMutlipleResult(object): + + def __init__(self, value): + self.children = [] + for v in value: + res = mock.MagicMock() + res.value = json.dumps(v) + self.children.append(res) + + +class FakeEtcdResult(object): + + def __init__(self, value): + self.value = json.dumps(value) + + +class EtcdDbContainerTestCase(DbContainerTestCase): + + def setUp(self): + cfg.CONF.set_override('db_type', 'etcd') + super(EtcdDbContainerTestCase, self).setUp() + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_create_container(self, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + utils.create_test_container() + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_create_container_already_exists(self, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + utils.create_test_container() + mock_read.side_effect = lambda *args: None + self.assertRaises(exception.ContainerAlreadyExists, + utils.create_test_container) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_get_container_by_id(self, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + container = utils.create_test_container() + mock_read.side_effect = lambda *args: FakeEtcdMutlipleResult( + [container.as_dict()]) + res = dbapi.Connection.get_container_by_id(self.context, container.id) + self.assertEqual(container.id, res.id) + self.assertEqual(container.uuid, res.uuid) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_get_container_by_uuid(self, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + container = utils.create_test_container() + mock_read.side_effect = lambda *args: FakeEtcdResult( + container.as_dict()) + res = dbapi.Connection.get_container_by_uuid(self.context, + container.uuid) + self.assertEqual(container.id, res.id) + self.assertEqual(container.uuid, res.uuid) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_get_container_by_name(self, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + container = utils.create_test_container() + mock_read.side_effect = lambda *args: FakeEtcdMutlipleResult( + [container.as_dict()]) + res = dbapi.Connection.get_container_by_name( + self.context, container.name) + self.assertEqual(container.id, res.id) + self.assertEqual(container.uuid, res.uuid) + + @mock.patch.object(etcd_client, 'read') + def test_get_container_that_does_not_exist(self, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + self.assertRaises(exception.ContainerNotFound, + dbapi.Connection.get_container_by_id, + self.context, 99) + self.assertRaises(exception.ContainerNotFound, + dbapi.Connection.get_container_by_uuid, + self.context, + uuidutils.generate_uuid()) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_list_container(self, mock_write, mock_read): + uuids = [] + containers = [] + mock_read.side_effect = etcd.EtcdKeyNotFound + for i in range(1, 6): + container = utils.create_test_container( + uuid=uuidutils.generate_uuid()) + containers.append(container.as_dict()) + uuids.append(six.text_type(container['uuid'])) + mock_read.side_effect = lambda *args: FakeEtcdMutlipleResult( + containers) + res = dbapi.Connection.list_container(self.context) + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), sorted(res_uuids)) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_list_container_sorted(self, mock_write, mock_read): + uuids = [] + containers = [] + mock_read.side_effect = etcd.EtcdKeyNotFound + for _ in range(5): + container = utils.create_test_container( + uuid=uuidutils.generate_uuid()) + containers.append(container.as_dict()) + uuids.append(six.text_type(container.uuid)) + mock_read.side_effect = lambda *args: FakeEtcdMutlipleResult( + containers) + res = dbapi.Connection.list_container(self.context, sort_key='uuid') + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), res_uuids) + + self.assertRaises(exception.InvalidParameterValue, + dbapi.Connection.list_container, + self.context, + sort_key='foo') + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_list_container_with_filters(self, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + + container1 = utils.create_test_container( + name='container-one', + uuid=uuidutils.generate_uuid()) + container2 = utils.create_test_container( + name='container-two', + uuid=uuidutils.generate_uuid()) + + mock_read.side_effect = lambda *args: FakeEtcdMutlipleResult( + [container1.as_dict(), container2.as_dict()]) + + res = dbapi.Connection.list_container( + self.context, filters={'name': 'container-one'}) + self.assertEqual([container1.id], [r.id for r in res]) + + res = dbapi.Connection.list_container( + self.context, filters={'name': 'container-two'}) + self.assertEqual([container2.id], [r.id for r in res]) + + res = dbapi.Connection.list_container( + self.context, filters={'name': 'container-three'}) + self.assertEqual([], [r.id for r in res]) + + res = dbapi.Connection.list_container( + self.context, + filters={'name': container1.name}) + self.assertEqual([container1.id], [r.id for r in res]) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + @mock.patch.object(etcd_client, 'delete') + def test_destroy_container(self, mock_delete, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + container = utils.create_test_container() + mock_read.side_effect = lambda *args: FakeEtcdMutlipleResult( + [container.as_dict()]) + dbapi.Connection.destroy_container(self.context, container.id) + mock_delete.assert_called_once_with('/containers/%s' % container.uuid) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + @mock.patch.object(etcd_client, 'delete') + def test_destroy_container_by_uuid(self, mock_delete, + mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + container = utils.create_test_container() + mock_read.side_effect = lambda *args: FakeEtcdResult( + container.as_dict()) + dbapi.Connection.destroy_container(self.context, container.uuid) + mock_delete.assert_called_once_with('/containers/%s' % container.uuid) + + @mock.patch.object(etcd_client, 'read') + def test_destroy_container_that_does_not_exist(self, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + self.assertRaises(exception.ContainerNotFound, + dbapi.Connection.destroy_container, self.context, + uuidutils.generate_uuid()) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + @mock.patch.object(etcd_client, 'update') + def test_update_container(self, mock_update, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + container = utils.create_test_container() + new_image = 'new-image' + + mock_read.side_effect = lambda *args: FakeEtcdResult( + container.as_dict()) + dbapi.Connection.update_container(self.context, container.uuid, + {'image': new_image}) + self.assertEqual(new_image, json.loads( + mock_update.call_args_list[0][0][0].value)['image']) + + @mock.patch.object(etcd_client, 'read') + def test_update_container_not_found(self, mock_read): + container_uuid = uuidutils.generate_uuid() + new_image = 'new-image' + mock_read.side_effect = etcd.EtcdKeyNotFound + self.assertRaises(exception.ContainerNotFound, + dbapi.Connection.update_container, self.context, + container_uuid, {'image': new_image}) + + @mock.patch.object(etcd_client, 'read') + @mock.patch.object(etcd_client, 'write') + def test_update_container_uuid(self, mock_write, mock_read): + mock_read.side_effect = etcd.EtcdKeyNotFound + container = utils.create_test_container() + self.assertRaises(exception.InvalidParameterValue, + dbapi.Connection.update_container, self.context, + container.id, {'uuid': ''}) diff --git a/zun/tests/unit/db/utils.py b/zun/tests/unit/db/utils.py index 03987e532..7821622a6 100644 --- a/zun/tests/unit/db/utils.py +++ b/zun/tests/unit/db/utils.py @@ -11,10 +11,13 @@ # under the License. """Zun test utilities.""" +from oslo_config import cfg from zun.common import name_generator from zun.db import api as db_api +CONF = cfg.CONF + def get_test_container(**kw): return { @@ -50,7 +53,7 @@ def create_test_container(**kw): """ container = get_test_container(**kw) # Let DB generate ID if it isn't specified explicitly - if 'id' not in kw: + if CONF.db_type == 'sql' and 'id' not in kw: del container['id'] dbapi = db_api.get_instance() return dbapi.create_container(container) diff --git a/zun/tests/unit/objects/test_container.py b/zun/tests/unit/objects/test_container.py index 1b0c49f82..2efc27876 100644 --- a/zun/tests/unit/objects/test_container.py +++ b/zun/tests/unit/objects/test_container.py @@ -111,7 +111,7 @@ class TestContainerObject(base.DbTestCase): container = objects.Container.get_by_uuid(self.context, uuid) container.destroy() mock_get_container.assert_called_once_with(self.context, uuid) - mock_destroy_container.assert_called_once_with(uuid) + mock_destroy_container.assert_called_once_with(None, uuid) self.assertEqual(self.context, container._context) def test_save(self): @@ -129,9 +129,10 @@ class TestContainerObject(base.DbTestCase): mock_get_container.assert_called_once_with(self.context, uuid) mock_update_container.assert_called_once_with( - uuid, {'image': 'container.img', - 'environment': {"key1": "val", "key2": "val2"}, - 'memory': '512m'}) + None, uuid, + {'image': 'container.img', + 'environment': {"key1": "val", "key2": "val2"}, + 'memory': '512m'}) self.assertEqual(self.context, container._context) def test_refresh(self):