From f4c9f56a99fdf24c441531fde8820f9214fb5d50 Mon Sep 17 00:00:00 2001 From: Kaleb Pomeroy Date: Wed, 12 Mar 2014 14:36:08 -0500 Subject: [PATCH] Adds the foundation for datastore capabilities This allows for capabilities to be set in the mysql database capabilities table and associated to datastores with the datastore_capabilities table. This allows users to have datastore specific functionality that is configurable and able to be served to end users. This commit is for the groundwork for the capabilities API. Change-Id: I7153d435bf8c00dc2a874a66c7a2b64e8fbafa09 Partially-Implements: blueprint capabilities --- trove/common/exception.py | 10 + trove/datastore/models.py | 285 ++++++++++++++++++ trove/db/sqlalchemy/mappers.py | 4 + .../027_add_datastore_capabilities.py | 61 ++++ trove/tests/unittests/datastore/base.py | 73 +++++ .../unittests/datastore/test_capability.py | 52 ++++ .../unittests/datastore/test_datastore.py | 15 +- .../datastore/test_datastore_versions.py | 49 +++ 8 files changed, 543 insertions(+), 6 deletions(-) create mode 100644 trove/db/sqlalchemy/migrate_repo/versions/027_add_datastore_capabilities.py create mode 100644 trove/tests/unittests/datastore/base.py create mode 100644 trove/tests/unittests/datastore/test_capability.py create mode 100644 trove/tests/unittests/datastore/test_datastore_versions.py diff --git a/trove/common/exception.py b/trove/common/exception.py index 6ebfb3e34f..5cd2f5a796 100644 --- a/trove/common/exception.py +++ b/trove/common/exception.py @@ -65,6 +65,16 @@ class NotFound(TroveError): message = _("Resource %(uuid)s cannot be found") +class CapabilityNotFound(NotFound): + + message = _("Capability '%(capability)s' cannot be found.") + + +class CapabilityDisabled(TroveError): + + message = _("Capability '%(capability)s' is disabled.") + + class FlavorNotFound(TroveError): message = _("Resource %(uuid)s cannot be found") diff --git a/trove/datastore/models.py b/trove/datastore/models.py index 93b0cfee5c..de8ee8f848 100644 --- a/trove/datastore/models.py +++ b/trove/datastore/models.py @@ -32,7 +32,9 @@ db_api = get_db_api() def persisted_models(): return { 'datastore': DBDatastore, + 'capabilities': DBCapabilities, 'datastore_version': DBDatastoreVersion, + 'capability_overrides': DBCapabilityOverrides, } @@ -41,12 +43,284 @@ class DBDatastore(dbmodels.DatabaseModelBase): _data_fields = ['id', 'name', 'default_version_id'] +class DBCapabilities(dbmodels.DatabaseModelBase): + + _data_fields = ['id', 'name', 'description', 'enabled'] + + +class DBCapabilityOverrides(dbmodels.DatabaseModelBase): + + _data_fields = ['id', 'capability_id', 'datastore_version_id', 'enabled'] + + class DBDatastoreVersion(dbmodels.DatabaseModelBase): _data_fields = ['id', 'datastore_id', 'name', 'manager', 'image_id', 'packages', 'active'] +class Capabilities(object): + + def __init__(self, datastore_version_id=None): + self.capabilities = [] + self.datastore_version_id = datastore_version_id + + def __contains__(self, item): + return item in [capability.name for capability in self.capabilities] + + def __len__(self): + return len(self.capabilities) + + def __iter__(self): + for item in self.capabilities: + yield item + + def __repr__(self): + return '<%s: %s>' % (type(self), self.capabilities) + + def add(self, capability, enabled): + """ + Add a capability override to a datastore version. + """ + if self.datastore_version_id is not None: + DBCapabilityOverrides.create( + capability_id=capability.id, + datastore_version_id=self.datastore_version_id, + enabled=enabled) + self._load() + + def _load(self): + """ + Bulk load and override default capabilities with configured + datastore version specific settings. + """ + capability_defaults = [Capability(c) + for c in DBCapabilities.find_all()] + + capability_overrides = [] + if self.datastore_version_id is not None: + # This should always happen but if there is any future case where + # we don't have a datastore version id number it won't stop + # defaults from rendering. + capability_overrides = [ + CapabilityOverride(ce) + for ce in DBCapabilityOverrides.find_all( + datastore_version_id=self.datastore_version_id) + ] + + def override(cap): + # This logic is necessary to apply datastore version specific + # capability overrides when they are present in the database. + for capability_override in capability_overrides: + if cap.id == capability_override.capability_id: + # we have a mapped entity that indicates this datastore + # version has an override so we honor that. + return capability_override + + # There were no overrides for this capability so we just hand it + # right back. + return cap + + self.capabilities = map(override, capability_defaults) + + LOG.debug('Capabilities for datastore %(ds_id)s: %(capabilities)s' % + {'ds_id': self.datastore_version_id, + 'capabilities': self.capabilities}) + + @classmethod + def load(cls, datastore_version_id=None): + """ + Generates a Capabilities object by looking up all capabilities from + defaults and overrides and provides the one structure that should be + used as the interface to controlling capabilities per datastore. + + :returns Capabilities: + """ + self = cls(datastore_version_id) + self._load() + return self + + +class BaseCapability(object): + def __init__(self, db_info): + self.db_info = db_info + + def __repr__(self): + return ('<%(my_class)s: name: %(name)s, enabled: %(enabled)s>' % + {'my_class': type(self), 'name': self.name, + 'enabled': self.enabled}) + + @property + def id(self): + """ + The capability's id + + :returns str: + """ + return self.db_info.id + + @property + def enabled(self): + """ + Is the capability/feature enabled? + + :returns bool: + """ + return self.db_info.enabled + + def enable(self): + """ + Enable the capability. + """ + self.db_info.enabled = True + self.db_info.save() + + def disable(self): + """ + Disable the capability + """ + self.db_info.enabled = False + self.db_info.save() + + def delete(self): + """ + Delete the capability from the database. + """ + + self.db_info.delete() + + +class CapabilityOverride(BaseCapability): + """ + A capability override is simply an setting that applies to a + specific datastore version that overrides the default setting in the + base capability's entry for Trove. + """ + def __init__(self, db_info): + super(CapabilityOverride, self).__init__(db_info) + # This *may* be better solved with a join in the SQLAlchemy model but + # I was unable to get our query object to work properly for this. + parent_capability = Capability.load(db_info.capability_id) + if parent_capability: + self.parent_name = parent_capability.name + self.parent_description = parent_capability.description + else: + raise exception.CapabilityNotFound( + _("Somehow we got a datastore version capability without a " + "parent, that shouldn't happen. %s") % db_info.capability_id) + + @property + def name(self): + """ + The name of the capability. + + :returns str: + """ + return self.parent_name + + @property + def description(self): + """ + The description of the capability. + + :returns str: + """ + return self.parent_description + + @property + def capability_id(self): + """ + Because capability overrides is an association table there are times + where having the capability id is necessary. + + :returns str: + """ + return self.db_info.capability_id + + @classmethod + def load(cls, capability_id): + """ + Generates a CapabilityOverride object from the capability_override id. + + :returns CapabilityOverride: + """ + try: + return cls(DBCapabilityOverrides.find_by( + capability_id=capability_id)) + except exception.ModelNotFoundError: + raise exception.CapabilityNotFound( + _("Capability Override not found for " + "capability %s") % capability_id) + + @classmethod + def create(cls, capability, datastore_version_id, enabled): + """ + Create a new CapabilityOverride. + + :param capability: The capability to be overridden for + this DS Version + :param datastore_version_id: The datastore version to apply the + override to. + :param enabled: Set enabled to True or False + + :returns CapabilityOverride: + """ + + return CapabilityOverride( + DBCapabilityOverrides.create( + capability_id=capability.id, + datastore_version_id=datastore_version_id, + enabled=enabled) + ) + + +class Capability(BaseCapability): + @property + def name(self): + """ + The Capability name + + :returns str: + """ + return self.db_info.name + + @property + def description(self): + """ + The Capability description + + :returns str: + """ + return self.db_info.description + + @classmethod + def load(cls, capability_id_or_name): + """ + Generates a Capability object by looking up the capability first by + ID then by name. + + :returns Capability: + """ + try: + return cls(DBCapabilities.find_by(id=capability_id_or_name)) + except exception.ModelNotFoundError: + try: + return cls(DBCapabilities.find_by(name=capability_id_or_name)) + except exception.ModelNotFoundError: + raise exception.CapabilityNotFound( + capability=capability_id_or_name) + + @classmethod + def create(cls, name, description, enabled=False): + """ + Creates a new capability. + + :returns Capability: + """ + return Capability(DBCapabilities.create( + name=name, description=description, enabled=enabled)) + + class Datastore(object): def __init__(self, db_info): @@ -74,6 +348,9 @@ class Datastore(object): def default_version_id(self): return self.db_info.default_version_id + def delete(self): + self.db_info.delete() + class Datastores(object): @@ -96,6 +373,7 @@ class Datastores(object): class DatastoreVersion(object): def __init__(self, db_info): + self._capabilities = None self.db_info = db_info @classmethod @@ -147,6 +425,13 @@ class DatastoreVersion(object): def manager(self): return self.db_info.manager + @property + def capabilities(self): + if self._capabilities is None: + self._capabilities = Capabilities.load(self.db_info.id) + + return self._capabilities + class DatastoreVersions(object): diff --git a/trove/db/sqlalchemy/mappers.py b/trove/db/sqlalchemy/mappers.py index 6c80a5499c..a36d36b19b 100644 --- a/trove/db/sqlalchemy/mappers.py +++ b/trove/db/sqlalchemy/mappers.py @@ -32,6 +32,10 @@ def map(engine, models): Table('datastores', meta, autoload=True)) orm.mapper(models['datastore_version'], Table('datastore_versions', meta, autoload=True)) + orm.mapper(models['capabilities'], + Table('capabilities', meta, autoload=True)) + orm.mapper(models['capability_overrides'], + Table('capability_overrides', meta, autoload=True)) orm.mapper(models['service_statuses'], Table('service_statuses', meta, autoload=True)) orm.mapper(models['dns_records'], diff --git a/trove/db/sqlalchemy/migrate_repo/versions/027_add_datastore_capabilities.py b/trove/db/sqlalchemy/migrate_repo/versions/027_add_datastore_capabilities.py new file mode 100644 index 0000000000..082e885272 --- /dev/null +++ b/trove/db/sqlalchemy/migrate_repo/versions/027_add_datastore_capabilities.py @@ -0,0 +1,61 @@ +# Copyright (c) 2014 Rackspace Hosting +# +# 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. + +from sqlalchemy import ForeignKey +from sqlalchemy.schema import Column +from sqlalchemy.schema import MetaData +from sqlalchemy.schema import UniqueConstraint + +from trove.db.sqlalchemy.migrate_repo.schema import create_tables +from trove.db.sqlalchemy.migrate_repo.schema import drop_tables +from trove.db.sqlalchemy.migrate_repo.schema import String +from trove.db.sqlalchemy.migrate_repo.schema import Table +from trove.db.sqlalchemy.migrate_repo.schema import Boolean + + +meta = MetaData() + +capabilities = Table( + 'capabilities', + meta, + Column('id', String(36), primary_key=True, nullable=False), + Column('name', String(255), unique=True), + Column('description', String(255), nullable=False), + Column('enabled', Boolean()) +) + + +capability_overrides = Table( + 'capability_overrides', + meta, + Column('id', String(36), primary_key=True, nullable=False), + Column('datastore_version_id', String(36), + ForeignKey('datastore_versions.id')), + Column('capability_id', String(36), ForeignKey('capabilities.id')), + Column('enabled', Boolean()), + UniqueConstraint('datastore_version_id', 'capability_id', + name='idx_datastore_capabilities_enabled') +) + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + Table('datastores', meta, autoload=True) + Table('datastore_versions', meta, autoload=True) + create_tables([capabilities, capability_overrides]) + + +def downgrade(migrate_engine): + meta.bind = migrate_engine + drop_tables([capabilities, capability_overrides]) diff --git a/trove/tests/unittests/datastore/base.py b/trove/tests/unittests/datastore/base.py new file mode 100644 index 0000000000..5c21f3758d --- /dev/null +++ b/trove/tests/unittests/datastore/base.py @@ -0,0 +1,73 @@ +# Copyright (c) 2014 Rackspace Hosting +# +# 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. + +import testtools +from trove.datastore import models as datastore_models +from trove.datastore.models import Datastore +from trove.datastore.models import Capability +from trove.datastore.models import DatastoreVersion +from trove.datastore.models import DBCapabilityOverrides +from trove.tests.unittests.util import util +import uuid + + +class TestDatastoreBase(testtools.TestCase): + + def setUp(self): + # Basic setup and mock/fake structures for testing only + super(TestDatastoreBase, self).setUp() + util.init_db() + self.rand_id = str(uuid.uuid4()) + self.ds_name = "my-test-datastore" + self.rand_id + self.ds_version = "my-test-version" + self.rand_id + self.capability_name = "root_on_create" + self.rand_id + self.capability_desc = "Enables root on create" + self.capability_enabled = True + + datastore_models.update_datastore(self.ds_name, False) + self.datastore = Datastore.load(self.ds_name) + + datastore_models.update_datastore_version( + self.ds_name, self.ds_version, "mysql", "", "", True) + + self.datastore_version = DatastoreVersion.load(self.datastore, + self.ds_version) + self.test_id = self.datastore_version.id + + self.cap1 = Capability.create(self.capability_name, + self.capability_desc, True) + self.cap2 = Capability.create("require_volume" + self.rand_id, + "Require external volume", True) + self.cap3 = Capability.create("test_capability" + self.rand_id, + "Test capability", False) + + def tearDown(self): + super(TestDatastoreBase, self).tearDown() + capabilities_overridden = DBCapabilityOverrides.find_all( + datastore_version_id=self.datastore_version.id).all() + + for ce in capabilities_overridden: + ce.delete() + + self.cap1.delete() + self.cap2.delete() + self.cap3.delete() + Datastore.load(self.ds_name).delete() + + def capability_name_filter(self, capabilities): + new_capabilities = [] + for capability in capabilities: + if self.rand_id in capability.name: + new_capabilities.append(capability) + return new_capabilities diff --git a/trove/tests/unittests/datastore/test_capability.py b/trove/tests/unittests/datastore/test_capability.py new file mode 100644 index 0000000000..0e9f795cf7 --- /dev/null +++ b/trove/tests/unittests/datastore/test_capability.py @@ -0,0 +1,52 @@ +# Copyright (c) 2014 Rackspace Hosting +# +# 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. + +from trove.tests.unittests.datastore.base import TestDatastoreBase +from trove.datastore.models import CapabilityOverride +from trove.datastore.models import Capability +from trove.common.exception import CapabilityNotFound + + +class TestCapabilities(TestDatastoreBase): + def setUp(self): + super(TestCapabilities, self).setUp() + + def tearDown(self): + super(TestCapabilities, self).tearDown() + + def test_capability(self): + cap = Capability.load(self.capability_name) + self.assertEqual(cap.name, self.capability_name) + self.assertEqual(cap.description, self.capability_desc) + self.assertEqual(cap.enabled, self.capability_enabled) + + def test_ds_capability_create_disabled(self): + self.ds_cap = CapabilityOverride.create( + self.cap1, self.datastore_version.id, enabled=False) + self.assertFalse(self.ds_cap.enabled) + + self.ds_cap.delete() + + def test_capability_enabled(self): + self.assertTrue(Capability.load(self.capability_name).enabled) + + def test_capability_disabled(self): + capability = Capability.load(self.capability_name) + capability.disable() + self.assertFalse(capability.enabled) + + self.assertFalse(Capability.load(self.capability_name).enabled) + + def test_load_nonexistent_capability(self): + self.assertRaises(CapabilityNotFound, Capability.load, "non-existent") diff --git a/trove/tests/unittests/datastore/test_datastore.py b/trove/tests/unittests/datastore/test_datastore.py index 8a77cfbe8d..bb2abb7db7 100644 --- a/trove/tests/unittests/datastore/test_datastore.py +++ b/trove/tests/unittests/datastore/test_datastore.py @@ -13,16 +13,19 @@ # License for the specific language governing permissions and limitations # under the License. -from testtools import TestCase from trove.datastore import models as datastore_models -from trove.common.exception import DatastoreDefaultDatastoreNotFound +from trove.datastore.models import Datastore +from trove.tests.unittests.datastore.base import TestDatastoreBase +from trove.common import exception -class TestDatastore(TestCase): - def setUp(self): - super(TestDatastore, self).setUp() +class TestDatastore(TestDatastoreBase): def test_create_failure_with_datastore_default_notfound(self): self.assertRaises( - DatastoreDefaultDatastoreNotFound, + exception.DatastoreDefaultDatastoreNotFound, datastore_models.get_datastore_version) + + def test_load_datastore(self): + datastore = Datastore.load(self.ds_name) + self.assertEqual(datastore.name, self.ds_name) diff --git a/trove/tests/unittests/datastore/test_datastore_versions.py b/trove/tests/unittests/datastore/test_datastore_versions.py new file mode 100644 index 0000000000..355acb6731 --- /dev/null +++ b/trove/tests/unittests/datastore/test_datastore_versions.py @@ -0,0 +1,49 @@ +# Copyright (c) 2014 Rackspace Hosting +# +# 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. + +from trove.datastore.models import DatastoreVersion +from trove.tests.unittests.datastore.base import TestDatastoreBase + + +class TestDatastoreVersions(TestDatastoreBase): + + def test_load_datastore_version(self): + datastore_version = DatastoreVersion.load(self.datastore, + self.ds_version) + self.assertEqual(datastore_version.name, self.ds_version) + + def test_datastore_verison_capabilities(self): + self.datastore_version.capabilities.add(self.cap1, enabled=False) + test_filtered_capabilities = self.capability_name_filter( + self.datastore_version.capabilities) + self.assertEqual(len(test_filtered_capabilities), 3, + 'Capabilities the test thinks it has are: %s, ' + 'Filtered capabilities: %s' % + (self.datastore_version.capabilities, + test_filtered_capabilities)) + + # Test a fresh reloading of the datastore + self.datastore_version = DatastoreVersion.load(self.datastore, + self.ds_version) + test_filtered_capabilities = self.capability_name_filter( + self.datastore_version.capabilities) + self.assertEqual(len(test_filtered_capabilities), 3, + 'Capabilities the test thinks it has are: %s, ' + 'Filtered capabilities: %s' % + (self.datastore_version.capabilities, + test_filtered_capabilities)) + + self.assertIn(self.cap2.name, self.datastore_version.capabilities) + self.assertNotIn("non-existent", self.datastore_version.capabilities) + self.assertIn(self.cap1.name, self.datastore_version.capabilities)