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)