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