diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index ed8fd19342..5c26308c38 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -2200,6 +2200,15 @@ revert_to_snapshot_support_share_capability: required: true type: boolean min_version: 2.27 +scheduler_hints: + description: | + One or more scheduler_hints key and value pairs as a dictionary + of strings. e.g. keys are same_host, different_host and values must be + a comma separated list of Share IDs. + in: body + required: false + type: object + min_version: 2.65 security_service_dns_ip: description: | The DNS IP address that is used inside the project network. diff --git a/api-ref/source/samples/share-create-request.json b/api-ref/source/samples/share-create-request.json index 09b9835c29..4b2bcccd03 100644 --- a/api-ref/source/samples/share-create-request.json +++ b/api-ref/source/samples/share-create-request.json @@ -12,6 +12,10 @@ "metadata": { "project": "my_app", "aim": "doc" + }, + "scheduler_hints": { + "same_host": "d9c66489-cf02-4156-b0f2-527f3211b243,4ffee55f-ba98-42d2-a8ce-e7cecb169182", + "different_host": "903685eb-f242-4105-903d-4bef2db94be4" } } } diff --git a/api-ref/source/shares.inc b/api-ref/source/shares.inc index e913989d38..e31aa86b72 100644 --- a/api-ref/source/shares.inc +++ b/api-ref/source/shares.inc @@ -370,6 +370,7 @@ Request - metadata: metadata - share_network_id: share_network_id_request - availability_zone: availability_zone_request + - scheduler_hints: scheduler_hints Request example --------------- diff --git a/manila/api/openstack/api_version_request.py b/manila/api/openstack/api_version_request.py index 86e085665b..1d9cc5e911 100644 --- a/manila/api/openstack/api_version_request.py +++ b/manila/api/openstack/api_version_request.py @@ -171,13 +171,14 @@ REST_API_VERSION_HISTORY = """ actions on the share network's endpoint: 'update_security_service', 'update_security_service_check' and 'add_security_service_check'. + * 2.65 - Added ability to set scheduler hints via the share create API. """ # The minimum and maximum versions of the API supported # The default api version request is defined to be the # minimum version of the API supported. _MIN_API_VERSION = "2.0" -_MAX_API_VERSION = "2.63" +_MAX_API_VERSION = "2.65" DEFAULT_API_VERSION = _MIN_API_VERSION diff --git a/manila/api/openstack/rest_api_version_history.rst b/manila/api/openstack/rest_api_version_history.rst index 6dad063f78..c593a27dd4 100644 --- a/manila/api/openstack/rest_api_version_history.rst +++ b/manila/api/openstack/rest_api_version_history.rst @@ -358,3 +358,8 @@ user documentation. Added 'force' field to extend share api, which can extend share directly without go through share scheduler. +2.65 +---- + Added ability to specify "scheduler_hints" in the request body of the POST + /shares request. These hints will invoke Affinity/Anti-Affinity scheduler + filters during share creation and share migration. diff --git a/manila/api/v1/shares.py b/manila/api/v1/shares.py index 293c302796..245bcda235 100644 --- a/manila/api/v1/shares.py +++ b/manila/api/v1/shares.py @@ -235,7 +235,8 @@ class ShareMixin(object): @wsgi.Controller.authorize('create') def _create(self, req, body, check_create_share_from_snapshot_support=False, - check_availability_zones_extra_spec=False): + check_availability_zones_extra_spec=False, + scheduler_hints=None): """Creates a new share.""" context = req.environ['manila.context'] @@ -412,6 +413,9 @@ class ShareMixin(object): kwargs['share_type'] = share_type if share_network_id: kwargs['share_network_id'] = share_network_id + + kwargs['scheduler_hints'] = scheduler_hints + new_share = self.share_api.create(context, share_proto, size, diff --git a/manila/api/v2/shares.py b/manila/api/v2/shares.py index 14a22fa9eb..a008d147ec 100644 --- a/manila/api/v2/shares.py +++ b/manila/api/v2/shares.py @@ -178,8 +178,20 @@ class ShareController(shares.ShareMixin, return data - @wsgi.Controller.api_version("2.48") + @wsgi.Controller.api_version("2.65") def create(self, req, body): + if not self.is_valid_body(body, 'share'): + raise exc.HTTPUnprocessableEntity() + + share = body['share'] + scheduler_hints = share.pop('scheduler_hints', None) + return self._create(req, body, + check_create_share_from_snapshot_support=True, + check_availability_zones_extra_spec=True, + scheduler_hints=scheduler_hints) + + @wsgi.Controller.api_version("2.48", "2.64") # noqa + def create(self, req, body): # pylint: disable=function-redefined # noqa F811 return self._create(req, body, check_create_share_from_snapshot_support=True, check_availability_zones_extra_spec=True) diff --git a/manila/db/api.py b/manila/db/api.py index 3817e7093f..c4b5d9a18f 100644 --- a/manila/db/api.py +++ b/manila/db/api.py @@ -766,13 +766,16 @@ def security_service_get_all_by_project(context, project_id): #################### - - def share_metadata_get(context, share_id): """Get all metadata for a share.""" return IMPL.share_metadata_get(context, share_id) +def share_metadata_get_item(context, share_id, key): + """Get metadata item for given key and for a given share..""" + return IMPL.share_metadata_get_item(context, share_id, key) + + def share_metadata_delete(context, share_id, key): """Delete the given metadata item.""" IMPL.share_metadata_delete(context, share_id, key) @@ -783,6 +786,11 @@ def share_metadata_update(context, share, metadata, delete): IMPL.share_metadata_update(context, share, metadata, delete) +def share_metadata_update_item(context, share_id, item): + """update meta item containing key and value for given share.""" + IMPL.share_metadata_update_item(context, share_id, item) + + ################### def share_export_location_get_by_uuid(context, export_location_uuid, diff --git a/manila/db/sqlalchemy/api.py b/manila/db/sqlalchemy/api.py index 8750e69076..c75a61037a 100644 --- a/manila/db/sqlalchemy/api.py +++ b/manila/db/sqlalchemy/api.py @@ -3416,6 +3416,22 @@ def share_metadata_get(context, share_id): return _share_metadata_get(context, share_id) +@require_context +@require_share_exists +def share_metadata_get_item(context, share_id, key, session=None): + session = session or get_session() + try: + row = _share_metadata_get_item(context, share_id, key, + session=session) + except exception.ShareMetadataNotFound: + raise exception.ShareMetadataNotFound() + + result = {} + result[row['key']] = row['value'] + + return result + + @require_context @require_share_exists def share_metadata_delete(context, share_id, key): @@ -3429,6 +3445,12 @@ def share_metadata_update(context, share_id, metadata, delete): return _share_metadata_update(context, share_id, metadata, delete) +@require_context +@require_share_exists +def share_metadata_update_item(context, share_id, item, session=None): + return _share_metadata_update(context, share_id, item, delete=False) + + def _share_metadata_get_query(context, share_id, session=None): return (model_query(context, models.ShareMetadata, session=session, read_deleted="no"). diff --git a/manila/exception.py b/manila/exception.py index c1572c9594..5873c19d8b 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -175,7 +175,7 @@ class InvalidParameterValue(Invalid): class InvalidUUID(Invalid): - message = _("Expected a uuid but received %(uuid)s.") + message = _("%(uuid)s is not a valid uuid.") class InvalidDriverMode(Invalid): @@ -546,6 +546,10 @@ class ShareNotFound(NotFound): message = _("Share %(share_id)s could not be found.") +class ShareInstanceNotFound(NotFound): + message = _("Share instance %(share_instance_id)s could not be found.") + + class ShareSnapshotNotFound(NotFound): message = _("Snapshot %(snapshot_id)s could not be found.") diff --git a/manila/scheduler/drivers/filter.py b/manila/scheduler/drivers/filter.py index 8b6706ed40..334da6166d 100644 --- a/manila/scheduler/drivers/filter.py +++ b/manila/scheduler/drivers/filter.py @@ -23,6 +23,7 @@ filters and weighing functions. from oslo_config import cfg from oslo_log import log +from manila.db import api as db_api from manila import exception from manila.i18n import _ from manila.message import api as message_api @@ -34,6 +35,11 @@ from manila.share import share_types CONF = cfg.CONF LOG = log.getLogger(__name__) +AFFINITY_HINT = 'same_host' +ANTI_AFFINITY_HINT = 'different_host' +AFFINITY_KEY = "__affinity_same_host" +ANTI_AFFINITY_KEY = "__affinity_different_host" + class FilterScheduler(base.Scheduler): """Scheduler that can be used for filtering and weighing.""" @@ -214,7 +220,8 @@ class FilterScheduler(base.Scheduler): 'replication_domain': replication_domain, }) - self.populate_filter_properties_share(request_spec, filter_properties) + self.populate_filter_properties_share(context, request_spec, + filter_properties) return filter_properties, share_properties @@ -315,7 +322,17 @@ class FilterScheduler(base.Scheduler): "exc": "exc" }) - def populate_filter_properties_share(self, request_spec, + def populate_filter_properties_share_scheduler_hint(self, context, + share_id, hints, + key, hint): + try: + result = db_api.share_metadata_get_item(context, share_id, key) + except exception.ShareMetadataNotFound: + pass + else: + hints.update({hint: result.get(key)}) + + def populate_filter_properties_share(self, context, request_spec, filter_properties): """Stuff things into filter_properties. @@ -331,6 +348,25 @@ class FilterScheduler(base.Scheduler): filter_properties['metadata'] = shr.get('metadata') filter_properties['snapshot_id'] = shr.get('snapshot_id') + share_id = request_spec.get('share_id', None) + if not share_id: + filter_properties['scheduler_hints'] = {} + return + + try: + db_api.share_get(context, share_id) + except exception.NotFound: + filter_properties['scheduler_hints'] = {} + else: + hints = {} + self.populate_filter_properties_share_scheduler_hint( + context, share_id, hints, + AFFINITY_KEY, AFFINITY_HINT) + self.populate_filter_properties_share_scheduler_hint( + context, share_id, hints, + ANTI_AFFINITY_KEY, ANTI_AFFINITY_HINT) + filter_properties['scheduler_hints'] = hints + def schedule_create_share_group(self, context, share_group_id, request_spec, filter_properties): diff --git a/manila/scheduler/filters/affinity.py b/manila/scheduler/filters/affinity.py new file mode 100644 index 0000000000..eedab61ece --- /dev/null +++ b/manila/scheduler/filters/affinity.py @@ -0,0 +1,121 @@ +# Copyright (c) 2021 SAP. +# 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 oslo_log import log + +from manila import exception +from manila.scheduler.filters import base_host +from manila.share import api + +LOG = log.getLogger(__name__) + +AFFINITY_FILTER = 'same_host' +ANTI_AFFINITY_FILTER = 'different_host' + + +class AffinityBaseFilter(base_host.BaseHostFilter): + """Base class of affinity filters""" + _filter_type = None + + def __init__(self): + self.share_api = api.API() + + def filter_all(self, filter_obj_list, filter_properties): + # _filter_type should be defined in subclass + if self._filter_type is None: + raise AffinityFilterTypeNotSetError + + try: + filter_properties = self._validate(filter_properties) + except SchedulerHintsNotSet: + # AffinityFilter/AntiAffinityFilter is skipped if corresponding + # hint is not set. If the "scheduler_hints" is not set, both + # filters are skipped. + return filter_obj_list + except (exception.InvalidUUID, + exception.ShareNotFound, + exception.ShareInstanceNotFound) as e: + # Stop scheduling share when above errors are caught + LOG.error('%(filter_name)s: %(error)s', { + 'filter_name': self.__class__.__name__, + 'error': e}) + return None + else: + # Return list of hosts which pass the function host_passes() + # overriden in AffinityFilter and AntiAffinityFilter. + return [obj for obj in filter_obj_list + if self._filter_one(obj, filter_properties)] + + def _validate(self, filter_properties): + context = filter_properties['context'] + hints = filter_properties.get('scheduler_hints') + + if hints is None: + raise SchedulerHintsNotSet + else: + share_uuids = hints.get(self._filter_type) + if share_uuids is None: + raise SchedulerHintsNotSet + + share_uuids = share_uuids.split(",") + + filter_properties['scheduler_hints'][self._filter_type] = [] + + filtered_hosts = [] + for uuid in share_uuids: + try: + share = self.share_api.get(context, uuid) + except exception.NotFound: + raise exception.ShareNotFound(uuid) + instances = share.get('instances') + if len(instances) == 0: + raise exception.ShareInstanceNotFound(share_instance_id=uuid) + filtered_hosts.append( + [instance.get('host') for instance in instances]) + + if self._filter_type == AFFINITY_FILTER: + filter_properties['scheduler_hints'][self._filter_type] = list( + set.intersection(*map(set, filtered_hosts))) + else: + filter_properties['scheduler_hints'][self._filter_type] = list( + set.union(*map(set, filtered_hosts))) + + return filter_properties + + +class AffinityFilter(AffinityBaseFilter): + _filter_type = AFFINITY_FILTER + + def host_passes(self, host_state, filter_properties): + allowed_hosts = \ + filter_properties['scheduler_hints'][self._filter_type] + return host_state.host in allowed_hosts + + +class AntiAffinityFilter(AffinityBaseFilter): + _filter_type = ANTI_AFFINITY_FILTER + + def host_passes(self, host_state, filter_properties): + forbidden_hosts = \ + filter_properties['scheduler_hints'][self._filter_type] + return host_state.host not in forbidden_hosts + + +class SchedulerHintsNotSet(Exception): + pass + + +class AffinityFilterTypeNotSetError(Exception): + pass diff --git a/manila/scheduler/host_manager.py b/manila/scheduler/host_manager.py index 1724b7d781..6a719838eb 100644 --- a/manila/scheduler/host_manager.py +++ b/manila/scheduler/host_manager.py @@ -48,6 +48,8 @@ host_manager_opts = [ 'DriverFilter', 'ShareReplicationFilter', 'CreateFromSnapshotFilter', + 'AffinityFilter', + 'AntiAffinityFilter', ], help='Which filter class names to use for filtering hosts ' 'when not specified in the request.'), diff --git a/manila/share/api.py b/manila/share/api.py index 8af116406f..f99131dfc2 100644 --- a/manila/share/api.py +++ b/manila/share/api.py @@ -26,6 +26,7 @@ from oslo_log import log from oslo_utils import excutils from oslo_utils import strutils from oslo_utils import timeutils +from oslo_utils import uuidutils import six from manila.api import common as api_common @@ -64,6 +65,11 @@ LOG = log.getLogger(__name__) GB = 1048576 * 1024 QUOTAS = quota.QUOTAS +AFFINITY_HINT = 'same_host' +ANTI_AFFINITY_HINT = 'different_host' +AFFINITY_KEY = "__affinity_same_host" +ANTI_AFFINITY_KEY = "__affinity_different_host" + def locked_security_service_update_operation(operation): """Lock decorator for security service operation. @@ -184,7 +190,7 @@ class API(base.Base): snapshot_id=None, availability_zone=None, metadata=None, share_network_id=None, share_type=None, is_public=False, share_group_id=None, share_group_snapshot_member=None, - availability_zones=None): + availability_zones=None, scheduler_hints=None): """Create new share.""" self._check_metadata_properties(metadata) @@ -367,6 +373,8 @@ class API(base.Base): QUOTAS.rollback( context, reservations, share_type_id=share_type_id) + self.save_scheduler_hints(context, share, scheduler_hints) + host = None snapshot_host = None if snapshot: @@ -384,7 +392,7 @@ class API(base.Base): availability_zone=availability_zone, share_group=share_group, share_group_snapshot_member=share_group_snapshot_member, share_type_id=share_type_id, availability_zones=availability_zones, - snapshot_host=snapshot_host) + snapshot_host=snapshot_host, scheduler_hints=scheduler_hints) # Retrieve the share with instance details share = self.db.share_get(context, share['id']) @@ -461,7 +469,7 @@ class API(base.Base): host=None, availability_zone=None, share_group=None, share_group_snapshot_member=None, share_type_id=None, availability_zones=None, - snapshot_host=None): + snapshot_host=None, scheduler_hints=None): request_spec, share_instance = ( self.create_share_instance_and_get_request_spec( context, share, availability_zone=availability_zone, @@ -493,14 +501,17 @@ class API(base.Base): share_instance, host, request_spec=request_spec, - filter_properties={}, + filter_properties={'scheduler_hints': scheduler_hints}, snapshot_id=share['snapshot_id'], ) else: # Create share instance from scratch or from snapshot could happen # on hosts other than the source host. self.scheduler_rpcapi.create_share_instance( - context, request_spec=request_spec, filter_properties={}) + context, + request_spec=request_spec, + filter_properties={'scheduler_hints': scheduler_hints}, + ) return share_instance @@ -961,6 +972,7 @@ class API(base.Base): 'terminated_at': timeutils.utcnow()} share_ref = self.db.share_update(context, share['id'], update_data) + self.delete_scheduler_hints(context, share) self.share_rpcapi.unmanage_share(context, share_ref) # NOTE(u_glide): We should update 'updated_at' timestamp of @@ -1170,6 +1182,8 @@ class API(base.Base): "members.") % share_group_snapshot_members_count) raise exception.InvalidShare(reason=msg) self._check_is_share_busy(share) + self.delete_scheduler_hints(context, share) + for share_instance in share.instances: if share_instance['host']: self.delete_instance(context, share_instance, force=force) @@ -2052,6 +2066,81 @@ class API(base.Base): """Delete the given metadata item from a share.""" self.db.share_metadata_delete(context, share['id'], key) + def _validate_scheduler_hints(self, context, share, share_uuids): + for uuid in share_uuids: + if not uuidutils.is_uuid_like(uuid): + raise exception.InvalidUUID(uuid=uuid) + try: + self.get(context, uuid) + except (exception.NotFound, exception.PolicyNotAuthorized): + raise exception.ShareNotFound(share_id=uuid) + + def _save_scheduler_hints(self, context, share, share_uuids, key): + share_uuids = share_uuids.split(",") + + self._validate_scheduler_hints(context, share, share_uuids) + val_uuids = None + for uuid in share_uuids: + try: + result = self.db.share_metadata_get_item(context, uuid, key) + except exception.ShareMetadataNotFound: + item = {key: share['id']} + else: + existing_uuids = result.get(key, "") + item = {key: + ','.join(existing_uuids.split(',') + [share['id']])} + self.db.share_metadata_update_item(context, uuid, item) + if not val_uuids: + val_uuids = uuid + else: + val_uuids = val_uuids + "," + uuid + + if val_uuids: + item = {key: val_uuids} + self.db.share_metadata_update_item(context, share['id'], item) + + def save_scheduler_hints(self, context, share, scheduler_hints=None): + if scheduler_hints is None: + return + + same_host_uuids = scheduler_hints.get(AFFINITY_HINT, None) + different_host_uuids = scheduler_hints.get(ANTI_AFFINITY_HINT, None) + + if same_host_uuids: + self._save_scheduler_hints(context, share, same_host_uuids, + AFFINITY_KEY) + if different_host_uuids: + self._save_scheduler_hints(context, share, different_host_uuids, + ANTI_AFFINITY_KEY) + + def _delete_scheduler_hints(self, context, share, key): + try: + result = self.db.share_metadata_get_item(context, share['id'], + key) + except exception.ShareMetadataNotFound: + return + + share_uuids = result.get(key, "").split(",") + for uuid in share_uuids: + try: + result = self.db.share_metadata_get_item(context, uuid, key) + except exception.ShareMetadataNotFound: + continue + + new_val_uuids = [val_uuid for val_uuid + in result.get(key, "").split(",") + if val_uuid != share['id']] + if not new_val_uuids: + self.db.share_metadata_delete(context, uuid, key) + else: + item = {key: ','.join(new_val_uuids)} + self.db.share_metadata_update_item(context, uuid, item) + self.db.share_metadata_delete(context, share['id'], key) + + def delete_scheduler_hints(self, context, share): + self._delete_scheduler_hints(context, share, AFFINITY_KEY) + self._delete_scheduler_hints(context, share, ANTI_AFFINITY_KEY) + def _check_is_share_busy(self, share): """Raises an exception if share is busy with an active task.""" if share.is_busy: diff --git a/manila/tests/api/v2/test_shares.py b/manila/tests/api/v2/test_shares.py index 33f4b31469..ddc445aa01 100644 --- a/manila/tests/api/v2/test_shares.py +++ b/manila/tests/api/v2/test_shares.py @@ -560,7 +560,8 @@ class ShareAPITest(test.TestCase): is_public=False, metadata=None, snapshot_id=None, - availability_zone=az_name) + availability_zone=az_name, + scheduler_hints=None) def test_share_create_with_sg_and_different_availability_zone(self): sg_id = 'fake_sg_id' diff --git a/manila/tests/scheduler/drivers/test_filter.py b/manila/tests/scheduler/drivers/test_filter.py index 9efefec220..913cbc1e2b 100644 --- a/manila/tests/scheduler/drivers/test_filter.py +++ b/manila/tests/scheduler/drivers/test_filter.py @@ -152,7 +152,7 @@ class FilterSchedulerTestCase(test_base.SchedulerTestCase): 'share_properties': {'project_id': 1, 'size': 1}, 'share_instance_properties': {}, 'share_type': {'name': 'NFS'}, - 'share_id': ['fake-id1'], + 'share_id': 'fake-id1', } self.assertRaises(exception.NoValidHost, sched.schedule_create_share, fake_context, request_spec, {}) @@ -177,7 +177,7 @@ class FilterSchedulerTestCase(test_base.SchedulerTestCase): 'share_properties': {'project_id': 1, 'size': 1}, 'share_instance_properties': {}, 'share_type': {'name': 'NFS'}, - 'share_id': ['fake-id1'], + 'share_id': 'fake-id1', } self.assertRaises(exception.NoValidHost, sched.schedule_create_share, fake_context, request_spec, {}) diff --git a/manila/tests/scheduler/filters/test_affinity.py b/manila/tests/scheduler/filters/test_affinity.py new file mode 100644 index 0000000000..424f4c9bd7 --- /dev/null +++ b/manila/tests/scheduler/filters/test_affinity.py @@ -0,0 +1,132 @@ +# Copyright (c) 2021 SAP. +# 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 ddt +from unittest import mock + +from manila import exception +from manila.scheduler.filters import affinity +from manila import test +from manila.tests.scheduler import fakes + + +fake_hosts = [ + fakes.FakeHostState('host1', {}), + fakes.FakeHostState('host2', {}), + fakes.FakeHostState('host3', {}), + ] + +fake_shares_1 = { + 'abb6e0ac-7c3e-4ce0-8a69-5a166d246882': { + 'instances': [ + {'host': fake_hosts[0].host} + ] + }, + '4de0cc74-450c-4468-8159-52128cf03407': { + 'instances': [ + {'host': fake_hosts[0].host} + ] + }, + } + +fake_shares_2 = { + 'c920fb61-e250-4c3c-a25d-1fdd9ca7cbc3': { + 'instances': [ + {'host': fake_hosts[1].host} + ] + }, + } + +fake_shares_3 = { + '3923bebf-9825-4a66-971e-6092a9fe2dbb': { + 'instances': [ + {'host': fake_hosts[2].host} + ] + }, + } + + +@ddt.ddt +class AffinityFilterTestCase(test.TestCase): + """Test case for AffinityFilter.""" + + def setUp(self): + super(AffinityFilterTestCase, self).setUp() + self.filter = affinity.AffinityFilter() + self.anti_filter = affinity.AntiAffinityFilter() + + def _make_filter_hints(self, *hints): + return { + 'context': None, + 'scheduler_hints': {'same_host': ','.join(list(hints))}, + } + + def _make_anti_filter_hints(self, *hints): + return { + 'context': None, + 'scheduler_hints': {'different_host': ','.join(list(hints))}, + } + + def _fake_get(self, context, uuid): + if uuid in fake_shares_1.keys(): + return fake_shares_1[uuid] + if uuid in fake_shares_2.keys(): + return fake_shares_2[uuid] + if uuid in fake_shares_3.keys(): + return fake_shares_3[uuid] + raise exception.ShareNotFound(uuid) + + @ddt.data('b5c207da-ac0b-43b0-8691-c6c9e860199d') + @mock.patch('manila.share.api.API.get') + def test_affinity_share_not_found(self, unknown_id, mock_share_get): + mock_share_get.side_effect = self._fake_get + self.assertRaises(exception.ShareNotFound, + self.filter._validate, + self._make_filter_hints(unknown_id)) + + @ddt.data( + {'context': None}, + {'context': None, 'scheduler_hints': None}, + {'context': None, 'scheduler_hints': {}}, + ) + def test_affinity_scheduler_hint_not_set(self, hints): + self.assertRaises(affinity.SchedulerHintsNotSet, + self.filter._validate, hints) + + @ mock.patch('manila.share.api.API.get') + def test_affinity_filter(self, mock_share_get): + mock_share_get.side_effect = self._fake_get + + share_ids = fake_shares_1.keys() + hints = self._make_filter_hints(*share_ids) + valid_hosts = self.filter.filter_all(fake_hosts, hints) + valid_hosts = [h.host for h in valid_hosts] + + self.assertIn('host1', valid_hosts) + self.assertNotIn('host2', valid_hosts) + self.assertNotIn('host3', valid_hosts) + + @ mock.patch('manila.share.api.API.get') + def test_anti_affinity_filter(self, mock_share_get): + mock_share_get.side_effect = self._fake_get + + share_ids = fake_shares_2.keys() + hints = self._make_anti_filter_hints(*share_ids) + valid_hosts = self.anti_filter.filter_all(fake_hosts, hints) + valid_hosts = [h.host for h in valid_hosts] + + self.assertIn('host1', valid_hosts) + self.assertIn('host3', valid_hosts) + self.assertNotIn('host2', valid_hosts) diff --git a/manila/tests/share/test_api.py b/manila/tests/share/test_api.py index ce4f2f2422..f993797b1a 100644 --- a/manila/tests/share/test_api.py +++ b/manila/tests/share/test_api.py @@ -825,7 +825,8 @@ class ShareAPITestCase(test.TestCase): self.context, share, share_network_id=fake_share_network_id, host=None, availability_zone=None, share_group=None, share_group_snapshot_member=None, share_type_id=None, - availability_zones=expected_azs, snapshot_host=None + availability_zones=expected_azs, snapshot_host=None, + scheduler_hints=None ) db_api.share_get.assert_called_once() @@ -980,7 +981,7 @@ class ShareAPITestCase(test.TestCase): share_instance, host, request_spec=mock.ANY, - filter_properties={}, + filter_properties={'scheduler_hints': None}, snapshot_id=share['snapshot_id'], ) self.assertFalse( @@ -993,7 +994,8 @@ class ShareAPITestCase(test.TestCase): (self.api.scheduler_rpcapi.create_share_instance. assert_called_once_with( - self.context, request_spec=mock.ANY, filter_properties={})) + self.context, request_spec=mock.ANY, + filter_properties={'scheduler_hints': None})) self.assertFalse(self.api.share_rpcapi.create_share_instance.called) def test_create_share_instance_from_snapshot(self): @@ -2273,7 +2275,8 @@ class ShareAPITestCase(test.TestCase): availability_zone=az, share_group=None, share_group_snapshot_member=None, availability_zones=None, - snapshot_host=snapshot['share']['instance']['host']) + snapshot_host=snapshot['share']['instance']['host'], + scheduler_hints=None) share_api.policy.check_policy.assert_called_once_with( self.context, 'share_snapshot', 'get_snapshot') quota.QUOTAS.reserve.assert_called_once_with( diff --git a/releasenotes/notes/affinity-filter-747d3d7c51157172.yaml b/releasenotes/notes/affinity-filter-747d3d7c51157172.yaml new file mode 100644 index 0000000000..068e895d23 --- /dev/null +++ b/releasenotes/notes/affinity-filter-747d3d7c51157172.yaml @@ -0,0 +1,14 @@ +--- +features: + - Add AffinityFilter and AntiAffinityFilter to manila's scheduler. + These hard affinity and anti-affinity filter needs user to specify + affinity/anti-affinity share ids to the field + "share.scheduler_hints.same_host" or + "share.scheduler_hints.different_host" in the request payload when + creating a manila share. The hints are stored as share metadata. The + filter properties are populated from this metadata during share + migration and so filters will be applied when migrating a manila share. +upgrade: + - To add AffinityFilter and AntiAffinityFilter to an active deployment, + their references must be added to the manila.scheduler.filters section in + setup.cfg and must be enabled in manila.conf. diff --git a/setup.cfg b/setup.cfg index b07fc0ed89..c717388169 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,6 +44,8 @@ console_scripts = wsgi_scripts = manila-wsgi = manila.wsgi.wsgi:initialize_application manila.scheduler.filters = + AffinityFilter = manila.scheduler.filters.affinity:AffinityFilter + AntiAffinityFilter = manila.scheduler.filters.affinity:AntiAffinityFilter AvailabilityZoneFilter = manila.scheduler.filters.availability_zone:AvailabilityZoneFilter CapabilitiesFilter = manila.scheduler.filters.capabilities:CapabilitiesFilter CapacityFilter = manila.scheduler.filters.capacity:CapacityFilter