From 610354413e89ca1be6566e98cfa297ada676bc63 Mon Sep 17 00:00:00 2001 From: Matt Van Dijk Date: Thu, 24 Nov 2016 15:25:20 -0500 Subject: [PATCH] Associate datastore, version with volume-type Cinder supports multiple volume types and volume types can be explicitly requested in create requests. This change allows users to restrict the allowed volume types for a given datastore/version in a manner similar to flavors. Co-Authored-By: amrith Change-Id: I790751ade042e271ba1cc902a8ef4d3c3a8dc557 Implements: blueprint associate-volume-type-datastore --- ...olume-type-datastore-97defb9279b61c1f.yaml | 9 + trove/cmd/manage.py | 73 ++++++ trove/common/api.py | 24 ++ trove/common/exception.py | 29 +- trove/datastore/models.py | 248 +++++++++++++++--- trove/datastore/service.py | 15 ++ trove/instance/models.py | 4 + trove/tests/fakes/guestagent.py | 2 +- trove/tests/unittests/datastore/base.py | 3 + .../test_datastore_version_metadata.py | 128 ++++++++- .../instance/test_instance_models.py | 6 +- trove/tests/unittests/volume_type/__init__.py | 0 .../unittests/volume_type/test_volume_type.py | 52 ++++ .../volume_type/test_volume_type_views.py | 60 +++++ trove/volume_type/__init__.py | 0 trove/volume_type/models.py | 74 ++++++ trove/volume_type/service.py | 36 +++ trove/volume_type/views.py | 46 ++++ 18 files changed, 768 insertions(+), 41 deletions(-) create mode 100644 releasenotes/notes/associate-volume-type-datastore-97defb9279b61c1f.yaml create mode 100644 trove/tests/unittests/volume_type/__init__.py create mode 100644 trove/tests/unittests/volume_type/test_volume_type.py create mode 100644 trove/tests/unittests/volume_type/test_volume_type_views.py create mode 100644 trove/volume_type/__init__.py create mode 100644 trove/volume_type/models.py create mode 100644 trove/volume_type/service.py create mode 100644 trove/volume_type/views.py diff --git a/releasenotes/notes/associate-volume-type-datastore-97defb9279b61c1f.yaml b/releasenotes/notes/associate-volume-type-datastore-97defb9279b61c1f.yaml new file mode 100644 index 0000000000..ba23043e4f --- /dev/null +++ b/releasenotes/notes/associate-volume-type-datastore-97defb9279b61c1f.yaml @@ -0,0 +1,9 @@ +--- +features: + - Added the ability to associate datastore versions with volume types. This + enables operators to limit the volume types available when launching + datastores. The associations are set via the trove-manage tool commands + datastore_version_volume_type_add, datastore_version_volume_type_delete, + and datastore_version_volume_type_list. If a user attempts to create an + instance with a volume type that is not on the approved list for the + specified datastore version they will receive an error. diff --git a/trove/cmd/manage.py b/trove/cmd/manage.py index b84f7964f3..cadd21d3f4 100644 --- a/trove/cmd/manage.py +++ b/trove/cmd/manage.py @@ -116,6 +116,52 @@ class Commands(object): except exception.DatastoreVersionNotFound as e: print(e) + def datastore_version_volume_type_add(self, datastore_name, + datastore_version_name, + volume_type_ids): + """Adds volume type assiciation for a given datastore version id.""" + try: + dsmetadata = datastore_models.DatastoreVersionMetadata + dsmetadata.add_datastore_version_volume_type_association( + datastore_name, datastore_version_name, + volume_type_ids.split(",")) + print("Added volume type '%s' to the '%s' '%s'." + % (volume_type_ids, datastore_name, datastore_version_name)) + except exception.DatastoreVersionNotFound as e: + print(e) + + def datastore_version_volume_type_delete(self, datastore_name, + datastore_version_name, + volume_type_id): + """Deletes a volume type association with a given datastore.""" + try: + dsmetadata = datastore_models.DatastoreVersionMetadata + dsmetadata.delete_datastore_version_volume_type_association( + datastore_name, datastore_version_name, volume_type_id) + print("Deleted volume type '%s' from '%s' '%s'." + % (volume_type_id, datastore_name, datastore_version_name)) + except exception.DatastoreVersionNotFound as e: + print(e) + + def datastore_version_volume_type_list(self, datastore_name, + datastore_version_name): + """Lists volume type association with a given datastore.""" + try: + dsmetadata = datastore_models.DatastoreVersionMetadata + vtlist = dsmetadata.list_datastore_volume_type_associations( + datastore_name, datastore_version_name) + if vtlist.count() > 0: + for volume_type in vtlist: + print ("Datastore: %s, Version: %s, Volume Type: %s" % + (datastore_name, datastore_version_name, + volume_type.value)) + else: + print("No Volume Type Associations found for Datastore: %s, " + "Version: %s." % + (datastore_name, datastore_version_name)) + except exception.DatastoreVersionNotFound as e: + print(e) + def params_of(self, command_name): if Commands.has(command_name): return utils.MethodInspector(getattr(self, command_name)) @@ -205,6 +251,33 @@ def main(): 'datastore version.') parser.add_argument('flavor_id', help='The flavor to be deleted for ' 'a given datastore and datastore version.') + parser = subparser.add_parser( + 'datastore_version_volume_type_add', help='Adds volume_type ' + 'association to a given datastore and datastore version.') + parser.add_argument('datastore_name', help='Name of the datastore.') + parser.add_argument('datastore_version_name', help='Name of the ' + 'datastore version.') + parser.add_argument('volume_type_ids', help='Comma separated list of ' + 'volume_type ids.') + + parser = subparser.add_parser( + 'datastore_version_volume_type_delete', + help='Deletes a volume_type ' + 'associated with a given datastore and datastore version.') + parser.add_argument('datastore_name', help='Name of the datastore.') + parser.add_argument('datastore_version_name', help='Name of the ' + 'datastore version.') + parser.add_argument('volume_type_id', help='The volume_type to be ' + 'deleted for a given datastore and datastore ' + 'version.') + + parser = subparser.add_parser( + 'datastore_version_volume_type_list', + help='Lists the volume_types ' + 'associated with a given datastore and datastore version.') + parser.add_argument('datastore_name', help='Name of the datastore.') + parser.add_argument('datastore_version_name', help='Name of the ' + 'datastore version.') cfg.custom_parser('action', actions) cfg.parse_args(sys.argv) diff --git a/trove/common/api.py b/trove/common/api.py index 4fd794e69c..78b67227fa 100644 --- a/trove/common/api.py +++ b/trove/common/api.py @@ -25,6 +25,7 @@ from trove.instance.service import InstanceController from trove.limits.service import LimitsController from trove.module.service import ModuleController from trove.versions import VersionsController +from trove.volume_type.service import VolumeTypesController class API(wsgi.Router): @@ -36,6 +37,7 @@ class API(wsgi.Router): self._cluster_router(mapper) self._datastore_router(mapper) self._flavor_router(mapper) + self._volume_type_router(mapper) self._versions_router(mapper) self._limits_router(mapper) self._backups_router(mapper) @@ -66,6 +68,13 @@ class API(wsgi.Router): action="list_associated_flavors", conditions={'method': ['GET']} ) + mapper.connect( + "/{tenant_id}/datastores/{datastore}/versions/" + "{version_id}/volume-types", + controller=datastore_resource, + action="list_associated_volume_types", + conditions={'method': ['GET']} + ) mapper.connect("/{tenant_id}/datastores/versions/{uuid}", controller=datastore_resource, action="version_show_by_uuid") @@ -168,6 +177,17 @@ class API(wsgi.Router): action="show", conditions={'method': ['GET']}) + def _volume_type_router(self, mapper): + volume_type_resource = VolumeTypesController().create_resource() + mapper.connect("/{tenant_id}/volume-types", + controller=volume_type_resource, + action="index", + conditions={'method': ['GET']}) + mapper.connect("/{tenant_id}/volume-types/{id}", + controller=volume_type_resource, + action="show", + conditions={'method': ['GET']}) + def _limits_router(self, mapper): limits_resource = LimitsController().create_resource() mapper.connect("/{tenant_id}/limits", @@ -227,6 +247,10 @@ class API(wsgi.Router): controller=modules_resource, action="instances", conditions={'method': ['GET']}) + mapper.connect("/{tenant_id}/modules/{id}/instances", + controller=modules_resource, + action="reapply", + conditions={'method': ['PUT']}) def _configurations_router(self, mapper): parameters_resource = ParametersController().create_resource() diff --git a/trove/common/exception.py b/trove/common/exception.py index 020a9b48b7..1662bbe3fb 100644 --- a/trove/common/exception.py +++ b/trove/common/exception.py @@ -121,16 +121,41 @@ class DatastoresNotFound(NotFound): class DatastoreFlavorAssociationNotFound(NotFound): - message = _("Flavor %(flavor_id)s is not supported for datastore " + message = _("Flavor %(id)s is not supported for datastore " "%(datastore)s version %(datastore_version)s") class DatastoreFlavorAssociationAlreadyExists(TroveError): - message = _("Flavor %(flavor_id)s is already associated with " + message = _("Flavor %(id)s is already associated with " "datastore %(datastore)s version %(datastore_version)s") +class DatastoreVolumeTypeAssociationNotFound(NotFound): + + message = _("The volume type %(id)s is not valid for datastore " + "%(datastore)s and version %(version_id)s.") + + +class DatastoreVolumeTypeAssociationAlreadyExists(TroveError): + + message = _("Datastore '%(datastore)s' version %(datastore_version)s " + "and volume-type %(id)s mapping already exists.") + + +class DataStoreVersionVolumeTypeRequired(TroveError): + + message = _("Only specific volume types are allowed for a " + "datastore %(datastore)s version %(datastore_version)s. " + "You must specify a valid volume type.") + + +class DatastoreVersionNoVolumeTypes(TroveError): + + message = _("No valid volume types could be found for datastore " + "%(datastore)s and version %(datastore_version)s.") + + class DatastoreNoVersion(TroveError): message = _("Datastore '%(datastore)s' has no version '%(version)s'.") diff --git a/trove/datastore/models.py b/trove/datastore/models.py index 07d70554d3..9e2e79367d 100644 --- a/trove/datastore/models.py +++ b/trove/datastore/models.py @@ -25,7 +25,7 @@ from trove.common import utils from trove.db import get_db_api from trove.db import models as dbmodels from trove.flavor.models import Flavor as flavor_model - +from trove.volume_type import models as volume_type_models LOG = logging.getLogger(__name__) CONF = cfg.CONF @@ -519,6 +519,38 @@ def get_datastore_version(type=None, version=None, return_inactive=False): return (datastore, datastore_version) +def get_datastore_or_version(datastore=None, datastore_version=None): + """ + Validate that the specified datastore/version exists, and return the + corresponding ids. This differs from 'get_datastore_version' in that + you don't need to specify both - specifying only a datastore will + return 'None' in the ds_ver field. Raises DatastoreNoVersion if + you pass in a ds_ver without a ds. Originally designed for module + management. + + :param datastore: Datastore name or id + :param datastore_version: Version name or id + :return: Tuple of ds_id, ds_ver_id if found + """ + + datastore_id = None + datastore_version_id = None + if datastore: + if datastore_version: + ds, ds_ver = get_datastore_version( + type=datastore, version=datastore_version) + datastore_id = ds.id + datastore_version_id = ds_ver.id + else: + ds = Datastore.load(datastore) + datastore_id = ds.id + elif datastore_version: + # Cannot specify version without datastore. + raise exception.DatastoreNoVersion( + datastore=datastore, version=datastore_version) + return datastore_id, datastore_version_id + + def update_datastore(name, default_version): db_api.configure_db(CONF) try: @@ -562,16 +594,37 @@ def update_datastore_version(datastore, name, manager, image_id, packages, class DatastoreVersionMetadata(object): + @classmethod + def _datastore_version_find(cls, datastore_name, + datastore_version_name): + """ + Helper to find a datastore version id for a given + datastore and datastore version name. + """ + db_api.configure_db(CONF) + db_ds_record = DBDatastore.find_by( + name=datastore_name + ) + db_dsv_record = DBDatastoreVersion.find_by( + datastore_id=db_ds_record.id, + name=datastore_version_name + ) + + return db_dsv_record.id + @classmethod def _datastore_version_metadata_add(cls, datastore_name, datastore_version_name, datastore_version_id, key, value, exception_class): - """Create an entry in the Datastore Version Metadata table.""" - # Do we have a mapping in the db? - # yes: and its deleted then modify the association - # yes: and its not deleted then error on create - # no: then just create the new association + """ + Create a record of the specified key and value in the + metadata table. + """ + # if an association does not exist, create a new one. + # if a deleted association exists, undelete it. + # if an un-deleted association exists, raise an exception. + try: db_record = DBDatastoreVersionMetadata.find_by( datastore_version_id=datastore_version_id, @@ -585,9 +638,11 @@ class DatastoreVersionMetadata(object): raise exception_class( datastore=datastore_name, datastore_version=datastore_version_name, - flavor_id=value) + id=value) except exception.NotFound: pass + + # the record in the database only contains the datastore_verion_id DBDatastoreVersionMetadata.create( datastore_version_id=datastore_version_id, key=key, value=value) @@ -595,8 +650,19 @@ class DatastoreVersionMetadata(object): @classmethod def _datastore_version_metadata_delete(cls, datastore_name, datastore_version_name, - datastore_version_id, key, value, exception_class): + """ + Delete a record of the specified key and value in the + metadata table. + """ + # if an association does not exist, raise an exception + # if a deleted association exists, raise an exception + # if an un-deleted association exists, delete it + + datastore_version_id = cls._datastore_version_find( + datastore_name, + datastore_version_name) + try: db_record = DBDatastoreVersionMetadata.find_by( datastore_version_id=datastore_version_id, @@ -608,26 +674,20 @@ class DatastoreVersionMetadata(object): raise exception_class( datastore=datastore_name, datastore_version=datastore_version_name, - flavor_id=value) + id=value) except exception.ModelNotFoundError: raise exception_class(datastore=datastore_name, datastore_version=datastore_version_name, - flavor_id=value) + id=value) @classmethod def add_datastore_version_flavor_association(cls, datastore_name, datastore_version_name, flavor_ids): - db_api.configure_db(CONF) - db_ds_record = DBDatastore.find_by( - name=datastore_name - ) - db_datastore_id = db_ds_record.id - db_dsv_record = DBDatastoreVersion.find_by( - datastore_id=db_datastore_id, - name=datastore_version_name - ) - datastore_version_id = db_dsv_record.id + datastore_version_id = cls._datastore_version_find( + datastore_name, + datastore_version_name) + for flavor_id in flavor_ids: cls._datastore_version_metadata_add( datastore_name, datastore_version_name, @@ -638,19 +698,8 @@ class DatastoreVersionMetadata(object): def delete_datastore_version_flavor_association(cls, datastore_name, datastore_version_name, flavor_id): - db_api.configure_db(CONF) - db_ds_record = DBDatastore.find_by( - name=datastore_name - ) - db_datastore_id = db_ds_record.id - db_dsv_record = DBDatastoreVersion.find_by( - datastore_id=db_datastore_id, - name=datastore_version_name - ) - datastore_version_id = db_dsv_record.id cls._datastore_version_metadata_delete( - datastore_name, datastore_version_name, - datastore_version_id, 'flavor', flavor_id, + datastore_name, datastore_version_name, 'flavor', flavor_id, exception.DatastoreFlavorAssociationNotFound) @classmethod @@ -689,3 +738,138 @@ class DatastoreVersionMetadata(object): else: msg = _("Specify both the datastore and datastore_version_id.") raise exception.BadRequest(msg) + + @classmethod + def add_datastore_version_volume_type_association(cls, datastore_name, + datastore_version_name, + volume_type_names): + datastore_version_id = cls._datastore_version_find( + datastore_name, + datastore_version_name) + + # the database record will contain + # datastore_version_id, 'volume_type', volume_type_name + for volume_type_name in volume_type_names: + cls._datastore_version_metadata_add( + datastore_name, datastore_version_name, + datastore_version_id, 'volume_type', volume_type_name, + exception.DatastoreVolumeTypeAssociationAlreadyExists) + + @classmethod + def delete_datastore_version_volume_type_association( + cls, datastore_name, + datastore_version_name, + volume_type_name): + cls._datastore_version_metadata_delete( + datastore_name, datastore_version_name, 'volume_type', + volume_type_name, + exception.DatastoreVolumeTypeAssociationNotFound) + + @classmethod + def list_datastore_version_volume_type_associations(cls, + datastore_version_id): + """ + List the datastore associations for a given datastore version id as + found in datastore version metadata. Note that this may return an + empty set (if no associations are provided) + """ + if datastore_version_id: + return DBDatastoreVersionMetadata.find_all( + datastore_version_id=datastore_version_id, + key='volume_type', deleted=False + ) + else: + msg = _("Specify the datastore_version_id.") + raise exception.BadRequest(msg) + + @classmethod + def list_datastore_volume_type_associations(cls, + datastore_name, + datastore_version_name): + """ + List the datastore associations for a given datastore and version. + """ + if datastore_name and datastore_version_name: + datastore_version_id = cls._datastore_version_find( + datastore_name, datastore_version_name) + return cls.list_datastore_version_volume_type_associations( + datastore_version_id) + else: + msg = _("Specify the datastore_name and datastore_version_name.") + raise exception.BadRequest(msg) + + @classmethod + def datastore_volume_type_associations_exist(cls, + datastore_name, + datastore_version_name): + return cls.list_datastore_volume_type_associations( + datastore_name, + datastore_version_name).count() > 0 + + @classmethod + def allowed_datastore_version_volume_types(cls, context, + datastore_name, + datastore_version_name): + """ + List all allowed volume types for a given datastore and + datastore version. If datastore version metadata is + provided, then the valid volume types in that list are + allowed. If datastore version metadata is not provided + then all volume types known to cinder are allowed. + """ + if datastore_name and datastore_version_name: + # first obtain the list in the dsvmetadata + datastore_version_id = cls._datastore_version_find( + datastore_name, datastore_version_name) + + metadata = cls.list_datastore_version_volume_type_associations( + datastore_version_id) + + # then get the list of all volume types + all_volume_types = volume_type_models.VolumeTypes(context) + + # if there's metadata: intersect, + # else, whatever cinder has. + if (metadata.count() != 0): + # the volume types from metadata first + ds_volume_types = tuple(f.value for f in metadata) + + # Cinder volume type names are unique, intersect + allowed_volume_types = tuple( + f for f in all_volume_types + if ((f.name in ds_volume_types) or + (f.id in ds_volume_types))) + else: + allowed_volume_types = tuple(all_volume_types) + + return allowed_volume_types + else: + msg = _("Specify the datastore_name and datastore_version_name.") + raise exception.BadRequest(msg) + + @classmethod + def validate_volume_type(cls, context, volume_type, + datastore_name, datastore_version_name): + if cls.datastore_volume_type_associations_exist( + datastore_name, datastore_version_name): + allowed = cls.allowed_datastore_version_volume_types( + context, datastore_name, datastore_version_name) + if len(allowed) == 0: + raise exception.DatastoreVersionNoVolumeTypes( + datastore=datastore_name, + datastore_version=datastore_version_name) + if volume_type is None: + raise exception.DataStoreVersionVolumeTypeRequired( + datastore=datastore_name, + datastore_version=datastore_version_name) + + allowed_names = tuple(f.name for f in allowed) + for n in allowed_names: + LOG.debug("Volume Type: %s is allowed for datastore " + "%s, version %s." % + (n, datastore_name, datastore_version_name)) + if volume_type not in allowed_names: + raise exception.DatastoreVolumeTypeAssociationNotFound( + datastore=datastore_name, + version_id=datastore_version_name, + id=volume_type) diff --git a/trove/datastore/service.py b/trove/datastore/service.py index 0f69c029f6..24420ed70f 100644 --- a/trove/datastore/service.py +++ b/trove/datastore/service.py @@ -20,6 +20,7 @@ from trove.common import policy from trove.common import wsgi from trove.datastore import models, views from trove.flavor import views as flavor_views +from trove.volume_type import views as volume_type_view class DatastoreController(wsgi.Controller): @@ -90,3 +91,17 @@ class DatastoreController(wsgi.Controller): list_datastore_version_flavor_associations( context, datastore, version_id)) return wsgi.Result(flavor_views.FlavorsView(flavors, req).data(), 200) + + def list_associated_volume_types(self, req, tenant_id, datastore, + version_id): + """ + Return all known volume types if no restrictions have been + established in datastore_version_metadata, otherwise return + that restricted set. + """ + context = req.environ[wsgi.CONTEXT_KEY] + volume_types = (models.DatastoreVersionMetadata. + allowed_datastore_version_volume_types( + context, datastore, version_id)) + return wsgi.Result(volume_type_view.VolumeTypesView( + volume_types, req).data(), 200) diff --git a/trove/instance/models.py b/trove/instance/models.py index b18999bf70..bf3e89c490 100644 --- a/trove/instance/models.py +++ b/trove/instance/models.py @@ -41,6 +41,7 @@ from trove.common.trove_remote import create_trove_client from trove.common import utils from trove.configuration.models import Configuration from trove.datastore import models as datastore_models +from trove.datastore.models import DatastoreVersionMetadata as dvm from trove.datastore.models import DBDatastoreVersionMetadata from trove.db import get_db_api from trove.db import models as dbmodels @@ -879,6 +880,9 @@ class Instance(BuiltInstance): deltas = {'instances': 1} volume_support = datastore_cfg.volume_support if volume_support: + call_args['volume_type'] = volume_type + dvm.validate_volume_type(context, volume_type, + datastore.name, datastore_version.name) call_args['volume_size'] = volume_size validate_volume_size(volume_size) deltas['volumes'] = volume_size diff --git a/trove/tests/fakes/guestagent.py b/trove/tests/fakes/guestagent.py index baf0bee9ca..29cb6cadef 100644 --- a/trove/tests/fakes/guestagent.py +++ b/trove/tests/fakes/guestagent.py @@ -322,7 +322,7 @@ class FakeGuest(object): backup.checksum = 'fake-md5-sum' backup.size = BACKUP_SIZE backup.save() - eventlet.spawn_after(8, finish_create_backup) + eventlet.spawn_after(10, finish_create_backup) def mount_volume(self, device_path=None, mount_point=None): pass diff --git a/trove/tests/unittests/datastore/base.py b/trove/tests/unittests/datastore/base.py index a64b6718e9..4230f1224d 100644 --- a/trove/tests/unittests/datastore/base.py +++ b/trove/tests/unittests/datastore/base.py @@ -37,6 +37,7 @@ class TestDatastoreBase(trove_testtools.TestCase): self.capability_enabled = True self.datastore_version_id = str(uuid.uuid4()) self.flavor_id = 1 + self.volume_type = 'some-valid-volume-type' datastore_models.update_datastore(self.ds_name, False) self.datastore = Datastore.load(self.ds_name) @@ -45,6 +46,8 @@ class TestDatastoreBase(trove_testtools.TestCase): self.ds_name, self.ds_version, "mysql", "", "", True) DatastoreVersionMetadata.add_datastore_version_flavor_association( self.ds_name, self.ds_version, [self.flavor_id]) + DatastoreVersionMetadata.add_datastore_version_volume_type_association( + self.ds_name, self.ds_version, [self.volume_type]) self.datastore_version = DatastoreVersion.load(self.datastore, self.ds_version) diff --git a/trove/tests/unittests/datastore/test_datastore_version_metadata.py b/trove/tests/unittests/datastore/test_datastore_version_metadata.py index cc115c29c9..d6346e8434 100644 --- a/trove/tests/unittests/datastore/test_datastore_version_metadata.py +++ b/trove/tests/unittests/datastore/test_datastore_version_metadata.py @@ -12,7 +12,10 @@ # License for the specific language governing permissions and limitations # under the License. +import mock + from trove.common import exception +from trove.common import remote from trove.datastore import models as datastore_models from trove.tests.unittests.datastore.base import TestDatastoreBase @@ -20,6 +23,12 @@ from trove.tests.unittests.datastore.base import TestDatastoreBase class TestDatastoreVersionMetadata(TestDatastoreBase): def setUp(self): super(TestDatastoreVersionMetadata, self).setUp() + self.dsmetadata = datastore_models.DatastoreVersionMetadata + self.volume_types = [ + {'id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'name': 'type_1'}, + {'id': 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'name': 'type_2'}, + {'id': 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'name': 'type_3'}, + ] def tearDown(self): super(TestDatastoreVersionMetadata, self).tearDown() @@ -35,7 +44,18 @@ class TestDatastoreVersionMetadata(TestDatastoreBase): self.assertEqual(ds_version.id, mapping.datastore_version_id) self.assertEqual('flavor', str(mapping.key)) - def test_add_existing_associations(self): + def test_map_volume_types_to_datastores(self): + datastore = datastore_models.Datastore.load(self.ds_name) + ds_version = datastore_models.DatastoreVersion.load(datastore, + self.ds_version) + mapping = datastore_models.DBDatastoreVersionMetadata.find_by( + datastore_version_id=ds_version.id, + value=self.volume_type, deleted=False, key='volume_type') + self.assertEqual(str(self.volume_type), mapping.value) + self.assertEqual(ds_version.id, mapping.datastore_version_id) + self.assertEqual('volume_type', str(mapping.key)) + + def test_add_existing_flavor_associations(self): dsmetadata = datastore_models.DatastoreVersionMetadata self.assertRaisesRegexp( exception.DatastoreFlavorAssociationAlreadyExists, @@ -44,7 +64,14 @@ class TestDatastoreVersionMetadata(TestDatastoreBase): dsmetadata.add_datastore_version_flavor_association, self.ds_name, self.ds_version, [self.flavor_id]) - def test_delete_nonexistent_mapping(self): + def test_add_existing_volume_type_associations(self): + dsmetadata = datastore_models.DatastoreVersionMetadata + self.assertRaises( + exception.DatastoreVolumeTypeAssociationAlreadyExists, + dsmetadata.add_datastore_version_volume_type_association, + self.ds_name, self.ds_version, [self.volume_type]) + + def test_delete_nonexistent_flavor_mapping(self): dsmeta = datastore_models.DatastoreVersionMetadata self.assertRaisesRegexp( exception.DatastoreFlavorAssociationNotFound, @@ -53,7 +80,15 @@ class TestDatastoreVersionMetadata(TestDatastoreBase): dsmeta.delete_datastore_version_flavor_association, self.ds_name, self.ds_version, flavor_id=2) - def test_delete_mapping(self): + def test_delete_nonexistent_volume_type_mapping(self): + dsmeta = datastore_models.DatastoreVersionMetadata + self.assertRaises( + exception.DatastoreVolumeTypeAssociationNotFound, + dsmeta.delete_datastore_version_volume_type_association, + self.ds_name, self.ds_version, + volume_type_name='some random thing') + + def test_delete_flavor_mapping(self): flavor_id = 2 dsmetadata = datastore_models. DatastoreVersionMetadata dsmetadata.add_datastore_version_flavor_association(self.ds_name, @@ -79,3 +114,90 @@ class TestDatastoreVersionMetadata(TestDatastoreBase): delete_datastore_version_flavor_association(self.ds_name, self.ds_version, flavor_id) + + def test_delete_volume_type_mapping(self): + volume_type = 'this is bogus' + dsmetadata = datastore_models. DatastoreVersionMetadata + dsmetadata.add_datastore_version_volume_type_association( + self.ds_name, + self.ds_version, + [volume_type]) + dsmetadata.delete_datastore_version_volume_type_association( + self.ds_name, + self.ds_version, + volume_type) + datastore = datastore_models.Datastore.load(self.ds_name) + ds_version = datastore_models.DatastoreVersion.load(datastore, + self.ds_version) + mapping = datastore_models.DBDatastoreVersionMetadata.find_by( + datastore_version_id=ds_version.id, value=volume_type, + key='volume_type') + self.assertTrue(mapping.deleted) + # check update + dsmetadata.add_datastore_version_volume_type_association( + self.ds_name, self.ds_version, [volume_type]) + mapping = datastore_models.DBDatastoreVersionMetadata.find_by( + datastore_version_id=ds_version.id, value=volume_type, + key='volume_type') + self.assertFalse(mapping.deleted) + # clear the mapping + dsmetadata.delete_datastore_version_volume_type_association( + self.ds_name, + self.ds_version, + volume_type) + + @mock.patch.object(datastore_models.DatastoreVersionMetadata, + '_datastore_version_find') + @mock.patch.object(datastore_models.DatastoreVersionMetadata, + 'list_datastore_version_volume_type_associations') + @mock.patch.object(remote, 'create_cinder_client') + def _mocked_allowed_datastore_version_volume_types(self, + trove_volume_types, + mock_cinder_client, + mock_list, *args): + """Call this with a list of strings specifying volume types.""" + cinder_vts = [] + for vt in self.volume_types: + cinder_type = mock.Mock() + cinder_type.id = vt.get('id') + cinder_type.name = vt.get('name') + cinder_vts.append(cinder_type) + mock_cinder_client.return_value.volume_types.list.return_value = ( + cinder_vts) + + mock_trove_list_result = mock.MagicMock() + mock_trove_list_result.count.return_value = len(trove_volume_types) + mock_trove_list_result.__iter__.return_value = [] + for trove_vt in trove_volume_types: + trove_type = mock.Mock() + trove_type.value = trove_vt + mock_trove_list_result.__iter__.return_value.append(trove_type) + mock_list.return_value = mock_trove_list_result + + return self.dsmetadata.allowed_datastore_version_volume_types( + None, 'ds', 'dsv') + + def _assert_equal_types(self, test_dict, output_obj): + self.assertEqual(test_dict.get('id'), output_obj.id) + self.assertEqual(test_dict.get('name'), output_obj.name) + + def test_allowed_volume_types_from_ids(self): + id1 = self.volume_types[0].get('id') + id2 = self.volume_types[1].get('id') + res = self._mocked_allowed_datastore_version_volume_types([id1, id2]) + self._assert_equal_types(self.volume_types[0], res[0]) + self._assert_equal_types(self.volume_types[1], res[1]) + + def test_allowed_volume_types_from_names(self): + name1 = self.volume_types[0].get('name') + name2 = self.volume_types[1].get('name') + res = self._mocked_allowed_datastore_version_volume_types([name1, + name2]) + self._assert_equal_types(self.volume_types[0], res[0]) + self._assert_equal_types(self.volume_types[1], res[1]) + + def test_allowed_volume_types_no_restrictions(self): + res = self._mocked_allowed_datastore_version_volume_types([]) + self._assert_equal_types(self.volume_types[0], res[0]) + self._assert_equal_types(self.volume_types[1], res[1]) + self._assert_equal_types(self.volume_types[2], res[2]) diff --git a/trove/tests/unittests/instance/test_instance_models.py b/trove/tests/unittests/instance/test_instance_models.py index 3dbde5a412..2cfdb46255 100644 --- a/trove/tests/unittests/instance/test_instance_models.py +++ b/trove/tests/unittests/instance/test_instance_models.py @@ -374,7 +374,7 @@ class TestReplication(trove_testtools.TestCase): self.master_status.save() self.assertRaises(exception.UnprocessableEntity, Instance.create, - None, 'name', 1, "UUID", [], [], None, + None, 'name', 1, "UUID", [], [], self.datastore, self.datastore_version, 1, None, slave_of_id=self.master.id) @@ -382,7 +382,7 @@ class TestReplication(trove_testtools.TestCase): def test_replica_with_invalid_slave_of_id(self, mock_logging): self.assertRaises(exception.NotFound, Instance.create, - None, 'name', 1, "UUID", [], [], None, + None, 'name', 1, "UUID", [], [], self.datastore, self.datastore_version, 1, None, slave_of_id=str(uuid.uuid4())) @@ -399,6 +399,6 @@ class TestReplication(trove_testtools.TestCase): slave_of_id=self.master.id) self.replica_info.save() self.assertRaises(exception.Forbidden, Instance.create, - None, 'name', 2, "UUID", [], [], None, + None, 'name', 2, "UUID", [], [], self.datastore, self.datastore_version, 1, None, slave_of_id=self.replica_info.id) diff --git a/trove/tests/unittests/volume_type/__init__.py b/trove/tests/unittests/volume_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/trove/tests/unittests/volume_type/test_volume_type.py b/trove/tests/unittests/volume_type/test_volume_type.py new file mode 100644 index 0000000000..47db2fd60c --- /dev/null +++ b/trove/tests/unittests/volume_type/test_volume_type.py @@ -0,0 +1,52 @@ +# Copyright 2016 Tesora, Inc. +# All Rights Reserved. +# +# 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 mock + +from trove.common import remote +from trove.tests.unittests import trove_testtools +from trove.volume_type import models + + +class TestVolumeType(trove_testtools.TestCase): + + def test_volume_type(self): + cinder_volume_type = mock.MagicMock() + cinder_volume_type.id = 123 + cinder_volume_type.name = 'test_type' + cinder_volume_type.is_public = True + cinder_volume_type.description = 'Test volume type' + + volume_type = models.VolumeType(cinder_volume_type) + + self.assertEqual(cinder_volume_type.id, volume_type.id) + self.assertEqual(cinder_volume_type.name, volume_type.name) + self.assertEqual(cinder_volume_type.is_public, volume_type.is_public) + self.assertEqual(cinder_volume_type.description, + volume_type.description) + + @mock.patch.object(remote, 'create_cinder_client') + def test_volume_types(self, mock_client): + mock_context = mock.MagicMock() + mock_types = [mock.MagicMock(), mock.MagicMock()] + + mock_client(mock_context).volume_types.list.return_value = mock_types + + volume_types = models.VolumeTypes(mock_context) + + for i, volume_type in enumerate(volume_types): + self.assertEqual(mock_types[i], volume_type.volume_type, + "Volume type {} does not match.".format(i)) diff --git a/trove/tests/unittests/volume_type/test_volume_type_views.py b/trove/tests/unittests/volume_type/test_volume_type_views.py new file mode 100644 index 0000000000..b81b087abb --- /dev/null +++ b/trove/tests/unittests/volume_type/test_volume_type_views.py @@ -0,0 +1,60 @@ +# Copyright 2016 Tesora, Inc. +# All Rights Reserved. +# +# 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 mock + +from trove.tests.unittests import trove_testtools +from trove.volume_type import views + + +class TestVolumeTypeViews(trove_testtools.TestCase): + + def test_volume_type_view(self): + test_id = 'test_id' + test_name = 'test_name' + test_is_public = True + test_description = 'Test description' + test_req = mock.MagicMock() + + volume_type = mock.MagicMock() + volume_type.id = test_id + volume_type.name = test_name + volume_type.is_public = test_is_public + volume_type.description = test_description + + volume_type_view = views.VolumeTypeView(volume_type, req=test_req) + data = volume_type_view.data() + + self.assertEqual(volume_type, volume_type_view.volume_type) + self.assertEqual(test_req, volume_type_view.req) + self.assertEqual(test_id, data['volume_type']['id']) + self.assertEqual(test_name, data['volume_type']['name']) + self.assertEqual(test_is_public, data['volume_type']['is_public']) + self.assertEqual(test_description, data['volume_type']['description']) + self.assertEqual(test_req, volume_type_view.req) + + @mock.patch.object(views, 'VolumeTypeView') + def test_volume_types_view(self, mock_single_view): + test_type_1 = mock.MagicMock() + test_type_2 = mock.MagicMock() + + volume_types_view = views.VolumeTypesView([test_type_1, test_type_2]) + + self.assertEqual( + {'volume_types': [ + mock_single_view(test_type_1, None).data()['volume_type'], + mock_single_view(test_type_2, None).data()['volume_type']]}, + volume_types_view.data()) diff --git a/trove/volume_type/__init__.py b/trove/volume_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/trove/volume_type/models.py b/trove/volume_type/models.py new file mode 100644 index 0000000000..2b8184244c --- /dev/null +++ b/trove/volume_type/models.py @@ -0,0 +1,74 @@ +# Copyright 2016 Tesora, Inc +# +# All Rights Reserved. +# +# 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. + +"""Model classes that form the core of volume-support functionality""" + +from cinderclient import exceptions as cinder_exception +from trove.common import exception as trove_exception +from trove.common import models +from trove.common import remote + + +class VolumeType(object): + + _data_fields = ['id', 'name', 'is_public', 'description'] + + def __init__(self, volume_type=None): + """Initialize a cinder client volume_type object""" + self.volume_type = volume_type + + @classmethod + def load(cls, volume_type_id, context=None, client=None): + if not(client or context): + raise trove_exception.InvalidModelError( + "client or context must be provided to load a volume_type") + if not client: + client = remote.create_cinder_client(context) + try: + volume_type = client.volume_types.get(volume_type_id) + except cinder_exception.NotFound: + raise trove_exception.NotFound(uuid=volume_type_id) + except cinder_exception.ClientException as ce: + raise trove_exception.TroveError(str(ce)) + return cls(volume_type) + + @property + def id(self): + return self.volume_type.id + + @property + def name(self): + return self.volume_type.name + + @property + def is_public(self): + return self.volume_type.is_public + + @property + def description(self): + return self.volume_type.description + + +class VolumeTypes(models.CinderRemoteModelBase): + + def __init__(self, context): + volume_types = remote.create_cinder_client(context).volume_types.list() + self.volume_types = [VolumeType(volume_type=item) + for item in volume_types] + + def __iter__(self): + for item in self.volume_types: + yield item diff --git a/trove/volume_type/service.py b/trove/volume_type/service.py new file mode 100644 index 0000000000..d265fb7c9f --- /dev/null +++ b/trove/volume_type/service.py @@ -0,0 +1,36 @@ +# Copyright 2016 Tesora, Inc +# +# All Rights Reserved. +# +# 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.common import wsgi +from trove.volume_type import models +from trove.volume_type import views + + +class VolumeTypesController(wsgi.Controller): + """A controller for the Cinder Volume Types functionality.""" + + def show(self, req, tenant_id, id): + """Return a single volume type.""" + context = req.environ[wsgi.CONTEXT_KEY] + volume_type = models.VolumeType.load(id, context=context) + return wsgi.Result(views.VolumeTypeView(volume_type, req).data(), 200) + + def index(self, req, tenant_id): + """Return all volume types.""" + context = req.environ[wsgi.CONTEXT_KEY] + volume_types = models.VolumeTypes(context=context) + return wsgi.Result(views.VolumeTypesView(volume_types, + req).data(), 200) diff --git a/trove/volume_type/views.py b/trove/volume_type/views.py new file mode 100644 index 0000000000..423e00c1a1 --- /dev/null +++ b/trove/volume_type/views.py @@ -0,0 +1,46 @@ +# Copyright 2016 Tesora, Inc +# +# All Rights Reserved. +# +# 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 VolumeTypeView(object): + + def __init__(self, volume_type, req=None): + self.volume_type = volume_type + self.req = req + + def data(self): + volume_type = { + 'id': self.volume_type.id, + 'name': self.volume_type.name, + 'is_public': self.volume_type.is_public, + 'description': self.volume_type.description + } + return {"volume_type": volume_type} + + +class VolumeTypesView(object): + + def __init__(self, volume_types, req=None): + self.volume_types = volume_types + self.req = req + + def data(self): + data = [] + for volume_type in self.volume_types: + data.append(VolumeTypeView(volume_type, + req=self.req).data()['volume_type']) + + return {"volume_types": data}