diff --git a/cinder/tests/unit/test_volume_types.py b/cinder/tests/unit/test_volume_types.py index a4c3451e0d9..0410f68406a 100644 --- a/cinder/tests/unit/test_volume_types.py +++ b/cinder/tests/unit/test_volume_types.py @@ -633,3 +633,49 @@ class VolumeTypeTestCase(test.TestCase): 'volume_type_project.test_suffix', {'volume_type_id': volume_type_id, 'project_id': project_id}) + + def test_provision_filter_on_size(self): + volume_types.create(self.ctxt, "type1", + {"key1": "val1", "key2": "val2"}) + volume_types.create(self.ctxt, "type2", + {volume_types.MIN_SIZE_KEY: "12", + "key3": "val3"}) + volume_types.create(self.ctxt, "type3", + {volume_types.MAX_SIZE_KEY: "99", + "key4": "val4"}) + volume_types.create(self.ctxt, "type4", + {volume_types.MIN_SIZE_KEY: "24", + volume_types.MAX_SIZE_KEY: "99", + "key4": "val4"}) + + # Make sure we don't raise if there are no min/max set + type1 = volume_types.get_by_name_or_id(self.ctxt, 'type1') + volume_types.provision_filter_on_size(self.ctxt, type1, "11") + + # verify minimum size requirements + type2 = volume_types.get_by_name_or_id(self.ctxt, 'type2') + self.assertRaises(exception.InvalidInput, + volume_types.provision_filter_on_size, + self.ctxt, type2, "11") + volume_types.provision_filter_on_size(self.ctxt, type2, "12") + volume_types.provision_filter_on_size(self.ctxt, type2, "100") + + # verify max size requirements + type3 = volume_types.get_by_name_or_id(self.ctxt, 'type3') + self.assertRaises(exception.InvalidInput, + volume_types.provision_filter_on_size, + self.ctxt, type3, "100") + volume_types.provision_filter_on_size(self.ctxt, type3, "99") + volume_types.provision_filter_on_size(self.ctxt, type3, "1") + + # verify min and max + type4 = volume_types.get_by_name_or_id(self.ctxt, 'type4') + self.assertRaises(exception.InvalidInput, + volume_types.provision_filter_on_size, + self.ctxt, type4, "20") + self.assertRaises(exception.InvalidInput, + volume_types.provision_filter_on_size, + self.ctxt, type4, "130") + volume_types.provision_filter_on_size(self.ctxt, type4, "24") + volume_types.provision_filter_on_size(self.ctxt, type4, "99") + volume_types.provision_filter_on_size(self.ctxt, type4, "30") diff --git a/cinder/tests/unit/volume/test_volume.py b/cinder/tests/unit/volume/test_volume.py index 0318a949680..b50758779de 100644 --- a/cinder/tests/unit/volume/test_volume.py +++ b/cinder/tests/unit/volume/test_volume.py @@ -104,6 +104,7 @@ class VolumeTestCase(base.BaseVolumeTestCase): v2_fakes.fake_default_type_get( id=fake.VOLUME_TYPE2_ID)) self.vol_type = db.volume_type_get_by_name(elevated, '__DEFAULT__') + self._setup_volume_types() def _create_volume(self, context, **kwargs): return tests_utils.create_volume( @@ -196,6 +197,26 @@ class VolumeTestCase(base.BaseVolumeTestCase): self.volume.driver._initialized = False self.assertFalse(self.volume.is_working()) + def _create_min_max_size_dict(self, min_size, max_size): + return {volume_types.MIN_SIZE_KEY: min_size, + volume_types.MAX_SIZE_KEY: max_size} + + def _setup_volume_types(self): + """Creates 2 types, one with size limits, one without.""" + + spec_dict = self._create_min_max_size_dict(2, 4) + sized_vol_type_dict = {'name': 'limit', + 'extra_specs': spec_dict} + db.volume_type_create(self.context, sized_vol_type_dict) + self.sized_vol_type = db.volume_type_get_by_name( + self.context, sized_vol_type_dict['name']) + + unsized_vol_type_dict = {'name': 'unsized', 'extra_specs': {}} + db.volume_type_create(context.get_admin_context(), + unsized_vol_type_dict) + self.unsized_vol_type = db.volume_type_get_by_name( + self.context, unsized_vol_type_dict['name']) + @mock.patch('cinder.tests.unit.fake_notifier.FakeNotifier._notify') @mock.patch.object(QUOTAS, 'reserve') @mock.patch.object(QUOTAS, 'commit') @@ -628,6 +649,35 @@ class VolumeTestCase(base.BaseVolumeTestCase): volume_type=db_vol_type) self.assertEqual(db_vol_type.get('id'), volume['volume_type_id']) + @mock.patch('cinder.quota.QUOTAS.rollback', new=mock.MagicMock()) + @mock.patch('cinder.quota.QUOTAS.commit', new=mock.MagicMock()) + @mock.patch('cinder.quota.QUOTAS.reserve', return_value=["RESERVATION"]) + def test_create_volume_with_volume_type_size_limits(self, _mock_reserve): + """Test that volume type size limits are enforced.""" + volume_api = cinder.volume.api.API() + + volume = volume_api.create(self.context, + 2, + 'name', + 'description', + volume_type=self.sized_vol_type) + self.assertEqual(self.sized_vol_type['id'], volume['volume_type_id']) + + self.assertRaises(exception.InvalidInput, + volume_api.create, + self.context, + 1, + 'name', + 'description', + volume_type=self.sized_vol_type) + self.assertRaises(exception.InvalidInput, + volume_api.create, + self.context, + 5, + 'name', + 'description', + volume_type=self.sized_vol_type) + def test_create_volume_with_multiattach_volume_type(self): """Test volume creation with multiattach volume type.""" elevated = context.get_admin_context() @@ -2543,6 +2593,26 @@ class VolumeTestCase(base.BaseVolumeTestCase): # clean up self.volume.delete_volume(self.context, volume) + @mock.patch.object(QUOTAS, 'limit_check') + @mock.patch.object(QUOTAS, 'reserve') + def test_extend_volume_with_volume_type_limit(self, reserve, limit_check): + """Test volume can be extended at API level.""" + volume_api = cinder.volume.api.API() + volume = tests_utils.create_volume( + self.context, size=2, + volume_type_id=self.sized_vol_type['id']) + + volume_api.scheduler_rpcapi = mock.MagicMock() + volume_api.scheduler_rpcapi.extend_volume = mock.MagicMock() + + volume_api._extend(self.context, volume, 3) + + self.assertRaises(exception.InvalidInput, + volume_api._extend, + self.context, + volume, + 5) + def test_extend_volume_driver_not_initialized(self): """Test volume can be extended at API level.""" # create a volume and assign to host diff --git a/cinder/tests/unit/volume/test_volume_retype.py b/cinder/tests/unit/volume/test_volume_retype.py index f9abb401ff6..05503a7d7d4 100644 --- a/cinder/tests/unit/volume/test_volume_retype.py +++ b/cinder/tests/unit/volume/test_volume_retype.py @@ -16,6 +16,7 @@ from unittest import mock from oslo_config import cfg from cinder import context +from cinder import db from cinder import exception from cinder import objects from cinder.policies import volume_actions as vol_action_policies @@ -167,3 +168,56 @@ class VolumeRetypeTestCase(base.BaseVolumeTestCase): volume.refresh() self.assertEqual('available', volume.status) + + def test_retype_with_volume_type_resize_limits(self): + + def _create_min_max_size_dict(min_size, max_size): + return {volume_types.MIN_SIZE_KEY: min_size, + volume_types.MAX_SIZE_KEY: max_size} + + def _setup_volume_types(): + spec_dict = _create_min_max_size_dict(2, 4) + sized_vol_type_dict = {'name': 'limit_type', + 'extra_specs': spec_dict} + db.volume_type_create(self.context, sized_vol_type_dict) + self.sized_vol_type = db.volume_type_get_by_name( + self.context, sized_vol_type_dict['name']) + + unsized_vol_type_dict = {'name': 'unsized_type', 'extra_specs': {}} + db.volume_type_create(context.get_admin_context(), + unsized_vol_type_dict) + self.unsized_vol_type = db.volume_type_get_by_name( + self.context, unsized_vol_type_dict['name']) + + _setup_volume_types() + volume_1 = tests_utils.create_volume( + self.context, + host=CONF.host, + status='available', + volume_type_id=self.default_vol_type.id, + size=1) + volume_3 = tests_utils.create_volume( + self.context, + host=CONF.host, + status='available', + volume_type_id=self.default_vol_type.id, + size=3) + volume_9 = tests_utils.create_volume( + self.context, + host=CONF.host, + status='available', + volume_type_id=self.default_vol_type.id, + size=9) + + self.assertRaises(exception.InvalidInput, + self.volume_api.retype, + self.context, volume_1, + 'limit_type', + migration_policy='on-demand') + self.assertRaises(exception.InvalidInput, + self.volume_api.retype, + self.context, volume_9, + 'limit_type', + migration_policy='on-demand') + self.volume_api.retype(self.context, volume_3, + 'limit_type', migration_policy='on-demand') diff --git a/cinder/volume/api.py b/cinder/volume/api.py index dbe7599cbc2..c132caae464 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -242,6 +242,9 @@ class API(base.Base): 'than zero).') % size raise exception.InvalidInput(reason=msg) + # ensure we pass the volume_type provisioning filter on size + volume_types.provision_filter_on_size(context, volume_type, size) + if consistencygroup and (not cgsnapshot and not source_cg): if not volume_type: msg = _("volume_type must be provided when creating " @@ -1389,6 +1392,14 @@ class API(base.Base): 'size': volume.size}) raise exception.InvalidInput(reason=msg) + # Make sure we pass the potential size limitations in the volume type + try: + volume_type = volume_types.get_volume_type(context, + volume.volume_type_id) + except (exception.InvalidVolumeType, exception.VolumeTypeNotFound): + volume_type = None + volume_types.provision_filter_on_size(context, volume_type, new_size) + result = volume.conditional_update(value, expected) if not result: msg = (_("Volume %(vol_id)s status must be '%(expected)s' " @@ -1635,6 +1646,9 @@ class API(base.Base): new_type_id = new_type['id'] + # Make sure we pass the potential size limitations in the volume type + volume_types.provision_filter_on_size(context, new_type, volume.size) + # NOTE(jdg): We check here if multiattach is involved in either side # of the retype, we can't change multiattach on an in-use volume # because there's things the hypervisor needs when attaching, so diff --git a/cinder/volume/volume_types.py b/cinder/volume/volume_types.py index 5249a044183..319a99593ab 100644 --- a/cinder/volume/volume_types.py +++ b/cinder/volume/volume_types.py @@ -40,6 +40,9 @@ ENCRYPTION_IGNORED_FIELDS = ['volume_type_id', 'created_at', 'updated_at', 'deleted_at', 'encryption_id'] DEFAULT_VOLUME_TYPE = "__DEFAULT__" +MIN_SIZE_KEY = "provisioning:min_vol_size" +MAX_SIZE_KEY = "provisioning:max_vol_size" + def create(context, name, @@ -400,3 +403,38 @@ def volume_types_encryption_changed(context, vol_type_id1, vol_type_id2): enc1_filtered = _get_encryption(enc1) if enc1 else None enc2_filtered = _get_encryption(enc2) if enc2 else None return enc1_filtered != enc2_filtered + + +def provision_filter_on_size(context, volume_type, size): + """This function filters volume provisioning requests on size limits. + + If a volume type has provisioning size min/max set, this filter + will ensure that the volume size requested is within the size + limits specified in the volume type. + """ + + if not volume_type: + volume_type = get_default_volume_type() + + if volume_type: + size_int = int(size) + extra_specs = volume_type.get('extra_specs', {}) + min_size = extra_specs.get(MIN_SIZE_KEY) + if min_size and size_int < int(min_size): + msg = _("Specified volume size of '%(req_size)d' is less " + "than the minimum required size of '%(min_size)s' " + "for volume type '%(vol_type)s'.") % { + 'req_size': size_int, 'min_size': min_size, + 'vol_type': volume_type['name'] + } + raise exception.InvalidInput(reason=msg) + + max_size = extra_specs.get(MAX_SIZE_KEY) + if max_size and size_int > int(max_size): + msg = _("Specified volume size of '%(req_size)d' is " + "greater than the maximum allowable size of " + "'%(max_size)s' for volume type '%(vol_type)s'." + ) % { + 'req_size': size_int, 'max_size': max_size, + 'vol_type': volume_type['name']} + raise exception.InvalidInput(reason=msg) diff --git a/releasenotes/notes/min-max-vol-size-on-type-bc7c75ea73a74d02.yaml b/releasenotes/notes/min-max-vol-size-on-type-bc7c75ea73a74d02.yaml new file mode 100644 index 00000000000..3983d9a72fe --- /dev/null +++ b/releasenotes/notes/min-max-vol-size-on-type-bc7c75ea73a74d02.yaml @@ -0,0 +1,5 @@ +--- +features: + - Ability to add minimum and maximum volume size restrictions which + can be set on a per volume-type granularity. New volume type keys of + 'provisioning:min_vol_size' and 'provisioning:max_vol_size'.