From f858e537dd2f408b125fa4bd27b76feed73d749d Mon Sep 17 00:00:00 2001 From: Goutham Pacha Ravi Date: Tue, 13 Oct 2015 08:41:26 -0400 Subject: [PATCH] Share Replication API and Scheduler Support This patch provides the scheduler support to filter share backends matching replication capabilities reported by the hosts and the replication_type extra_spec provided via the share_type during share creation. It also adds wsgi routes, API endpoints and driver entry routines to support the actions: list, show, create, delete and promote share replicas. It augments the ShareInstance DB model with a 'replica_state' attribute and the Share DB Model with 'replication_type' attribute to support these workflows. Replica states are periodically updated from the respective backends that the replicas are created on. APIImpact Impact on existing APIs: In Microversion 2.11, the /shares APIs return 2 additional fields during index and show calls for each share: 'has_replicas' and 'replication_type'. Similarly, the field 'replica_state' is added to the API response for /share-instances. Also, deletion of a share that has replicas is forbidden, returning error code 403. DocImpact Co-Authored-By: Alex Meade Implements: blueprint manila-share-replication Change-Id: I10515d55b1291c34777a31d8c6a3a1954f551235 --- etc/manila/policy.json | 8 +- manila/api/openstack/api_version_request.py | 3 +- .../openstack/rest_api_version_history.rst | 7 + manila/api/v1/shares.py | 9 +- manila/api/v2/router.py | 7 + manila/api/v2/share_replicas.py | 182 +++++ manila/api/views/share_instance.py | 5 + manila/api/views/share_replicas.py | 75 ++ manila/api/views/shares.py | 6 + manila/common/constants.py | 11 +- manila/db/api.py | 68 +- .../293fac1130ca_add_replication_attrs.py | 41 ++ manila/db/sqlalchemy/api.py | 179 ++++- manila/db/sqlalchemy/models.py | 62 +- manila/exception.py | 9 + manila/scheduler/drivers/base.py | 15 + manila/scheduler/drivers/filter.py | 26 + manila/scheduler/host_manager.py | 7 + manila/scheduler/manager.py | 20 +- manila/scheduler/rpcapi.py | 16 +- manila/share/api.py | 243 ++++-- manila/share/driver.py | 301 ++++++++ manila/share/manager.py | 433 ++++++++++- manila/share/rpcapi.py | 43 +- manila/tests/api/contrib/stubs.py | 9 +- manila/tests/api/v2/test_share_instances.py | 17 +- manila/tests/api/v2/test_share_replicas.py | 514 +++++++++++++ manila/tests/api/v2/test_shares.py | 121 +++ manila/tests/db/fakes.py | 6 + .../alembic/migrations_data_checks.py | 119 +++ manila/tests/db/sqlalchemy/test_api.py | 338 +++++++++ manila/tests/db/sqlalchemy/test_models.py | 49 ++ manila/tests/db_utils.py | 12 + manila/tests/fake_share.py | 62 ++ manila/tests/fake_utils.py | 8 + manila/tests/scheduler/drivers/test_filter.py | 89 +++ manila/tests/scheduler/fakes.py | 71 +- manila/tests/scheduler/test_host_manager.py | 11 + manila/tests/scheduler/test_rpcapi.py | 7 + manila/tests/share/test_api.py | 243 +++++- manila/tests/share/test_driver.py | 26 + manila/tests/share/test_manager.py | 696 ++++++++++++++++++ manila/tests/share/test_rpcapi.py | 25 + manila/tests/test_exception.py | 14 + manila_tempest_tests/config.py | 2 +- manila_tempest_tests/tests/api/test_shares.py | 14 +- .../tests/api/test_shares_actions.py | 14 + .../share-replication-81ecf4a32a5c83b6.yaml | 4 + 48 files changed, 4143 insertions(+), 104 deletions(-) create mode 100644 manila/api/v2/share_replicas.py create mode 100644 manila/api/views/share_replicas.py create mode 100644 manila/db/migrations/alembic/versions/293fac1130ca_add_replication_attrs.py create mode 100644 manila/tests/api/v2/test_share_replicas.py create mode 100644 releasenotes/notes/share-replication-81ecf4a32a5c83b6.yaml diff --git a/etc/manila/policy.json b/etc/manila/policy.json index 80eb35419d..4cb593847e 100644 --- a/etc/manila/policy.json +++ b/etc/manila/policy.json @@ -110,5 +110,11 @@ "cgsnapshot:update" : "rule:default", "cgsnapshot:delete": "rule:default", "cgsnapshot:get_cgsnapshot": "rule:default", - "cgsnapshot:get_all": "rule:default" + "cgsnapshot:get_all": "rule:default", + + "share_replica:get_all": "rule:default", + "share_replica:show": "rule:default", + "share_replica:create" : "rule:default", + "share_replica:delete": "rule:default", + "share_replica:promote": "rule:default" } diff --git a/manila/api/openstack/api_version_request.py b/manila/api/openstack/api_version_request.py index c0d4b15ed0..14ad27c1ac 100644 --- a/manila/api/openstack/api_version_request.py +++ b/manila/api/openstack/api_version_request.py @@ -57,6 +57,7 @@ REST_API_VERSION_HISTORY = """ * 2.9 - Add export locations API * 2.10 - Field 'access_rules_status' was added to shares and share instances. + * 2.11 - Share Replication support """ @@ -64,7 +65,7 @@ REST_API_VERSION_HISTORY = """ # The default api version request is defined to be the # the minimum version of the API supported. _MIN_API_VERSION = "2.0" -_MAX_API_VERSION = "2.10" +_MAX_API_VERSION = "2.11" 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 e2da1efc39..fed16ea22a 100644 --- a/manila/api/openstack/rest_api_version_history.rst +++ b/manila/api/openstack/rest_api_version_history.rst @@ -78,3 +78,10 @@ user documentation. 2.10 ---- Field 'access_rules_status' was added to shares and share instances. + +2.11 +---- + Share Replication support added. All Share replication APIs are tagged + 'Experimental'. Share APIs return two new attributes: 'has_replicas' and + 'replication_type'. Share instance APIs return a new attribute, + 'replica_state'. diff --git a/manila/api/v1/shares.py b/manila/api/v1/shares.py index 3ae213377c..a65ee5861f 100644 --- a/manila/api/v1/shares.py +++ b/manila/api/v1/shares.py @@ -93,6 +93,8 @@ class ShareMixin(object): raise exc.HTTPNotFound() except exception.InvalidShare as e: raise exc.HTTPForbidden(explanation=six.text_type(e)) + except exception.Conflict as e: + raise exc.HTTPConflict(explanation=six.text_type(e)) return webob.Response(status_int=202) @@ -116,7 +118,12 @@ class ShareMixin(object): except ValueError: raise exc.HTTPBadRequest( explanation=_("Bad value for 'force_host_copy'")) - self.share_api.migrate_share(context, share, host, force_host_copy) + + try: + self.share_api.migrate_share(context, share, host, force_host_copy) + except exception.Conflict as e: + raise exc.HTTPConflict(explanation=six.text_type(e)) + return webob.Response(status_int=202) def index(self, req): diff --git a/manila/api/v2/router.py b/manila/api/v2/router.py index b8ac74f268..3d32776e12 100644 --- a/manila/api/v2/router.py +++ b/manila/api/v2/router.py @@ -41,6 +41,7 @@ from manila.api.v2 import services from manila.api.v2 import share_export_locations from manila.api.v2 import share_instance_export_locations from manila.api.v2 import share_instances +from manila.api.v2 import share_replicas from manila.api.v2 import share_types from manila.api.v2 import shares from manila.api import versions @@ -280,3 +281,9 @@ class APIRouter(manila.api.openstack.APIRouter): controller=self.resources["cgsnapshots"], collection={"detail": "GET"}, member={"members": "GET", "action": "POST"}) + + self.resources['share-replicas'] = share_replicas.create_resource() + mapper.resource("share-replica", "share-replicas", + controller=self.resources['share-replicas'], + collection={'detail': 'GET'}, + member={'action': 'POST'}) diff --git a/manila/api/v2/share_replicas.py b/manila/api/v2/share_replicas.py new file mode 100644 index 0000000000..3392f9edca --- /dev/null +++ b/manila/api/v2/share_replicas.py @@ -0,0 +1,182 @@ +# Copyright 2015 Goutham Pacha Ravi +# 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. + +"""The Share Replication API.""" + +from oslo_log import log +import six +import webob +from webob import exc + +from manila.api import common +from manila.api.openstack import wsgi +from manila.api.views import share_replicas as replication_view +from manila.common import constants +from manila import db +from manila import exception +from manila.i18n import _ +from manila import share + + +LOG = log.getLogger(__name__) +MIN_SUPPORTED_API_VERSION = '2.11' + + +class ShareReplicationController(wsgi.Controller): + """The Share Replication API controller for the OpenStack API.""" + + resource_name = 'share_replica' + _view_builder_class = replication_view.ReplicationViewBuilder + + def __init__(self): + super(ShareReplicationController, self).__init__() + self.share_api = share.API() + + @wsgi.Controller.api_version(MIN_SUPPORTED_API_VERSION, experimental=True) + def index(self, req): + """Return a summary list of replicas.""" + return self._get_replicas(req) + + @wsgi.Controller.api_version(MIN_SUPPORTED_API_VERSION, experimental=True) + def detail(self, req): + """Returns a detailed list of replicas.""" + return self._get_replicas(req, is_detail=True) + + @wsgi.Controller.authorize('get_all') + def _get_replicas(self, req, is_detail=False): + """Returns list of replicas.""" + context = req.environ['manila.context'] + + share_id = req.params.get('share_id') + if share_id: + try: + replicas = db.share_replicas_get_all_by_share( + context, share_id) + except exception.NotFound: + msg = _("Share with share ID %s not found.") % share_id + raise exc.HTTPNotFound(explanation=msg) + else: + replicas = db.share_replicas_get_all(context) + + limited_list = common.limited(replicas, req) + if is_detail: + replicas = self._view_builder.detail_list(req, limited_list) + else: + replicas = self._view_builder.summary_list(req, limited_list) + + return replicas + + @wsgi.Controller.api_version(MIN_SUPPORTED_API_VERSION, experimental=True) + @wsgi.Controller.authorize + def show(self, req, id): + """Return data about the given replica.""" + context = req.environ['manila.context'] + + try: + replica = db.share_replica_get(context, id) + except exception.ShareReplicaNotFound: + msg = _("Replica %s not found.") % id + raise exc.HTTPNotFound(explanation=msg) + + return self._view_builder.detail(req, replica) + + @wsgi.Controller.api_version(MIN_SUPPORTED_API_VERSION, experimental=True) + @wsgi.response(202) + @wsgi.Controller.authorize + def create(self, req, body): + """Add a replica to an existing share.""" + context = req.environ['manila.context'] + + if not self.is_valid_body(body, 'share_replica'): + msg = _("Body does not contain 'share_replica' information.") + raise exc.HTTPUnprocessableEntity(explanation=msg) + + share_id = body.get('share_replica').get('share_id') + availability_zone = body.get('share_replica').get('availability_zone') + share_network_id = body.get('share_replica').get('share_network_id') + + if not share_id: + msg = _("Must provide Share ID to add replica.") + raise exc.HTTPBadRequest(explanation=msg) + + try: + share_ref = db.share_get(context, share_id) + except exception.NotFound: + msg = _("No share exists with ID %s.") + raise exc.HTTPNotFound(explanation=msg % share_id) + + try: + new_replica = self.share_api.create_share_replica( + context, share_ref, availability_zone=availability_zone, + share_network_id=share_network_id) + except exception.AvailabilityZoneNotFound as e: + raise exc.HTTPBadRequest(explanation=six.text_type(e)) + except exception.ReplicationException as e: + raise exc.HTTPBadRequest(explanation=six.text_type(e)) + except exception.ShareBusyException as e: + raise exc.HTTPBadRequest(explanation=six.text_type(e)) + + return self._view_builder.detail(req, new_replica) + + @wsgi.Controller.api_version(MIN_SUPPORTED_API_VERSION, experimental=True) + @wsgi.Controller.authorize + def delete(self, req, id): + """Delete a replica.""" + context = req.environ['manila.context'] + + try: + replica = db.share_replica_get(context, id) + except exception.ShareReplicaNotFound: + msg = _("No replica exists with ID %s.") + raise exc.HTTPNotFound(explanation=msg % id) + + try: + self.share_api.delete_share_replica(context, replica) + except exception.ReplicationException as e: + raise exc.HTTPBadRequest(explanation=six.text_type(e)) + + return webob.Response(status_int=202) + + @wsgi.Controller.api_version(MIN_SUPPORTED_API_VERSION, experimental=True) + @wsgi.action('promote') + @wsgi.response(202) + @wsgi.Controller.authorize + def promote(self, req, id, body): + """Promote a replica to active state.""" + context = req.environ['manila.context'] + + try: + replica = db.share_replica_get(context, id) + except exception.ShareReplicaNotFound: + msg = _("No replica exists with ID %s.") + raise exc.HTTPNotFound(explanation=msg % id) + + replica_state = replica.get('replica_state') + + if replica_state == constants.REPLICA_STATE_ACTIVE: + return webob.Response(status_int=200) + + try: + replica = self.share_api.promote_share_replica(context, replica) + except exception.ReplicationException as e: + raise exc.HTTPBadRequest(explanation=six.text_type(e)) + except exception.AdminRequired as e: + raise exc.HTTPForbidden(explanation=six.text_type(e)) + + return self._view_builder.detail(req, replica) + + +def create_resource(): + return wsgi.Resource(ShareReplicationController()) diff --git a/manila/api/views/share_instance.py b/manila/api/views/share_instance.py index 70a353099a..d8e7905139 100644 --- a/manila/api/views/share_instance.py +++ b/manila/api/views/share_instance.py @@ -21,6 +21,7 @@ class ViewBuilder(common.ViewBuilder): _detail_version_modifiers = [ "remove_export_locations", "add_access_rules_status_field", + "add_replication_fields", ] def detail_list(self, request, instances): @@ -71,3 +72,7 @@ class ViewBuilder(common.ViewBuilder): instance_dict['access_rules_status'] = ( share_instance.get('access_rules_status') ) + + @common.ViewBuilder.versioned_method("2.11") + def add_replication_fields(self, instance_dict, share_instance): + instance_dict['replica_state'] = share_instance.get('replica_state') diff --git a/manila/api/views/share_replicas.py b/manila/api/views/share_replicas.py new file mode 100644 index 0000000000..bfd759ffbf --- /dev/null +++ b/manila/api/views/share_replicas.py @@ -0,0 +1,75 @@ +# Copyright 2015 Goutham Pacha Ravi +# 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 manila.api import common + + +class ReplicationViewBuilder(common.ViewBuilder): + """Model a server API response as a python dictionary.""" + + _collection_name = 'share_replicas' + _collection_links = 'share_replica_links' + + def summary_list(self, request, replicas): + """Summary view of a list of replicas.""" + return self._list_view(self.summary, request, replicas) + + def detail_list(self, request, replicas): + """Detailed view of a list of replicas.""" + return self._list_view(self.detail, request, replicas) + + def summary(self, request, replica): + """Generic, non-detailed view of a share replica.""" + + replica_dict = { + 'id': replica.get('id'), + 'share_id': replica.get('share_id'), + 'status': replica.get('status'), + 'replica_state': replica.get('replica_state'), + } + return {'share_replica': replica_dict} + + def detail(self, request, replica): + """Detailed view of a single replica.""" + + replica_dict = { + 'id': replica.get('id'), + 'share_id': replica.get('share_id'), + 'availability_zone': replica.get('availability_zone'), + 'created_at': replica.get('created_at'), + 'host': replica.get('host'), + 'status': replica.get('status'), + 'share_network_id': replica.get('share_network_id'), + 'share_server_id': replica.get('share_server_id'), + 'replica_state': replica.get('replica_state') + } + + return {'share_replica': replica_dict} + + def _list_view(self, func, request, replicas): + """Provide a view for a list of replicas.""" + + replicas_list = [func(request, replica)['share_replica'] + for replica in replicas] + + replica_links = self._get_collection_links(request, + replicas, + self._collection_name) + replicas_dict = {self._collection_name: replicas_list} + + if replica_links: + replicas_dict[self._collection_links] = replica_links + + return replicas_dict diff --git a/manila/api/views/shares.py b/manila/api/views/shares.py index b84aaa2a31..dc223fa6a7 100644 --- a/manila/api/views/shares.py +++ b/manila/api/views/shares.py @@ -27,6 +27,7 @@ class ViewBuilder(common.ViewBuilder): "modify_share_type_field", "remove_export_locations", "add_access_rules_status_field", + "add_replication_fields", ] def summary_list(self, request, shares): @@ -128,6 +129,11 @@ class ViewBuilder(common.ViewBuilder): def add_access_rules_status_field(self, share_dict, share): share_dict['access_rules_status'] = share.get('access_rules_status') + @common.ViewBuilder.versioned_method('2.11') + def add_replication_fields(self, share_dict, share): + share_dict['replication_type'] = share.get('replication_type') + share_dict['has_replicas'] = share['has_replicas'] + def _list_view(self, func, request, shares): """Provide a view for a list of shares.""" shares_list = [func(request, share)['share'] for share in shares] diff --git a/manila/common/constants.py b/manila/common/constants.py index 53e25376e2..eb842f470a 100644 --- a/manila/common/constants.py +++ b/manila/common/constants.py @@ -35,6 +35,7 @@ STATUS_SHRINKING_ERROR = 'shrinking_error' STATUS_SHRINKING_POSSIBLE_DATA_LOSS_ERROR = ( 'shrinking_possible_data_loss_error' ) +STATUS_REPLICATION_CHANGE = 'replication_change' STATUS_TASK_STATE_MIGRATION_STARTING = 'migration_starting' STATUS_TASK_STATE_MIGRATION_IN_PROGRESS = 'migration_in_progress' STATUS_TASK_STATE_MIGRATION_ERROR = 'migration_error' @@ -104,12 +105,17 @@ TASK_STATE_STATUSES = ( STATUS_TASK_STATE_MIGRATION_IN_PROGRESS, ) +REPLICA_STATE_ACTIVE = 'active' +REPLICA_STATE_IN_SYNC = 'in_sync' +REPLICA_STATE_OUT_OF_SYNC = 'out_of_sync' + class ExtraSpecs(object): # Extra specs key names DRIVER_HANDLES_SHARE_SERVERS = "driver_handles_share_servers" SNAPSHOT_SUPPORT = "snapshot_support" + REPLICATION_TYPE_SPEC = "replication_type" # Extra specs containers REQUIRED = ( @@ -120,9 +126,8 @@ class ExtraSpecs(object): SNAPSHOT_SUPPORT, ) # NOTE(cknight): Some extra specs are necessary parts of the Manila API and - # should be visible to non-admin users. This list matches the UNDELETABLE - # list today, but that may not always remain true. - TENANT_VISIBLE = UNDELETABLE + # should be visible to non-admin users. UNDELETABLE specs are user-visible. + TENANT_VISIBLE = UNDELETABLE + (REPLICATION_TYPE_SPEC, ) BOOLEAN = ( DRIVER_HANDLES_SHARE_SERVERS, SNAPSHOT_SUPPORT, diff --git a/manila/db/api.py b/manila/db/api.py index 1035cf6232..3cc49338db 100644 --- a/manila/db/api.py +++ b/manila/db/api.py @@ -43,7 +43,6 @@ these objects be simple dictionaries. from oslo_config import cfg from oslo_db import api as db_api - db_opts = [ cfg.StrOpt('db_backend', default='sqlalchemy', @@ -79,6 +78,8 @@ def authorize_quota_class_context(context, class_name): ################### + + def service_destroy(context, service_id): """Destroy the service or raise if it does not exist.""" return IMPL.service_destroy(context, service_id) @@ -415,6 +416,15 @@ def share_access_create(context, values): return IMPL.share_access_create(context, values) +def share_instance_access_copy(context, share_id, instance_id): + """Maps the existing access rules for the share to the instance in the DB. + + Adds the instance mapping to the share's access rules and + returns the share's access rules. + """ + return IMPL.share_instance_access_copy(context, share_id, instance_id) + + def share_access_get(context, access_id): """Get share access rule.""" return IMPL.share_access_get(context, access_id) @@ -1028,3 +1038,59 @@ def cgsnapshot_member_update(context, member_id, values): Raises NotFound if cgsnapshot member does not exist. """ return IMPL.cgsnapshot_member_update(context, member_id, values) + + +#################### + +def share_replicas_get_all(context, with_share_server=False, + with_share_data=False): + """Returns all share replicas regardless of share.""" + return IMPL.share_replicas_get_all( + context, with_share_server=with_share_server, + with_share_data=with_share_data) + + +def share_replicas_get_all_by_share(context, share_id, with_share_server=False, + with_share_data=False): + """Returns all share replicas for a given share.""" + return IMPL.share_replicas_get_all_by_share( + context, share_id, with_share_server=with_share_server, + with_share_data=with_share_data) + + +def share_replicas_get_available_active_replica(context, share_id, + with_share_server=False, + with_share_data=False): + """Returns an active replica for a given share.""" + return IMPL.share_replicas_get_available_active_replica( + context, share_id, with_share_server=with_share_server, + with_share_data=with_share_data) + + +def share_replicas_get_active_replicas_by_share(context, share_id, + with_share_server=False, + with_share_data=False): + """Returns all active replicas for a given share.""" + return IMPL.share_replicas_get_active_replicas_by_share( + context, share_id, with_share_server=with_share_server, + with_share_data=with_share_data) + + +def share_replica_get(context, replica_id, with_share_server=False, + with_share_data=False): + """Get share replica by id.""" + return IMPL.share_replica_get( + context, replica_id, with_share_server=with_share_server, + with_share_data=with_share_data) + + +def share_replica_update(context, share_replica_id, values, + with_share_data=False): + """Updates a share replica with given values.""" + return IMPL.share_replica_update(context, share_replica_id, values, + with_share_data=with_share_data) + + +def share_replica_delete(context, share_replica_id): + """Deletes a share replica.""" + return IMPL.share_replica_delete(context, share_replica_id) diff --git a/manila/db/migrations/alembic/versions/293fac1130ca_add_replication_attrs.py b/manila/db/migrations/alembic/versions/293fac1130ca_add_replication_attrs.py new file mode 100644 index 0000000000..18c4157af5 --- /dev/null +++ b/manila/db/migrations/alembic/versions/293fac1130ca_add_replication_attrs.py @@ -0,0 +1,41 @@ +# Copyright 2015 Goutham Pacha Ravi. +# 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. + +"""Add replication attributes to Share and ShareInstance models. + +Revision ID: 293fac1130ca +Revises: 344c1ac4747f +Create Date: 2015-09-10 15:45:07.273043 + +""" + +# revision identifiers, used by Alembic. +revision = '293fac1130ca' +down_revision = '344c1ac4747f' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + """Add replication attributes to Shares and ShareInstances.""" + op.add_column('shares', sa.Column('replication_type', sa.String(255))) + op.add_column('share_instances', + sa.Column('replica_state', sa.String(255))) + + +def downgrade(): + """Remove replication attributes from Shares and ShareInstances.""" + op.drop_column('shares', 'replication_type') + op.drop_column('share_instances', 'replica_state') diff --git a/manila/db/sqlalchemy/api.py b/manila/db/sqlalchemy/api.py index 01e7b4e708..f39e2ddc61 100644 --- a/manila/db/sqlalchemy/api.py +++ b/manila/db/sqlalchemy/api.py @@ -1292,6 +1292,162 @@ def share_instances_get_all_by_consistency_group_id(context, cg_id): return instances +################ + +def _share_replica_get_with_filters(context, share_id=None, replica_id=None, + replica_state=None, status=None, + with_share_server=True, session=None): + + query = model_query(context, models.ShareInstance, session=session, + read_deleted="no") + + if share_id is not None: + query = query.filter(models.ShareInstance.share_id == share_id) + + if replica_id is not None: + query = query.filter(models.ShareInstance.id == replica_id) + + if replica_state is not None: + query = query.filter( + models.ShareInstance.replica_state == replica_state) + else: + query = query.filter(models.ShareInstance.replica_state.isnot(None)) + + if status is not None: + query = query.filter(models.ShareInstance.status == status) + + if with_share_server: + query = query.options(joinedload('share_server')) + + return query + + +def _set_replica_share_data(context, replicas, session): + if replicas and not isinstance(replicas, list): + replicas = [replicas] + + for replica in replicas: + parent_share = share_get(context, replica['share_id'], session=session) + replica.set_share_data(parent_share) + + return replicas + + +@require_context +def share_replicas_get_all(context, with_share_data=False, + with_share_server=True, session=None): + """Returns replica instances for all available replicated shares.""" + session = session or get_session() + + result = _share_replica_get_with_filters( + context, with_share_server=with_share_server, session=session).all() + + if with_share_data: + result = _set_replica_share_data(context, result, session) + + return result + + +@require_context +def share_replicas_get_all_by_share(context, share_id, + with_share_data=False, + with_share_server=False, session=None): + """Returns replica instances for a given share.""" + session = session or get_session() + + result = _share_replica_get_with_filters( + context, with_share_server=with_share_server, + share_id=share_id, session=session).all() + + if with_share_data: + result = _set_replica_share_data(context, result, session) + + return result + + +@require_context +def share_replicas_get_available_active_replica(context, share_id, + with_share_data=False, + with_share_server=False, + session=None): + """Returns an 'active' replica instance that is 'available'.""" + session = session or get_session() + + result = _share_replica_get_with_filters( + context, with_share_server=with_share_server, share_id=share_id, + replica_state=constants.REPLICA_STATE_ACTIVE, + status=constants.STATUS_AVAILABLE, session=session).first() + + if result and with_share_data: + result = _set_replica_share_data(context, result, session)[0] + + return result + + +@require_context +def share_replicas_get_active_replicas_by_share(context, share_id, + with_share_data=False, + with_share_server=False, + session=None): + """Returns all active replicas for a given share.""" + session = session or get_session() + + result = _share_replica_get_with_filters( + context, with_share_server=with_share_server, share_id=share_id, + replica_state=constants.REPLICA_STATE_ACTIVE, session=session).all() + + if with_share_data: + result = _set_replica_share_data(context, result, session) + + return result + + +@require_context +def share_replica_get(context, replica_id, with_share_data=False, + with_share_server=False, session=None): + """Returns summary of requested replica if available.""" + session = session or get_session() + + result = _share_replica_get_with_filters( + context, with_share_server=with_share_server, + replica_id=replica_id, session=session).first() + + if result is None: + raise exception.ShareReplicaNotFound(replica_id=replica_id) + + if with_share_data: + result = _set_replica_share_data(context, result, session)[0] + + return result + + +@require_context +@oslo_db_api.wrap_db_retry(max_retries=5, retry_on_deadlock=True) +def share_replica_update(context, share_replica_id, values, + with_share_data=False, session=None): + """Updates a share replica with specified values.""" + session = session or get_session() + + with session.begin(): + ensure_availability_zone_exists(context, values, session, strict=False) + updated_share_replica = _share_instance_update( + context, share_replica_id, values, session=session) + + if with_share_data: + updated_share_replica = _set_replica_share_data( + context, updated_share_replica, session)[0] + + return updated_share_replica + + +@require_context +def share_replica_delete(context, share_replica_id, session=None): + """Deletes a share replica.""" + session = session or get_session() + + share_instance_delete(context, share_replica_id, session=session) + + ################ @@ -1378,8 +1534,10 @@ def share_update(context, share_id, values): @require_context def share_get(context, share_id, session=None): result = _share_get_query(context, session).filter_by(id=share_id).first() + if result is None: raise exception.NotFound() + return result @@ -1574,6 +1732,23 @@ def share_access_create(context, values): return share_access_get(context, access_ref['id']) +def share_instance_access_copy(context, share_id, instance_id, session=None): + """Copy access rules from share to share instance.""" + session = session or get_session() + + share_access_rules = share_access_get_all_for_share( + context, share_id, session=session) + for access_rule in share_access_rules: + values = { + 'share_instance_id': instance_id, + 'access_id': access_rule['id'], + } + + _share_instance_access_create(values, session) + + return share_access_rules + + def _share_instance_access_create(values, session): access_ref = models.ShareInstanceAccessMapping() access_ref.update(ensure_model_dict_has_id(values)) @@ -1608,8 +1783,8 @@ def share_instance_access_get(context, access_id, instance_id): @require_context -def share_access_get_all_for_share(context, share_id): - session = get_session() +def share_access_get_all_for_share(context, share_id, session=None): + session = session or get_session() return _share_access_get_query(context, session, {'share_id': share_id}).all() diff --git a/manila/db/sqlalchemy/models.py b/manila/db/sqlalchemy/models.py index cf6ba7b973..ceda05112a 100644 --- a/manila/db/sqlalchemy/models.py +++ b/manila/db/sqlalchemy/models.py @@ -209,10 +209,16 @@ class Share(BASE, ManilaBase): @property def export_locations(self): - # TODO(u_glide): Return a map with lists of locations per AZ when - # replication functionality will be implemented. + # TODO(gouthamr): Return AZ specific export locations for replicated + # shares. + # NOTE(gouthamr): For a replicated share, export locations of the + # 'active' instances are chosen, if 'available'. all_export_locations = [] - for instance in self.instances: + select_instances = list(filter( + lambda x: x['replica_state'] == constants.REPLICA_STATE_ACTIVE, + self.instances)) or self.instances + + for instance in select_instances: if instance['status'] == constants.STATUS_AVAILABLE: for export_location in instance.export_locations: all_export_locations.append(export_location['path']) @@ -238,19 +244,46 @@ class Share(BASE, ManilaBase): def share_server_id(self): return self.__getattr__('share_server_id') + @property + def has_replicas(self): + if len(self.instances) > 1: + # NOTE(gouthamr): The 'primary' instance of a replicated share + # has a 'replica_state' set to 'active'. Only the secondary replica + # instances need to be regarded as true 'replicas' by users. + replicas = (list(filter(lambda x: x['replica_state'] is not None, + self.instances))) + return len(replicas) > 1 + return False + @property def instance(self): - # NOTE(ganso): We prefer instances with AVAILABLE status, - # and we also prefer to show any status other than TRANSITIONAL ones. + # NOTE(gouthamr): The order of preference: status 'replication_change', + # followed by 'available' and 'error'. If replicated share and + # not undergoing a 'replication_change', only 'active' instances are + # preferred. result = None if len(self.instances) > 0: - for instance in self.instances: - if instance.status == constants.STATUS_AVAILABLE: - return instance - elif instance.status not in constants.TRANSITIONAL_STATUSES: - result = instance - if result is None: - result = self.instances[0] + order = (constants.STATUS_REPLICATION_CHANGE, + constants.STATUS_AVAILABLE, constants.STATUS_ERROR) + other_statuses = ( + [x['status'] for x in self.instances if + x['status'] not in order and + x['status'] not in constants.TRANSITIONAL_STATUSES] + ) + order = (order + tuple(other_statuses) + + constants.TRANSITIONAL_STATUSES) + sorted_instances = sorted( + self.instances, key=lambda x: order.index(x['status'])) + + select_instances = sorted_instances + if (select_instances[0]['status'] != + constants.STATUS_REPLICATION_CHANGE): + select_instances = ( + list(filter(lambda x: x['replica_state'] == + constants.REPLICA_STATE_ACTIVE, + sorted_instances)) or sorted_instances + ) + result = select_instances[0] return result @property @@ -267,6 +300,7 @@ class Share(BASE, ManilaBase): display_description = Column(String(255)) snapshot_id = Column(String(36)) snapshot_support = Column(Boolean, default=True) + replication_type = Column(String(255), nullable=True) share_proto = Column(String(255)) share_type_id = Column(String(36), ForeignKey('share_types.id'), nullable=True) @@ -300,7 +334,8 @@ class Share(BASE, ManilaBase): class ShareInstance(BASE, ManilaBase): __tablename__ = 'share_instances' - _extra_keys = ['name', 'export_location', 'availability_zone'] + _extra_keys = ['name', 'export_location', 'availability_zone', + 'replica_state'] _proxified_properties = ('user_id', 'project_id', 'size', 'display_name', 'display_description', 'snapshot_id', 'share_proto', 'share_type_id', @@ -345,6 +380,7 @@ class ShareInstance(BASE, ManilaBase): scheduled_at = Column(DateTime) launched_at = Column(DateTime) terminated_at = Column(DateTime) + replica_state = Column(String(255), nullable=True) availability_zone_id = Column(String(36), ForeignKey('availability_zones.id'), diff --git a/manila/exception.py b/manila/exception.py index 7a09e0cb40..296f02220f 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -718,3 +718,12 @@ class ShareMountException(ManilaException): class ShareCopyDataException(ManilaException): message = _("Failed to copy data: %(reason)s") + + +# Replication +class ReplicationException(ManilaException): + message = _("Unable to perform a replication action: %(reason)s.") + + +class ShareReplicaNotFound(NotFound): + message = _("Share Replica %(replica_id)s could not be found.") diff --git a/manila/scheduler/drivers/base.py b/manila/scheduler/drivers/base.py index e20e55a8d8..cff8a1a266 100644 --- a/manila/scheduler/drivers/base.py +++ b/manila/scheduler/drivers/base.py @@ -51,6 +51,16 @@ def share_update_db(context, share_id, host): return db.share_update(context, share_id, values) +def share_replica_update_db(context, share_replica_id, host): + """Set the host and the scheduled_at field of a share replica. + + :returns: A Share Replica with the updated fields set. + """ + now = timeutils.utcnow() + values = {'host': host, 'scheduled_at': now} + return db.share_replica_update(context, share_replica_id, values) + + def cg_update_db(context, cg_id, host): '''Set the host and set the updated_at field of a consistency group. @@ -114,3 +124,8 @@ class Scheduler(object): filter_properties): """Must override schedule method for migration to work.""" raise NotImplementedError(_("Must implement host_passes_filters")) + + def schedule_create_replica(self, context, request_spec, + filter_properties): + """Must override schedule method for create replica to work.""" + raise NotImplementedError(_("Must implement schedule_create_replica")) diff --git a/manila/scheduler/drivers/filter.py b/manila/scheduler/drivers/filter.py index 942e354487..b7d2c18d8c 100644 --- a/manila/scheduler/drivers/filter.py +++ b/manila/scheduler/drivers/filter.py @@ -105,6 +105,32 @@ class FilterScheduler(base.Scheduler): snapshot_id=snapshot_id ) + def schedule_create_replica(self, context, request_spec, + filter_properties): + share_replica_id = request_spec['share_instance_properties'].get('id') + + weighed_host = self._schedule_share( + context, request_spec, filter_properties) + + if not weighed_host: + msg = _('Failed to find a weighted host for scheduling share ' + 'replica %s.') + raise exception.NoValidHost(reason=msg % share_replica_id) + + host = weighed_host.obj.host + + updated_share_replica = base.share_replica_update_db( + context, share_replica_id, host) + self._post_select_populate_filter_properties(filter_properties, + weighed_host.obj) + + # context is not serializable + filter_properties.pop('context', None) + + self.share_rpcapi.create_share_replica( + context, updated_share_replica, host, request_spec=request_spec, + filter_properties=filter_properties) + def _format_filter_properties(self, context, filter_properties, request_spec): diff --git a/manila/scheduler/host_manager.py b/manila/scheduler/host_manager.py index 34ffc2fbd2..c4339d59fb 100644 --- a/manila/scheduler/host_manager.py +++ b/manila/scheduler/host_manager.py @@ -127,6 +127,7 @@ class HostState(object): self.consistency_group_support = False self.dedupe = False self.compression = False + self.replication_type = None # PoolState for all pools self.pools = {} @@ -292,6 +293,9 @@ class HostState(object): if 'compression' not in pool_cap: pool_cap['compression'] = self.compression + if not pool_cap.get('replication_type'): + pool_cap['replication_type'] = self.replication_type + def update_backend(self, capability): self.share_backend_name = capability.get('share_backend_name') self.vendor_name = capability.get('vendor_name') @@ -303,6 +307,7 @@ class HostState(object): self.consistency_group_support = capability.get( 'consistency_group_support', False) self.updated = capability['timestamp'] + self.replication_type = capability.get('replication_type') def consume_from_share(self, share): """Incrementally update host state from an share.""" @@ -365,6 +370,8 @@ class PoolState(HostState): 'dedupe', False) self.compression = capability.get( 'compression', False) + self.replication_type = capability.get( + 'replication_type', self.replication_type) def update_pools(self, capability): # Do nothing, since we don't have pools within pool, yet diff --git a/manila/scheduler/manager.py b/manila/scheduler/manager.py index 689819ead6..b150d06e0e 100644 --- a/manila/scheduler/manager.py +++ b/manila/scheduler/manager.py @@ -59,7 +59,7 @@ MAPPING = { class SchedulerManager(manager.Manager): """Chooses a host to create shares.""" - RPC_API_VERSION = '1.4' + RPC_API_VERSION = '1.5' def __init__(self, scheduler_driver=None, service_name=None, *args, **kwargs): @@ -206,3 +206,21 @@ class SchedulerManager(manager.Manager): with excutils.save_and_reraise_exception(): self._set_cg_error_state('create_consistency_group', context, ex, request_spec) + + def create_share_replica(self, context, request_spec=None, + filter_properties=None): + try: + self.driver.schedule_create_replica(context, request_spec, + filter_properties) + except Exception as ex: + with excutils.save_and_reraise_exception(): + + msg = _LW("Failed to schedule the new share replica: %s") + + LOG.warning(msg % ex) + + db.share_replica_update( + context, + request_spec.get('share_instance_properties').get('id'), + {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) diff --git a/manila/scheduler/rpcapi.py b/manila/scheduler/rpcapi.py index bdc4014554..2db3ecad4e 100644 --- a/manila/scheduler/rpcapi.py +++ b/manila/scheduler/rpcapi.py @@ -36,15 +36,16 @@ class SchedulerAPI(object): Replace create_share() - > create_share_instance() 1.3 - Add create_consistency_group method 1.4 - Add migrate_share_to_host method + 1.5 - Add create_share_replica """ - RPC_API_VERSION = '1.4' + RPC_API_VERSION = '1.5' def __init__(self): super(SchedulerAPI, self).__init__() target = messaging.Target(topic=CONF.scheduler_topic, version=self.RPC_API_VERSION) - self.client = rpc.get_client(target, version_cap='1.4') + self.client = rpc.get_client(target, version_cap='1.5') def create_share_instance(self, ctxt, request_spec=None, filter_properties=None): @@ -98,3 +99,14 @@ class SchedulerAPI(object): force_host_copy=force_host_copy, request_spec=request_spec_p, filter_properties=filter_properties) + + def create_share_replica(self, ctxt, request_spec=None, + filter_properties=None): + request_spec_p = jsonutils.to_primitive(request_spec) + cctxt = self.client.prepare(version='1.5') + return cctxt.cast( + ctxt, + 'create_share_replica', + request_spec=request_spec_p, + filter_properties=filter_properties, + ) diff --git a/manila/share/api.py b/manila/share/api.py index ab90fc6b3a..0e74c9cd54 100644 --- a/manila/share/api.py +++ b/manila/share/api.py @@ -170,6 +170,8 @@ class API(base.Base): share_type.get('extra_specs', {}).get( 'snapshot_support', True) if share_type else True, strict=True) + replication_type = share_type.get('extra_specs', {}).get( + 'replication_type') if share_type else None except ValueError as e: raise exception.InvalidParameterValue(six.text_type(e)) @@ -221,6 +223,7 @@ class API(base.Base): 'project_id': context.project_id, 'snapshot_id': snapshot_id, 'snapshot_support': snapshot_support, + 'replication_type': replication_type, 'metadata': metadata, 'display_name': name, 'display_description': description, @@ -264,25 +267,11 @@ class API(base.Base): consistency_group=None, cgsnapshot_member=None): policy.check_policy(context, 'share', 'create') - availability_zone_id = None - if availability_zone: - availability_zone_id = self.db.availability_zone_get( - context, availability_zone).id - - # TODO(u_glide): Add here validation that provided share network - # doesn't conflict with provided availability_zone when Neutron - # will have AZ support. - - share_instance = self.db.share_instance_create( - context, share['id'], - { - 'share_network_id': share_network_id, - 'status': constants.STATUS_CREATING, - 'scheduled_at': timeutils.utcnow(), - 'host': host if host else '', - 'availability_zone_id': availability_zone_id, - } - ) + request_spec, share_instance = ( + self._create_share_instance_and_get_request_spec( + context, share, availability_zone=availability_zone, + consistency_group=consistency_group, host=host, + share_network_id=share_network_id)) if cgsnapshot_member: host = cgsnapshot_member['share']['host'] @@ -292,45 +281,6 @@ class API(base.Base): # NOTE(ameade): Do not cast to driver if creating from cgsnapshot return - share_properties = { - 'size': share['size'], - 'user_id': share['user_id'], - 'project_id': share['project_id'], - 'metadata': self.db.share_metadata_get(context, share['id']), - 'share_server_id': share['share_server_id'], - 'snapshot_support': share['snapshot_support'], - 'share_proto': share['share_proto'], - 'share_type_id': share['share_type_id'], - 'is_public': share['is_public'], - 'consistency_group_id': share['consistency_group_id'], - 'source_cgsnapshot_member_id': share[ - 'source_cgsnapshot_member_id'], - 'snapshot_id': share['snapshot_id'], - } - share_instance_properties = { - 'availability_zone_id': share_instance['availability_zone_id'], - 'share_network_id': share_instance['share_network_id'], - 'share_server_id': share_instance['share_server_id'], - 'share_id': share_instance['share_id'], - 'host': share_instance['host'], - 'status': share_instance['status'], - } - - share_type = None - if share['share_type_id']: - share_type = self.db.share_type_get( - context, share['share_type_id']) - - request_spec = { - 'share_properties': share_properties, - 'share_instance_properties': share_instance_properties, - 'share_proto': share['share_proto'], - 'share_id': share['id'], - 'snapshot_id': share['snapshot_id'], - 'share_type': share_type, - 'consistency_group': consistency_group, - } - if host: self.share_rpcapi.create_share_instance( context, @@ -348,6 +298,168 @@ class API(base.Base): return share_instance + def _create_share_instance_and_get_request_spec( + self, context, share, availability_zone=None, + consistency_group=None, host=None, share_network_id=None): + + availability_zone_id = None + if availability_zone: + availability_zone_id = self.db.availability_zone_get( + context, availability_zone).id + + # TODO(u_glide): Add here validation that provided share network + # doesn't conflict with provided availability_zone when Neutron + # will have AZ support. + share_instance = self.db.share_instance_create( + context, share['id'], + { + 'share_network_id': share_network_id, + 'status': constants.STATUS_CREATING, + 'scheduled_at': timeutils.utcnow(), + 'host': host if host else '', + 'availability_zone_id': availability_zone_id, + } + ) + + share_properties = { + 'id': share['id'], + 'size': share['size'], + 'user_id': share['user_id'], + 'project_id': share['project_id'], + 'metadata': self.db.share_metadata_get(context, share['id']), + 'share_server_id': share['share_server_id'], + 'snapshot_support': share['snapshot_support'], + 'share_proto': share['share_proto'], + 'share_type_id': share['share_type_id'], + 'is_public': share['is_public'], + 'consistency_group_id': share['consistency_group_id'], + 'source_cgsnapshot_member_id': share[ + 'source_cgsnapshot_member_id'], + 'snapshot_id': share['snapshot_id'], + 'replication_type': share['replication_type'], + } + share_instance_properties = { + 'id': share_instance['id'], + 'availability_zone_id': share_instance['availability_zone_id'], + 'share_network_id': share_instance['share_network_id'], + 'share_server_id': share_instance['share_server_id'], + 'share_id': share_instance['share_id'], + 'host': share_instance['host'], + 'status': share_instance['status'], + 'replica_state': share_instance['replica_state'], + } + + share_type = None + if share['share_type_id']: + share_type = self.db.share_type_get( + context, share['share_type_id']) + + request_spec = { + 'share_properties': share_properties, + 'share_instance_properties': share_instance_properties, + 'share_proto': share['share_proto'], + 'share_id': share['id'], + 'snapshot_id': share['snapshot_id'], + 'share_type': share_type, + 'consistency_group': consistency_group, + 'availability_zone_id': availability_zone_id, + } + return request_spec, share_instance + + def create_share_replica(self, context, share, availability_zone=None, + share_network_id=None): + + if not share.get('replication_type'): + msg = _("Replication not supported for share %s.") + raise exception.InvalidShare(message=msg % share['id']) + + self._check_is_share_busy(share) + + if not self.db.share_replicas_get_available_active_replica( + context, share['id']): + msg = _("Share %s does not have any active replica in available " + "state.") + raise exception.ReplicationException(reason=msg % share['id']) + + request_spec, share_replica = ( + self._create_share_instance_and_get_request_spec( + context, share, availability_zone=availability_zone, + share_network_id=share_network_id)) + + self.db.share_replica_update( + context, share_replica['id'], + {'replica_state': constants.REPLICA_STATE_OUT_OF_SYNC}) + + self.scheduler_rpcapi.create_share_replica( + context, request_spec=request_spec, filter_properties={}) + + return share_replica + + def delete_share_replica(self, context, share_replica, force=False): + # Disallow deletion of ONLY active replica + replicas = self.db.share_replicas_get_all_by_share( + context, share_replica['share_id']) + active_replicas = list(filter( + lambda x: x['replica_state'] == constants.REPLICA_STATE_ACTIVE, + replicas)) + if (share_replica.get('replica_state') == + constants.REPLICA_STATE_ACTIVE and len(active_replicas) == 1): + msg = _("Cannot delete last active replica.") + raise exception.ReplicationException(reason=msg) + + LOG.info(_LI("Deleting replica %s."), id) + + if not share_replica['host']: + self.db.share_replica_update(context, share_replica['id'], + {'terminated_at': timeutils.utcnow()}) + self.db.share_replica_delete(context, share_replica['id']) + else: + host = share_utils.extract_host(share_replica['host']) + + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_DELETING, + 'terminated_at': timeutils.utcnow()} + ) + + self.share_rpcapi.delete_share_replica( + context, + share_replica['id'], + host, + share_id=share_replica['share_id'], + force=force) + + def promote_share_replica(self, context, share_replica): + + if share_replica.get('status') != constants.STATUS_AVAILABLE: + msg = _("Replica %(replica_id)s must be in %(status)s state to be " + "promoted.") + raise exception.ReplicationException( + reason=msg % {'replica_id': share_replica['id'], + 'status': constants.STATUS_AVAILABLE}) + + replica_state = share_replica['replica_state'] + + if (replica_state in (constants.REPLICA_STATE_OUT_OF_SYNC, + constants.STATUS_ERROR) + and not context.is_admin): + msg = _("Promoting a replica with 'replica_state': %s requires " + "administrator privileges.") + raise exception.AdminRequired( + message=msg % replica_state) + + host = share_utils.extract_host(share_replica['host']) + + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_REPLICATION_CHANGE}) + + self.share_rpcapi.promote_share_replica( + context, share_replica['id'], host, + share_id=share_replica['share_id']) + + return self.db.share_replica_get(context, share_replica['id']) + def manage(self, context, share_data, driver_options): policy.check_policy(context, 'share', 'manage') @@ -419,6 +531,13 @@ class API(base.Base): "statuses": statuses} raise exception.InvalidShare(reason=msg) + # NOTE(gouthamr): If the share has more than one replica, + # it can't be deleted until the additional replicas are removed. + if share.has_replicas: + msg = _("Share %s has replicas. Remove the replicas before " + "deleting the share.") % share_id + raise exception.Conflict(err=msg) + snapshots = self.db.share_snapshot_get_all_for_share(context, share_id) if len(snapshots): msg = _("Share still has %d dependent snapshots") % len(snapshots) @@ -580,6 +699,14 @@ class API(base.Base): share_instance = share.instance + # NOTE(gouthamr): Ensure share does not have replicas. + # Currently share migrations are disallowed for replicated shares. + if share.has_replicas: + msg = _('Share %s has replicas. Remove the replicas before ' + 'attempting to migrate the share.') % share['id'] + LOG.error(msg) + raise exception.Conflict(err=msg) + # We only handle "available" share for now if share_instance['status'] != constants.STATUS_AVAILABLE: msg = _('Share instance %(instance_id)s status must be available, ' diff --git a/manila/share/driver.py b/manila/share/driver.py index f0ee3dca00..0c00afc221 100644 --- a/manila/share/driver.py +++ b/manila/share/driver.py @@ -618,6 +618,20 @@ class ShareDriver(object): should be added/deleted. Driver can ignore rules in 'access_rules' and apply only rules from 'add_rules' and 'delete_rules'. + Drivers must be mindful of this call for share replicas. When + 'update_access' is called on one of the replicas, the call is likely + propagated to all replicas belonging to the share, especially when + individual rules are added or removed. If a particular access rule + does not make sense to the driver in the context of a given replica, + the driver should be careful to report a correct behavior, and take + meaningful action. For example, if R/W access is requested on a + replica that is part of a "readable" type replication; R/O access + may be added by the driver instead of R/W. Note that raising an + exception *will* result in the access_rules_status on the replica, + and the share itself being "out_of_sync". Drivers can sync on the + valid access rules that are provided on the create_replica and + promote_replica calls. + :param context: Current context :param share: Share model with share data. :param access_rules: All access rules for given share @@ -1096,3 +1110,290 @@ class ShareDriver(object): :return: list of share instances. """ return share_instances + + def create_replica(self, context, active_replica, new_replica, + access_rules, share_server=None): + """Replicate the active replica to a new replica on this backend. + + :param context: Current context + :param active_replica: A current active replica instance dictionary. + EXAMPLE: + .. code:: + + { + 'id': 'd487b88d-e428-4230-a465-a800c2cce5f8', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'deleted': False, + 'host': 'openstack2@cmodeSSVMNFS1', + 'status': 'available', + 'scheduled_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'launched_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'terminated_at': None, + 'replica_state': 'active', + 'availability_zone_id': 'e2c2db5c-cb2f-4697-9966-c06fb200cb80', + 'export_locations': [ + , + ], + 'access_rules_status': 'in_sync', + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'share_server_id': '4ce78e7b-0ef6-4730-ac2a-fd2defefbd05', + 'share_server': or None, + } + :param new_replica: The share replica dictionary. + EXAMPLE: + .. code:: + + { + 'id': 'e82ff8b6-65f0-11e5-9d70-feff819cdc9f', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'deleted': False, + 'host': 'openstack2@cmodeSSVMNFS2', + 'status': 'available', + 'scheduled_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'launched_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'terminated_at': None, + 'replica_state': 'out_of_sync', + 'availability_zone_id': 'f6e146d0-65f0-11e5-9d70-feff819cdc9f', + 'export_locations': [ + models.ShareInstanceExportLocations, + ], + 'access_rules_status': 'out_of_sync', + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'share_server_id': 'e6155221-ea00-49ef-abf9-9f89b7dd900a', + 'share_server': or None, + } + :param access_rules: A list of access rules that other instances of + the share already obey. Drivers are expected to apply access rules + to the new replica or disregard access rules that don't apply. + EXAMPLE: + .. code:: + [ { + 'id': 'f0875f6f-766b-4865-8b41-cccb4cdf1676', + 'deleted' = False, + 'share_id' = 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'access_type' = 'ip', + 'access_to' = '172.16.20.1', + 'access_level' = 'rw', + }] + :param share_server: or None, + Share server of the replica being created. + :return: None or a dictionary containing export_locations, + replica_state and access_rules_status. export_locations is a list of + paths and replica_state is one of active, in_sync, out_of_sync or + error. A backend supporting 'writable' type replication should return + 'active' as the replica_state. Export locations should be in the + same format as returned during the create_share call. + EXAMPLE: + .. code:: + { + 'export_locations': [ + { + 'path': '172.16.20.22/sample/export/path', + 'is_admin_only': False, + 'metadata': {'some_key': 'some_value'}, + }, + ], + 'replica_state': 'in_sync', + 'access_rules_status': 'in_sync', + } + """ + raise NotImplementedError() + + def delete_replica(self, context, active_replica, replica, + share_server=None): + """Delete a replica. This is called on the destination backend. + + :param context: Current context + :param active_replica: A current active replica instance dictionary. + EXAMPLE: + .. code:: + + { + 'id': 'd487b88d-e428-4230-a465-a800c2cce5f8', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'deleted': False, + 'host': 'openstack2@cmodeSSVMNFS1', + 'status': 'available', + 'scheduled_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'launched_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'terminated_at': None, + 'replica_state': 'active', + 'availability_zone_id': 'e2c2db5c-cb2f-4697-9966-c06fb200cb80', + 'export_locations': [ + models.ShareInstanceExportLocations, + ], + 'access_rules_status': 'in_sync', + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'share_server_id': '4ce78e7b-0ef6-4730-ac2a-fd2defefbd05', + 'share_server': or None, + } + :param replica: Dictionary of the share replica being deleted. + EXAMPLE: + .. code:: + + { + 'id': 'e82ff8b6-65f0-11e5-9d70-feff819cdc9f', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'deleted': False, + 'host': 'openstack2@cmodeSSVMNFS2', + 'status': 'available', + 'scheduled_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'launched_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'terminated_at': None, + 'replica_state': 'in_sync', + 'availability_zone_id': 'f6e146d0-65f0-11e5-9d70-feff819cdc9f', + 'export_locations': [ + models.ShareInstanceExportLocations + ], + 'access_rules_status': 'out_of_sync', + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'share_server_id': '53099868-65f1-11e5-9d70-feff819cdc9f', + 'share_server': or None, + } + :param share_server: or None, + Share server of the replica to be deleted. + :return: None. + """ + raise NotImplementedError() + + def promote_replica(self, context, replica_list, replica, access_rules, + share_server=None): + """Promote a replica to 'active' replica state. + + :param context: Current context + :param replica_list: List of all replicas for a particular share. + This list also contains the replica to be promoted. The 'active' + replica will have its 'replica_state' attr set to 'active'. + EXAMPLE: + .. code:: + + [ + { + 'id': 'd487b88d-e428-4230-a465-a800c2cce5f8', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'replica_state': 'in_sync', + ... + 'share_server_id': '4ce78e7b-0ef6-4730-ac2a-fd2defefbd05', + 'share_server': or None, + }, + { + 'id': '10e49c3e-aca9-483b-8c2d-1c337b38d6af', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'replica_state': 'active', + ... + 'share_server_id': 'f63629b3-e126-4448-bec2-03f788f76094', + 'share_server': or None, + }, + { + 'id': 'e82ff8b6-65f0-11e5-9d70-feff819cdc9f', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'replica_state': 'in_sync', + ... + 'share_server_id': '07574742-67ea-4dfd-9844-9fbd8ada3d87', + 'share_server': or None, + }, + ... + ] + + :param replica: Dictionary of the replica to be promoted. + EXAMPLE: + .. code:: + + { + 'id': 'e82ff8b6-65f0-11e5-9d70-feff819cdc9f', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'deleted': False, + 'host': 'openstack2@cmodeSSVMNFS2', + 'status': 'available', + 'scheduled_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'launched_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'terminated_at': None, + 'replica_state': 'in_sync', + 'availability_zone_id': 'f6e146d0-65f0-11e5-9d70-feff819cdc9f', + 'export_locations': [ + models.ShareInstanceExportLocations + ], + 'access_rules_status': 'in_sync', + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'share_server_id': '07574742-67ea-4dfd-9844-9fbd8ada3d87', + 'share_server': or None, + } + :param access_rules: A list of access rules that other instances of + the share already obey. + EXAMPLE: + .. code:: + [ { + 'id': 'f0875f6f-766b-4865-8b41-cccb4cdf1676', + 'deleted' = False, + 'share_id' = 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'access_type' = 'ip', + 'access_to' = '172.16.20.1', + 'access_level' = 'rw', + }] + :param share_server: or None, + Share server of the replica to be promoted. + :return: updated_replica_list or None + The driver can return the updated list as in the request + parameter. Changes that will be updated to the Database are: + 'export_locations', 'access_rules_status' and 'replica_state'. + :raises Exception + This can be any exception derived from BaseException. This is + re-raised by the manager after some necessary cleanup. If the + driver raises an exception during promotion, it is assumed + that all of the replicas of the share are in an inconsistent + state. Recovery is only possible through the periodic update + call and/or administrator intervention to correct the 'status' + of the affected replicas if they become healthy again. + """ + raise NotImplementedError() + + def update_replica_state(self, context, replica, + access_rules, share_server=None): + """Update the replica_state of a replica. + + Drivers should fix replication relationships that were broken if + possible inside this method. + + :param context: Current context + :param replica: Dictionary of the replica being updated. + EXAMPLE: + .. code:: + + { + 'id': 'd487b88d-e428-4230-a465-a800c2cce5f8', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'deleted': False, + 'host': 'openstack2@cmodeSSVMNFS1', + 'status': 'available', + 'scheduled_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'launched_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'terminated_at': None, + 'replica_state': 'active', + 'availability_zone_id': 'e2c2db5c-cb2f-4697-9966-c06fb200cb80', + 'export_locations': [ + models.ShareInstanceExportLocations, + ], + 'access_rules_status': 'in_sync', + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'share_server_id': '4ce78e7b-0ef6-4730-ac2a-fd2defefbd05', + } + :param access_rules: A list of access rules that other replicas of + the share already obey. The driver could attempt to sync on any + un-applied access_rules. + EXAMPLE: + .. code:: + [ { + 'id': 'f0875f6f-766b-4865-8b41-cccb4cdf1676', + 'deleted' = False, + 'share_id' = 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'access_type' = 'ip', + 'access_to' = '172.16.20.1', + 'access_level' = 'rw', + }] + :param share_server: or None + :return: replica_state + replica_state - a str value denoting the replica_state that the + replica can have. Valid values are 'in_sync' and 'out_of_sync' + or None (to leave the current replica_state unchanged). + """ + raise NotImplementedError() diff --git a/manila/share/manager.py b/manila/share/manager.py index d42439a456..bee07b9933 100644 --- a/manila/share/manager.py +++ b/manila/share/manager.py @@ -89,6 +89,11 @@ share_manager_opts = [ 'will wait for a share server to go unutilized before ' 'deleting it.', deprecated_group='DEFAULT'), + cfg.IntOpt('replica_state_update_interval', + default=300, + help='This value, specified in seconds, determines how often ' + 'the share manager will poll for the health ' + '(replica_state) of each replica instance.'), ] CONF = cfg.CONF @@ -107,6 +112,28 @@ MAPPING = { QUOTAS = quota.QUOTAS +def locked_share_replica_operation(operation): + """Lock decorator for share replica operations. + + Takes a named lock prior to executing the operation. The lock is named with + the id of the share to which the replica belongs. + + Intended use: + If a replica operation uses this decorator, it will block actions on + all share replicas of the share until the named lock is free. This is + used to protect concurrent operations on replicas of the same share e.g. + promote ReplicaA while deleting ReplicaB, both belonging to the same share. + """ + + def wrapped(instance, context, share_replica_id, share_id=None, **kwargs): + @utils.synchronized("%s" % share_id, external=True) + def locked_operation(*_args, **_kwargs): + return operation(*_args, **_kwargs) + return locked_operation(instance, context, share_replica_id, + share_id=share_id, **kwargs) + return wrapped + + def add_hooks(f): def wrapped(self, *args, **kwargs): @@ -137,7 +164,7 @@ def add_hooks(f): class ShareManager(manager.SchedulerDependentManager): """Manages NAS storages.""" - RPC_API_VERSION = '1.7' + RPC_API_VERSION = '1.8' def __init__(self, share_driver=None, service_name=None, *args, **kwargs): """Load the driver from args, or from flags.""" @@ -812,9 +839,376 @@ class ShareManager(manager.SchedulerDependentManager): self.db.share_instance_update( context, share_instance_id, {'status': constants.STATUS_AVAILABLE, - 'launched_at': timeutils.utcnow()} + 'launched_at': timeutils.utcnow()}) + + share = self.db.share_get(context, share_instance['share_id']) + + if share.get('replication_type'): + self.db.share_replica_update( + context, share_instance_id, + {'replica_state': constants.REPLICA_STATE_ACTIVE}) + + def _update_share_replica_access_rules_state(self, context, + share_replica_id, state): + """Update the access_rules_status for the share replica.""" + + self.db.share_instance_update_access_status( + context, share_replica_id, state) + + @add_hooks + @utils.require_driver_initialized + @locked_share_replica_operation + def create_share_replica(self, context, share_replica_id, share_id=None, + request_spec=None, filter_properties=None): + """Create a share replica.""" + context = context.elevated() + + share_replica = self.db.share_replica_get( + context, share_replica_id, with_share_data=True, + with_share_server=True) + + if not share_replica['availability_zone']: + share_replica = self.db.share_replica_update( + context, share_replica['id'], + {'availability_zone': CONF.storage_availability_zone}, + with_share_data=True ) + current_active_replica = ( + self.db.share_replicas_get_available_active_replica( + context, share_replica['share_id'], with_share_data=True, + with_share_server=True)) + + if not current_active_replica: + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) + msg = _("An active instance with 'available' status does " + "not exist to add replica to share %s.") + raise exception.ReplicationException( + reason=msg % share_replica['share_id']) + + # We need the share_network_id in case of + # driver_handles_share_server=True + share_network_id = share_replica.get('share_network_id', None) + + if (share_network_id and + not self.driver.driver_handles_share_servers): + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) + raise exception.InvalidDriverMode( + "Driver does not expect share-network to be provided " + "with current configuration.") + + if share_network_id: + try: + share_server, share_replica = ( + self._provide_share_server_for_share( + context, share_network_id, share_replica) + ) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.error(_LE("Failed to get share server " + "for share replica creation.")) + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) + else: + share_server = None + + # Map the existing access rules for the share to + # the replica in the DB. + share_access_rules = self.db.share_instance_access_copy( + context, share_replica['share_id'], share_replica['id']) + + current_active_replica = self._get_share_replica_dict( + context, current_active_replica) + share_replica = self._get_share_replica_dict(context, share_replica) + + try: + replica_ref = self.driver.create_replica( + context, current_active_replica, share_replica, + share_access_rules, share_server=share_server) + + except Exception: + with excutils.save_and_reraise_exception(): + LOG.error(_LE("Share replica %s failed on creation."), + share_replica['id']) + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) + self._update_share_replica_access_rules_state( + context, share_replica['id'], constants.STATUS_ERROR) + + if replica_ref.get('export_locations'): + if isinstance(replica_ref.get('export_locations'), list): + self.db.share_export_locations_update( + context, share_replica['id'], + replica_ref.get('export_locations')) + else: + msg = _LW('Invalid export locations passed to the share ' + 'manager.') + LOG.warning(msg) + + if replica_ref.get('replica_state'): + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_AVAILABLE, + 'replica_state': replica_ref.get('replica_state')}) + + if replica_ref.get('access_rules_status'): + self._update_share_replica_access_rules_state( + context, share_replica['id'], + replica_ref.get('access_rules_status')) + else: + self._update_share_replica_access_rules_state( + context, share_replica['id'], constants.STATUS_ACTIVE) + + LOG.info(_LI("Share replica %s created successfully."), + share_replica['id']) + + @add_hooks + @utils.require_driver_initialized + @locked_share_replica_operation + def delete_share_replica(self, context, share_replica_id, share_id=None, + force=False): + """Delete a share replica.""" + context = context.elevated() + share_replica = self.db.share_replica_get( + context, share_replica_id, with_share_data=True, + with_share_server=True) + + # Get the active replica + current_active_replica = ( + self.db.share_replicas_get_available_active_replica( + context, share_replica['share_id'], + with_share_data=True, with_share_server=True) + ) + share_server = self._get_share_server(context, share_replica) + + current_active_replica = self._get_share_replica_dict( + context, current_active_replica) + share_replica = self._get_share_replica_dict(context, share_replica) + + try: + self.access_helper.update_access_rules( + context, + share_replica_id, + delete_rules="all", + share_server=share_server + ) + except Exception: + with excutils.save_and_reraise_exception() as exc_context: + # Set status to 'error' from 'deleting' since + # access_rules_status has been set to 'error'. + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_ERROR}) + if force: + msg = _("The driver was unable to delete access rules " + "for the replica: %s. Will attempt to delete the " + "replica anyway.") + LOG.error(msg % share_replica['id']) + exc_context.reraise = False + + try: + self.driver.delete_replica( + context, current_active_replica, share_replica, + share_server=share_server) + except Exception: + with excutils.save_and_reraise_exception() as exc_context: + if force: + msg = _("The driver was unable to delete the share " + "replica: %s on the backend. Since this " + "operation is forced, the replica will be " + "deleted from Manila's database. A cleanup on " + "the backend may be necessary.") + LOG.error(msg % share_replica['id']) + exc_context.reraise = False + else: + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_ERROR_DELETING, + 'replica_state': constants.STATUS_ERROR}) + + self.db.share_replica_delete(context, share_replica['id']) + LOG.info(_LI("Share replica %s deleted successfully."), + share_replica['id']) + + @add_hooks + @utils.require_driver_initialized + @locked_share_replica_operation + def promote_share_replica(self, context, share_replica_id, share_id=None): + """Promote a share replica to active state.""" + context = context.elevated() + share_replica = self.db.share_replica_get( + context, share_replica_id, with_share_data=True, + with_share_server=True) + share_server = self._get_share_server(context, share_replica) + + # Get list of all replicas for share + replica_list = ( + self.db.share_replicas_get_all_by_share( + context, share_replica['share_id'], + with_share_data=True, with_share_server=True) + ) + + try: + old_active_replica = list(filter( + lambda r: ( + r['replica_state'] == constants.REPLICA_STATE_ACTIVE), + replica_list))[0] + except IndexError: + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_AVAILABLE}) + msg = _("Share %(share)s has no replica with 'replica_state' " + "set to %(state)s. Promoting %(replica)s is not " + "possible.") + raise exception.ReplicationException( + reason=msg % {'share': share_replica['share_id'], + 'state': constants.REPLICA_STATE_ACTIVE, + 'replica': share_replica['id']}) + + access_rules = self.db.share_access_get_all_for_share( + context, share_replica['share_id']) + + replica_list = [self._get_share_replica_dict(context, r) + for r in replica_list] + share_replica = self._get_share_replica_dict(context, share_replica) + + try: + updated_replica_list = ( + self.driver.promote_replica( + context, replica_list, share_replica, access_rules, + share_server=share_server) + ) + except Exception: + with excutils.save_and_reraise_exception(): + # (NOTE) gouthamr: If the driver throws an exception at + # this stage, there is a good chance that the replicas are + # somehow altered on the backend. We loop through the + # replicas and set their 'status's to 'error' and + # leave the 'replica_state' unchanged. This also changes the + # 'status' of the replica that failed to promote to 'error' as + # before this operation. The backend may choose to update + # the actual replica_state during the replica_monitoring + # stage. + updates = {'status': constants.STATUS_ERROR} + for replica_ref in replica_list: + self.db.share_replica_update( + context, replica_ref['id'], updates) + + if not updated_replica_list: + self.db.share_replica_update( + context, share_replica['id'], + {'status': constants.STATUS_AVAILABLE, + 'replica_state': constants.REPLICA_STATE_ACTIVE}) + self.db.share_replica_update( + context, old_active_replica['id'], + {'replica_state': constants.REPLICA_STATE_OUT_OF_SYNC}) + else: + for updated_replica in updated_replica_list: + updated_export_locs = updated_replica.get( + 'export_locations') + if(updated_export_locs and + isinstance(updated_export_locs, list)): + self.db.share_export_locations_update( + context, updated_replica['id'], + updated_export_locs) + + updated_replica_state = updated_replica.get( + 'replica_state') + updates = {'replica_state': updated_replica_state} + # Change the promoted replica's status from 'available' to + # 'replication_change'. + if updated_replica['id'] == share_replica['id']: + updates['status'] = constants.STATUS_AVAILABLE + if updated_replica_state == constants.STATUS_ERROR: + updates['status'] = constants.STATUS_ERROR + self.db.share_replica_update( + context, updated_replica['id'], updates) + + if updated_replica.get('access_rules_status'): + self._update_share_replica_access_rules_state( + context, share_replica['id'], + updated_replica.get('access_rules_status')) + + LOG.info(_LI("Share replica %s: promoted to active state " + "successfully."), share_replica['id']) + + @periodic_task.periodic_task(spacing=CONF.replica_state_update_interval) + @utils.require_driver_initialized + def periodic_share_replica_update(self, context): + LOG.debug("Updating status of share replica instances.") + replicas = self.db.share_replicas_get_all(context, + with_share_data=True) + + # Filter only non-active replicas belonging to this backend + def qualified_replica(r): + return (share_utils.extract_host(r['host']) == + share_utils.extract_host(self.host)) + + replicas = list(filter(lambda x: qualified_replica(x), replicas)) + for replica in replicas: + self._share_replica_update( + context, replica, share_id=replica['share_id']) + + @locked_share_replica_operation + def _share_replica_update(self, context, share_replica, share_id=None): + share_server = self._get_share_server(context, share_replica) + replica_state = None + + # Re-grab the replica: + share_replica = self.db.share_replica_get( + context, share_replica['id'], with_share_data=True, + with_share_server=True) + + # We don't poll for replicas that are busy in some operation, + # or if they are the 'active' instance. + if (share_replica['status'] in constants.TRANSITIONAL_STATUSES + or share_replica['replica_state'] == + constants.REPLICA_STATE_ACTIVE): + return + + access_rules = self.db.share_access_get_all_for_share( + context, share_replica['share_id']) + + LOG.debug("Updating status of share share_replica %s: ", + share_replica['id']) + + share_replica = self._get_share_replica_dict(context, share_replica) + + try: + + replica_state = self.driver.update_replica_state( + context, share_replica, access_rules, share_server) + + except Exception: + # If the replica_state was previously in 'error', it is + # possible that the driver throws an exception during its + # update. This exception can be ignored. + with excutils.save_and_reraise_exception() as exc_context: + if (share_replica.get('replica_state') == + constants.STATUS_ERROR): + exc_context.reraise = False + + if replica_state in (constants.REPLICA_STATE_IN_SYNC, + constants.REPLICA_STATE_OUT_OF_SYNC, + constants.STATUS_ERROR): + self.db.share_replica_update(context, share_replica['id'], + {'replica_state': replica_state}) + elif replica_state: + msg = (_LW("Replica %(id)s cannot be set to %(state)s " + "through update call.") % + {'id': share_replica['id'], 'state': replica_state}) + LOG.warning(msg) + @add_hooks @utils.require_driver_initialized def manage_share(self, context, share_id, driver_options): @@ -1711,3 +2105,38 @@ class ShareManager(manager.SchedulerDependentManager): LOG.info(_LI("Consistency group snapshot %s: deleted successfully"), cgsnapshot_id) + + def _get_share_replica_dict(self, context, share_replica): + # TODO(gouthamr): remove method when the db layer returns primitives + share_replica_ref = { + 'id': share_replica.get('id'), + 'share_id': share_replica.get('share_id'), + 'host': share_replica.get('host'), + 'status': share_replica.get('status'), + 'replica_state': share_replica.get('replica_state'), + 'availability_zone_id': share_replica.get('availability_zone_id'), + 'export_locations': share_replica.get('export_locations'), + 'share_network_id': share_replica.get('share_network_id'), + 'share_server_id': share_replica.get('share_server_id'), + 'deleted': share_replica.get('deleted'), + 'terminated_at': share_replica.get('terminated_at'), + 'launched_at': share_replica.get('launched_at'), + 'scheduled_at': share_replica.get('scheduled_at'), + 'share_server': self._get_share_server(context, share_replica), + 'access_rules_status': share_replica.get('access_rules_status'), + # Share details + 'user_id': share_replica.get('user_id'), + 'project_id': share_replica.get('project_id'), + 'size': share_replica.get('size'), + 'display_name': share_replica.get('display_name'), + 'display_description': share_replica.get('display_description'), + 'snapshot_id': share_replica.get('snapshot_id'), + 'share_proto': share_replica.get('share_proto'), + 'share_type_id': share_replica.get('share_type_id'), + 'is_public': share_replica.get('is_public'), + 'consistency_group_id': share_replica.get('consistency_group_id'), + 'source_cgsnapshot_member_id': share_replica.get( + 'source_cgsnapshot_member_id'), + } + + return share_replica_ref diff --git a/manila/share/rpcapi.py b/manila/share/rpcapi.py index 256721d8c0..46d13e2aee 100644 --- a/manila/share/rpcapi.py +++ b/manila/share/rpcapi.py @@ -46,6 +46,10 @@ class ShareAPI(object): get_migration_info() get_driver_migration_info() 1.7 - Update target call API in allow/deny access methods + 1.8 - Introduce Share Replication: + create_share_replica() + delete_share_replica() + promote_share_replica() """ BASE_RPC_API_VERSION = '1.0' @@ -54,7 +58,7 @@ class ShareAPI(object): super(ShareAPI, self).__init__() target = messaging.Target(topic=CONF.share_topic, version=self.BASE_RPC_API_VERSION) - self.client = rpc.get_client(target, version_cap='1.7') + self.client = rpc.get_client(target, version_cap='1.8') def create_share_instance(self, ctxt, share_instance, host, request_spec, filter_properties, @@ -200,3 +204,40 @@ class ShareAPI(object): ctxt, 'delete_cgsnapshot', cgsnapshot_id=cgsnapshot['id']) + + def create_share_replica(self, ctxt, share_replica, host, + request_spec, filter_properties): + new_host = utils.extract_host(host) + cctxt = self.client.prepare(server=new_host, version='1.8') + request_spec_p = jsonutils.to_primitive(request_spec) + cctxt.cast( + ctxt, + 'create_share_replica', + share_replica_id=share_replica['id'], + request_spec=request_spec_p, + filter_properties=filter_properties, + share_id=share_replica['share_id'], + ) + + def delete_share_replica(self, ctxt, share_replica_id, host, + share_id=None, force=False): + new_host = utils.extract_host(host) + cctxt = self.client.prepare(server=new_host, version='1.8') + cctxt.cast( + ctxt, + 'delete_share_replica', + share_replica_id=share_replica_id, + share_id=share_id, + force=force, + ) + + def promote_share_replica(self, ctxt, share_replica_id, host, + share_id=None): + new_host = utils.extract_host(host) + cctxt = self.client.prepare(server=new_host, version='1.8') + cctxt.cast( + ctxt, + 'promote_share_replica', + share_replica_id=share_replica_id, + share_id=share_id, + ) diff --git a/manila/tests/api/contrib/stubs.py b/manila/tests/api/contrib/stubs.py index df3d64f621..ab166f243c 100644 --- a/manila/tests/api/contrib/stubs.py +++ b/manila/tests/api/contrib/stubs.py @@ -43,13 +43,20 @@ def stub_share(id, **kwargs): 'share_server_id': 'fake_share_server_id', 'is_public': False, 'snapshot_support': True, + 'replication_type': None, + 'has_replicas': False, } share.update(kwargs) # NOTE(ameade): We must wrap the dictionary in an class in order to stub # object attributes. class wrapper(dict): - pass + def __getattr__(self, item): + try: + return self[item] + except KeyError: + raise AttributeError() + fake_share = wrapper() fake_share.instance = {'id': "fake_instance_id"} fake_share.update(share) diff --git a/manila/tests/api/v2/test_share_instances.py b/manila/tests/api/v2/test_share_instances.py index a786f188b9..1fe3d2fab3 100644 --- a/manila/tests/api/v2/test_share_instances.py +++ b/manila/tests/api/v2/test_share_instances.py @@ -121,7 +121,19 @@ class ShareInstancesAPITest(test.TestCase): self.mock_policy_check.assert_called_once_with( self.admin_context, self.resource_name, 'show') - @ddt.data("2.3", "2.8", "2.9") + def test_show_with_replica_state(self): + test_instance = db_utils.create_share(size=1).instance + req = self._get_request('fake', version="2.11") + id = test_instance['id'] + + actual_result = self.controller.show(req, id) + + self.assertEqual(id, actual_result['share_instance']['id']) + self.assertIn("replica_state", actual_result['share_instance']) + self.mock_policy_check.assert_called_once_with( + self.admin_context, self.resource_name, 'show') + + @ddt.data("2.3", "2.8", "2.9", "2.11") def test_get_share_instances(self, version): test_share = db_utils.create_share(size=1) id = test_share['id'] @@ -147,6 +159,9 @@ class ShareInstancesAPITest(test.TestCase): assert_method = self.assertIn assert_method("export_location", instance) assert_method("export_locations", instance) + if (api_version_request.APIVersionRequest(version) > + api_version_request.APIVersionRequest("2.10")): + self.assertIn("replica_state", instance) self.mock_policy_check.assert_has_calls([ get_instances_policy_check_call, share_policy_check_call]) diff --git a/manila/tests/api/v2/test_share_replicas.py b/manila/tests/api/v2/test_share_replicas.py new file mode 100644 index 0000000000..f5d6239729 --- /dev/null +++ b/manila/tests/api/v2/test_share_replicas.py @@ -0,0 +1,514 @@ +# Copyright 2015 Goutham Pacha Ravi +# 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 +import mock +from oslo_config import cfg +import six +from webob import exc + +from manila.api.v2 import share_replicas +from manila.common import constants +from manila import context +from manila import exception +from manila import policy +from manila import share +from manila import test +from manila.tests.api import fakes +from manila.tests import fake_share + +CONF = cfg.CONF + + +@ddt.ddt +class ShareReplicasApiTest(test.TestCase): + """Share Replicas API Test Cases.""" + def setUp(self): + super(ShareReplicasApiTest, self).setUp() + self.controller = share_replicas.ShareReplicationController() + self.resource_name = self.controller.resource_name + self.api_version = share_replicas.MIN_SUPPORTED_API_VERSION + self.replicas_req = fakes.HTTPRequest.blank( + '/share-replicas', version=self.api_version, + experimental=True) + self.context = context.RequestContext('user', 'fake', False) + self.replicas_req.environ['manila.context'] = self.context + self.admin_context = context.RequestContext('admin', 'fake', True) + self.mock_policy_check = self.mock_object(policy, 'check_policy') + + def _get_fake_replica(self, summary=False, **values): + replica = fake_share.fake_replica(**values) + expected_keys = {'id', 'share_id', 'status', 'replica_state'} + expected_replica = {key: replica[key] for key in replica if key + in expected_keys} + + if not summary: + expected_replica.update({ + 'host': replica['host'], + 'availability_zone': None, + 'created_at': None, + 'share_server_id': replica['share_server_id'], + 'share_network_id': replica['share_network_id'], + }) + + return replica, expected_replica + + def test_list_replicas_summary(self): + fake_replica, expected_replica = self._get_fake_replica(summary=True) + self.mock_object(share_replicas.db, 'share_replicas_get_all', + mock.Mock(return_value=[fake_replica])) + + res_dict = self.controller.index(self.replicas_req) + + self.assertEqual([expected_replica], res_dict['share_replicas']) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'get_all') + + def test_list_share_replicas_summary(self): + fake_replica, expected_replica = self._get_fake_replica(summary=True) + self.mock_object(share_replicas.db, 'share_replicas_get_all_by_share', + mock.Mock(return_value=[fake_replica])) + req = fakes.HTTPRequest.blank( + '/share-replicas?share_id=FAKE_SHARE_ID', + version=self.api_version, experimental=True) + req_context = req.environ['manila.context'] + + res_dict = self.controller.index(req) + + self.assertEqual([expected_replica], res_dict['share_replicas']) + self.mock_policy_check.assert_called_once_with( + req_context, self.resource_name, 'get_all') + + def test_list_replicas_detail(self): + fake_replica, expected_replica = self._get_fake_replica() + self.mock_object(share_replicas.db, 'share_replicas_get_all', + mock.Mock(return_value=[fake_replica])) + + res_dict = self.controller.detail(self.replicas_req) + + self.assertEqual([expected_replica], res_dict['share_replicas']) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'get_all') + + def test_list_replicas_detail_with_limit(self): + fake_replica_1, expected_replica_1 = self._get_fake_replica() + fake_replica_2, expected_replica_2 = self._get_fake_replica( + id="fake_id2") + self.mock_object( + share_replicas.db, 'share_replicas_get_all', + mock.Mock(return_value=[fake_replica_1, fake_replica_2])) + req = fakes.HTTPRequest.blank('/share-replicas?limit=1', + version=self.api_version, + experimental=True) + req_context = req.environ['manila.context'] + + res_dict = self.controller.detail(req) + + self.assertEqual(1, len(res_dict['share_replicas'])) + self.assertEqual([expected_replica_1], res_dict['share_replicas']) + self.mock_policy_check.assert_called_once_with( + req_context, self.resource_name, 'get_all') + + def test_list_replicas_detail_with_limit_and_offset(self): + fake_replica_1, expected_replica_1 = self._get_fake_replica() + fake_replica_2, expected_replica_2 = self._get_fake_replica( + id="fake_id2") + self.mock_object( + share_replicas.db, 'share_replicas_get_all', + mock.Mock(return_value=[fake_replica_1, fake_replica_2])) + req = fakes.HTTPRequest.blank( + '/share-replicas/detail?limit=1&offset=1', + version=self.api_version, experimental=True) + req_context = req.environ['manila.context'] + + res_dict = self.controller.detail(req) + + self.assertEqual(1, len(res_dict['share_replicas'])) + self.assertEqual([expected_replica_2], res_dict['share_replicas']) + self.mock_policy_check.assert_called_once_with( + req_context, self.resource_name, 'get_all') + + def test_list_share_replicas_detail_invalid_share(self): + self.mock_object(share_replicas.db, 'share_replicas_get_all_by_share', + mock.Mock(side_effect=exception.NotFound)) + mock__view_builder_call = self.mock_object( + share_replicas.replication_view.ReplicationViewBuilder, + 'detail_list') + req = self.replicas_req + req.GET['share_id'] = 'FAKE_SHARE_ID' + + self.assertRaises(exc.HTTPNotFound, + self.controller.detail, req) + self.assertFalse(mock__view_builder_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'get_all') + + def test_list_share_replicas_detail(self): + fake_replica, expected_replica = self._get_fake_replica() + self.mock_object(share_replicas.db, 'share_replicas_get_all_by_share', + mock.Mock(return_value=[fake_replica])) + req = fakes.HTTPRequest.blank( + '/share-replicas?share_id=FAKE_SHARE_ID', + version=self.api_version, experimental=True) + req_context = req.environ['manila.context'] + + res_dict = self.controller.detail(req) + + self.assertEqual([expected_replica], res_dict['share_replicas']) + self.mock_policy_check.assert_called_once_with( + req_context, self.resource_name, 'get_all') + + def test_list_share_replicas_with_limit(self): + fake_replica_1, expected_replica_1 = self._get_fake_replica() + fake_replica_2, expected_replica_2 = self._get_fake_replica( + id="fake_id2") + self.mock_object( + share_replicas.db, 'share_replicas_get_all_by_share', + mock.Mock(return_value=[fake_replica_1, fake_replica_2])) + req = fakes.HTTPRequest.blank( + '/share-replicas?share_id=FAKE_SHARE_ID&limit=1', + version=self.api_version, experimental=True) + req_context = req.environ['manila.context'] + + res_dict = self.controller.detail(req) + + self.assertEqual(1, len(res_dict['share_replicas'])) + self.assertEqual([expected_replica_1], res_dict['share_replicas']) + self.mock_policy_check.assert_called_once_with( + req_context, self.resource_name, 'get_all') + + def test_list_share_replicas_with_limit_and_offset(self): + fake_replica_1, expected_replica_1 = self._get_fake_replica() + fake_replica_2, expected_replica_2 = self._get_fake_replica( + id="fake_id2") + self.mock_object( + share_replicas.db, 'share_replicas_get_all_by_share', + mock.Mock(return_value=[fake_replica_1, fake_replica_2])) + req = fakes.HTTPRequest.blank( + '/share-replicas?share_id=FAKE_SHARE_ID&limit=1&offset=1', + version=self.api_version, experimental=True) + req_context = req.environ['manila.context'] + + res_dict = self.controller.detail(req) + + self.assertEqual(1, len(res_dict['share_replicas'])) + self.assertEqual([expected_replica_2], res_dict['share_replicas']) + self.mock_policy_check.assert_called_once_with( + req_context, self.resource_name, 'get_all') + + def test_show(self): + fake_replica, expected_replica = self._get_fake_replica() + self.mock_object( + share_replicas.db, 'share_replica_get', + mock.Mock(return_value=fake_replica)) + + res_dict = self.controller.show( + self.replicas_req, fake_replica.get('id')) + + self.assertEqual(expected_replica, res_dict['share_replica']) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'show') + + def test_show_no_replica(self): + mock__view_builder_call = self.mock_object( + share_replicas.replication_view.ReplicationViewBuilder, 'detail') + fake_exception = exception.ShareReplicaNotFound( + replica_id='FAKE_REPLICA_ID') + self.mock_object(share_replicas.db, 'share_replica_get', mock.Mock( + side_effect=fake_exception)) + + self.assertRaises(exc.HTTPNotFound, + self.controller.show, + self.replicas_req, + 'FAKE_REPLICA_ID') + self.assertFalse(mock__view_builder_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'show') + + def test_create_invalid_body(self): + body = {} + mock__view_builder_call = self.mock_object( + share_replicas.replication_view.ReplicationViewBuilder, + 'detail_list') + + self.assertRaises(exc.HTTPUnprocessableEntity, + self.controller.create, + self.replicas_req, body) + self.assertEqual(0, mock__view_builder_call.call_count) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'create') + + def test_create_no_share_id(self): + body = { + 'share_replica': { + 'share_id': None, + 'availability_zone': None, + } + } + mock__view_builder_call = self.mock_object( + share_replicas.replication_view.ReplicationViewBuilder, + 'detail_list') + + self.assertRaises(exc.HTTPBadRequest, + self.controller.create, + self.replicas_req, body) + self.assertFalse(mock__view_builder_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'create') + + def test_create_invalid_share_id(self): + body = { + 'share_replica': { + 'share_id': 'FAKE_SHAREID', + 'availability_zone': 'FAKE_AZ' + } + } + mock__view_builder_call = self.mock_object( + share_replicas.replication_view.ReplicationViewBuilder, + 'detail_list') + self.mock_object(share_replicas.db, 'share_get', + mock.Mock(side_effect=exception.NotFound)) + + self.assertRaises(exc.HTTPNotFound, + self.controller.create, + self.replicas_req, body) + self.assertFalse(mock__view_builder_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'create') + + @ddt.data(exception.AvailabilityZoneNotFound, + exception.ReplicationException, exception.ShareBusyException) + def test_create_exception_path(self, exception_type): + fake_replica, _ = self._get_fake_replica( + replication_type='writable') + mock__view_builder_call = self.mock_object( + share_replicas.replication_view.ReplicationViewBuilder, + 'detail_list') + body = { + 'share_replica': { + 'share_id': 'FAKE_SHAREID', + 'availability_zone': 'FAKE_AZ' + } + } + exc_args = {'id': 'xyz', 'reason': 'abc'} + self.mock_object(share_replicas.db, 'share_get', + mock.Mock(return_value=fake_replica)) + self.mock_object(share.API, 'create_share_replica', + mock.Mock(side_effect=exception_type(**exc_args))) + + self.assertRaises(exc.HTTPBadRequest, + self.controller.create, + self.replicas_req, body) + self.assertFalse(mock__view_builder_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'create') + + def test_create(self): + fake_replica, expected_replica = self._get_fake_replica( + replication_type='writable') + body = { + 'share_replica': { + 'share_id': 'FAKE_SHAREID', + 'availability_zone': 'FAKE_AZ' + } + } + self.mock_object(share_replicas.db, 'share_get', + mock.Mock(return_value=fake_replica)) + self.mock_object(share.API, 'create_share_replica', + mock.Mock(return_value=fake_replica)) + self.mock_object(share_replicas.db, + 'share_replicas_get_available_active_replica', + mock.Mock(return_value=[{'id': 'active1'}])) + + res_dict = self.controller.create(self.replicas_req, body) + + self.assertEqual(expected_replica, res_dict['share_replica']) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'create') + + def test_delete_invalid_replica(self): + fake_exception = exception.ShareReplicaNotFound( + replica_id='FAKE_REPLICA_ID') + self.mock_object(share_replicas.db, 'share_replica_get', + mock.Mock(side_effect=fake_exception)) + mock_delete_replica_call = self.mock_object( + share.API, 'delete_share_replica') + + self.assertRaises( + exc.HTTPNotFound, self.controller.delete, + self.replicas_req, 'FAKE_REPLICA_ID') + self.assertFalse(mock_delete_replica_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'delete') + + def test_delete_exception(self): + fake_replica_1 = self._get_fake_replica( + share_id='FAKE_SHARE_ID', + replica_state=constants.REPLICA_STATE_ACTIVE)[0] + fake_replica_2 = self._get_fake_replica( + share_id='FAKE_SHARE_ID', + replica_state=constants.REPLICA_STATE_ACTIVE)[0] + exception_type = exception.ReplicationException(reason='xyz') + self.mock_object(share_replicas.db, 'share_replica_get', + mock.Mock(return_value=fake_replica_1)) + self.mock_object( + share_replicas.db, 'share_replicas_get_all_by_share', + mock.Mock(return_value=[fake_replica_1, fake_replica_2])) + self.mock_object(share.API, 'delete_share_replica', + mock.Mock(side_effect=exception_type)) + + self.assertRaises(exc.HTTPBadRequest, self.controller.delete, + self.replicas_req, 'FAKE_REPLICA_ID') + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'delete') + + def test_delete(self): + fake_replica = self._get_fake_replica( + share_id='FAKE_SHARE_ID', + replica_state=constants.REPLICA_STATE_ACTIVE)[0] + self.mock_object(share_replicas.db, 'share_replica_get', + mock.Mock(return_value=fake_replica)) + self.mock_object(share.API, 'delete_share_replica') + + resp = self.controller.delete( + self.replicas_req, 'FAKE_REPLICA_ID') + + self.assertEqual(202, resp.status_code) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'delete') + + def test_promote_invalid_replica_id(self): + body = {'promote': None} + fake_exception = exception.ShareReplicaNotFound( + replica_id='FAKE_REPLICA_ID') + self.mock_object(share_replicas.db, 'share_replica_get', + mock.Mock(side_effect=fake_exception)) + + self.assertRaises(exc.HTTPNotFound, + self.controller.promote, + self.replicas_req, + 'FAKE_REPLICA_ID', body) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'promote') + + def test_promote_already_active(self): + body = {'promote': None} + replica, expected_replica = self._get_fake_replica( + replica_state=constants.REPLICA_STATE_ACTIVE) + self.mock_object(share_replicas.db, 'share_replica_get', + mock.Mock(return_value=replica)) + mock_api_promote_replica_call = self.mock_object( + share.API, 'promote_share_replica') + + resp = self.controller.promote(self.replicas_req, replica['id'], body) + + self.assertEqual(200, resp.status_code) + self.assertFalse(mock_api_promote_replica_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'promote') + + def test_promote_replication_exception(self): + body = {'promote': None} + replica, expected_replica = self._get_fake_replica( + replica_state=constants.REPLICA_STATE_IN_SYNC) + exception_type = exception.ReplicationException(reason='xyz') + self.mock_object(share_replicas.db, 'share_replica_get', + mock.Mock(return_value=replica)) + mock_api_promote_replica_call = self.mock_object( + share.API, 'promote_share_replica', + mock.Mock(side_effect=exception_type)) + + self.assertRaises(exc.HTTPBadRequest, + self.controller.promote, + self.replicas_req, + replica['id'], + body) + self.assertTrue(mock_api_promote_replica_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'promote') + + def test_promote_admin_required_exception(self): + body = {'promote': None} + replica, expected_replica = self._get_fake_replica( + replica_state=constants.REPLICA_STATE_IN_SYNC) + self.mock_object(share_replicas.db, 'share_replica_get', + mock.Mock(return_value=replica)) + mock_api_promote_replica_call = self.mock_object( + share.API, 'promote_share_replica', + mock.Mock(side_effect=exception.AdminRequired)) + + self.assertRaises(exc.HTTPForbidden, + self.controller.promote, + self.replicas_req, + replica['id'], + body) + self.assertTrue(mock_api_promote_replica_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'promote') + + def test_promote(self): + body = {'promote': None} + replica, expected_replica = self._get_fake_replica( + replica_state=constants.REPLICA_STATE_IN_SYNC) + self.mock_object(share_replicas.db, 'share_replica_get', + mock.Mock(return_value=replica)) + mock_api_promote_replica_call = self.mock_object( + share.API, 'promote_share_replica', + mock.Mock(return_value=replica)) + + resp = self.controller.promote(self.replicas_req, replica['id'], body) + + self.assertEqual(expected_replica, resp['share_replica']) + self.assertTrue(mock_api_promote_replica_call.called) + self.mock_policy_check.assert_called_once_with( + self.context, self.resource_name, 'promote') + + @ddt.data('index', 'detail', 'show', 'create', 'delete', 'promote') + def test_policy_not_authorized(self, method_name): + + method = getattr(self.controller, method_name) + arguments = { + 'id': 'FAKE_REPLICA_ID', + 'body': {'FAKE_KEY': 'FAKE_VAL'}, + } + if method_name in ('index', 'detail'): + arguments.clear() + + noauthexc = exception.PolicyNotAuthorized(action=six.text_type(method)) + + with mock.patch.object( + policy, 'check_policy', mock.Mock(side_effect=noauthexc)): + + self.assertRaises( + exc.HTTPForbidden, method, self.replicas_req, **arguments) + + @ddt.data('index', 'detail', 'show', 'create', 'delete', 'promote') + def test_upsupported_microversion(self, method_name): + + unsupported_microversions = ('1.0', '2.2', '2.8') + method = getattr(self.controller, method_name) + arguments = { + 'id': 'FAKE_REPLICA_ID', + 'body': {'FAKE_KEY': 'FAKE_VAL'}, + } + if method_name in ('index', 'detail'): + arguments.clear() + + for microversion in unsupported_microversions: + req = fakes.HTTPRequest.blank( + '/share-replicas', version=microversion, + experimental=True) + self.assertRaises(exception.VersionNotFoundForAPIMethod, + method, req, **arguments) diff --git a/manila/tests/api/v2/test_shares.py b/manila/tests/api/v2/test_shares.py index ffabd5d6a2..38c897c2cd 100644 --- a/manila/tests/api/v2/test_shares.py +++ b/manila/tests/api/v2/test_shares.py @@ -25,6 +25,7 @@ import webob from manila.api import common from manila.api.openstack import api_version_request as api_version +from manila.api.v2 import share_replicas from manila.api.v2 import shares from manila.common import constants from manila import context @@ -203,6 +204,29 @@ class ShareAPITest(test.TestCase): self.controller.create, req, {'share': self.share}) share_types.get_default_share_type.assert_called_once_with() + def test_share_create_with_replication(self): + self.mock_object(share_api.API, 'create', self.create_mock) + + body = {"share": copy.deepcopy(self.share)} + req = fakes.HTTPRequest.blank( + '/shares', version=share_replicas.MIN_SUPPORTED_API_VERSION) + + res_dict = self.controller.create(req, body) + + expected = self._get_expected_share_detailed_response(self.share) + + expected['share']['task_state'] = None + expected['share']['consistency_group_id'] = None + expected['share']['source_cgsnapshot_member_id'] = None + expected['share']['replication_type'] = None + expected['share']['share_type_name'] = None + expected['share']['has_replicas'] = False + expected['share']['access_rules_status'] = 'active' + expected['share'].pop('export_location') + expected['share'].pop('export_locations') + + self.assertEqual(expected, res_dict) + def test_share_create_with_share_net(self): shr = { "size": 100, @@ -250,6 +274,22 @@ class ShareAPITest(test.TestCase): self.mock_object(share_api.API, 'migrate_share') getattr(self.controller, method)(req, share['id'], body) + def test_migrate_share_has_replicas(self): + share = db_utils.create_share() + req = fakes.HTTPRequest.blank('/shares/%s/action' % share['id'], + use_admin_context=True) + req.method = 'POST' + req.headers['content-type'] = 'application/json' + req.api_version_request = api_version.APIVersionRequest('2.10') + req.api_version_request.experimental = True + body = {'migrate_share': {'host': 'fake_host'}} + self.mock_object(share_api.API, 'migrate_share', + mock.Mock(side_effect=exception.Conflict(err='err'))) + + self.assertRaises(webob.exc.HTTPConflict, + self.controller.migrate_share, + req, share['id'], body) + @ddt.data('2.5', '2.6', '2.7') def test_migrate_share_no_share_id(self, version): req = fakes.HTTPRequest.blank('/shares/%s/action' % 'fake_id', @@ -500,11 +540,40 @@ class ShareAPITest(test.TestCase): self.controller.show, req, '1') + def test_share_show_with_replication_type(self): + req = fakes.HTTPRequest.blank( + '/shares/1', version=share_replicas.MIN_SUPPORTED_API_VERSION) + res_dict = self.controller.show(req, '1') + + expected = self._get_expected_share_detailed_response() + + expected['share']['task_state'] = None + expected['share']['consistency_group_id'] = None + expected['share']['source_cgsnapshot_member_id'] = None + expected['share']['access_rules_status'] = 'active' + expected['share']['share_type_name'] = None + expected['share']['replication_type'] = None + expected['share']['has_replicas'] = False + expected['share'].pop('export_location') + expected['share'].pop('export_locations') + + self.assertEqual(expected, res_dict) + def test_share_delete(self): req = fakes.HTTPRequest.blank('/shares/1') resp = self.controller.delete(req, 1) self.assertEqual(202, resp.status_int) + def test_share_delete_has_replicas(self): + req = fakes.HTTPRequest.blank('/shares/1') + self.mock_object(share_api.API, 'get', + mock.Mock(return_value=self.share)) + self.mock_object(share_api.API, 'delete', + mock.Mock(side_effect=exception.Conflict(err='err'))) + + self.assertRaises( + webob.exc.HTTPConflict, self.controller.delete, req, 1) + def test_share_delete_in_consistency_group_param_not_provided(self): fake_share = stubs.stub_share('fake_share', consistency_group_id='fake_cg_id') @@ -827,6 +896,58 @@ class ShareAPITest(test.TestCase): expected['shares'][0].pop('export_locations') self._list_detail_test_common(req, expected) + def test_share_list_detail_with_replication_type(self): + self.mock_object(share_api.API, 'get_all', + stubs.stub_share_get_all_by_project) + env = {'QUERY_STRING': 'name=Share+Test+Name'} + req = fakes.HTTPRequest.blank( + '/shares/detail', environ=env, + version=share_replicas.MIN_SUPPORTED_API_VERSION) + res_dict = self.controller.detail(req) + expected = { + 'shares': [ + { + 'status': 'fakestatus', + 'description': 'displaydesc', + 'availability_zone': 'fakeaz', + 'name': 'displayname', + 'share_proto': 'FAKEPROTO', + 'metadata': {}, + 'project_id': 'fakeproject', + 'access_rules_status': 'active', + 'host': 'fakehost', + 'id': '1', + 'snapshot_id': '2', + 'share_network_id': None, + 'created_at': datetime.datetime(1, 1, 1, 1, 1, 1), + 'size': 1, + 'share_type_name': None, + 'share_type': '1', + 'volume_type': '1', + 'is_public': False, + 'consistency_group_id': None, + 'source_cgsnapshot_member_id': None, + 'snapshot_support': True, + 'has_replicas': False, + 'replication_type': None, + 'task_state': None, + 'links': [ + { + 'href': 'http://localhost/v1/fake/shares/1', + 'rel': 'self' + }, + { + 'href': 'http://localhost/fake/shares/1', + 'rel': 'bookmark' + } + ], + } + ] + } + self.assertEqual(expected, res_dict) + self.assertEqual(res_dict['shares'][0]['volume_type'], + res_dict['shares'][0]['share_type']) + def test_remove_invalid_options(self): ctx = context.RequestContext('fakeuser', 'fakeproject', is_admin=False) search_opts = {'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd'} diff --git a/manila/tests/db/fakes.py b/manila/tests/db/fakes.py index bc0f7f6f09..2e45f6b338 100644 --- a/manila/tests/db/fakes.py +++ b/manila/tests/db/fakes.py @@ -36,6 +36,12 @@ class FakeModel(object): def __repr__(self): return '' % self.values + def get(self, key, default=None): + return self.__getattr__(key) or default + + def __contains__(self, key): + return self._getattr__(key) + def stub_out(stubs, funcs): """Set the stubs in mapping in the db api.""" diff --git a/manila/tests/db/migrations/alembic/migrations_data_checks.py b/manila/tests/db/migrations/alembic/migrations_data_checks.py index befff3ac19..7d8f69b2aa 100644 --- a/manila/tests/db/migrations/alembic/migrations_data_checks.py +++ b/manila/tests/db/migrations/alembic/migrations_data_checks.py @@ -34,6 +34,7 @@ See BaseMigrationChecks class for more information. """ import abc +import datetime from oslo_utils import uuidutils import six @@ -351,3 +352,121 @@ class AccessRulesStatusMigrationChecks(BaseMigrationChecks): for rule in engine.execute(share_instances_rules_table.select()): valid_state = valid_statuses[rule['share_instance_id']] self.test_case.assertEqual(valid_state, rule['state']) + + +@map_to_migration('293fac1130ca') +class ShareReplicationMigrationChecks(BaseMigrationChecks): + + valid_share_display_names = ('FAKE_SHARE_1', 'FAKE_SHARE_2', + 'FAKE_SHARE_3') + valid_share_ids = [] + valid_replication_types = ('writable', 'readable', 'dr') + + def _load_tables_and_get_data(self, engine): + share_table = utils.load_table('shares', engine) + share_instances_table = utils.load_table('share_instances', engine) + + shares = engine.execute( + share_table.select().where(share_table.c.id.in_( + self.valid_share_ids)) + ).fetchall() + share_instances = engine.execute(share_instances_table.select().where( + share_instances_table.c.share_id.in_(self.valid_share_ids)) + ).fetchall() + + return shares, share_instances + + def _new_share(self, **kwargs): + share = { + 'id': uuidutils.generate_uuid(), + 'display_name': 'fake_share', + 'size': '1', + 'deleted': 'False', + 'share_proto': 'fake_proto', + 'user_id': 'fake_user_id', + 'project_id': 'fake_project_uuid', + 'snapshot_support': '1', + 'task_state': None, + } + share.update(kwargs) + return share + + def _new_instance(self, share_id=None, **kwargs): + instance = { + 'id': uuidutils.generate_uuid(), + 'share_id': share_id or uuidutils.generate_uuid(), + 'deleted': 'False', + 'host': 'openstack@BackendZ#PoolA', + 'status': 'available', + 'scheduled_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'launched_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'terminated_at': None, + 'access_rules_status': 'active', + } + instance.update(kwargs) + return instance + + def setup_upgrade_data(self, engine): + + shares_data = [] + instances_data = [] + self.valid_share_ids = [] + + for share_display_name in self.valid_share_display_names: + share_ref = self._new_share(display_name=share_display_name) + shares_data.append(share_ref) + instances_data.append(self._new_instance(share_id=share_ref['id'])) + + shares_table = utils.load_table('shares', engine) + + for share in shares_data: + self.valid_share_ids.append(share['id']) + engine.execute(shares_table.insert(share)) + + shares_instances_table = utils.load_table('share_instances', engine) + + for share_instance in instances_data: + engine.execute(shares_instances_table.insert(share_instance)) + + def check_upgrade(self, engine, _): + shares, share_instances = self._load_tables_and_get_data(engine) + share_ids = [share['id'] for share in shares] + share_instance_share_ids = [share_instance['share_id'] for + share_instance in share_instances] + + # Assert no data is lost + for sid in self.valid_share_ids: + self.test_case.assertIn(sid, share_ids) + self.test_case.assertIn(sid, share_instance_share_ids) + + for share in shares: + self.test_case.assertIn(share['display_name'], + self.valid_share_display_names) + self.test_case.assertEqual('False', share.deleted) + self.test_case.assertTrue(hasattr(share, 'replication_type')) + + for share_instance in share_instances: + self.test_case.assertTrue(hasattr(share_instance, 'replica_state')) + + def check_downgrade(self, engine): + shares, share_instances = self._load_tables_and_get_data(engine) + share_ids = [share['id'] for share in shares] + share_instance_share_ids = [share_instance['share_id'] for + share_instance in share_instances] + # Assert no data is lost + for sid in self.valid_share_ids: + self.test_case.assertIn(sid, share_ids) + self.test_case.assertIn(sid, share_instance_share_ids) + + for share in shares: + self.test_case.assertEqual('False', share.deleted) + self.test_case.assertIn(share.display_name, + self.valid_share_display_names) + self.test_case.assertFalse(hasattr(share, 'replication_type')) + + for share_instance in share_instances: + self.test_case.assertEqual('False', share_instance.deleted) + self.test_case.assertIn(share_instance.share_id, + self.valid_share_ids) + self.test_case.assertFalse( + hasattr(share_instance, 'replica_state')) diff --git a/manila/tests/db/sqlalchemy/test_api.py b/manila/tests/db/sqlalchemy/test_api.py index e609570582..b1e9cbfc1d 100644 --- a/manila/tests/db/sqlalchemy/test_api.py +++ b/manila/tests/db/sqlalchemy/test_api.py @@ -217,6 +217,344 @@ class ShareDatabaseAPITestCase(test.TestCase): self.assertEqual(2, len(actual_result)) self.assertEqual(shares[0]['id'], actual_result[1]['id']) + @ddt.data(None, 'writable') + def test_share_get_has_replicas_field(self, replication_type): + share = db_utils.create_share(replication_type=replication_type) + + db_share = db_api.share_get(self.ctxt, share['id']) + + self.assertTrue('has_replicas' in db_share) + + @ddt.data({'with_share_data': False, 'with_share_server': False}, + {'with_share_data': False, 'with_share_server': True}, + {'with_share_data': True, 'with_share_server': False}, + {'with_share_data': True, 'with_share_server': True}) + @ddt.unpack + def test_share_replicas_get_all(self, with_share_data, + with_share_server): + share_server = db_utils.create_share_server() + share_1 = db_utils.create_share() + share_2 = db_utils.create_share() + db_utils.create_share_replica( + replica_state=constants.REPLICA_STATE_ACTIVE, + share_id=share_1['id'], + share_server_id=share_server['id']) + db_utils.create_share_replica( + replica_state=constants.REPLICA_STATE_IN_SYNC, + share_id=share_1['id'], + share_server_id=share_server['id']) + db_utils.create_share_replica( + replica_state=constants.REPLICA_STATE_OUT_OF_SYNC, + share_id=share_2['id'], + share_server_id=share_server['id']) + db_utils.create_share_replica(share_id=share_2['id']) + expected_ss_keys = { + 'backend_details', 'host', 'id', + 'share_network_id', 'status', + } + expected_share_keys = { + 'project_id', 'share_type_id', 'display_name', + 'name', 'share_proto', 'is_public', + 'source_cgsnapshot_member_id', + } + session = db_api.get_session() + + with session.begin(): + share_replicas = db_api.share_replicas_get_all( + self.ctxt, with_share_server=with_share_server, + with_share_data=with_share_data, session=session) + + self.assertEqual(3, len(share_replicas)) + for replica in share_replicas: + if with_share_server: + self.assertTrue(expected_ss_keys.issubset( + replica['share_server'].keys())) + else: + self.assertFalse('share_server' in replica.keys()) + self.assertEqual( + with_share_data, + expected_share_keys.issubset(replica.keys())) + + @ddt.data({'with_share_data': False, 'with_share_server': False}, + {'with_share_data': False, 'with_share_server': True}, + {'with_share_data': True, 'with_share_server': False}, + {'with_share_data': True, 'with_share_server': True}) + @ddt.unpack + def test_share_replicas_get_all_by_share(self, with_share_data, + with_share_server): + share_server = db_utils.create_share_server() + share = db_utils.create_share() + db_utils.create_share_replica( + replica_state=constants.REPLICA_STATE_ACTIVE, + share_id=share['id'], + share_server_id=share_server['id']) + db_utils.create_share_replica( + replica_state=constants.REPLICA_STATE_IN_SYNC, + share_id=share['id'], + share_server_id=share_server['id']) + db_utils.create_share_replica( + replica_state=constants.REPLICA_STATE_OUT_OF_SYNC, + share_id=share['id'], + share_server_id=share_server['id']) + expected_ss_keys = { + 'backend_details', 'host', 'id', + 'share_network_id', 'status', + } + expected_share_keys = { + 'project_id', 'share_type_id', 'display_name', + 'name', 'share_proto', 'is_public', + 'source_cgsnapshot_member_id', + } + session = db_api.get_session() + + with session.begin(): + share_replicas = db_api.share_replicas_get_all_by_share( + self.ctxt, share['id'], + with_share_server=with_share_server, + with_share_data=with_share_data, session=session) + + self.assertEqual(3, len(share_replicas)) + for replica in share_replicas: + if with_share_server: + self.assertTrue(expected_ss_keys.issubset( + replica['share_server'].keys())) + else: + self.assertFalse('share_server' in replica.keys()) + self.assertEqual(with_share_data, + expected_share_keys.issubset(replica.keys())) + + def test_share_replicas_get_available_active_replica(self): + share_server = db_utils.create_share_server() + share_1 = db_utils.create_share() + share_2 = db_utils.create_share() + share_3 = db_utils.create_share() + db_utils.create_share_replica( + id='Replica1', + share_id=share_1['id'], + status=constants.STATUS_AVAILABLE, + replica_state=constants.REPLICA_STATE_ACTIVE, + share_server_id=share_server['id']) + db_utils.create_share_replica( + id='Replica2', + status=constants.STATUS_AVAILABLE, + share_id=share_1['id'], + replica_state=constants.REPLICA_STATE_ACTIVE, + share_server_id=share_server['id']) + db_utils.create_share_replica( + id='Replica3', + status=constants.STATUS_AVAILABLE, + share_id=share_2['id'], + replica_state=constants.REPLICA_STATE_ACTIVE) + db_utils.create_share_replica( + id='Replica4', + status=constants.STATUS_ERROR, + share_id=share_2['id'], + replica_state=constants.REPLICA_STATE_ACTIVE) + db_utils.create_share_replica( + id='Replica5', + status=constants.STATUS_AVAILABLE, + share_id=share_2['id'], + replica_state=constants.REPLICA_STATE_IN_SYNC) + db_utils.create_share_replica( + id='Replica6', + share_id=share_3['id'], + status=constants.STATUS_AVAILABLE, + replica_state=constants.REPLICA_STATE_IN_SYNC) + session = db_api.get_session() + expected_ss_keys = { + 'backend_details', 'host', 'id', + 'share_network_id', 'status', + } + expected_share_keys = { + 'project_id', 'share_type_id', 'display_name', + 'name', 'share_proto', 'is_public', + 'source_cgsnapshot_member_id', + } + + with session.begin(): + replica_share_1 = ( + db_api.share_replicas_get_available_active_replica( + self.ctxt, share_1['id'], with_share_server=True, + session=session) + ) + replica_share_2 = ( + db_api.share_replicas_get_available_active_replica( + self.ctxt, share_2['id'], with_share_data=True, + session=session) + ) + replica_share_3 = ( + db_api.share_replicas_get_available_active_replica( + self.ctxt, share_3['id'], session=session) + ) + + self.assertIn(replica_share_1.get('id'), ['Replica1', 'Replica2']) + self.assertTrue(expected_ss_keys.issubset( + replica_share_1['share_server'].keys())) + self.assertFalse( + expected_share_keys.issubset(replica_share_1.keys())) + self.assertEqual(replica_share_2.get('id'), 'Replica3') + self.assertFalse(replica_share_2['share_server']) + self.assertTrue( + expected_share_keys.issubset(replica_share_2.keys())) + self.assertIsNone(replica_share_3) + + def test_share_replicas_get_active_replicas_by_share(self): + db_utils.create_share_replica( + id='Replica1', + share_id='FAKE_SHARE_ID1', + status=constants.STATUS_AVAILABLE, + replica_state=constants.REPLICA_STATE_ACTIVE) + db_utils.create_share_replica( + id='Replica2', + status=constants.STATUS_AVAILABLE, + share_id='FAKE_SHARE_ID1', + replica_state=constants.REPLICA_STATE_ACTIVE) + db_utils.create_share_replica( + id='Replica3', + status=constants.STATUS_AVAILABLE, + share_id='FAKE_SHARE_ID2', + replica_state=constants.REPLICA_STATE_ACTIVE) + db_utils.create_share_replica( + id='Replica4', + status=constants.STATUS_ERROR, + share_id='FAKE_SHARE_ID2', + replica_state=constants.REPLICA_STATE_ACTIVE) + db_utils.create_share_replica( + id='Replica5', + status=constants.STATUS_AVAILABLE, + share_id='FAKE_SHARE_ID2', + replica_state=constants.REPLICA_STATE_IN_SYNC) + db_utils.create_share_replica( + id='Replica6', + share_id='FAKE_SHARE_ID3', + status=constants.STATUS_AVAILABLE, + replica_state=constants.REPLICA_STATE_IN_SYNC) + + def get_active_replica_ids(share_id): + active_replicas = ( + db_api.share_replicas_get_active_replicas_by_share( + self.ctxt, share_id) + ) + return [r['id'] for r in active_replicas] + + active_ids_shr1 = get_active_replica_ids('FAKE_SHARE_ID1') + active_ids_shr2 = get_active_replica_ids('FAKE_SHARE_ID2') + active_ids_shr3 = get_active_replica_ids('FAKE_SHARE_ID3') + + self.assertEqual(active_ids_shr1, ['Replica1', 'Replica2']) + self.assertEqual(active_ids_shr2, ['Replica3', 'Replica4']) + self.assertEqual([], active_ids_shr3) + + def test_share_replica_get_exception(self): + replica = db_utils.create_share_replica(share_id='FAKE_SHARE_ID') + + self.assertRaises(exception.ShareReplicaNotFound, + db_api.share_replica_get, + self.ctxt, replica['id']) + + def test_share_replica_get_without_share_data(self): + share = db_utils.create_share() + replica = db_utils.create_share_replica( + share_id=share['id'], + replica_state=constants.REPLICA_STATE_ACTIVE) + expected_extra_keys = { + 'project_id', 'share_type_id', 'display_name', + 'name', 'share_proto', 'is_public', + 'source_cgsnapshot_member_id', + } + + share_replica = db_api.share_replica_get(self.ctxt, replica['id']) + + self.assertIsNotNone(share_replica['replica_state']) + self.assertEqual(share['id'], share_replica['share_id']) + self.assertFalse(expected_extra_keys.issubset(share_replica.keys())) + + def test_share_replica_get_with_share_data(self): + share = db_utils.create_share() + replica = db_utils.create_share_replica( + share_id=share['id'], + replica_state=constants.REPLICA_STATE_ACTIVE) + expected_extra_keys = { + 'project_id', 'share_type_id', 'display_name', + 'name', 'share_proto', 'is_public', + 'source_cgsnapshot_member_id', + } + + share_replica = db_api.share_replica_get( + self.ctxt, replica['id'], with_share_data=True) + + self.assertIsNotNone(share_replica['replica_state']) + self.assertEqual(share['id'], share_replica['share_id']) + self.assertTrue(expected_extra_keys.issubset(share_replica.keys())) + + def test_share_replica_get_with_share_server(self): + session = db_api.get_session() + share_server = db_utils.create_share_server() + share = db_utils.create_share() + replica = db_utils.create_share_replica( + share_id=share['id'], + replica_state=constants.REPLICA_STATE_ACTIVE, + share_server_id=share_server['id'] + ) + expected_extra_keys = { + 'backend_details', 'host', 'id', + 'share_network_id', 'status', + } + with session.begin(): + share_replica = db_api.share_replica_get( + self.ctxt, replica['id'], with_share_server=True, + session=session) + + self.assertIsNotNone(share_replica['replica_state']) + self.assertEqual( + share_server['id'], share_replica['share_server_id']) + self.assertTrue(expected_extra_keys.issubset( + share_replica['share_server'].keys())) + + def test_share_replica_update(self): + share = db_utils.create_share() + replica = db_utils.create_share_replica( + share_id=share['id'], replica_state=constants.REPLICA_STATE_ACTIVE) + + updated_replica = db_api.share_replica_update( + self.ctxt, replica['id'], + {'replica_state': constants.REPLICA_STATE_OUT_OF_SYNC}) + + self.assertEqual(constants.REPLICA_STATE_OUT_OF_SYNC, + updated_replica['replica_state']) + + def test_share_replica_delete(self): + share = db_utils.create_share() + share = db_api.share_get(self.ctxt, share['id']) + replica = db_utils.create_share_replica( + share_id=share['id'], replica_state=constants.REPLICA_STATE_ACTIVE) + + self.assertEqual(1, len( + db_api.share_replicas_get_all_by_share(self.ctxt, share['id']))) + + db_api.share_replica_delete(self.ctxt, replica['id']) + + self.assertEqual( + [], db_api.share_replicas_get_all_by_share(self.ctxt, share['id'])) + + def test_share_instance_access_copy(self): + share = db_utils.create_share() + rules = [] + for i in range(0, 5): + rules.append(db_utils.create_access(share_id=share['id'])) + + instance = db_utils.create_share_instance(share_id=share['id']) + + share_access_rules = db_api.share_instance_access_copy( + self.ctxt, share['id'], instance['id']) + share_access_rule_ids = [a['id'] for a in share_access_rules] + + self.assertEqual(5, len(share_access_rules)) + for rule_id in share_access_rule_ids: + self.assertIsNotNone( + db_api.share_instance_access_get( + self.ctxt, rule_id, instance['id'])) + @ddt.ddt class ConsistencyGroupDatabaseAPITestCase(test.TestCase): diff --git a/manila/tests/db/sqlalchemy/test_models.py b/manila/tests/db/sqlalchemy/test_models.py index 9fe425eef3..b5d01914b8 100644 --- a/manila/tests/db/sqlalchemy/test_models.py +++ b/manila/tests/db/sqlalchemy/test_models.py @@ -76,6 +76,55 @@ class ShareTestCase(test.TestCase): self.assertEqual(constants.STATUS_CREATING, share.instance['status']) + @ddt.data(constants.STATUS_AVAILABLE, constants.STATUS_ERROR, + constants.STATUS_CREATING) + def test_share_instance_replication_change(self, status): + + instance_list = [ + db_utils.create_share_instance( + status=constants.STATUS_REPLICATION_CHANGE, + share_id='fake_id'), + db_utils.create_share_instance( + status=status, share_id='fake_id'), + db_utils.create_share_instance( + status=constants.STATUS_ERROR_DELETING, share_id='fake_id') + ] + + share1 = db_utils.create_share(instances=instance_list) + share2 = db_utils.create_share(instances=list(reversed(instance_list))) + + self.assertEqual( + constants.STATUS_REPLICATION_CHANGE, share1.instance['status']) + self.assertEqual( + constants.STATUS_REPLICATION_CHANGE, share2.instance['status']) + + def test_share_instance_prefer_active_instance(self): + + instance_list = [ + db_utils.create_share_instance( + status=constants.STATUS_AVAILABLE, + share_id='fake_id', + replica_state=constants.REPLICA_STATE_IN_SYNC), + db_utils.create_share_instance( + status=constants.STATUS_CREATING, + share_id='fake_id', + replica_state=constants.REPLICA_STATE_OUT_OF_SYNC), + db_utils.create_share_instance( + status=constants.STATUS_ERROR, share_id='fake_id', + replica_state=constants.REPLICA_STATE_ACTIVE), + db_utils.create_share_instance( + status=constants.STATUS_MANAGING, share_id='fake_id', + replica_state=constants.REPLICA_STATE_ACTIVE), + ] + + share1 = db_utils.create_share(instances=instance_list) + share2 = db_utils.create_share(instances=list(reversed(instance_list))) + + self.assertEqual( + constants.STATUS_ERROR, share1.instance['status']) + self.assertEqual( + constants.STATUS_ERROR, share2.instance['status']) + def test_access_rules_status_no_instances(self): share = db_utils.create_share(instances=[]) diff --git a/manila/tests/db_utils.py b/manila/tests/db_utils.py index ecd7cedca4..19b145bd78 100644 --- a/manila/tests/db_utils.py +++ b/manila/tests/db_utils.py @@ -98,6 +98,18 @@ def create_share_instance(**kwargs): kwargs.pop('share_id'), kwargs) +def create_share_replica(**kwargs): + """Create a share replica object.""" + replica = { + 'host': 'fake', + 'status': constants.STATUS_CREATING, + } + replica.update(kwargs) + + return db.share_instance_create(context.get_admin_context(), + kwargs.pop('share_id'), kwargs) + + def create_snapshot(**kwargs): """Create a snapshot object.""" with_share = kwargs.pop('with_share', False) diff --git a/manila/tests/fake_share.py b/manila/tests/fake_share.py index c31f2c11e7..3b88618147 100644 --- a/manila/tests/fake_share.py +++ b/manila/tests/fake_share.py @@ -13,6 +13,8 @@ # 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 datetime +import uuid from manila.tests.db import fakes as db_fakes @@ -29,6 +31,8 @@ def fake_share(**kwargs): 'project_id': 'fake_project_uuid', 'availability_zone': 'fake_az', 'snapshot_support': 'True', + 'replication_type': None, + 'is_busy': False, } share.update(kwargs) return db_fakes.FakeModel(share) @@ -58,3 +62,61 @@ def fake_access(**kwargs): } access.update(kwargs) return db_fakes.FakeModel(access) + + +def fake_replica(id=None, as_primitive=True, for_manager=False, **kwargs): + replica = { + 'id': id or str(uuid.uuid4()), + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'deleted': False, + 'host': 'openstack@BackendZ#PoolA', + 'status': 'available', + 'scheduled_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'launched_at': datetime.datetime(2015, 8, 10, 0, 5, 58), + 'terminated_at': None, + 'replica_state': None, + 'availability_zone_id': 'f6e146d0-65f0-11e5-9d70-feff819cdc9f', + 'export_locations': [{'path': 'path1'}, {'path': 'path2'}], + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'share_server_id': '53099868-65f1-11e5-9d70-feff819cdc9f', + 'access_rules_status': 'out_of_sync', + } + if for_manager: + replica.update({ + 'user_id': None, + 'project_id': None, + 'share_type_id': None, + 'size': None, + 'display_name': None, + 'display_description': None, + 'snapshot_id': None, + 'share_proto': None, + 'is_public': None, + 'consistency_group_id': None, + 'source_cgsnapshot_member_id': None, + 'availability_zone': 'fake_az', + }) + replica.update(kwargs) + if as_primitive: + return replica + else: + return db_fakes.FakeModel(replica) + + +def fake_replica_request_spec(as_primitive=True, **kwargs): + request_spec = { + 'share_properties': fake_share( + id='f0e4bb5e-65f0-11e5-9d70-feff819cdc9f'), + 'share_instance_properties': fake_replica( + id='9c0db763-a109-4862-b010-10f2bd395295'), + 'share_proto': 'nfs', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'snapshot_id': None, + 'share_type': 'fake_share_type', + 'consistency_group': None, + } + request_spec.update(kwargs) + if as_primitive: + return request_spec + else: + return db_fakes.FakeModel(request_spec) diff --git a/manila/tests/fake_utils.py b/manila/tests/fake_utils.py index b56d49cbfc..cc884ef8db 100644 --- a/manila/tests/fake_utils.py +++ b/manila/tests/fake_utils.py @@ -17,6 +17,7 @@ import re from eventlet import greenthread +import mock from oslo_log import log import six @@ -109,3 +110,10 @@ def stub_out_utils_execute(testcase): fake_execute_set_repliers([]) fake_execute_clear_log() testcase.mock_object(utils, 'execute', fake_execute) + + +def get_fake_lock_context(): + context_manager_mock = mock.Mock() + setattr(context_manager_mock, '__enter__', mock.Mock()) + setattr(context_manager_mock, '__exit__', mock.Mock()) + return context_manager_mock diff --git a/manila/tests/scheduler/drivers/test_filter.py b/manila/tests/scheduler/drivers/test_filter.py index 0f0aa09b63..44e0566537 100644 --- a/manila/tests/scheduler/drivers/test_filter.py +++ b/manila/tests/scheduler/drivers/test_filter.py @@ -30,6 +30,7 @@ from manila.tests.scheduler.drivers import test_base from manila.tests.scheduler import fakes SNAPSHOT_SUPPORT = constants.ExtraSpecs.SNAPSHOT_SUPPORT +REPLICATION_TYPE_SPEC = constants.ExtraSpecs.REPLICATION_TYPE_SPEC @ddt.ddt @@ -133,6 +134,59 @@ class FilterSchedulerTestCase(test_base.SchedulerTestCase): self.assertIsNone(weighed_host) self.assertTrue(_mock_service_get_all_by_topic.called) + @ddt.data( + *[{'name': 'foo', 'extra_specs': { + SNAPSHOT_SUPPORT: 'True', REPLICATION_TYPE_SPEC: v + }} for v in ('writable', 'readable', 'dr')] + ) + @mock.patch('manila.db.service_get_all_by_topic') + def test__schedule_share_with_valid_replication_spec( + self, share_type, _mock_service_get_all_by_topic): + sched = fakes.FakeFilterScheduler() + sched.host_manager = fakes.FakeHostManager() + fake_context = context.RequestContext('user', 'project', + is_admin=True) + fakes.mock_host_manager_db_calls(_mock_service_get_all_by_topic) + request_spec = { + 'share_type': share_type, + 'share_properties': {'project_id': 1, 'size': 1}, + 'share_instance_properties': {'project_id': 1, 'size': 1}, + } + weighed_host = sched._schedule_share(fake_context, request_spec, {}) + + self.assertIsNotNone(weighed_host) + self.assertIsNotNone(weighed_host.obj) + self.assertTrue(hasattr(weighed_host.obj, REPLICATION_TYPE_SPEC)) + expected_replication_type_support = ( + share_type.get('extra_specs', {}).get(REPLICATION_TYPE_SPEC)) + self.assertEqual( + expected_replication_type_support, + getattr(weighed_host.obj, REPLICATION_TYPE_SPEC)) + self.assertTrue(_mock_service_get_all_by_topic.called) + + @ddt.data( + *[{'name': 'foo', 'extra_specs': { + SNAPSHOT_SUPPORT: 'True', REPLICATION_TYPE_SPEC: v + }} for v in ('None', 'readwrite', 'activesync')] + ) + @mock.patch('manila.db.service_get_all_by_topic') + def test__schedule_share_with_invalid_replication_type_spec( + self, share_type, _mock_service_get_all_by_topic): + sched = fakes.FakeFilterScheduler() + sched.host_manager = fakes.FakeHostManager() + fake_context = context.RequestContext('user', 'project', + is_admin=True) + fakes.mock_host_manager_db_calls(_mock_service_get_all_by_topic) + request_spec = { + 'share_type': share_type, + 'share_properties': {'project_id': 1, 'size': 1}, + 'share_instance_properties': {'project_id': 1, 'size': 1}, + } + weighed_host = sched._schedule_share(fake_context, request_spec, {}) + + self.assertIsNone(weighed_host) + self.assertTrue(_mock_service_get_all_by_topic.called) + @mock.patch('manila.db.service_get_all_by_topic') def test_schedule_share_with_cg_pool_support( self, _mock_service_get_all_by_topic): @@ -450,3 +504,38 @@ class FilterSchedulerTestCase(test_base.SchedulerTestCase): sched.host_passes_filters, ctx, 'host3#_pool0', request_spec, {}) self.assertTrue(_mock_service_get_topic.called) + + def test_schedule_create_replica_no_host(self): + sched = fakes.FakeFilterScheduler() + request_spec = fakes.fake_replica_request_spec() + + self.mock_object(self.driver_cls, '_schedule_share', + mock.Mock(return_value=None)) + + self.assertRaises(exception.NoValidHost, + sched.schedule_create_replica, + self.context, request_spec, {}) + + def test_schedule_create_replica(self): + sched = fakes.FakeFilterScheduler() + request_spec = fakes.fake_replica_request_spec() + host = 'fake_host' + replica_id = request_spec['share_instance_properties']['id'] + mock_update_db_call = self.mock_object( + base, 'share_replica_update_db', + mock.Mock(return_value='replica')) + mock_share_rpcapi_call = self.mock_object( + sched.share_rpcapi, 'create_share_replica') + self.mock_object( + self.driver_cls, '_schedule_share', + mock.Mock(return_value=fakes.get_fake_host(host_name=host))) + + retval = sched.schedule_create_replica( + self.context, fakes.fake_replica_request_spec(), {}) + + self.assertIsNone(retval) + mock_update_db_call.assert_called_once_with( + self.context, replica_id, host) + mock_share_rpcapi_call.assert_called_once_with( + self.context, 'replica', host, request_spec=request_spec, + filter_properties={}) diff --git a/manila/tests/scheduler/fakes.py b/manila/tests/scheduler/fakes.py index 09dcb86f6a..4a85d28efe 100644 --- a/manila/tests/scheduler/fakes.py +++ b/manila/tests/scheduler/fakes.py @@ -79,6 +79,7 @@ SHARE_SERVICE_STATES_WITH_POOLS = { timestamp=None, reserved_percentage=0, driver_handles_share_servers=False, snapshot_support=True, + replication_type=None, pools=[dict(pool_name='pool1', total_capacity_gb=51, free_capacity_gb=41, @@ -90,6 +91,7 @@ SHARE_SERVICE_STATES_WITH_POOLS = { timestamp=None, reserved_percentage=0, driver_handles_share_servers=False, snapshot_support=True, + replication_type=None, pools=[dict(pool_name='pool2', total_capacity_gb=52, free_capacity_gb=42, @@ -101,6 +103,7 @@ SHARE_SERVICE_STATES_WITH_POOLS = { timestamp=None, reserved_percentage=0, driver_handles_share_servers=False, snapshot_support=True, + replication_type=None, pools=[dict(pool_name='pool3', total_capacity_gb=53, free_capacity_gb=43, @@ -113,6 +116,7 @@ SHARE_SERVICE_STATES_WITH_POOLS = { timestamp=None, reserved_percentage=0, driver_handles_share_servers=False, snapshot_support=True, + replication_type=None, pools=[dict(pool_name='pool4a', total_capacity_gb=541, free_capacity_gb=441, @@ -133,6 +137,7 @@ SHARE_SERVICE_STATES_WITH_POOLS = { timestamp=None, reserved_percentage=0, driver_handles_share_servers=False, snapshot_support=True, + replication_type=None, pools=[dict(pool_name='pool5a', total_capacity_gb=551, free_capacity_gb=451, @@ -150,6 +155,8 @@ SHARE_SERVICE_STATES_WITH_POOLS = { 'host6@FFF': dict(share_backend_name='FFF', timestamp=None, reserved_percentage=0, driver_handles_share_servers=False, + snapshot_support=True, + replication_type=None, pools=[dict(pool_name='pool6a', total_capacity_gb='unknown', free_capacity_gb='unknown', @@ -184,7 +191,9 @@ class FakeHostManager(host_manager.HostManager): 'thin_provisioning': False, 'reserved_percentage': 10, 'timestamp': None, - 'snapshot_support': True}, + 'snapshot_support': True, + 'replication_type': 'writable', + }, 'host2': {'total_capacity_gb': 2048, 'free_capacity_gb': 300, 'allocated_capacity_gb': 1748, @@ -193,7 +202,9 @@ class FakeHostManager(host_manager.HostManager): 'thin_provisioning': True, 'reserved_percentage': 10, 'timestamp': None, - 'snapshot_support': True}, + 'snapshot_support': True, + 'replication_type': 'readable', + }, 'host3': {'total_capacity_gb': 512, 'free_capacity_gb': 256, 'allocated_capacity_gb': 256, @@ -202,8 +213,9 @@ class FakeHostManager(host_manager.HostManager): 'thin_provisioning': False, 'consistency_group_support': 'host', 'reserved_percentage': 0, + 'snapshot_support': True, 'timestamp': None, - 'snapshot_support': True}, + }, 'host4': {'total_capacity_gb': 2048, 'free_capacity_gb': 200, 'allocated_capacity_gb': 1848, @@ -212,7 +224,9 @@ class FakeHostManager(host_manager.HostManager): 'thin_provisioning': True, 'reserved_percentage': 5, 'timestamp': None, - 'snapshot_support': True}, + 'snapshot_support': True, + 'replication_type': 'dr', + }, 'host5': {'total_capacity_gb': 2048, 'free_capacity_gb': 500, 'allocated_capacity_gb': 1548, @@ -221,15 +235,18 @@ class FakeHostManager(host_manager.HostManager): 'thin_provisioning': True, 'reserved_percentage': 5, 'timestamp': None, + 'snapshot_support': True, 'consistency_group_support': 'pool', - 'snapshot_support': True}, + 'replication_type': None, + }, 'host6': {'total_capacity_gb': 'unknown', 'free_capacity_gb': 'unknown', 'allocated_capacity_gb': 1548, 'thin_provisioning': False, 'reserved_percentage': 5, + 'snapshot_support': True, 'timestamp': None, - 'snapshot_support': True}, + }, } @@ -275,3 +292,45 @@ class FakeWeigher2(base_host_weigher.BaseHostWeigher): class FakeClass(object): def __init__(self): pass + + +def fake_replica_request_spec(**kwargs): + request_spec = { + 'share_properties': { + 'id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'name': 'fakename', + 'size': 1, + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'availability_zone': 'fake_az', + 'replication_type': 'dr', + }, + 'share_instance_properties': { + 'id': '8d5566df-1e83-4373-84b8-6f8153a0ac41', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'host': 'openstack@BackendZ#PoolA', + 'status': 'available', + 'availability_zone_id': 'f6e146d0-65f0-11e5-9d70-feff819cdc9f', + 'share_network_id': '4ccd5318-65f1-11e5-9d70-feff819cdc9f', + 'share_server_id': '53099868-65f1-11e5-9d70-feff819cdc9f', + }, + 'share_proto': 'nfs', + 'share_id': 'f0e4bb5e-65f0-11e5-9d70-feff819cdc9f', + 'snapshot_id': None, + 'share_type': 'fake_share_type', + 'consistency_group': None, + } + request_spec.update(kwargs) + return request_spec + + +def get_fake_host(host_name=None): + + class FakeHost(object): + def __init__(self, host_name=None): + self.host = host_name or 'openstack@BackendZ#PoolA' + + class FakeWeightedHost(object): + def __init__(self, host_name=None): + self.obj = FakeHost(host_name=host_name) + + return FakeWeightedHost(host_name=host_name) diff --git a/manila/tests/scheduler/test_host_manager.py b/manila/tests/scheduler/test_host_manager.py index faa26c6fdc..d61af2de8a 100644 --- a/manila/tests/scheduler/test_host_manager.py +++ b/manila/tests/scheduler/test_host_manager.py @@ -199,6 +199,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': False, 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, { 'name': 'host2@back1#BBB', @@ -222,6 +223,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': False, 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, { 'name': 'host2@back2#CCC', @@ -245,6 +247,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': False, 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, ] @@ -290,6 +293,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': False, 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, { 'name': 'host2@BBB#pool2', @@ -314,6 +318,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': False, 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, { 'name': 'host3@CCC#pool3', @@ -338,6 +343,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': 'pool', 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, { 'name': 'host4@DDD#pool4a', @@ -362,6 +368,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': 'host', 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, { 'name': 'host4@DDD#pool4b', @@ -386,6 +393,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': 'host', 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, ] @@ -443,6 +451,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': False, 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, { 'name': 'host2@back1#BBB', @@ -466,6 +475,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': False, 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, ] @@ -515,6 +525,7 @@ class HostManagerTestCase(test.TestCase): 'consistency_group_support': False, 'dedupe': False, 'compression': False, + 'replication_type': None, }, }, ] diff --git a/manila/tests/scheduler/test_rpcapi.py b/manila/tests/scheduler/test_rpcapi.py index ff606b0d49..414602d8db 100644 --- a/manila/tests/scheduler/test_rpcapi.py +++ b/manila/tests/scheduler/test_rpcapi.py @@ -110,3 +110,10 @@ class SchedulerRpcAPITestCase(test.TestCase): request_spec='fake_request_spec', filter_properties='filter_properties', version='1.4') + + def test_create_share_replica(self): + self._test_scheduler_api('create_share_replica', + rpc_method='cast', + request_spec='fake_request_spec', + filter_properties='filter_properties', + version='1.5') diff --git a/manila/tests/share/test_api.py b/manila/tests/share/test_api.py index c82a5d45a7..29ad76f394 100644 --- a/manila/tests/share/test_api.py +++ b/manila/tests/share/test_api.py @@ -34,6 +34,7 @@ from manila.share import api as share_api from manila.share import share_types from manila import test from manila.tests import db_utils +from manila.tests import fake_share as fakes from manila.tests import utils as test_utils from manila import utils @@ -213,12 +214,9 @@ class ShareAPITestCase(test.TestCase): share_type_id=share_type_id, ) share_instance = db_utils.create_share_instance(share_id=share['id']) - share_metadata = {'fake': 'fake'} share_type = {'fake': 'fake'} self.mock_object(db_api, 'share_instance_create', mock.Mock(return_value=share_instance)) - self.mock_object(db_api, 'share_metadata_get', - mock.Mock(return_value=share_metadata)) self.mock_object(db_api, 'share_type_get', mock.Mock(return_value=share_type)) az_mock = mock.Mock() @@ -701,8 +699,6 @@ class ShareAPITestCase(test.TestCase): 'availability_zone_id': 'fake_id', } ) - db_api.share_metadata_get.assert_called_once_with(self.context, - share['id']) db_api.share_type_get.assert_called_once_with(self.context, share['share_type_id']) self.api.share_rpcapi.create_share_instance.assert_called_once_with( @@ -1090,6 +1086,17 @@ class ShareAPITestCase(test.TestCase): self.assertRaises(exception.InvalidShare, self.api.delete, self.context, share) + def test_delete_share_has_replicas(self): + share = self._setup_delete_mocks(constants.STATUS_AVAILABLE, + replication_type='writable') + db_utils.create_share_replica(share_id=share['id'], + replica_state='in_sync') + db_utils.create_share_replica(share_id=share['id'], + replica_state='out_of_sync') + + self.assertRaises(exception.Conflict, self.api.delete, + self.context, share) + @mock.patch.object(db_api, 'count_cgsnapshot_members_in_share', mock.Mock(return_value=2)) def test_delete_dependent_cgsnapshot_members(self): @@ -1704,6 +1711,27 @@ class ShareAPITestCase(test.TestCase): self.assertRaises(exception.InvalidShare, self.api.migrate_share, self.context, share, host, True) + def test_migrate_share_has_replicas(self): + host = 'fake2@backend#pool' + share = db_utils.create_share( + host='fake@backend#pool', status=constants.STATUS_AVAILABLE, + replication_type='dr') + for i in range(1, 4): + db_utils.create_share_replica( + share_id=share['id'], replica_state='in_sync') + self.mock_object(db_api, 'share_snapshot_get_all_for_share', + mock.Mock(return_value=True)) + mock_log = self.mock_object(share_api, 'LOG') + mock_snapshot_get_call = self.mock_object( + db_api, 'share_snapshot_get_all_for_share') + # Share was updated after adding replicas, grabbing it again. + share = db_api.share_get(self.context, share['id']) + + self.assertRaises(exception.Conflict, self.api.migrate_share, + self.context, share, host, True) + self.assertTrue(mock_log.error.called) + self.assertFalse(mock_snapshot_get_call.called) + def test_migrate_share_invalid_host(self): host = 'fake@backend#pool' share = db_utils.create_share( @@ -1745,6 +1773,211 @@ class ShareAPITestCase(test.TestCase): db_api.share_update.assert_any_call( mock.ANY, share['id'], mock.ANY) + @ddt.data({}, {'replication_type': None}) + def test_create_share_replica_invalid_share_type(self, attributes): + share = fakes.fake_share(id='FAKE_SHARE_ID', **attributes) + mock_request_spec_call = self.mock_object( + self.api, '_create_share_instance_and_get_request_spec') + mock_db_update_call = self.mock_object(db_api, 'share_replica_update') + mock_scheduler_rpcapi_call = self.mock_object( + self.api.scheduler_rpcapi, 'create_share_replica') + + self.assertRaises(exception.InvalidShare, + self.api.create_share_replica, + self.context, share) + self.assertFalse(mock_request_spec_call.called) + self.assertFalse(mock_db_update_call.called) + self.assertFalse(mock_scheduler_rpcapi_call.called) + + def test_create_share_replica_busy_share(self): + share = fakes.fake_share( + id='FAKE_SHARE_ID', + task_state='doing_something_real_important', + is_busy=True, + replication_type='dr') + mock_request_spec_call = self.mock_object( + self.api, '_create_share_instance_and_get_request_spec') + mock_db_update_call = self.mock_object(db_api, 'share_replica_update') + mock_scheduler_rpcapi_call = self.mock_object( + self.api.scheduler_rpcapi, 'create_share_replica') + + self.assertRaises(exception.ShareBusyException, + self.api.create_share_replica, + self.context, share) + self.assertFalse(mock_request_spec_call.called) + self.assertFalse(mock_db_update_call.called) + self.assertFalse(mock_scheduler_rpcapi_call.called) + + @ddt.data(None, []) + def test_create_share_replica_no_active_replica(self, active_replicas): + share = fakes.fake_share( + id='FAKE_SHARE_ID', replication_type='dr') + mock_request_spec_call = self.mock_object( + self.api, '_create_share_instance_and_get_request_spec') + mock_db_update_call = self.mock_object(db_api, 'share_replica_update') + mock_scheduler_rpcapi_call = self.mock_object( + self.api.scheduler_rpcapi, 'create_share_replica') + self.mock_object(db_api, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=active_replicas)) + + self.assertRaises(exception.ReplicationException, + self.api.create_share_replica, + self.context, share) + self.assertFalse(mock_request_spec_call.called) + self.assertFalse(mock_db_update_call.called) + self.assertFalse(mock_scheduler_rpcapi_call.called) + + def test_create_share_replica(self): + request_spec = fakes.fake_replica_request_spec() + replica = request_spec['share_instance_properties'] + share = fakes.fake_share( + id=replica['share_id'], replication_type='dr') + fake_replica = fakes.fake_replica(replica['id']) + fake_request_spec = fakes.fake_replica_request_spec() + self.mock_object(db_api, 'share_replicas_get_available_active_replica', + mock.Mock(return_value='FAKE_ACTIVE_REPLICA')) + self.mock_object( + share_api.API, '_create_share_instance_and_get_request_spec', + mock.Mock(return_value=(fake_request_spec, fake_replica))) + self.mock_object(db_api, 'share_replica_update') + mock_sched_rpcapi_call = self.mock_object( + self.api.scheduler_rpcapi, 'create_share_replica') + + result = self.api.create_share_replica( + self.context, share, availability_zone='FAKE_AZ') + + self.assertTrue(mock_sched_rpcapi_call.called) + self.assertEqual(replica, result) + + def test_delete_last_active_replica(self): + fake_replica = fakes.fake_replica( + share_id='FAKE_SHARE_ID', + replica_state=constants.REPLICA_STATE_ACTIVE) + self.mock_object(db_api, 'share_replicas_get_all_by_share', + mock.Mock(return_value=[fake_replica])) + mock_log = self.mock_object(share_api.LOG, 'info') + + self.assertRaises( + exception.ReplicationException, self.api.delete_share_replica, + self.context, fake_replica) + self.assertFalse(mock_log.called) + + def test_delete_share_replica_no_host(self): + replica = fakes.fake_replica('FAKE_ID', host='') + mock_sched_rpcapi_call = self.mock_object( + self.share_rpcapi, 'delete_share_replica') + mock_db_replica_delete_call = self.mock_object( + db_api, 'share_replica_delete') + mock_db_update_call = self.mock_object(db_api, 'share_replica_update') + + self.api.delete_share_replica(self.context, replica) + + self.assertFalse(mock_sched_rpcapi_call.called) + mock_db_replica_delete_call.assert_called_once_with( + self.context, replica['id']) + mock_db_update_call.assert_called_once_with( + self.context, replica['id'], + {'terminated_at': mock.ANY}) + + @ddt.data(True, False) + def test_delete_share_replica(self, force): + replica = fakes.fake_replica('FAKE_ID', host='HOSTA@BackendB#PoolC') + mock_sched_rpcapi_call = self.mock_object( + self.share_rpcapi, 'delete_share_replica') + mock_db_update_call = self.mock_object(db_api, 'share_replica_update') + + self.api.delete_share_replica(self.context, replica, force=force) + + mock_sched_rpcapi_call.assert_called_once_with( + self.context, replica['id'], + 'HOSTA@BackendB', share_id=replica['share_id'], force=force) + mock_db_update_call.assert_called_once_with( + self.context, replica['id'], + {'status': constants.STATUS_DELETING, + 'terminated_at': mock.ANY}) + + @ddt.data(constants.STATUS_CREATING, constants.STATUS_DELETING, + constants.STATUS_ERROR, constants.STATUS_EXTENDING, + constants.STATUS_REPLICATION_CHANGE, constants.STATUS_MANAGING, + constants.STATUS_ERROR_DELETING) + def test_promote_share_replica_non_available_status(self, status): + replica = fakes.fake_replica( + status=status, replica_state=constants.REPLICA_STATE_IN_SYNC) + mock_extract_host_call = self.mock_object( + share_api.share_utils, 'extract_host') + mock_rpcapi_promote_share_replica_call = self.mock_object( + self.share_rpcapi, 'promote_share_replica') + + self.assertRaises(exception.ReplicationException, + self.api.promote_share_replica, + self.context, + replica) + self.assertFalse(mock_extract_host_call.called) + self.assertFalse(mock_rpcapi_promote_share_replica_call.called) + + @ddt.data(constants.REPLICA_STATE_OUT_OF_SYNC, constants.STATUS_ERROR) + def test_promote_share_replica_out_of_sync_non_admin(self, replica_state): + fake_user_context = context.RequestContext( + user_id=None, project_id=None, is_admin=False, + read_deleted='no', overwrite=False) + replica = fakes.fake_replica( + status=constants.STATUS_AVAILABLE, + replica_state=replica_state) + mock_extract_host_call = self.mock_object( + share_api.share_utils, 'extract_host') + mock_rpcapi_promote_share_replica_call = self.mock_object( + self.share_rpcapi, 'promote_share_replica') + + self.assertRaises(exception.AdminRequired, + self.api.promote_share_replica, + fake_user_context, + replica) + self.assertFalse(mock_extract_host_call.called) + self.assertFalse(mock_rpcapi_promote_share_replica_call.called) + + @ddt.data(constants.REPLICA_STATE_OUT_OF_SYNC, constants.STATUS_ERROR) + def test_promote_share_replica_admin_authorized(self, replica_state): + replica = fakes.fake_replica( + status=constants.STATUS_AVAILABLE, + replica_state=replica_state, host='HOSTA@BackendB#PoolC') + self.mock_object(db_api, 'share_replica_get', + mock.Mock(return_value=replica)) + mock_extract_host_call = self.mock_object( + share_api.share_utils, 'extract_host', + mock.Mock(return_value='HOSTA')) + mock_rpcapi_promote_share_replica_call = self.mock_object( + self.share_rpcapi, 'promote_share_replica') + mock_db_update_call = self.mock_object(db_api, 'share_replica_update') + + retval = self.api.promote_share_replica( + self.context, replica) + + self.assertEqual(replica, retval) + self.assertTrue(mock_extract_host_call.called) + mock_db_update_call.assert_called_once_with( + self.context, replica['id'], + {'status': constants.STATUS_REPLICATION_CHANGE}) + mock_rpcapi_promote_share_replica_call.assert_called_once_with( + self.context, replica['id'], 'HOSTA', share_id=replica['share_id']) + + def test_promote_share_replica(self): + replica = fakes.fake_replica('FAKE_ID', host='HOSTA@BackendB#PoolC') + self.mock_object(db_api, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db_api, 'share_replica_update') + mock_extract_host_call = self.mock_object( + share_api.share_utils, 'extract_host', + mock.Mock(return_value='HOSTA')) + mock_sched_rpcapi_call = self.mock_object( + self.share_rpcapi, 'promote_share_replica') + + result = self.api.promote_share_replica(self.context, replica) + + mock_sched_rpcapi_call.assert_called_once_with( + self.context, replica['id'], 'HOSTA', share_id=replica['share_id']) + mock_extract_host_call.assert_called_once_with('HOSTA@BackendB#PoolC') + self.assertEqual(replica, result) + class OtherTenantsShareActionsTestCase(test.TestCase): def setUp(self): diff --git a/manila/tests/share/test_driver.py b/manila/tests/share/test_driver.py index b97948d705..ec4b56499d 100644 --- a/manila/tests/share/test_driver.py +++ b/manila/tests/share/test_driver.py @@ -699,3 +699,29 @@ class ShareDriverTestCase(test.TestCase): 'fake_share', 'fake_access_rules' ) + + def test_create_replica(self): + share_driver = self._instantiate_share_driver(None, True) + self.assertRaises(NotImplementedError, + share_driver.create_replica, + 'fake_context', 'fake_active_replica', + 'fake_new_replica', []) + + def test_delete_replica(self): + share_driver = self._instantiate_share_driver(None, True) + self.assertRaises(NotImplementedError, + share_driver.delete_replica, + 'fake_context', 'fake_active_replica', + 'fake_replica') + + def test_promote_replica(self): + share_driver = self._instantiate_share_driver(None, True) + self.assertRaises(NotImplementedError, + share_driver.promote_replica, + 'fake_context', [], 'fake_replica', []) + + def test_update_replica_state(self): + share_driver = self._instantiate_share_driver(None, True) + self.assertRaises(NotImplementedError, + share_driver.update_replica_state, + 'fake_context', [], 'fake_replica') diff --git a/manila/tests/share/test_manager.py b/manila/tests/share/test_manager.py index 5e465bf7f0..5dc6538862 100644 --- a/manila/tests/share/test_manager.py +++ b/manila/tests/share/test_manager.py @@ -15,9 +15,11 @@ """Test of Share Manager for Manila.""" import datetime +import random import ddt import mock +from oslo_concurrency import lockutils from oslo_serialization import jsonutils from oslo_utils import importutils from oslo_utils import timeutils @@ -36,11 +38,43 @@ from manila.share import migration from manila.share import rpcapi from manila.share import share_types from manila import test +from manila.tests.api import fakes as test_fakes from manila.tests import db_utils +from manila.tests import fake_share as fakes +from manila.tests import fake_utils from manila.tests import utils as test_utils from manila import utils +def fake_replica(**kwargs): + return fakes.fake_replica(for_manager=True, **kwargs) + + +class LockedOperationsTestCase(test.TestCase): + + class FakeManager: + + @manager.locked_share_replica_operation + def fake_replica_operation(self, context, replica, share_id=None): + pass + + def setUp(self): + super(self.__class__, self).setUp() + self.manager = self.FakeManager() + self.fake_context = test_fakes.FakeRequestContext + self.lock_call = self.mock_object( + utils, 'synchronized', mock.Mock(return_value=lambda f: f)) + + @ddt.data({'id': 'FAKE_REPLICA_ID'}, 'FAKE_REPLICA_ID') + @ddt.unpack + def test_locked_share_replica_operation(self, **replica): + + self.manager.fake_replica_operation(self.fake_context, replica, + share_id='FAKE_SHARE_ID') + + self.assertTrue(self.lock_call.called) + + @ddt.ddt class ShareManagerTestCase(test.TestCase): @@ -55,6 +89,10 @@ class ShareManagerTestCase(test.TestCase): self.mock_object(self.share_manager.driver, 'check_for_setup_error') self.context = context.get_admin_context() self.share_manager.driver.initialized = True + mock.patch.object( + lockutils, 'lock', fake_utils.get_fake_lock_context()) + self.synchronized_lock_decorator_call = self.mock_object( + utils, 'synchronized', mock.Mock(return_value=lambda f: f)) def test_share_manager_instance(self): fake_service_name = "fake_service" @@ -161,6 +199,10 @@ class ShareManagerTestCase(test.TestCase): "delete_consistency_group", "create_cgsnapshot", "delete_cgsnapshot", + "create_share_replica", + "delete_share_replica", + "promote_share_replica", + "periodic_share_replica_update", ) def test_call_driver_when_its_init_failed(self, method_name): self.mock_object(self.share_manager.driver, 'do_setup', @@ -477,6 +519,660 @@ class ShareManagerTestCase(test.TestCase): self.assertTrue(len(shr['export_location']) > 0) self.assertEqual(2, len(shr['export_locations'])) + def test_create_share_instance_for_share_with_replication_support(self): + """Test update call is made to update replica_state.""" + share = db_utils.create_share(replication_type='writable') + share_id = share['id'] + + self.share_manager.create_share_instance(self.context, + share.instance['id']) + + self.assertEqual(share_id, db.share_get(context.get_admin_context(), + share_id).id) + + shr = db.share_get(self.context, share_id) + shr_instance = db.share_instance_get(self.context, + share.instance['id']) + + self.assertEqual(constants.STATUS_AVAILABLE, shr['status'],) + self.assertEqual(constants.REPLICA_STATE_ACTIVE, + shr_instance['replica_state']) + + @ddt.data([], None) + def test_create_share_replica_no_active_replicas(self, active_replicas): + replica = fake_replica() + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=active_replicas)) + self.mock_object( + db, 'share_replica_get', mock.Mock(return_value=replica)) + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_driver_replica_call = self.mock_object( + self.share_manager.driver, 'create_replica') + + self.assertRaises(exception.ReplicationException, + self.share_manager.create_share_replica, + self.context, replica) + mock_replica_update_call.assert_called_once_with( + mock.ANY, replica['id'], {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) + self.assertFalse(mock_driver_replica_call.called) + + def test_create_share_replica_with_share_network_id_and_not_dhss(self): + replica = fake_replica() + manager.CONF.set_default('driver_handles_share_servers', False) + self.mock_object(db, 'share_access_get_all_for_share', + mock.Mock(return_value=[])) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=fake_replica(id='fake2'))) + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_driver_replica_call = self.mock_object( + self.share_manager.driver, 'create_replica') + + self.assertRaises(exception.InvalidDriverMode, + self.share_manager.create_share_replica, + self.context, replica) + mock_replica_update_call.assert_called_once_with( + mock.ANY, replica['id'], {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) + self.assertFalse(mock_driver_replica_call.called) + + def test_create_share_replica_with_share_server_exception(self): + replica = fake_replica() + manager.CONF.set_default('driver_handles_share_servers', True) + self.mock_object(db, 'share_instance_access_copy', + mock.Mock(return_value=[])) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=fake_replica(id='fake2'))) + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_driver_replica_call = self.mock_object( + self.share_manager.driver, 'create_replica') + + self.assertRaises(exception.NotFound, + self.share_manager.create_share_replica, + self.context, replica) + mock_replica_update_call.assert_called_once_with( + mock.ANY, replica['id'], {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) + self.assertFalse(mock_driver_replica_call.called) + + def test_create_share_replica_driver_error_on_creation(self): + fake_access_rules = [{'id': '1'}, {'id': '2'}, {'id': '3'}] + replica = fake_replica(share_network_id='') + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_instance_access_copy', + mock.Mock(return_value=fake_access_rules)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=fake_replica(id='fake2'))) + self.mock_object(self.share_manager, + '_provide_share_server_for_share', + mock.Mock(return_value=('FAKE_SERVER', replica))) + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_export_locs_update_call = self.mock_object( + db, 'share_export_locations_update') + mock_log_error = self.mock_object(manager.LOG, 'error') + mock_log_info = self.mock_object(manager.LOG, 'info') + self.mock_object(db, 'share_instance_access_get', + mock.Mock(return_value=fake_access_rules[0])) + mock_share_replica_access_update = self.mock_object( + db, 'share_instance_update_access_status') + self.mock_object(self.share_manager, '_get_share_server') + + self.mock_object(self.share_manager.driver, 'create_replica', + mock.Mock(side_effect=exception.ManilaException)) + + self.assertRaises(exception.ManilaException, + self.share_manager.create_share_replica, + self.context, replica) + mock_replica_update_call.assert_called_once_with( + mock.ANY, replica['id'], {'status': constants.STATUS_ERROR, + 'replica_state': constants.STATUS_ERROR}) + self.assertEqual(1, mock_share_replica_access_update.call_count) + self.assertFalse(mock_export_locs_update_call.called) + self.assertTrue(mock_log_error.called) + self.assertFalse(mock_log_info.called) + + def test_create_share_replica_invalid_locations_state(self): + driver_retval = { + 'export_locations': 'FAKE_EXPORT_LOC', + } + replica = fake_replica(share_network='') + fake_access_rules = [{'id': '1'}, {'id': '2'}] + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=fake_replica(id='fake2'))) + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_instance_access_copy', + mock.Mock(return_value=fake_access_rules)) + self.mock_object(self.share_manager, + '_provide_share_server_for_share', + mock.Mock(return_value=('FAKE_SERVER', replica))) + self.mock_object(self.share_manager, '_get_share_server') + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_export_locs_update_call = self.mock_object( + db, 'share_export_locations_update') + mock_log_info = self.mock_object(manager.LOG, 'info') + mock_log_warning = self.mock_object(manager.LOG, 'warning') + mock_log_error = self.mock_object(manager.LOG, 'error') + self.mock_object(self.share_manager.driver, 'create_replica', + mock.Mock(return_value=driver_retval)) + self.mock_object(db, 'share_instance_access_get', + mock.Mock(return_value=fake_access_rules[0])) + mock_share_replica_access_update = self.mock_object( + db, 'share_instance_update_access_status') + + self.share_manager.create_share_replica(self.context, replica) + + self.assertFalse(mock_replica_update_call.called) + self.assertEqual(1, mock_share_replica_access_update.call_count) + self.assertFalse(mock_export_locs_update_call.called) + self.assertTrue(mock_log_info.called) + self.assertTrue(mock_log_warning.called) + self.assertFalse(mock_log_error.called) + + def test_create_share_replica_no_availability_zone(self): + replica = fake_replica( + availability_zone=None, share_network='', + replica_state=constants.REPLICA_STATE_OUT_OF_SYNC) + manager.CONF.set_default('storage_availability_zone', 'fake_az') + fake_access_rules = [{'id': '1'}, {'id': '2'}, {'id': '3'}] + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_instance_access_copy', + mock.Mock(return_value=fake_access_rules)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=fake_replica(id='fake2'))) + self.mock_object(self.share_manager, + '_provide_share_server_for_share', + mock.Mock(return_value=('FAKE_SERVER', replica))) + mock_replica_update_call = self.mock_object( + db, 'share_replica_update', mock.Mock(return_value=replica)) + mock_calls = [ + mock.call(mock.ANY, replica['id'], + {'availability_zone': 'fake_az'}, with_share_data=True), + mock.call(mock.ANY, replica['id'], + {'status': constants.STATUS_AVAILABLE, + 'replica_state': constants.REPLICA_STATE_OUT_OF_SYNC}), + ] + mock_export_locs_update_call = self.mock_object( + db, 'share_export_locations_update') + mock_log_info = self.mock_object(manager.LOG, 'info') + mock_log_warning = self.mock_object(manager.LOG, 'warning') + mock_log_error = self.mock_object(manager.LOG, 'warning') + self.mock_object(db, 'share_instance_access_get', + mock.Mock(return_value=fake_access_rules[0])) + mock_share_replica_access_update = self.mock_object( + self.share_manager, '_update_share_replica_access_rules_state') + self.mock_object( + self.share_manager.driver, 'create_replica', + mock.Mock(return_value=replica)) + self.mock_object(self.share_manager, '_get_share_server') + + self.share_manager.create_share_replica(self.context, replica) + + mock_replica_update_call.assert_has_calls(mock_calls, any_order=False) + mock_share_replica_access_update.assert_called_once_with( + mock.ANY, replica['id'], replica['access_rules_status']) + self.assertTrue(mock_export_locs_update_call.called) + self.assertTrue(mock_log_info.called) + self.assertFalse(mock_log_warning.called) + self.assertFalse(mock_log_error.called) + + def test_create_share_replica(self): + replica = fake_replica( + share_network='', replica_state=constants.REPLICA_STATE_IN_SYNC) + fake_access_rules = [{'id': '1'}, {'id': '2'}, {'id': '3'}] + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_instance_access_copy', + mock.Mock(return_value=fake_access_rules)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=fake_replica(id='fake2'))) + self.mock_object(self.share_manager, + '_provide_share_server_for_share', + mock.Mock(return_value=('FAKE_SERVER', replica))) + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_export_locs_update_call = self.mock_object( + db, 'share_export_locations_update') + mock_log_info = self.mock_object(manager.LOG, 'info') + mock_log_warning = self.mock_object(manager.LOG, 'warning') + mock_log_error = self.mock_object(manager.LOG, 'warning') + self.mock_object(db, 'share_instance_access_get', + mock.Mock(return_value=fake_access_rules[0])) + mock_share_replica_access_update = self.mock_object( + db, 'share_instance_update_access_status') + self.mock_object( + self.share_manager.driver, 'create_replica', + mock.Mock(return_value=replica)) + self.mock_object(self.share_manager, '_get_share_server') + + self.share_manager.create_share_replica(self.context, replica) + + mock_replica_update_call.assert_called_once_with( + mock.ANY, replica['id'], + {'status': constants.STATUS_AVAILABLE, + 'replica_state': constants.REPLICA_STATE_IN_SYNC}) + self.assertEqual(1, mock_share_replica_access_update.call_count) + self.assertTrue(mock_export_locs_update_call.called) + self.assertTrue(mock_log_info.called) + self.assertFalse(mock_log_warning.called) + self.assertFalse(mock_log_error.called) + + def test_delete_share_replica_access_rules_exception(self): + replica = fake_replica() + active_replica = fake_replica(id='Current_active_replica') + mock_error_log = self.mock_object(manager.LOG, 'error') + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=active_replica)) + self.mock_object(self.share_manager, '_get_share_server') + self.mock_object(self.share_manager.access_helper, + 'update_access_rules') + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_replica_delete_call = self.mock_object(db, 'share_replica_delete') + mock_drv_delete_replica_call = self.mock_object( + self.share_manager.driver, 'delete_replica') + self.mock_object( + self.share_manager.access_helper, 'update_access_rules', + mock.Mock(side_effect=exception.ManilaException)) + + self.assertRaises(exception.ManilaException, + self.share_manager.delete_share_replica, + self.context, replica, share_id=replica['share_id']) + mock_replica_update_call.assert_called_once_with( + mock.ANY, replica['id'], {'status': constants.STATUS_ERROR}) + self.assertFalse(mock_drv_delete_replica_call.called) + self.assertFalse(mock_replica_delete_call.called) + self.assertFalse(mock_error_log.called) + + def test_delete_share_replica_drv_misbehavior_ignored_with_the_force(self): + replica = fake_replica() + active_replica = fake_replica(id='Current_active_replica') + mock_error_log = self.mock_object(manager.LOG, 'error') + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=active_replica)) + self.mock_object(self.share_manager, '_get_share_server') + self.mock_object(self.share_manager.access_helper, + 'update_access_rules') + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_replica_delete_call = self.mock_object(db, 'share_replica_delete') + mock_drv_delete_replica_call = self.mock_object( + self.share_manager.driver, 'delete_replica', + mock.Mock(side_effect=exception.ManilaException)) + self.mock_object( + self.share_manager.access_helper, 'update_access_rules') + + self.share_manager.delete_share_replica( + self.context, replica, share_id=replica['share_id'], force=True) + + self.assertFalse(mock_replica_update_call.called) + self.assertTrue(mock_drv_delete_replica_call.called) + self.assertTrue(mock_replica_delete_call.called) + self.assertEqual(1, mock_error_log.call_count) + + def test_delete_share_replica_driver_exception(self): + replica = fake_replica() + active_replica = fake_replica(id='Current_active_replica') + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=active_replica)) + self.mock_object(self.share_manager, '_get_share_server') + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_replica_delete_call = self.mock_object(db, 'share_replica_delete') + self.mock_object( + self.share_manager.access_helper, 'update_access_rules') + self.mock_object(self.share_manager.driver, 'delete_replica', + mock.Mock(side_effect=exception.ManilaException)) + + self.assertRaises(exception.ManilaException, + self.share_manager.delete_share_replica, + self.context, replica) + self.assertTrue(mock_replica_update_call.called) + self.assertFalse(mock_replica_delete_call.called) + + def test_delete_share_replica_drv_exception_ignored_with_the_force(self): + replica = fake_replica() + active_replica = fake_replica(id='Current_active_replica') + mock_error_log = self.mock_object(manager.LOG, 'error') + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=active_replica)) + self.mock_object(self.share_manager, '_get_share_server') + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_replica_delete_call = self.mock_object(db, 'share_replica_delete') + self.mock_object( + self.share_manager.access_helper, 'update_access_rules') + self.mock_object(self.share_manager.driver, 'delete_replica', + mock.Mock(side_effect=exception.ManilaException)) + + self.share_manager.delete_share_replica( + self.context, replica, share_id=replica['share_id'], force=True) + + self.assertFalse(mock_replica_update_call.called) + self.assertTrue(mock_replica_delete_call.called) + self.assertEqual(1, mock_error_log.call_count) + + def test_delete_share_replica_both_exceptions_ignored_with_the_force(self): + replica = fake_replica() + active_replica = fake_replica(id='Current_active_replica') + mock_error_log = self.mock_object(manager.LOG, 'error') + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=active_replica)) + self.mock_object(self.share_manager, '_get_share_server') + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_replica_delete_call = self.mock_object(db, 'share_replica_delete') + self.mock_object( + self.share_manager.access_helper, 'update_access_rules', + mock.Mock(side_effect=exception.ManilaException)) + self.mock_object(self.share_manager.driver, 'delete_replica', + mock.Mock(side_effect=exception.ManilaException)) + + self.share_manager.delete_share_replica( + self.context, replica, share_id=replica['share_id'], force=True) + + mock_replica_update_call.assert_called_once_with( + mock.ANY, replica['id'], {'status': constants.STATUS_ERROR}) + self.assertTrue(mock_replica_delete_call.called) + self.assertEqual(2, mock_error_log.call_count) + + def test_delete_share_replica(self): + replica = fake_replica() + active_replica = fake_replica(id='current_active_replica') + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=active_replica)) + self.mock_object(self.share_manager, '_get_share_server') + mock_info_log = self.mock_object(manager.LOG, 'info') + mock_replica_update_call = self.mock_object(db, 'share_replica_update') + mock_replica_delete_call = self.mock_object(db, 'share_replica_delete') + self.mock_object( + self.share_manager.access_helper, 'update_access_rules') + self.mock_object(self.share_manager.driver, 'delete_replica') + + self.share_manager.delete_share_replica(self.context, replica) + + self.assertFalse(mock_replica_update_call.called) + self.assertTrue(mock_replica_delete_call.called) + self.assertTrue(mock_info_log.called) + + def test_promote_share_replica_no_active_replica(self): + replica = fake_replica() + replica_list = [replica] + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(self.share_manager, '_get_share_server') + self.mock_object(db, 'share_replicas_get_available_active_replica', + mock.Mock(return_value=replica_list)) + mock_info_log = self.mock_object(manager.LOG, 'info') + mock_driver_call = self.mock_object(self.share_manager.driver, + 'promote_replica') + mock_replica_update = self.mock_object(db, 'share_replica_update') + expected_update_call = mock.call( + mock.ANY, replica['id'], {'status': constants.STATUS_AVAILABLE}) + + self.assertRaises(exception.ReplicationException, + self.share_manager.promote_share_replica, + self.context, replica) + self.assertFalse(mock_info_log.called) + self.assertFalse(mock_driver_call.called) + mock_replica_update.assert_has_calls([expected_update_call]) + + def test_promote_share_replica_driver_exception(self): + replica = fake_replica() + active_replica = fake_replica( + id='current_active_replica', + replica_state=constants.REPLICA_STATE_ACTIVE) + replica_list = [replica, active_replica] + self.mock_object(db, 'share_access_get_all_for_share', + mock.Mock(return_value=[])) + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(self.share_manager, '_get_share_server') + self.mock_object(db, 'share_replicas_get_all_by_share', + mock.Mock(return_value=replica_list)) + self.mock_object(self.share_manager.driver, 'promote_replica', + mock.Mock(side_effect=exception.ManilaException)) + mock_info_log = self.mock_object(manager.LOG, 'info') + mock_replica_update = self.mock_object(db, 'share_replica_update') + expected_update_calls = [mock.call( + mock.ANY, r['id'], {'status': constants.STATUS_ERROR}) + for r in(replica, active_replica)] + + self.assertRaises(exception.ManilaException, + self.share_manager.promote_share_replica, + self.context, replica) + mock_replica_update.assert_has_calls(expected_update_calls) + self.assertFalse(mock_info_log.called) + + @ddt.data([], None) + def test_promote_share_replica_driver_updates_nothing(self, retval): + replica = fake_replica() + active_replica = fake_replica( + id='current_active_replica', + replica_state=constants.REPLICA_STATE_ACTIVE) + replica_list = [replica, active_replica] + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_access_get_all_for_share', + mock.Mock(return_value=[])) + self.mock_object(self.share_manager, '_get_share_server') + self.mock_object(db, 'share_replicas_get_all_by_share', + mock.Mock(return_value=replica_list)) + self.mock_object( + self.share_manager.driver, 'promote_replica', + mock.Mock(return_value=retval)) + mock_info_log = self.mock_object(manager.LOG, 'info') + mock_export_locs_update = self.mock_object( + db, 'share_export_locations_update') + mock_replica_update = self.mock_object(db, 'share_replica_update') + call_1 = mock.call(mock.ANY, replica['id'], + {'status': constants.STATUS_AVAILABLE, + 'replica_state': constants.REPLICA_STATE_ACTIVE}) + call_2 = mock.call( + mock.ANY, 'current_active_replica', + {'replica_state': constants.REPLICA_STATE_OUT_OF_SYNC}) + expected_update_calls = [call_1, call_2] + + self.share_manager.promote_share_replica(self.context, replica) + + self.assertFalse(mock_export_locs_update.called) + mock_replica_update.assert_has_calls(expected_update_calls, + any_order=True) + self.assertTrue(mock_info_log.called) + + def test_promote_share_replica_driver_updates_replica_list(self): + replica = fake_replica() + active_replica = fake_replica( + id='current_active_replica', + replica_state=constants.REPLICA_STATE_ACTIVE) + replica_list = [replica, active_replica, fake_replica(id=3)] + updated_replica_list = [ + { + 'id': replica['id'], + 'export_locations': ['TEST1', 'TEST2'], + 'replica_state': constants.REPLICA_STATE_ACTIVE, + }, + { + 'id': 'current_active_replica', + 'export_locations': 'junk_return_value', + 'replica_state': constants.REPLICA_STATE_IN_SYNC, + }, + { + 'id': 'current_active_replica', + 'export_locations': ['TEST1', 'TEST2'], + 'replica_state': constants.STATUS_ERROR, + }, + ] + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_access_get_all_for_share', + mock.Mock(return_value=[])) + self.mock_object(self.share_manager, '_get_share_server') + self.mock_object(db, 'share_replicas_get_all_by_share', + mock.Mock(return_value=replica_list)) + self.mock_object( + self.share_manager.driver, 'promote_replica', + mock.Mock(return_value=updated_replica_list)) + mock_info_log = self.mock_object(manager.LOG, 'info') + mock_export_locs_update = self.mock_object( + db, 'share_export_locations_update') + mock_replica_update = self.mock_object(db, 'share_replica_update') + reset_replication_change_call = mock.call( + mock.ANY, replica['id'], {'replica_state': constants.STATUS_ACTIVE, + 'status': constants.STATUS_AVAILABLE}) + + self.share_manager.promote_share_replica(self.context, replica) + + self.assertEqual(2, mock_export_locs_update.call_count) + self.assertEqual(3, mock_replica_update.call_count) + self.assertTrue( + reset_replication_change_call in mock_replica_update.mock_calls) + self.assertTrue(mock_info_log.called) + + @ddt.data(constants.REPLICA_STATE_IN_SYNC, + constants.REPLICA_STATE_OUT_OF_SYNC) + def test_update_share_replica_state_driver_exception(self, replica_state): + mock_debug_log = self.mock_object(manager.LOG, 'debug') + replica = fake_replica(replica_state=replica_state) + self.mock_object(self.share_manager.db, 'share_replicas_get_all', + mock.Mock(return_value=[replica])) + self.mock_object(db, 'share_server_get', + mock.Mock(return_value='fake_share_server')) + self.share_manager.host = replica['host'] + self.mock_object(self.share_manager.driver, 'update_replica_state', + mock.Mock(side_effect=exception.ManilaException)) + mock_db_update_call = self.mock_object( + self.share_manager.db, 'share_replica_update') + + self.assertRaises(exception.ManilaException, + self.share_manager.periodic_share_replica_update, + self.context) + self.assertFalse(mock_db_update_call.called) + self.assertEqual(1, mock_debug_log.call_count) + + @ddt.data('openstack1@watson#_pool0', 'openstack1@newton#_pool0') + def test_periodic_share_replica_update(self, host): + mock_debug_log = self.mock_object(manager.LOG, 'debug') + replicas = [ + fake_replica(host='openstack1@watson#pool4'), + fake_replica(host='openstack1@watson#pool5'), + fake_replica(host='openstack1@newton#pool5'), + fake_replica(host='openstack1@newton#pool5'), + + ] + self.mock_object(self.share_manager.db, 'share_replicas_get_all', + mock.Mock(return_value=replicas)) + mock_update_method = self.mock_object( + self.share_manager, '_share_replica_update') + + self.share_manager.host = host + + self.share_manager.periodic_share_replica_update(self.context) + + self.assertEqual(2, mock_update_method.call_count) + self.assertEqual(1, mock_debug_log.call_count) + + def test__share_replica_update_driver_exception_ignored(self): + mock_debug_log = self.mock_object(manager.LOG, 'debug') + replica = fake_replica(replica_state=constants.STATUS_ERROR) + self.mock_object(self.share_manager.db, 'share_replica_get', + mock.Mock(return_value=replica)) + self.mock_object(db, 'share_server_get', + mock.Mock(return_value='fake_share_server')) + self.share_manager.host = replica['host'] + self.mock_object(self.share_manager.driver, 'update_replica_state', + mock.Mock(side_effect=exception.ManilaException)) + mock_db_update_call = self.mock_object( + self.share_manager.db, 'share_replica_update') + + self.share_manager._share_replica_update( + self.context, replica, share_id=replica['share_id']) + + self.assertFalse(mock_db_update_call.called) + self.assertEqual(1, mock_debug_log.call_count) + + @ddt.data({'status': constants.STATUS_AVAILABLE, + 'replica_state': constants.REPLICA_STATE_ACTIVE, }, + {'status': constants.STATUS_DELETING, + 'replica_state': constants.REPLICA_STATE_IN_SYNC, }, + {'status': constants.STATUS_CREATING, + 'replica_state': constants.REPLICA_STATE_OUT_OF_SYNC, }, + {'status': constants.STATUS_MANAGING, + 'replica_state': constants.REPLICA_STATE_OUT_OF_SYNC, }, + {'status': constants.STATUS_UNMANAGING, + 'replica_state': constants.REPLICA_STATE_ACTIVE, }, + {'status': constants.STATUS_EXTENDING, + 'replica_state': constants.REPLICA_STATE_IN_SYNC, }, + {'status': constants.STATUS_SHRINKING, + 'replica_state': constants.REPLICA_STATE_IN_SYNC, }) + def test__share_replica_update_unqualified_replica(self, state): + mock_debug_log = self.mock_object(manager.LOG, 'debug') + mock_warning_log = self.mock_object(manager.LOG, 'warning') + mock_driver_call = self.mock_object( + self.share_manager.driver, 'update_replica_state') + mock_db_update_call = self.mock_object( + self.share_manager.db, 'share_replica_update') + replica = fake_replica(**state) + self.mock_object(db, 'share_server_get', + mock.Mock(return_value='fake_share_server')) + self.mock_object(db, 'share_replica_get', + mock.Mock(return_value=replica)) + + self.share_manager._share_replica_update(self.context, replica, + share_id=replica['share_id']) + + self.assertFalse(mock_debug_log.called) + self.assertFalse(mock_warning_log.called) + self.assertFalse(mock_driver_call.called) + self.assertFalse(mock_db_update_call.called) + + @ddt.data(None, constants.REPLICA_STATE_IN_SYNC, + constants.REPLICA_STATE_OUT_OF_SYNC, + constants.REPLICA_STATE_ACTIVE, + constants.STATUS_ERROR) + def test__share_replica_update(self, retval): + mock_debug_log = self.mock_object(manager.LOG, 'debug') + mock_warning_log = self.mock_object(manager.LOG, 'warning') + replica_states = [constants.REPLICA_STATE_IN_SYNC, + constants.REPLICA_STATE_OUT_OF_SYNC] + replica = fake_replica(replica_state=random.choice(replica_states), + share_server='fake_share_server') + del replica['availability_zone'] + self.mock_object(db, 'share_server_get', + mock.Mock(return_value='fake_share_server')) + mock_db_update_calls = [] + self.mock_object(self.share_manager.db, 'share_replica_get', + mock.Mock(return_value=replica)) + mock_driver_call = self.mock_object( + self.share_manager.driver, 'update_replica_state', + mock.Mock(return_value=retval)) + mock_db_update_call = self.mock_object( + self.share_manager.db, 'share_replica_update') + + self.share_manager._share_replica_update( + self.context, replica, share_id=replica['share_id']) + + if retval == constants.REPLICA_STATE_ACTIVE: + self.assertEqual(1, mock_warning_log.call_count) + elif retval: + self.assertEqual(0, mock_warning_log.call_count) + mock_driver_call.assert_called_once_with( + self.context, replica, [], 'fake_share_server') + mock_db_update_call.assert_has_calls(mock_db_update_calls) + self.assertEqual(1, mock_debug_log.call_count) + def test_create_delete_share_snapshot(self): """Test share's snapshot can be created and deleted.""" diff --git a/manila/tests/share/test_rpcapi.py b/manila/tests/share/test_rpcapi.py index 2b12e788cc..ce292c1518 100644 --- a/manila/tests/share/test_rpcapi.py +++ b/manila/tests/share/test_rpcapi.py @@ -41,11 +41,16 @@ class ShareRpcAPITestCase(test.TestCase): ) access = db_utils.create_access(share_id=share['id']) snapshot = db_utils.create_snapshot(share_id=share['id']) + share_replica = db_utils.create_share_replica( + id='fake_replica', + share_id='fake_share_id', + ) share_server = db_utils.create_share_server() cg = {'id': 'fake_cg_id', 'host': 'fake_host'} cgsnapshot = {'id': 'fake_cg_id'} host = {'host': 'fake_host', 'capabilities': 1} self.fake_share = jsonutils.to_primitive(share) + self.fake_share_replica = jsonutils.to_primitive(share_replica) self.fake_access = jsonutils.to_primitive(access) self.fake_snapshot = jsonutils.to_primitive(snapshot) self.fake_share_server = jsonutils.to_primitive(share_server) @@ -93,6 +98,9 @@ class ShareRpcAPITestCase(test.TestCase): if 'dest_host' in expected_msg: del expected_msg['dest_host'] expected_msg['host'] = self.fake_host + if 'share_replica' in expected_msg: + share_replica = expected_msg.pop('share_replica', None) + expected_msg['share_replica_id'] = share_replica['id'] if 'host' in kwargs: host = kwargs['host'] @@ -246,6 +254,23 @@ class ShareRpcAPITestCase(test.TestCase): share_instance=self.fake_share, share_server=self.fake_share_server) + def test_delete_share_replica(self): + self._test_share_api('delete_share_replica', + rpc_method='cast', + version='1.8', + share_replica_id=self.fake_share_replica['id'], + share_id=self.fake_share_replica['share_id'], + force=False, + host='fake_host') + + def test_promote_share_replica(self): + self._test_share_api('promote_share_replica', + rpc_method='cast', + version='1.8', + share_replica_id=self.fake_share_replica['id'], + share_id=self.fake_share_replica['share_id'], + host='fake_host') + class Desthost(object): host = 'fake_host' capabilities = 1 diff --git a/manila/tests/test_exception.py b/manila/tests/test_exception.py index 96e38e6175..1d520b4e54 100644 --- a/manila/tests/test_exception.py +++ b/manila/tests/test_exception.py @@ -118,6 +118,13 @@ class ManilaExceptionTestCase(test.TestCase): exc2 = exception.ManilaException(exc1) self.assertEqual("test message.", exc2.msg) + def test_replication_exception(self): + # Verify response code for exception.ReplicationException + reason = "Something bad happened." + e = exception.ReplicationException(reason=reason) + self.assertEqual(500, e.code) + self.assertIn(reason, e.msg) + class ManilaExceptionResponseCode400(test.TestCase): @@ -448,6 +455,13 @@ class ManilaExceptionResponseCode404(test.TestCase): self.assertEqual(404, e.code) self.assertIn(instance_id, e.msg) + def test_share_replica_not_found_exception(self): + # Verify response code for exception.ShareReplicaNotFound + replica_id = "FAKE_REPLICA_ID" + e = exception.ShareReplicaNotFound(replica_id=replica_id) + self.assertEqual(404, e.code) + self.assertIn(replica_id, e.msg) + def test_storage_resource_not_found(self): # verify response code for exception.StorageResourceNotFound name = "fake_name" diff --git a/manila_tempest_tests/config.py b/manila_tempest_tests/config.py index ad64b4b2ad..e52ca425b8 100644 --- a/manila_tempest_tests/config.py +++ b/manila_tempest_tests/config.py @@ -36,7 +36,7 @@ ShareGroup = [ help="The minimum api microversion is configured to be the " "value of the minimum microversion supported by Manila."), cfg.StrOpt("max_api_microversion", - default="2.10", + default="2.11", help="The maximum api microversion is configured to be the " "value of the latest microversion supported by Manila."), cfg.StrOpt("region", diff --git a/manila_tempest_tests/tests/api/test_shares.py b/manila_tempest_tests/tests/api/test_shares.py index 2527aa9502..7f8ae9f0aa 100644 --- a/manila_tempest_tests/tests/api/test_shares.py +++ b/manila_tempest_tests/tests/api/test_shares.py @@ -13,10 +13,10 @@ # License for the specific language governing permissions and limitations # under the License. -from tempest import config # noqa -from tempest import test # noqa -from tempest_lib import exceptions as lib_exc # noqa -import testtools # noqa +from tempest import config +from tempest import test +from tempest_lib import exceptions as lib_exc +import testtools from manila_tempest_tests.tests.api import base from manila_tempest_tests import utils @@ -78,6 +78,12 @@ class SharesNFSTest(base.BaseSharesTest): detailed_elements.remove('export_location') self.assertTrue(detailed_elements.issubset(share_get.keys()), msg) + # In v 2.11 and beyond, we expect key 'replication_type' in the + # share data returned by the share create API. + if utils.is_microversion_supported('2.11'): + detailed_elements.add('replication_type') + self.assertTrue(detailed_elements.issubset(share.keys()), msg) + # Delete share self.shares_v2_client.delete_share(share['id']) self.shares_v2_client.wait_for_resource_deletion(share_id=share['id']) diff --git a/manila_tempest_tests/tests/api/test_shares_actions.py b/manila_tempest_tests/tests/api/test_shares_actions.py index f249efb310..703dc71880 100644 --- a/manila_tempest_tests/tests/api/test_shares_actions.py +++ b/manila_tempest_tests/tests/api/test_shares_actions.py @@ -97,6 +97,8 @@ class SharesActionsTest(base.BaseSharesTest): expected_keys.append("share_type_name") if utils.is_microversion_ge(version, '2.10'): expected_keys.append("access_rules_status") + if utils.is_microversion_ge(version, '2.11'): + expected_keys.append("replication_type") actual_keys = list(share.keys()) [self.assertIn(key, actual_keys) for key in expected_keys] @@ -143,6 +145,11 @@ class SharesActionsTest(base.BaseSharesTest): def test_get_share_with_access_rules_status(self): self._get_share('2.10') + @test.attr(type=["gate", ]) + @utils.skip_if_microversion_not_supported('2.11') + def test_get_share_with_replication_type_key(self): + self._get_share('2.11') + @test.attr(type=["gate", ]) def test_list_shares(self): @@ -183,6 +190,8 @@ class SharesActionsTest(base.BaseSharesTest): keys.append("share_type_name") if utils.is_microversion_ge(version, '2.10'): keys.append("access_rules_status") + if utils.is_microversion_ge(version, '2.11'): + keys.append("replication_type") [self.assertIn(key, sh.keys()) for sh in shares for key in keys] @@ -220,6 +229,11 @@ class SharesActionsTest(base.BaseSharesTest): def test_list_shares_with_detail_with_access_rules_status(self): self._list_shares_with_detail('2.10') + @test.attr(type=["gate", ]) + @utils.skip_if_microversion_not_supported('2.11') + def test_list_shares_with_detail_replication_type_key(self): + self._list_shares_with_detail('2.11') + @test.attr(type=["gate", ]) def test_list_shares_with_detail_filter_by_metadata(self): filters = {'metadata': self.metadata} diff --git a/releasenotes/notes/share-replication-81ecf4a32a5c83b6.yaml b/releasenotes/notes/share-replication-81ecf4a32a5c83b6.yaml new file mode 100644 index 0000000000..70acd94ce6 --- /dev/null +++ b/releasenotes/notes/share-replication-81ecf4a32a5c83b6.yaml @@ -0,0 +1,4 @@ +--- +features: + - Shares can be replicated. Replicas can be added, listed, queried for + detail, promoted to be 'active' or removed. \ No newline at end of file