diff --git a/cinder/api/microversions.py b/cinder/api/microversions.py index 7624e4e995c..3db7c4e8985 100644 --- a/cinder/api/microversions.py +++ b/cinder/api/microversions.py @@ -141,6 +141,8 @@ MULTIATTACH_VOLUMES = '3.50' BACKUP_AZ = '3.51' +SUPPORT_VOLUME_TYPE_FILTER = '3.52' + def get_mv_header(version): """Gets a formatted HTTP microversion header. diff --git a/cinder/api/openstack/api_version_request.py b/cinder/api/openstack/api_version_request.py index 3756cae01ee..f41b4730122 100644 --- a/cinder/api/openstack/api_version_request.py +++ b/cinder/api/openstack/api_version_request.py @@ -115,6 +115,9 @@ REST_API_VERSION_HISTORY = """ * 3.49 - Support report backend storage state in service list. * 3.50 - Add multiattach capability * 3.51 - Add support for cross AZ backups. + * 3.52 - ``RESKEY:availability_zones`` is a reserved spec key for AZ + volume type, and filter volume type by ``extra_specs`` is + supported now. """ # The minimum and maximum versions of the API supported @@ -122,7 +125,7 @@ REST_API_VERSION_HISTORY = """ # minimum version of the API supported. # Explicitly using /v2 endpoints will still work _MIN_API_VERSION = "3.0" -_MAX_API_VERSION = "3.51" +_MAX_API_VERSION = "3.52" _LEGACY_API_VERSION2 = "2.0" UPDATED = "2017-09-19T20:18:14Z" diff --git a/cinder/api/openstack/rest_api_version_history.rst b/cinder/api/openstack/rest_api_version_history.rst index d1b60cea5ca..8efad41b40e 100644 --- a/cinder/api/openstack/rest_api_version_history.rst +++ b/cinder/api/openstack/rest_api_version_history.rst @@ -404,3 +404,8 @@ be used as a way to query if the capability exists in the Cinder service. 3.51 ---- Add support for cross AZ backups. + +3.52 +---- +``RESKEY:availability_zones`` is a reserved spec key for AZ volume type, +and filter volume type by ``extra_specs`` is supported now. diff --git a/cinder/api/v2/types.py b/cinder/api/v2/types.py index 8f3a29407e5..772f26286f6 100644 --- a/cinder/api/v2/types.py +++ b/cinder/api/v2/types.py @@ -15,10 +15,14 @@ """The volume type & volume types extra specs extension.""" -from oslo_utils import strutils +import ast from webob import exc +from oslo_log import log as logging +from oslo_utils import strutils + from cinder.api import common +from cinder.api import microversions as mv from cinder.api.openstack import wsgi from cinder.api.v2.views import types as views_types from cinder import exception @@ -26,6 +30,8 @@ from cinder.i18n import _ from cinder import utils from cinder.volume import volume_types +LOG = logging.getLogger(__name__) + class VolumeTypesController(wsgi.Controller): """The volume types API controller for the OpenStack API.""" @@ -76,26 +82,42 @@ class VolumeTypesController(wsgi.Controller): msg = _('Invalid is_public filter [%s]') % is_public raise exc.HTTPBadRequest(explanation=msg) + @common.process_general_filtering('volume_type') + def _process_volume_type_filtering(self, context=None, filters=None, + req_version=None): + utils.remove_invalid_filter_options(context, + filters, + self._get_vol_type_filter_options() + ) + def _get_volume_types(self, req): """Helper function that returns a list of type dicts.""" params = req.params.copy() marker, limit, offset = common.get_pagination_params(params) sort_keys, sort_dirs = common.get_sort_params(params) - # NOTE(wanghao): Currently, we still only support to filter by - # is_public. If we want to filter by more args, we should set params - # to filters. - filters = {} + filters = params context = req.environ['cinder.context'] + req_version = req.api_version_request + if req_version.matches(mv.SUPPORT_VOLUME_TYPE_FILTER): + self._process_volume_type_filtering(context=context, + filters=filters, + req_version=req_version) + else: + utils.remove_invalid_filter_options( + context, filters, self._get_vol_type_filter_options()) if context.is_admin: # Only admin has query access to all volume types filters['is_public'] = self._parse_is_public( req.params.get('is_public', None)) else: filters['is_public'] = True - utils.remove_invalid_filter_options(context, - filters, - self._get_vol_type_filter_options() - ) + if 'extra_specs' in filters: + try: + filters['extra_specs'] = ast.literal_eval( + filters['extra_specs']) + except (ValueError, SyntaxError): + LOG.debug('Could not evaluate "extra_specs" %s, assuming ' + 'dictionary string.', filters['extra_specs']) limited_types = volume_types.get_all_types(context, filters=filters, marker=marker, limit=limit, diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index 18ce053b0fb..f47145bb509 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -3606,8 +3606,16 @@ def _process_volume_types_filters(query, filters): searchdict = filters.pop('extra_specs') extra_specs = getattr(models.VolumeTypes, 'extra_specs') for k, v in searchdict.items(): - the_filter.extend([extra_specs.any(key=k, value=v, - deleted=False)]) + # NOTE(tommylikehu): We will use 'LIKE' operator for + # 'availability_zones' extra spec as it always store the + # AZ list info within the format: "az1, az2,...." + if k == 'RESKEY:availability_zones': + the_filter.extend([extra_specs.any( + models.VolumeTypeExtraSpecs.value.like(u'%%%s%%' % v), + key=k, deleted=False)]) + else: + the_filter.extend( + [extra_specs.any(key=k, value=v, deleted=False)]) if len(the_filter) > 1: query = query.filter(and_(*the_filter)) else: diff --git a/cinder/exception.py b/cinder/exception.py index dbc7c6d6d84..fd2ef78b572 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -207,6 +207,10 @@ class InvalidAvailabilityZone(Invalid): message = _("Availability zone '%(az)s' is invalid.") +class InvalidTypeAvailabilityZones(Invalid): + message = _("Volume type's availability zones are invalid %(az)s.") + + class InvalidVolumeType(Invalid): message = _("Invalid volume type: %(reason)s") diff --git a/cinder/objects/base.py b/cinder/objects/base.py index 7d81b21c84e..93f2dfb8104 100644 --- a/cinder/objects/base.py +++ b/cinder/objects/base.py @@ -144,6 +144,7 @@ OBJ_VERSIONS.add('1.33', {'Volume': '1.8'}) OBJ_VERSIONS.add('1.34', {'VolumeAttachment': '1.3'}) OBJ_VERSIONS.add('1.35', {'Backup': '1.6', 'BackupImport': '1.6'}) OBJ_VERSIONS.add('1.36', {'RequestSpec': '1.4'}) +OBJ_VERSIONS.add('1.37', {'RequestSpec': '1.5'}) class CinderObjectRegistry(base.VersionedObjectRegistry): diff --git a/cinder/objects/request_spec.py b/cinder/objects/request_spec.py index d28134a1429..98249ee48fd 100644 --- a/cinder/objects/request_spec.py +++ b/cinder/objects/request_spec.py @@ -27,7 +27,8 @@ class RequestSpec(base.CinderObject, base.CinderObjectDictCompat, # Version 1.2 Added ``resource_backend`` # Version 1.3: Added backup_id # Version 1.4: Add 'operation' - VERSION = '1.4' + # Version 1.5: Added 'availability_zones' + VERSION = '1.5' fields = { 'consistencygroup_id': fields.UUIDField(nullable=True), @@ -47,6 +48,7 @@ class RequestSpec(base.CinderObject, base.CinderObjectDictCompat, 'resource_backend': fields.StringField(nullable=True), 'backup_id': fields.UUIDField(nullable=True), 'operation': fields.StringField(nullable=True), + 'availability_zones': fields.ListOfStringsField(nullable=True), } obj_extra_fields = ['resource_properties'] @@ -100,7 +102,8 @@ class RequestSpec(base.CinderObject, base.CinderObjectDictCompat, added_fields = (((1, 1), ('group_id', 'group_backend')), ((1, 2), ('resource_backend')), ((1, 3), ('backup_id')), - ((1, 4), ('operation'))) + ((1, 4), ('operation')), + ((1, 5), ('availability_zones'))) for version, remove_fields in added_fields: if target_version < version: for obj_field in remove_fields: diff --git a/cinder/scheduler/driver.py b/cinder/scheduler/driver.py index a140d5eb1e8..293ed953901 100644 --- a/cinder/scheduler/driver.py +++ b/cinder/scheduler/driver.py @@ -41,7 +41,8 @@ CONF = cfg.CONF CONF.register_opts(scheduler_driver_opts) -def volume_update_db(context, volume_id, host, cluster_name): +def volume_update_db(context, volume_id, host, cluster_name, + availability_zone=None): """Set the host, cluster_name, and set the scheduled_at field of a volume. :returns: A Volume with the updated fields set properly. @@ -50,6 +51,7 @@ def volume_update_db(context, volume_id, host, cluster_name): volume.host = host volume.cluster_name = cluster_name volume.scheduled_at = timeutils.utcnow() + volume.availability_zone = availability_zone volume.save() # A volume object is expected to be returned, as it is used by diff --git a/cinder/scheduler/filter_scheduler.py b/cinder/scheduler/filter_scheduler.py index 6e0defa6660..daf47c437af 100644 --- a/cinder/scheduler/filter_scheduler.py +++ b/cinder/scheduler/filter_scheduler.py @@ -96,9 +96,11 @@ class FilterScheduler(driver.Scheduler): backend = backend.obj volume_id = request_spec['volume_id'] - updated_volume = driver.volume_update_db(context, volume_id, - backend.host, - backend.cluster_name) + updated_volume = driver.volume_update_db( + context, volume_id, + backend.host, + backend.cluster_name, + availability_zone=backend.service['availability_zone']) self._post_select_populate_filter_properties(filter_properties, backend) diff --git a/cinder/scheduler/filters/availability_zone_filter.py b/cinder/scheduler/filters/availability_zone_filter.py index 57b0a5495c2..9d9cdc06f1f 100644 --- a/cinder/scheduler/filters/availability_zone_filter.py +++ b/cinder/scheduler/filters/availability_zone_filter.py @@ -24,6 +24,12 @@ class AvailabilityZoneFilter(filters.BaseBackendFilter): def backend_passes(self, backend_state, filter_properties): spec = filter_properties.get('request_spec', {}) + availability_zones = spec.get('availability_zones') + + if availability_zones: + return (backend_state.service['availability_zone'] + in availability_zones) + props = spec.get('resource_properties', {}) availability_zone = props.get('availability_zone') diff --git a/cinder/tests/unit/api/v2/test_types.py b/cinder/tests/unit/api/v2/test_types.py index 527d2c4b8c5..11009a43aeb 100644 --- a/cinder/tests/unit/api/v2/test_types.py +++ b/cinder/tests/unit/api/v2/test_types.py @@ -186,7 +186,8 @@ class VolumeTypesApiTest(test.TestCase): def test_volume_types_index_with_invalid_filter(self): req = fakes.HTTPRequest.blank( '/v2/%s/types?id=%s' % (fake.PROJECT_ID, self.type_id1)) - req.environ['cinder.context'] = self.ctxt + req.environ['cinder.context'] = context.RequestContext( + user_id=fake.USER_ID, project_id=fake.PROJECT_ID, is_admin=False) res = self.controller.index(req) self.assertEqual(3, len(res['volume_types'])) diff --git a/cinder/tests/unit/api/v3/test_types.py b/cinder/tests/unit/api/v3/test_types.py new file mode 100644 index 00000000000..14f33f3cedd --- /dev/null +++ b/cinder/tests/unit/api/v3/test_types.py @@ -0,0 +1,89 @@ +# 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 cinder.api import microversions as mv +from cinder.api.v2 import types +from cinder import context +from cinder import objects +from cinder import test +from cinder.tests.unit.api import fakes +from cinder.tests.unit import fake_constants as fake + + +class VolumeTypesApiTest(test.TestCase): + + def _create_volume_type(self, ctxt, volume_type_name, extra_specs=None, + is_public=True, projects=None): + vol_type = objects.VolumeType(ctxt, + name=volume_type_name, + is_public=is_public, + description='', + extra_specs=extra_specs, + projects=projects) + vol_type.create() + return vol_type + + def setUp(self): + super(VolumeTypesApiTest, self).setUp() + self.controller = types.VolumeTypesController() + self.ctxt = context.RequestContext(user_id=fake.USER_ID, + project_id=fake.PROJECT_ID, + is_admin=True) + self.type1 = self._create_volume_type( + self.ctxt, 'volume_type1', + {'key1': 'value1', 'RESKEY:availability_zones': 'az1,az2'}) + self.type2 = self._create_volume_type( + self.ctxt, 'volume_type2', + {'key2': 'value2', 'RESKEY:availability_zones': 'az1,az3'}) + self.type3 = self._create_volume_type( + self.ctxt, 'volume_type3', + {'key3': 'value3'}, False, [fake.PROJECT_ID]) + self.addCleanup(self._cleanup) + + def _cleanup(self): + self.type1.destroy() + self.type2.destroy() + self.type3.destroy() + + def test_volume_types_index_with_extra_specs(self): + req = fakes.HTTPRequest.blank( + '/v3/%s/types?extra_specs={"key1":"value1"}' % fake.PROJECT_ID, + use_admin_context=False) + req.api_version_request = mv.get_api_version(mv.get_prior_version( + mv.SUPPORT_VOLUME_TYPE_FILTER)) + res_dict = self.controller.index(req) + + self.assertEqual(3, len(res_dict['volume_types'])) + + # Test filter volume type with extra specs + req = fakes.HTTPRequest.blank( + '/v3/%s/types?extra_specs={"key1":"value1"}' % fake.PROJECT_ID, + use_admin_context=True) + req.api_version_request = mv.get_api_version( + mv.SUPPORT_VOLUME_TYPE_FILTER) + res_dict = self.controller.index(req) + self.assertEqual(1, len(res_dict['volume_types'])) + self.assertDictEqual({'key1': 'value1', + 'RESKEY:availability_zones': 'az1,az2'}, + res_dict['volume_types'][0]['extra_specs']) + + # Test filter volume type with 'availability_zones' + req = fakes.HTTPRequest.blank( + '/v3/%s/types?extra_specs={"RESKEY:availability_zones":"az1"}' + % fake.PROJECT_ID, use_admin_context=True) + req.api_version_request = mv.get_api_version( + mv.SUPPORT_VOLUME_TYPE_FILTER) + res_dict = self.controller.index(req) + self.assertEqual(2, len(res_dict['volume_types'])) + self.assertEqual( + ['volume_type1', 'volume_type2'], + sorted([az['name'] for az in res_dict['volume_types']])) diff --git a/cinder/tests/unit/objects/test_objects.py b/cinder/tests/unit/objects/test_objects.py index d8b5f13d3a4..6d5b78c6548 100644 --- a/cinder/tests/unit/objects/test_objects.py +++ b/cinder/tests/unit/objects/test_objects.py @@ -42,7 +42,7 @@ object_data = { 'ManageableVolumeList': '1.0-15ecf022a68ddbb8c2a6739cfc9f8f5e', 'QualityOfServiceSpecs': '1.0-0b212e0a86ee99092229874e03207fe8', 'QualityOfServiceSpecsList': '1.0-15ecf022a68ddbb8c2a6739cfc9f8f5e', - 'RequestSpec': '1.4-2f858ebf18fa1dfe00fba7c3ec5cf303', + 'RequestSpec': '1.5-2f6efbb86107ee70cc1bb07f4bdb4ec7', 'Service': '1.6-e881b6b324151dd861e09cdfffcdaccd', 'ServiceList': '1.1-15ecf022a68ddbb8c2a6739cfc9f8f5e', 'Snapshot': '1.5-ac1cdbd5b89588f6a8f44afdf6b8b201', diff --git a/cinder/tests/unit/scheduler/test_host_filters.py b/cinder/tests/unit/scheduler/test_host_filters.py index 9518caff1d9..8ffc113f31e 100644 --- a/cinder/tests/unit/scheduler/test_host_filters.py +++ b/cinder/tests/unit/scheduler/test_host_filters.py @@ -1737,6 +1737,24 @@ class BasicFiltersTestCase(BackendFiltersTestCase): host = fakes.FakeBackendState('host1', {'service': service}) self.assertTrue(filt_cls.backend_passes(host, request)) + def test_availability_zone_filter_with_AZs(self): + filt_cls = self.class_map['AvailabilityZoneFilter']() + ctxt = context.RequestContext('fake', 'fake', is_admin=False) + request = { + 'context': ctxt, + 'request_spec': {'availability_zones': ['nova1', 'nova2']} + } + + host1 = fakes.FakeBackendState( + 'host1', {'service': {'availability_zone': 'nova1'}}) + host2 = fakes.FakeBackendState( + 'host2', {'service': {'availability_zone': 'nova2'}}) + host3 = fakes.FakeBackendState( + 'host3', {'service': {'availability_zone': 'nova3'}}) + self.assertTrue(filt_cls.backend_passes(host1, request)) + self.assertTrue(filt_cls.backend_passes(host2, request)) + self.assertFalse(filt_cls.backend_passes(host3, request)) + def test_availability_zone_filter_different(self): filt_cls = self.class_map['AvailabilityZoneFilter']() service = {'availability_zone': 'nova'} diff --git a/cinder/tests/unit/scheduler/test_scheduler.py b/cinder/tests/unit/scheduler/test_scheduler.py index 18a17c50402..4fb8fc40ce4 100644 --- a/cinder/tests/unit/scheduler/test_scheduler.py +++ b/cinder/tests/unit/scheduler/test_scheduler.py @@ -620,4 +620,5 @@ class SchedulerDriverModuleTestCase(test.TestCase): _mock_vol_update.assert_called_once_with( self.context, volume.id, {'host': 'fake_host', 'cluster_name': 'fake_cluster', - 'scheduled_at': scheduled_at}) + 'scheduled_at': scheduled_at, + 'availability_zone': None}) diff --git a/cinder/tests/unit/volume/flows/test_create_volume_flow.py b/cinder/tests/unit/volume/flows/test_create_volume_flow.py index 778b53235dd..172d5f0e39d 100644 --- a/cinder/tests/unit/volume/flows/test_create_volume_flow.py +++ b/cinder/tests/unit/volume/flows/test_create_volume_flow.py @@ -14,7 +14,9 @@ # under the License. """ Tests for create_volume TaskFlow """ +import six import sys +import uuid import ddt import mock @@ -268,7 +270,7 @@ class CreateVolumeFlowTestCase(test.TestCase): image_meta['size'] = 1 fake_image_service.create(self.ctxt, image_meta) fake_key_manager = mock_key_manager.MockKeyManager() - volume_type = 'type1' + volume_type = {'name': 'type1'} task = create_volume.ExtractVolumeRequestTask( fake_image_service, @@ -294,7 +296,7 @@ class CreateVolumeFlowTestCase(test.TestCase): expected_result = {'size': 1, 'snapshot_id': None, 'source_volid': None, - 'availability_zone': 'nova', + 'availability_zones': ['nova'], 'volume_type': volume_type, 'volume_type_id': 1, 'encryption_key_id': None, @@ -312,7 +314,7 @@ class CreateVolumeFlowTestCase(test.TestCase): @mock.patch('cinder.volume.flows.api.create_volume.' 'ExtractVolumeRequestTask.' '_get_volume_type_id') - def test_extract_availability_zone_without_fallback( + def test_extract_availability_zones_without_fallback( self, fake_get_type_id, fake_get_qos, @@ -325,7 +327,7 @@ class CreateVolumeFlowTestCase(test.TestCase): image_meta['size'] = 1 fake_image_service.create(self.ctxt, image_meta) fake_key_manager = mock_key_manager.MockKeyManager() - volume_type = 'type1' + volume_type = {'name': 'type1'} task = create_volume.ExtractVolumeRequestTask( fake_image_service, @@ -356,7 +358,117 @@ class CreateVolumeFlowTestCase(test.TestCase): @mock.patch('cinder.volume.flows.api.create_volume.' 'ExtractVolumeRequestTask.' '_get_volume_type_id') - def test_extract_availability_zone_with_fallback( + def test_extract_availability_zones_with_azs_not_matched( + self, + fake_get_type_id, + fake_get_qos, + fake_is_encrypted): + fake_image_service = fake_image.FakeImageService() + image_id = six.text_type(uuid.uuid4()) + image_meta = {} + image_meta['id'] = image_id + image_meta['status'] = 'active' + image_meta['size'] = 1 + fake_image_service.create(self.ctxt, image_meta) + fake_key_manager = mock_key_manager.MockKeyManager() + volume_type = {'name': 'type1', + 'extra_specs': + {'RESKEY:availability_zones': 'nova3'}} + + task = create_volume.ExtractVolumeRequestTask( + fake_image_service, {'nova1', 'nova2'}) + + fake_is_encrypted.return_value = False + fake_get_type_id.return_value = 1 + fake_get_qos.return_value = {'qos_specs': None} + self.assertRaises(exception.InvalidTypeAvailabilityZones, + task.execute, + self.ctxt, + size=1, + snapshot=None, + image_id=image_id, + source_volume=None, + availability_zone='notnova', + volume_type=volume_type, + metadata=None, + key_manager=fake_key_manager, + consistencygroup=None, + cgsnapshot=None, + group=None, + group_snapshot=None, + backup=None) + + @ddt.data({'type_azs': 'nova3', + 'self_azs': ['nova3'], + 'expected': ['nova3']}, + {'type_azs': 'nova3, nova2', + 'self_azs': ['nova3'], + 'expected': ['nova3']}, + {'type_azs': 'nova3,,,', + 'self_azs': ['nova3'], + 'expected': ['nova3']}, + {'type_azs': 'nova3', + 'self_azs': ['nova2'], + 'expected': exception.InvalidTypeAvailabilityZones}, + {'type_azs': ',,', + 'self_azs': ['nova2'], + 'expected': exception.InvalidTypeAvailabilityZones} + ) + @ddt.unpack + def test__extract_availability_zones_az_not_specified(self, type_azs, + self_azs, expected): + fake_image_service = fake_image.FakeImageService() + image_id = six.text_type(uuid.uuid4()) + image_meta = {} + image_meta['id'] = image_id + image_meta['status'] = 'active' + image_meta['size'] = 1 + fake_image_service.create(self.ctxt, image_meta) + volume_type = {'name': 'type1', + 'extra_specs': + {'RESKEY:availability_zones': type_azs}} + + task = create_volume.ExtractVolumeRequestTask( + fake_image_service, + {'nova'}) + task.availability_zones = self_azs + if isinstance(expected, list): + result = task._extract_availability_zones( + None, {}, {}, {}, volume_type=volume_type) + self.assertEqual(expected, result[0]) + else: + self.assertRaises( + expected, task._extract_availability_zones, + None, {}, {}, {}, volume_type=volume_type) + + def test__extract_availability_zones_az_not_in_type_azs(self): + self.override_config('allow_availability_zone_fallback', False) + fake_image_service = fake_image.FakeImageService() + image_id = six.text_type(uuid.uuid4()) + image_meta = {} + image_meta['id'] = image_id + image_meta['status'] = 'active' + image_meta['size'] = 1 + fake_image_service.create(self.ctxt, image_meta) + volume_type = {'name': 'type1', + 'extra_specs': + {'RESKEY:availability_zones': 'nova1, nova2'}} + + task = create_volume.ExtractVolumeRequestTask( + fake_image_service, + {'nova'}) + task.availability_zones = ['nova1'] + + self.assertRaises(exception.InvalidAvailabilityZone, + task._extract_availability_zones, + 'nova2', {}, {}, {}, volume_type=volume_type) + + @mock.patch('cinder.volume.volume_types.is_encrypted') + @mock.patch('cinder.volume.volume_types.get_volume_type_qos_specs') + @mock.patch('cinder.volume.flows.api.create_volume.' + 'ExtractVolumeRequestTask.' + '_get_volume_type_id') + def test_extract_availability_zones_with_fallback( self, fake_get_type_id, fake_get_qos, @@ -372,7 +484,7 @@ class CreateVolumeFlowTestCase(test.TestCase): image_meta['size'] = 1 fake_image_service.create(self.ctxt, image_meta) fake_key_manager = mock_key_manager.MockKeyManager() - volume_type = 'type1' + volume_type = {'name': 'type1'} task = create_volume.ExtractVolumeRequestTask( fake_image_service, @@ -398,7 +510,7 @@ class CreateVolumeFlowTestCase(test.TestCase): expected_result = {'size': 1, 'snapshot_id': None, 'source_volid': None, - 'availability_zone': 'nova', + 'availability_zones': ['nova'], 'volume_type': volume_type, 'volume_type_id': 1, 'encryption_key_id': None, @@ -434,7 +546,7 @@ class CreateVolumeFlowTestCase(test.TestCase): 'size': 1} fake_image_service.create(self.ctxt, image_meta) fake_key_manager = mock_key_manager.MockKeyManager() - volume_type = 'type1' + volume_type = {'name': 'type1'} with mock.patch.object(fake_key_manager, 'create_key', side_effect=castellan_exc.KeyManagerError): @@ -483,7 +595,7 @@ class CreateVolumeFlowTestCase(test.TestCase): image_meta['size'] = 1 fake_image_service.create(self.ctxt, image_meta) fake_key_manager = mock_key_manager.MockKeyManager() - volume_type = 'type1' + volume_type = {'name': 'type1'} task = create_volume.ExtractVolumeRequestTask( fake_image_service, @@ -509,7 +621,7 @@ class CreateVolumeFlowTestCase(test.TestCase): expected_result = {'size': (sys.maxsize + 1), 'snapshot_id': None, 'source_volid': None, - 'availability_zone': 'nova', + 'availability_zones': ['nova'], 'volume_type': volume_type, 'volume_type_id': 1, 'encryption_key_id': None, @@ -541,7 +653,7 @@ class CreateVolumeFlowTestCase(test.TestCase): image_meta['size'] = 1 fake_image_service.create(self.ctxt, image_meta) fake_key_manager = mock_key_manager.MockKeyManager() - volume_type = 'type1' + volume_type = {'name': 'type1'} task = create_volume.ExtractVolumeRequestTask( fake_image_service, @@ -568,7 +680,7 @@ class CreateVolumeFlowTestCase(test.TestCase): expected_result = {'size': 1, 'snapshot_id': None, 'source_volid': None, - 'availability_zone': 'nova', + 'availability_zones': ['nova'], 'volume_type': volume_type, 'volume_type_id': 1, 'encryption_key_id': None, @@ -596,7 +708,7 @@ class CreateVolumeFlowTestCase(test.TestCase): fake_get_qos, fake_is_encrypted): - image_volume_type = 'type_from_image' + image_volume_type = {'name': 'type_from_image'} fake_image_service = fake_image.FakeImageService() image_id = 6 image_meta = {} @@ -634,7 +746,7 @@ class CreateVolumeFlowTestCase(test.TestCase): expected_result = {'size': 1, 'snapshot_id': None, 'source_volid': None, - 'availability_zone': 'nova', + 'availability_zones': ['nova'], 'volume_type': image_volume_type, 'volume_type_id': 1, 'encryption_key_id': None, @@ -680,7 +792,7 @@ class CreateVolumeFlowTestCase(test.TestCase): fake_is_encrypted.return_value = False fake_get_type_id.return_value = 1 - fake_get_def_vol_type.return_value = 'fake_vol_type' + fake_get_def_vol_type.return_value = {'name': 'fake_vol_type'} fake_db_get_vol_type.side_effect = ( exception.VolumeTypeNotFoundByName(volume_type_name='invalid')) fake_get_qos.return_value = {'qos_specs': None} @@ -701,8 +813,8 @@ class CreateVolumeFlowTestCase(test.TestCase): expected_result = {'size': 1, 'snapshot_id': None, 'source_volid': None, - 'availability_zone': 'nova', - 'volume_type': 'fake_vol_type', + 'availability_zones': ['nova'], + 'volume_type': {'name': 'fake_vol_type'}, 'volume_type_id': 1, 'encryption_key_id': None, 'qos_specs': None, @@ -748,7 +860,7 @@ class CreateVolumeFlowTestCase(test.TestCase): fake_is_encrypted.return_value = False fake_get_type_id.return_value = 1 - fake_get_def_vol_type.return_value = 'fake_vol_type' + fake_get_def_vol_type.return_value = {'name': 'fake_vol_type'} fake_get_qos.return_value = {'qos_specs': None} result = task.execute(self.ctxt, size=1, @@ -767,8 +879,8 @@ class CreateVolumeFlowTestCase(test.TestCase): expected_result = {'size': 1, 'snapshot_id': None, 'source_volid': None, - 'availability_zone': 'nova', - 'volume_type': 'fake_vol_type', + 'availability_zones': ['nova'], + 'volume_type': {'name': 'fake_vol_type'}, 'volume_type_id': 1, 'encryption_key_id': None, 'qos_specs': None, diff --git a/cinder/volume/api.py b/cinder/volume/api.py index f2cec0bd1f9..9c59bf41f5d 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -1714,6 +1714,11 @@ class API(base.Base): 'quota_reservations': reservations, 'old_reservations': old_reservations} + type_azs = volume_utils.extract_availability_zones_from_volume_type( + new_type) + if type_azs is not None: + request_spec['availability_zones'] = type_azs + self.scheduler_rpcapi.retype(context, volume, request_spec=request_spec, filter_properties={}) diff --git a/cinder/volume/flows/api/create_volume.py b/cinder/volume/flows/api/create_volume.py index 91e000cf0b2..7690a77a81a 100644 --- a/cinder/volume/flows/api/create_volume.py +++ b/cinder/volume/flows/api/create_volume.py @@ -65,11 +65,11 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): # This task will produce the following outputs (said outputs can be # saved to durable storage in the future so that the flow can be # reconstructed elsewhere and continued). - default_provides = set(['availability_zone', 'size', 'snapshot_id', + default_provides = set(['size', 'snapshot_id', 'source_volid', 'volume_type', 'volume_type_id', 'encryption_key_id', 'consistencygroup_id', 'cgsnapshot_id', 'qos_specs', 'group_id', - 'refresh_az', 'backup_id']) + 'refresh_az', 'backup_id', 'availability_zones']) def __init__(self, image_service, availability_zones, **kwargs): super(ExtractVolumeRequestTask, self).__init__(addons=[ACTION], @@ -291,17 +291,27 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): 'volume_type': volume_type}) return volume_type - def _extract_availability_zone(self, availability_zone, snapshot, - source_volume, group): - """Extracts and returns a validated availability zone. + def _extract_availability_zones(self, availability_zone, snapshot, + source_volume, group, volume_type=None): + """Extracts and returns a validated availability zone list. This function will extract the availability zone (if not provided) from the snapshot or source_volume and then performs a set of validation checks on the provided or extracted availability zone and then returns the validated availability zone. """ - refresh_az = False + type_azs = vol_utils.extract_availability_zones_from_volume_type( + volume_type) + type_az_configured = type_azs is not None + if type_az_configured: + safe_azs = list( + set(type_azs).intersection(self.availability_zones)) + if not safe_azs: + raise exception.InvalidTypeAvailabilityZones(az=type_azs) + else: + safe_azs = self.availability_zones + # If the volume will be created in a group, it should be placed in # in same availability zone as the group. if group: @@ -325,14 +335,14 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): except (TypeError, KeyError): pass - if availability_zone is None: + if availability_zone is None and not type_az_configured: if CONF.default_availability_zone: availability_zone = CONF.default_availability_zone else: # For backwards compatibility use the storage_availability_zone availability_zone = CONF.storage_availability_zone - if availability_zone not in self.availability_zones: + if availability_zone and availability_zone not in safe_azs: refresh_az = True if CONF.allow_availability_zone_fallback: original_az = availability_zone @@ -349,7 +359,7 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): # If the configuration only allows cloning to the same availability # zone then we need to enforce that. - if CONF.cloned_volume_same_az: + if availability_zone and CONF.cloned_volume_same_az: snap_az = None try: snap_az = snapshot['volume']['availability_zone'] @@ -369,7 +379,10 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): "availability zone as the source volume") raise exception.InvalidInput(reason=msg) - return availability_zone, refresh_az + if availability_zone: + return [availability_zone], refresh_az + else: + return safe_azs, refresh_az def _get_encryption_key_id(self, key_manager, context, volume_type_id, snapshot, source_volume, @@ -439,9 +452,6 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): image_id, size) - availability_zone, refresh_az = self._extract_availability_zone( - availability_zone, snapshot, source_volume, group) - # TODO(joel-coffman): This special handling of snapshots to ensure that # their volume type matches the source volume is too convoluted. We # should copy encryption metadata from the encrypted volume type to the @@ -453,6 +463,10 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): volume_type = (image_volume_type if image_volume_type else def_vol_type) + availability_zones, refresh_az = self._extract_availability_zones( + availability_zone, snapshot, source_volume, group, + volume_type=volume_type) + volume_type_id = self._get_volume_type_id(volume_type, source_volume, snapshot) @@ -487,7 +501,6 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): 'size': size, 'snapshot_id': snapshot_id, 'source_volid': source_volid, - 'availability_zone': availability_zone, 'volume_type': volume_type, 'volume_type_id': volume_type_id, 'encryption_key_id': encryption_key_id, @@ -498,6 +511,7 @@ class ExtractVolumeRequestTask(flow_utils.CinderTask): 'replication_status': replication_status, 'refresh_az': refresh_az, 'backup_id': backup_id, + 'availability_zones': availability_zones } @@ -510,11 +524,11 @@ class EntryCreateTask(flow_utils.CinderTask): default_provides = set(['volume_properties', 'volume_id', 'volume']) def __init__(self): - requires = ['availability_zone', 'description', 'metadata', + requires = ['description', 'metadata', 'name', 'reservations', 'size', 'snapshot_id', 'source_volid', 'volume_type_id', 'encryption_key_id', 'consistencygroup_id', 'cgsnapshot_id', 'multiattach', - 'qos_specs', 'group_id', ] + 'qos_specs', 'group_id', 'availability_zones'] super(EntryCreateTask, self).__init__(addons=[ACTION], requires=requires) @@ -536,6 +550,7 @@ class EntryCreateTask(flow_utils.CinderTask): if src_vol is not None: bootable = src_vol.bootable + availability_zones = kwargs.pop('availability_zones') volume_properties = { 'size': kwargs.pop('size'), 'user_id': context.user_id, @@ -549,6 +564,8 @@ class EntryCreateTask(flow_utils.CinderTask): 'multiattach': kwargs.pop('multiattach'), 'bootable': bootable, } + if len(availability_zones) == 1: + volume_properties['availability_zone'] = availability_zones[0] # Merge in the other required arguments which should provide the rest # of the volume property fields (if applicable). @@ -732,7 +749,8 @@ class VolumeCastTask(flow_utils.CinderTask): requires = ['image_id', 'scheduler_hints', 'snapshot_id', 'source_volid', 'volume_id', 'volume', 'volume_type', 'volume_properties', 'consistencygroup_id', - 'cgsnapshot_id', 'group_id', 'backup_id', ] + 'cgsnapshot_id', 'group_id', 'backup_id', + 'availability_zones'] super(VolumeCastTask, self).__init__(addons=[ACTION], requires=requires) self.volume_rpcapi = volume_rpcapi diff --git a/cinder/volume/utils.py b/cinder/volume/utils.py index 0d5e8950f84..29c64ef57f6 100644 --- a/cinder/volume/utils.py +++ b/cinder/volume/utils.py @@ -639,6 +639,17 @@ def get_all_volume_groups(vg_name=None): utils.get_root_helper(), vg_name) + +def extract_availability_zones_from_volume_type(volume_type): + if not volume_type: + return None + extra_specs = volume_type.get('extra_specs', {}) + if 'RESKEY:availability_zones' not in extra_specs: + return None + azs = extra_specs.get('RESKEY:availability_zones', '').split(',') + return [az.strip() for az in azs if az != ''] + + # Default symbols to use for passwords. Avoids visually confusing characters. # ~6 bits per symbol DEFAULT_PASSWORD_SYMBOLS = ('23456789', # Removed: 0,1 diff --git a/doc/source/admin/blockstorage-availability-zone-type.rst b/doc/source/admin/blockstorage-availability-zone-type.rst new file mode 100644 index 00000000000..53e8544cb9d --- /dev/null +++ b/doc/source/admin/blockstorage-availability-zone-type.rst @@ -0,0 +1,52 @@ +======================= +Availability-zone types +======================= + +Background +---------- + +In a newly deployed region environment, the volume types (SSD, HDD or others) +may only exist on part of the AZs, but end users have no idea which AZ is +allowed for one specific volume type and they can't realize that only when +the volume failed to be scheduled to backend. In this case, we have supported +availability zone volume type in Rocky cycle which administrators can take +advantage of to fix that. + +How to config availability zone types? +-------------------------------------- + +We decided to use type's extra-specs to store this additional info, +administrators can turn it on by updating volume type's key +``RESKEY:availability_zones`` as below:: + + "RESKEY:availability_zones": "az1,az2,az3" + +It's an array list whose items are separated by comma and stored in string. +Once the availability zone type is configured, any UI component or client +can filter out invalid volume types based on their choice of availability +zone:: + + Request example: + /v3/{project_id}/types?extra_specs={'RESKEY:availability_zones':'az1'} + +Remember, Cinder will always try inexact match for this spec value, for +instance, when extra spec ``RESKEY:availability_zones`` is configured +with value ``az1,az2``, both ``az1`` and ``az2`` are valid inputs for query, +also this spec will not be used during performing capability filter, instead +it will be only used for choosing suitable availability zones in these two +cases below. + +1. Create volume, within this feature, now we can specify availability zone +via parameter ``availability_zone``, volume source (volume, snapshot, group), +configuration option ``default_availability_zone`` and +``storage_availability_zone``. When creating new volume, Cinder will try to +read the AZ(s) in the priority of:: + + source group > parameter availability_zone > source snapshot (or volume) > volume type > configuration default_availability_zone > storage_availability_zone + +If there is a conflict between any of them, 400 BadRequest will be raised, +also now a AZ list instead of single AZ will be delivered to +``AvailabilityZoneFilter``. + +2. Retype volume, this flow also has been updated, if new type has configured +``RESKEY:availability_zones`` Cinder scheduler will validate this as well. diff --git a/doc/source/admin/generalized_filters.rst b/doc/source/admin/generalized_filters.rst index 729b2eac0d7..711317023fd 100644 --- a/doc/source/admin/generalized_filters.rst +++ b/doc/source/admin/generalized_filters.rst @@ -78,3 +78,5 @@ valid for first. The supported APIs are marked with "*" below in the table. +-----------------+-------------------------------------------------------------------------+ | get pools | name, volume_type | +-----------------+-------------------------------------------------------------------------+ +| list types(3.51)| is_public, extra_specs | ++-----------------+-------------------------------------------------------------------------+ diff --git a/doc/source/admin/index.rst b/doc/source/admin/index.rst index 093edd51dc8..4b60a3fee8b 100644 --- a/doc/source/admin/index.rst +++ b/doc/source/admin/index.rst @@ -27,6 +27,7 @@ Amazon EC2 Elastic Block Storage (EBS) offering. blockstorage-api-throughput.rst blockstorage-manage-volumes.rst blockstorage-troubleshoot.rst + blockstorage-availability-zone-type.rst generalized_filters.rst blockstorage-backup-disks.rst blockstorage-boot-from-volume.rst diff --git a/etc/cinder/resource_filters.json b/etc/cinder/resource_filters.json index 867c1a70555..11c191c2432 100644 --- a/etc/cinder/resource_filters.json +++ b/etc/cinder/resource_filters.json @@ -10,5 +10,6 @@ "attachment": ["volume_id", "status", "instance_id", "attach_status"], "message": ["resource_uuid", "resource_type", "event_id", "request_id", "message_level"], - "pool": ["name", "volume_type"] + "pool": ["name", "volume_type"], + "volume_type": [] } diff --git a/releasenotes/notes/support-az-in-volumetype-8yt6fg67de3976ty.yaml b/releasenotes/notes/support-az-in-volumetype-8yt6fg67de3976ty.yaml new file mode 100644 index 00000000000..edb83a29990 --- /dev/null +++ b/releasenotes/notes/support-az-in-volumetype-8yt6fg67de3976ty.yaml @@ -0,0 +1,12 @@ +--- +features: | + Now availability zone is supported in volume type as below. + + * ``RESKEY:availability_zones`` now is a reserved spec key for AZ volume type, + and administrator can create AZ volume type that includes AZ restrictions + by adding a list of Az's to the extra specs similar to: + ``RESKEY:availability_zones: az1,az2``. + * Extra spec ``RESKEY:availability_zones`` will only be used for filtering backends + when creating and retyping volumes. + * Volume type can be filtered within extra spec: /types?extra_specs={"key":"value"} + since microversion "3.52".