From 306fa19079ccf8f5278fdf36341edecd95df04a7 Mon Sep 17 00:00:00 2001 From: TommyLike Date: Mon, 12 Mar 2018 15:16:27 +0800 Subject: [PATCH] Support availability-zone type Now availability zone is highly integrated into volume type's extra spec, it will be recognized when creating and retyping, also we can filter volume type by extra spec now. Change-Id: I4e6aa7af707bd063e7edf2b0bf28e3071ad5c67a Partial-Implements: bp support-az-in-volumetype --- cinder/api/microversions.py | 2 + cinder/api/openstack/api_version_request.py | 5 +- .../openstack/rest_api_version_history.rst | 5 + cinder/api/v2/types.py | 40 +++-- cinder/db/sqlalchemy/api.py | 12 +- cinder/exception.py | 4 + cinder/objects/base.py | 1 + cinder/objects/request_spec.py | 7 +- cinder/scheduler/driver.py | 4 +- cinder/scheduler/filter_scheduler.py | 8 +- .../filters/availability_zone_filter.py | 6 + cinder/tests/unit/api/v2/test_types.py | 3 +- cinder/tests/unit/api/v3/test_types.py | 89 ++++++++++ cinder/tests/unit/objects/test_objects.py | 2 +- .../tests/unit/scheduler/test_host_filters.py | 18 +++ cinder/tests/unit/scheduler/test_scheduler.py | 3 +- .../volume/flows/test_create_volume_flow.py | 152 +++++++++++++++--- cinder/volume/api.py | 5 + cinder/volume/flows/api/create_volume.py | 52 ++++-- cinder/volume/utils.py | 11 ++ .../blockstorage-availability-zone-type.rst | 52 ++++++ doc/source/admin/generalized_filters.rst | 2 + doc/source/admin/index.rst | 1 + etc/cinder/resource_filters.json | 3 +- ...ort-az-in-volumetype-8yt6fg67de3976ty.yaml | 12 ++ 25 files changed, 440 insertions(+), 59 deletions(-) create mode 100644 cinder/tests/unit/api/v3/test_types.py create mode 100644 doc/source/admin/blockstorage-availability-zone-type.rst create mode 100644 releasenotes/notes/support-az-in-volumetype-8yt6fg67de3976ty.yaml 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".