From c91f27f4e1dc221fa03a70af1a159ff99530b73b Mon Sep 17 00:00:00 2001 From: Xing Yang Date: Sun, 22 Nov 2015 16:10:44 -0500 Subject: [PATCH] Manage and unmanage snapshot Add APIs to support manage and unmanage share snapshots. Also add support in the Generic driver. This only supports for DHSS=False driver mode. Add provider_location column to the share_snapshots table to save data used to identify the snapshot on the backend. Also need to bump microversion. APIImpact DocImpact Change-Id: I87a066173c85d969607d132accd9f0e9bd49c235 Implements: blueprint manage-unmanage-snapshot --- contrib/ci/post_test_hook.sh | 5 + ...hare_back_ends_feature_support_mapping.rst | 66 +- etc/manila/policy.json | 2 + manila/api/openstack/api_version_request.py | 4 +- .../openstack/rest_api_version_history.rst | 4 + manila/api/v1/share_snapshots.py | 51 +- manila/api/v2/router.py | 8 +- manila/api/v2/share_snapshots.py | 177 +++++ manila/api/views/share_snapshots.py | 39 +- ...er_location_to_share_snapshot_instances.py | 36 + manila/db/sqlalchemy/api.py | 2 +- manila/db/sqlalchemy/models.py | 9 +- manila/exception.py | 14 + manila/share/api.py | 47 ++ manila/share/driver.py | 34 + manila/share/drivers/generic.py | 49 ++ manila/share/manager.py | 134 +++- manila/share/rpcapi.py | 19 +- manila/tests/api/fakes.py | 12 + manila/tests/api/v1/test_share_snapshots.py | 162 +---- manila/tests/api/v2/test_share_snapshots.py | 629 ++++++++++++++++++ .../alembic/migrations_data_checks.py | 54 ++ manila/tests/db_utils.py | 3 +- manila/tests/fake_share.py | 42 ++ manila/tests/policy.json | 2 + manila/tests/share/drivers/test_generic.py | 40 ++ manila/tests/share/test_driver.py | 14 +- manila/tests/share/test_manager.py | 217 +++++- manila/tests/share/test_rpcapi.py | 15 + manila/tests/test_exception.py | 21 + manila_tempest_tests/config.py | 17 +- .../services/share/v2/json/shares_client.py | 107 +++ .../tests/api/admin/test_snapshot_manage.py | 143 ++++ .../admin/test_snapshot_manage_negative.py | 109 +++ manila_tempest_tests/tests/api/base.py | 8 + manila_tempest_tests/utils.py | 9 + ...ge-unmanage-snapshot-bd92164472638f44.yaml | 3 + 37 files changed, 2082 insertions(+), 225 deletions(-) create mode 100644 manila/api/v2/share_snapshots.py create mode 100644 manila/db/migrations/alembic/versions/eb6d5544cbbd_add_provider_location_to_share_snapshot_instances.py create mode 100644 manila/tests/api/v2/test_share_snapshots.py create mode 100644 manila_tempest_tests/tests/api/admin/test_snapshot_manage.py create mode 100644 manila_tempest_tests/tests/api/admin/test_snapshot_manage_negative.py create mode 100644 releasenotes/notes/manage-unmanage-snapshot-bd92164472638f44.yaml diff --git a/contrib/ci/post_test_hook.sh b/contrib/ci/post_test_hook.sh index 64c8836c21..5934575365 100755 --- a/contrib/ci/post_test_hook.sh +++ b/contrib/ci/post_test_hook.sh @@ -66,6 +66,7 @@ PASSWORD_FOR_SAMBA_USER=${PASSWORD_FOR_SAMBA_USER:-$USERNAME_FOR_USER_RULES} RUN_MANILA_CG_TESTS=${RUN_MANILA_CG_TESTS:-True} RUN_MANILA_MANAGE_TESTS=${RUN_MANILA_MANAGE_TESTS:-True} +RUN_MANILA_MANAGE_SNAPSHOT_TESTS=${RUN_MANILA_MANAGE_SNAPSHOT_TESTS:-False} MANILA_CONF=${MANILA_CONF:-/etc/manila/manila.conf} @@ -128,6 +129,7 @@ if [[ "$TEST_TYPE" == "scenario" ]]; then echo "Set test set to scenario only" MANILA_TESTS='manila_tempest_tests.tests.scenario' elif [[ "$DRIVER" == "generic" ]]; then + RUN_MANILA_MANAGE_SNAPSHOT_TESTS=True if [[ "$POSTGRES_ENABLED" == "True" ]]; then # Run only CIFS tests on PostgreSQL DB backend # to reduce amount of tests per job using 'generic' share driver. @@ -165,6 +167,9 @@ iniset $TEMPEST_CONFIG share run_consistency_group_tests $RUN_MANILA_CG_TESTS # Enable manage/unmanage tests iniset $TEMPEST_CONFIG share run_manage_unmanage_tests $RUN_MANILA_MANAGE_TESTS +# Enable manage/unmanage snapshot tests +iniset $TEMPEST_CONFIG share run_manage_unmanage_snapshot_tests $RUN_MANILA_MANAGE_SNAPSHOT_TESTS + # Also, we should wait until service VM is available # before running Tempest tests using Generic driver in DHSS=False mode. source $BASE/new/manila/contrib/ci/common.sh diff --git a/doc/source/devref/share_back_ends_feature_support_mapping.rst b/doc/source/devref/share_back_ends_feature_support_mapping.rst index cd592145fa..8f48761cd1 100644 --- a/doc/source/devref/share_back_ends_feature_support_mapping.rst +++ b/doc/source/devref/share_back_ends_feature_support_mapping.rst @@ -30,39 +30,39 @@ Column value "-" means that this feature is not currently supported. Mapping of share drivers and share features support --------------------------------------------------- -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| Driver name | create/delete share | manage/unmanage share | extend share | shrink share | create/delete snapshot | create share from snapshot | -+========================================+=============================+=======================+==============+==============+========================+============================+ -| Generic (Cinder as back-end) | DHSS = True (J) & False (K) | K | L | L | J | J | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| NetApp Clustered Data ONTAP | DHSS = True (J) & False (K) | L | L | L | J | J | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| EMC VNX | DHSS = True (J) | \- | \- | \- | J | J | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| EMC Isilon | DHSS = False (K) | \- | M | \- | K | K | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| Red Hat GlusterFS | DHSS = False (J) | \- | \- | \- | volume layout (L) | volume layout (L) | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| Red Hat GlusterFS-Native | DHSS = False (J) | \- | \- | \- | K | L | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| HDFS | DHSS = False (K) | \- | M | \- | K | K | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| Hitachi HNAS | DHSS = False (L) | L | L | M | L | L | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| HPE 3PAR | DHSS = True (L) & False (K) | \- | \- | \- | K | K | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| Huawei | DHSS = True (M) & False(K) | L | L | L | K | M | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| IBM GPFS | DHSS = False(K) | \- | L | \- | K | K | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| LVM | DHSS = False (M) | \- | M | \- | M | M | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| Quobyte | DHSS = False (K) | \- | M | M | \- | \- | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| Windows SMB | DHSS = True (L) & False (L) | L | L | L | L | L | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ -| Oracle ZFSSA | DHSS = False (K) | \- | \- | \- | K | K | -+----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+ ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| Driver name | create/delete share | manage/unmanage share | extend share | shrink share | create/delete snapshot | create share from snapshot | manage/unmanage snapshot | ++========================================+=============================+=======================+==============+==============+========================+============================+==========================+ +| Generic (Cinder as back-end) | DHSS = True (J) & False (K) | K | L | L | J | J | DHSS = False (M) | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| NetApp Clustered Data ONTAP | DHSS = True (J) & False (K) | L | L | L | J | J | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| EMC VNX | DHSS = True (J) | \- | \- | \- | J | J | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| EMC Isilon | DHSS = False (K) | \- | M | \- | K | K | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| Red Hat GlusterFS | DHSS = False (J) | \- | \- | \- | volume layout (L) | volume layout (L) | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| Red Hat GlusterFS-Native | DHSS = False (J) | \- | \- | \- | K | L | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| HDFS | DHSS = False (K) | \- | M | \- | K | K | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| Hitachi HNAS | DHSS = False (L) | L | L | M | L | L | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| HPE 3PAR | DHSS = True (L) & False (K) | \- | \- | \- | K | K | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| Huawei | DHSS = True (M) & False(K) | L | L | L | K | M | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| IBM GPFS | DHSS = False(K) | \- | L | \- | K | K | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| LVM | DHSS = False (M) | \- | M | \- | M | M | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| Quobyte | DHSS = False (K) | \- | M | M | \- | \- | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| Windows SMB | DHSS = True (L) & False (L) | L | L | L | L | L | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| Oracle ZFSSA | DHSS = False (K) | \- | \- | \- | K | K | \- | ++----------------------------------------+-----------------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ .. note:: diff --git a/etc/manila/policy.json b/etc/manila/policy.json index ea6b3a6d72..9549ef8424 100644 --- a/etc/manila/policy.json +++ b/etc/manila/policy.json @@ -52,6 +52,8 @@ "share_snapshot:get_snapshot": "rule:default", "share_snapshot:get_all_snapshots": "rule:default", "share_snapshot:snapshot_update": "rule:default", + "share_snapshot:manage_snapshot": "rule:admin_api", + "share_snapshot:unmanage_snapshot": "rule:admin_api", "share_snapshot:force_delete": "rule:admin_api", "share_snapshot:reset_status": "rule:admin_api", diff --git a/manila/api/openstack/api_version_request.py b/manila/api/openstack/api_version_request.py index 14ad27c1ac..7956679790 100644 --- a/manila/api/openstack/api_version_request.py +++ b/manila/api/openstack/api_version_request.py @@ -58,14 +58,14 @@ REST_API_VERSION_HISTORY = """ * 2.10 - Field 'access_rules_status' was added to shares and share instances. * 2.11 - Share Replication support - + * 2.12 - Manage/unmanage snapshot API. """ # The minimum and maximum versions of the API supported # 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.11" +_MAX_API_VERSION = "2.12" 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 fed16ea22a..3bf6c10a91 100644 --- a/manila/api/openstack/rest_api_version_history.rst +++ b/manila/api/openstack/rest_api_version_history.rst @@ -85,3 +85,7 @@ user documentation. 'Experimental'. Share APIs return two new attributes: 'has_replicas' and 'replication_type'. Share instance APIs return a new attribute, 'replica_state'. + +2.12 +---- + Share snapshot manage and unmanage API. diff --git a/manila/api/v1/share_snapshots.py b/manila/api/v1/share_snapshots.py index 5f5ed588de..abcda09889 100644 --- a/manila/api/v1/share_snapshots.py +++ b/manila/api/v1/share_snapshots.py @@ -30,15 +30,8 @@ from manila import share LOG = log.getLogger(__name__) -class ShareSnapshotsController(wsgi.Controller, wsgi.AdminActionsMixin): - """The Share Snapshots API controller for the OpenStack API.""" - - resource_name = 'share_snapshot' - _view_builder_class = snapshot_views.ViewBuilder - - def __init__(self): - super(ShareSnapshotsController, self).__init__() - self.share_api = share.API() +class ShareSnapshotMixin(object): + """Mixin class for Share Snapshot Controllers.""" def _update(self, *args, **kwargs): db.share_snapshot_update(*args, **kwargs) @@ -49,26 +42,6 @@ class ShareSnapshotsController(wsgi.Controller, wsgi.AdminActionsMixin): def _delete(self, *args, **kwargs): return self.share_api.delete_snapshot(*args, **kwargs) - @wsgi.Controller.api_version('1.0', '2.6') - @wsgi.action('os-reset_status') - def snapshot_reset_status_legacy(self, req, id, body): - return self._reset_status(req, id, body) - - @wsgi.Controller.api_version('2.7') - @wsgi.action('reset_status') - def snapshot_reset_status(self, req, id, body): - return self._reset_status(req, id, body) - - @wsgi.Controller.api_version('1.0', '2.6') - @wsgi.action('os-force_delete') - def snapshot_force_delete_legacy(self, req, id, body): - return self._force_delete(req, id, body) - - @wsgi.Controller.api_version('2.7') - @wsgi.action('force_delete') - def snapshot_force_delete(self, req, id, body): - return self._force_delete(req, id, body) - def show(self, req, id): """Return data about the given snapshot.""" context = req.environ['manila.context'] @@ -219,5 +192,25 @@ class ShareSnapshotsController(wsgi.Controller, wsgi.AdminActionsMixin): req, dict(new_snapshot.items())) +class ShareSnapshotsController(ShareSnapshotMixin, wsgi.Controller, + wsgi.AdminActionsMixin): + """The Share Snapshots API controller for the OpenStack API.""" + + resource_name = 'share_snapshot' + _view_builder_class = snapshot_views.ViewBuilder + + def __init__(self): + super(ShareSnapshotsController, self).__init__() + self.share_api = share.API() + + @wsgi.action('os-reset_status') + def snapshot_reset_status_legacy(self, req, id, body): + return self._reset_status(req, id, body) + + @wsgi.action('os-force_delete') + def snapshot_force_delete_legacy(self, req, id, body): + return self._force_delete(req, id, body) + + def create_resource(): return wsgi.Resource(ShareSnapshotsController()) diff --git a/manila/api/v2/router.py b/manila/api/v2/router.py index 3d32776e12..e7c518f83a 100644 --- a/manila/api/v2/router.py +++ b/manila/api/v2/router.py @@ -29,7 +29,6 @@ from manila.api.v1 import share_manage from manila.api.v1 import share_metadata from manila.api.v1 import share_networks from manila.api.v1 import share_servers -from manila.api.v1 import share_snapshots from manila.api.v1 import share_types_extra_specs from manila.api.v1 import share_unmanage from manila.api.v2 import availability_zones @@ -42,6 +41,7 @@ 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_snapshots from manila.api.v2 import share_types from manila.api.v2 import shares from manila.api import versions @@ -199,6 +199,12 @@ class APIRouter(manila.api.openstack.APIRouter): collection={"detail": "GET"}, member={"action": "POST"}) + mapper.connect("snapshots", + "/{project_id}/snapshots/manage", + controller=self.resources["snapshots"], + action="manage", + conditions={"method": ["POST"]}) + self.resources["share_metadata"] = share_metadata.create_resource() share_metadata_controller = self.resources["share_metadata"] diff --git a/manila/api/v2/share_snapshots.py b/manila/api/v2/share_snapshots.py new file mode 100644 index 0000000000..1b0103f36e --- /dev/null +++ b/manila/api/v2/share_snapshots.py @@ -0,0 +1,177 @@ +# Copyright 2013 NetApp +# Copyright 2015 EMC Corporation. +# 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 snapshots api.""" + +from oslo_log import log +import six +import webob +from webob import exc + +from manila.api.openstack import wsgi +from manila.api.v1 import share_snapshots +from manila.api.views import share_snapshots as snapshot_views +from manila.common import constants +from manila import exception +from manila.i18n import _, _LI +from manila import share + +LOG = log.getLogger(__name__) + + +class ShareSnapshotsController(share_snapshots.ShareSnapshotMixin, + wsgi.Controller, wsgi.AdminActionsMixin): + """The Share Snapshots API V2 controller for the OpenStack API.""" + + resource_name = 'share_snapshot' + _view_builder_class = snapshot_views.ViewBuilder + + def __init__(self): + super(ShareSnapshotsController, self).__init__() + self.share_api = share.API() + + @wsgi.Controller.authorize('unmanage_snapshot') + def _unmanage(self, req, id, body=None): + """Unmanage a share snapshot.""" + context = req.environ['manila.context'] + + LOG.info(_LI("Unmanage share snapshot with id: %s."), id) + + try: + snapshot = self.share_api.get_snapshot(context, id) + + share = self.share_api.get(context, snapshot['share_id']) + if share.get('share_server_id'): + msg = _("Operation 'unmanage_snapshot' is not supported for " + "snapshots of shares that are created with share" + " servers (created with share-networks).") + raise exc.HTTPForbidden(explanation=msg) + elif snapshot['status'] in constants.TRANSITIONAL_STATUSES: + msg = _("Snapshot with transitional state cannot be " + "unmanaged. Snapshot '%(s_id)s' is in '%(state)s' " + "state.") % {'state': snapshot['status'], + 's_id': snapshot['id']} + raise exc.HTTPForbidden(explanation=msg) + + self.share_api.unmanage_snapshot(context, snapshot, share['host']) + except (exception.ShareSnapshotNotFound, exception.ShareNotFound) as e: + raise exc.HTTPNotFound(explanation=six.text_type(e)) + + return webob.Response(status_int=202) + + @wsgi.Controller.authorize('manage_snapshot') + def _manage(self, req, body): + """Instruct Manila to manage an existing snapshot. + + Required HTTP Body: + { + "snapshot": + { + "share_id": , + "provider_location": + } + } + + Optional elements in 'snapshot' are: + name A name for the new snapshot. + description A description for the new snapshot. + driver_options Driver specific dicts for the existing snapshot. + """ + + context = req.environ['manila.context'] + snapshot_data = self._validate_manage_parameters(context, body) + + # NOTE(vponomaryov): compatibility actions are required between API and + # DB layers for 'name' and 'description' API params that are + # represented in DB as 'display_name' and 'display_description' + # appropriately. + name = snapshot_data.get('display_name', + snapshot_data.get('name')) + description = snapshot_data.get( + 'display_description', snapshot_data.get('description')) + + snapshot = { + 'share_id': snapshot_data['share_id'], + 'provider_location': snapshot_data['provider_location'], + 'display_name': name, + 'display_description': description, + } + + driver_options = snapshot_data.get('driver_options', {}) + + try: + snapshot_ref = self.share_api.manage_snapshot(context, snapshot, + driver_options) + except (exception.ShareNotFound, exception.ShareSnapshotNotFound) as e: + raise exc.HTTPNotFound(explanation=six.text_type(e)) + except exception.ManageInvalidShareSnapshot as e: + raise exc.HTTPConflict(explanation=six.text_type(e)) + + return self._view_builder.detail(req, snapshot_ref) + + def _validate_manage_parameters(self, context, body): + if not (body and self.is_valid_body(body, 'snapshot')): + msg = _("Snapshot entity not found in request body.") + raise exc.HTTPUnprocessableEntity(explanation=msg) + + required_parameters = ('share_id', 'provider_location') + + data = body['snapshot'] + + for parameter in required_parameters: + if parameter not in data: + msg = _("Required parameter %s not found.") % parameter + raise exc.HTTPUnprocessableEntity(explanation=msg) + if not data.get(parameter): + msg = _("Required parameter %s is empty.") % parameter + raise exc.HTTPUnprocessableEntity(explanation=msg) + + return data + + @wsgi.Controller.api_version('2.0', '2.6') + @wsgi.action('os-reset_status') + def snapshot_reset_status_legacy(self, req, id, body): + return self._reset_status(req, id, body) + + @wsgi.Controller.api_version('2.7') + @wsgi.action('reset_status') + def snapshot_reset_status(self, req, id, body): + return self._reset_status(req, id, body) + + @wsgi.Controller.api_version('2.0', '2.6') + @wsgi.action('os-force_delete') + def snapshot_force_delete_legacy(self, req, id, body): + return self._force_delete(req, id, body) + + @wsgi.Controller.api_version('2.7') + @wsgi.action('force_delete') + def snapshot_force_delete(self, req, id, body): + return self._force_delete(req, id, body) + + @wsgi.Controller.api_version('2.12') + @wsgi.response(202) + def manage(self, req, body): + return self._manage(req, body) + + @wsgi.Controller.api_version('2.12') + @wsgi.action('unmanage') + def unmanage(self, req, id, body=None): + return self._unmanage(req, id, body) + + +def create_resource(): + return wsgi.Resource(ShareSnapshotsController()) diff --git a/manila/api/views/share_snapshots.py b/manila/api/views/share_snapshots.py index a74f6536e1..6f421464cd 100644 --- a/manila/api/views/share_snapshots.py +++ b/manila/api/views/share_snapshots.py @@ -20,6 +20,9 @@ class ViewBuilder(common.ViewBuilder): """Model a server API response as a python dictionary.""" _collection_name = 'snapshots' + _detail_version_modifiers = [ + "add_provider_location_field", + ] def summary_list(self, request, snapshots): """Show a list of share snapshots without many details.""" @@ -41,21 +44,31 @@ class ViewBuilder(common.ViewBuilder): def detail(self, request, snapshot): """Detailed view of a single share snapshot.""" - return { - 'snapshot': { - 'id': snapshot.get('id'), - 'share_id': snapshot.get('share_id'), - 'share_size': snapshot.get('share_size'), - 'created_at': snapshot.get('created_at'), - 'status': snapshot.get('status'), - 'name': snapshot.get('display_name'), - 'description': snapshot.get('display_description'), - 'size': snapshot.get('size'), - 'share_proto': snapshot.get('share_proto'), - 'links': self._get_links(request, snapshot['id']) - } + snapshot_dict = { + 'id': snapshot.get('id'), + 'share_id': snapshot.get('share_id'), + 'share_size': snapshot.get('share_size'), + 'created_at': snapshot.get('created_at'), + 'status': snapshot.get('status'), + 'name': snapshot.get('display_name'), + 'description': snapshot.get('display_description'), + 'size': snapshot.get('size'), + 'share_proto': snapshot.get('share_proto'), + 'links': self._get_links(request, snapshot['id']), } + # NOTE(xyang): Only retrieve provider_location for admin. + context = request.environ['manila.context'] + if context.is_admin: + self.update_versioned_resource_dict(request, snapshot_dict, + snapshot) + + return {'snapshot': snapshot_dict} + + @common.ViewBuilder.versioned_method("2.12") + def add_provider_location_field(self, snapshot_dict, snapshot): + snapshot_dict['provider_location'] = snapshot.get('provider_location') + def _list_view(self, func, request, snapshots): """Provide a view for a list of share snapshots.""" snapshots_list = [func(request, snapshot)['snapshot'] diff --git a/manila/db/migrations/alembic/versions/eb6d5544cbbd_add_provider_location_to_share_snapshot_instances.py b/manila/db/migrations/alembic/versions/eb6d5544cbbd_add_provider_location_to_share_snapshot_instances.py new file mode 100644 index 0000000000..7562b1b0b3 --- /dev/null +++ b/manila/db/migrations/alembic/versions/eb6d5544cbbd_add_provider_location_to_share_snapshot_instances.py @@ -0,0 +1,36 @@ +# 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 provider_location to share_snapshot_instances + +Revision ID: eb6d5544cbbd +Revises: 5155c7077f99 +Create Date: 2016-02-12 22:25:39.594545 + +""" + +# revision identifiers, used by Alembic. +revision = 'eb6d5544cbbd' +down_revision = '5155c7077f99' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + 'share_snapshot_instances', + sa.Column('provider_location', sa.String(255), nullable=True)) + + +def downgrade(): + op.drop_column('share_snapshot_instances', 'provider_location') diff --git a/manila/db/sqlalchemy/api.py b/manila/db/sqlalchemy/api.py index a183c37f69..9ef1130f6f 100644 --- a/manila/db/sqlalchemy/api.py +++ b/manila/db/sqlalchemy/api.py @@ -1128,7 +1128,7 @@ def extract_share_instance_values(values): def extract_snapshot_instance_values(values): - fields = ['status', 'progress'] + fields = ['status', 'progress', 'provider_location'] return extract_instance_values(values, fields) diff --git a/manila/db/sqlalchemy/models.py b/manila/db/sqlalchemy/models.py index 915cebd2c5..d0c2f11098 100644 --- a/manila/db/sqlalchemy/models.py +++ b/manila/db/sqlalchemy/models.py @@ -583,7 +583,8 @@ class ShareInstanceAccessMapping(BASE, ManilaBase): class ShareSnapshot(BASE, ManilaBase): """Represents a snapshot of a share.""" __tablename__ = 'share_snapshots' - _extra_keys = ['name', 'share_name', 'status', 'progress'] + _extra_keys = ['name', 'share_name', 'status', 'progress', + 'provider_location'] @property def name(self): @@ -603,6 +604,11 @@ class ShareSnapshot(BASE, ManilaBase): if self.instance: return self.instance.progress + @property + def provider_location(self): + if self.instance: + return self.instance.provider_location + @property def instance(self): if len(self.instances) > 0: @@ -664,6 +670,7 @@ class ShareSnapshotInstance(BASE, ManilaBase): String(36), ForeignKey('share_instances.id'), nullable=False) status = Column(String(255)) progress = Column(String(255)) + provider_location = Column(String(255)) share_instance = orm.relationship( ShareInstance, backref="snapshot_instances", primaryjoin=( diff --git a/manila/exception.py b/manila/exception.py index 296f02220f..ec02c3bf3f 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -426,6 +426,10 @@ class ExportLocationNotFound(NotFound): message = _("Export location %(uuid)s could not be found.") +class ShareNotFound(NotFound): + message = _("Share %(share_id)s could not be found.") + + class ShareSnapshotNotFound(NotFound): message = _("Snapshot %(snapshot_id)s could not be found.") @@ -443,6 +447,16 @@ class InvalidShareSnapshot(Invalid): message = _("Invalid share snapshot: %(reason)s.") +class ManageInvalidShareSnapshot(InvalidShareSnapshot): + message = _("Manage existing share snapshot failed due to " + "invalid share snapshot: %(reason)s.") + + +class UnmanageInvalidShareSnapshot(InvalidShareSnapshot): + message = _("Unmanage existing share snapshot failed due to " + "invalid share snapshot: %(reason)s.") + + class ShareMetadataNotFound(NotFound): message = _("Metadata item is not found.") diff --git a/manila/share/api.py b/manila/share/api.py index dc617dd87d..3d67814833 100644 --- a/manila/share/api.py +++ b/manila/share/api.py @@ -517,6 +517,53 @@ class API(base.Base): # share server here, when manage/unmanage operations will be supported # for driver_handles_share_servers=True mode + def manage_snapshot(self, context, snapshot_data, driver_options): + try: + share = self.db.share_get(context, snapshot_data['share_id']) + except exception.NotFound: + raise exception.ShareNotFound(share_id=snapshot_data['share_id']) + + existing_snapshots = self.db.share_snapshot_get_all_for_share( + context, snapshot_data['share_id']) + + for existing_snap in existing_snapshots: + for inst in existing_snap.get('instances'): + if (snapshot_data['provider_location'] == + inst['provider_location']): + msg = _("A share snapshot %(share_snapshot_id)s is " + "already managed for provider location " + "%(provider_location)s.") % { + 'share_snapshot_id': existing_snap['id'], + 'provider_location': + snapshot_data['provider_location'], + } + raise exception.ManageInvalidShareSnapshot( + reason=msg) + + snapshot_data.update({ + 'user_id': context.user_id, + 'project_id': context.project_id, + 'status': constants.STATUS_MANAGING, + 'share_size': share['size'], + 'progress': '0%', + 'share_proto': share['share_proto'] + }) + + snapshot = self.db.share_snapshot_create(context, snapshot_data) + + self.share_rpcapi.manage_snapshot(context, snapshot, share['host'], + driver_options) + return snapshot + + def unmanage_snapshot(self, context, snapshot, host): + update_data = {'status': constants.STATUS_UNMANAGING, + 'terminated_at': timeutils.utcnow()} + snapshot_ref = self.db.share_snapshot_update(context, + snapshot['id'], + update_data) + + self.share_rpcapi.unmanage_snapshot(context, snapshot_ref, host) + @policy.wrap_check_policy('share') def delete(self, context, share, force=False): """Delete share.""" diff --git a/manila/share/driver.py b/manila/share/driver.py index fa4c436b14..2b96f96edf 100644 --- a/manila/share/driver.py +++ b/manila/share/driver.py @@ -826,6 +826,40 @@ class ShareDriver(object): UnmanageInvalidShare exception, specifying a reason for the failure. """ + def manage_existing_snapshot(self, snapshot, driver_options): + """Brings an existing snapshot under Manila management. + + If provided snapshot is not valid, then raise a + ManageInvalidShareSnapshot exception, specifying a reason for + the failure. + + :param snapshot: ShareSnapshotInstance model with ShareSnapshot data. + Example: + {'id': , 'snapshot_id': < snapshot id>, + 'provider_location': , ......} + :param driver_options: Optional driver-specific options provided + by admin. Example: + {'key': 'value', ......} + :return: model_update dictionary with required key 'size', + which should contain size of the share snapshot. + """ + raise NotImplementedError() + + def unmanage_snapshot(self, snapshot): + """Removes the specified snapshot from Manila management. + + Does not delete the underlying backend share snapshot. + + For most drivers, this will not need to do anything. However, some + drivers might use this call as an opportunity to clean up any + Manila-specific configuration that they have associated with the + backend share snapshot. + + If provided share snapshot cannot be unmanaged, then raise an + UnmanageInvalidShareSnapshot exception, specifying a reason for + the failure. + """ + def extend_share(self, share, new_size, share_server=None): """Extends size of existing share. diff --git a/manila/share/drivers/generic.py b/manila/share/drivers/generic.py index cf3021f723..aaf6896742 100644 --- a/manila/share/drivers/generic.py +++ b/manila/share/drivers/generic.py @@ -745,6 +745,7 @@ class GenericShareDriver(driver.ExecuteMixin, driver.ShareDriver): def create_snapshot(self, context, snapshot, share_server=None): """Creates a snapshot.""" + model_update = {} volume = self._get_volume(self.admin_context, snapshot['share_id']) volume_snapshot_name = (self.configuration. volume_snapshot_name_template % snapshot['id']) @@ -762,14 +763,22 @@ class GenericShareDriver(driver.ExecuteMixin, driver.ShareDriver): self.admin_context, volume_snapshot['id']) + # NOTE(xyang): We should look at whether we still need to save + # volume_snapshot_id in private_storage later, now that is saved + # in provider_location. self.private_storage.update( snapshot['id'], {'volume_snapshot_id': volume_snapshot['id']}) + # NOTE(xyang): Need to update provider_location in the db so + # that it can be used in manage/unmanage snapshot tempest tests. + model_update['provider_location'] = volume_snapshot['id'] else: raise exception.ManilaException( _('Volume snapshot have not been ' 'created in %ss. Giving up') % self.configuration.max_time_to_create_volume) + return model_update + def delete_snapshot(self, context, snapshot, share_server=None): """Deletes a snapshot.""" volume_snapshot = self._get_volume_snapshot(self.admin_context, @@ -935,6 +944,46 @@ class GenericShareDriver(driver.ExecuteMixin, driver.ShareDriver): server_details, old_export_location) return {'size': share_size, 'export_locations': export_locations} + def manage_existing_snapshot(self, snapshot, driver_options): + """Manage existing share snapshot with manila. + + :param snapshot: Snapshot data + :param driver_options: Not used by the Generic driver currently + :return: dict with share snapshot size, example: {'size': 1} + """ + model_update = {} + volume_snapshot = None + snapshot_size = snapshot.get('share_size', 0) + provider_location = snapshot.get('provider_location') + try: + volume_snapshot = self.volume_api.get_snapshot( + self.admin_context, + provider_location) + except exception.VolumeSnapshotNotFound as e: + raise exception.ManageInvalidShareSnapshot( + reason=six.text_type(e)) + + if volume_snapshot: + snapshot_size = volume_snapshot['size'] + # NOTE(xyang): volume_snapshot_id is saved in private_storage + # in create_snapshot, so saving it here too for consistency. + # We should look at whether we still need to save it in + # private_storage later. + self.private_storage.update( + snapshot['id'], {'volume_snapshot_id': volume_snapshot['id']}) + # NOTE(xyang): provider_location is used to map a Manila snapshot + # to its name on the storage backend and prevent managing of the + # same snapshot twice. + model_update['provider_location'] = volume_snapshot['id'] + + model_update['size'] = snapshot_size + return model_update + + def unmanage_snapshot(self, snapshot): + """Unmanage share snapshot with manila.""" + + self.private_storage.delete(snapshot['id']) + def _get_mount_stats_by_index(self, mount_path, server_details, index, block_size='G'): """Get mount stats using df shell command. diff --git a/manila/share/manager.py b/manila/share/manager.py index 5216124824..4ca1ac13cd 100644 --- a/manila/share/manager.py +++ b/manila/share/manager.py @@ -164,7 +164,7 @@ def add_hooks(f): class ShareManager(manager.SchedulerDependentManager): """Manages NAS storages.""" - RPC_API_VERSION = '1.8' + RPC_API_VERSION = '1.9' def __init__(self, share_driver=None, service_name=None, *args, **kwargs): """Load the driver from args, or from flags.""" @@ -1308,6 +1308,80 @@ class ShareManager(manager.SchedulerDependentManager): {'status': constants.STATUS_MANAGE_ERROR, 'size': 1}) raise + @add_hooks + @utils.require_driver_initialized + def manage_snapshot(self, context, snapshot_id, driver_options): + if self.driver.driver_handles_share_servers: + msg = _("Manage snapshot is not supported for " + "driver_handles_share_servers=True mode.") + # NOTE(vponomaryov): set size as 1 because design expects size + # to be set, it also will allow us to handle delete/unmanage + # operations properly with this errored snapshot according to + # quotas. + self.db.share_snapshot_update( + context, snapshot_id, + {'status': constants.STATUS_MANAGE_ERROR, 'size': 1}) + raise exception.InvalidDriverMode(driver_mode=msg) + + context = context.elevated() + snapshot_ref = self.db.share_snapshot_get(context, snapshot_id) + share_server = self._get_share_server(context, + snapshot_ref['share']) + + if share_server: + msg = _("Manage snapshot is not supported for " + "share snapshots with share servers.") + # NOTE(vponomaryov): set size as 1 because design expects size + # to be set, it also will allow us to handle delete/unmanage + # operations properly with this errored snapshot according to + # quotas. + self.db.share_snapshot_update( + context, snapshot_id, + {'status': constants.STATUS_MANAGE_ERROR, 'size': 1}) + raise exception.InvalidShareSnapshot(reason=msg) + + snapshot_instance = self.db.share_snapshot_instance_get( + context, snapshot_ref.instance['id'], with_share_data=True + ) + project_id = snapshot_ref['project_id'] + + try: + snapshot_update = ( + self.driver.manage_existing_snapshot( + snapshot_instance, + driver_options) + or {} + ) + + if not snapshot_update.get('size'): + snapshot_update['size'] = snapshot_ref['share']['size'] + LOG.warning(_LI("Cannot get the size of the snapshot " + "%(snapshot_id)s. Using the size of " + "the share instead."), + {'snapshot_id': snapshot_id}) + + self._update_quota_usages(context, project_id, { + "snapshots": 1, + "snapshot_gigabytes": snapshot_update['size'], + }) + + snapshot_update.update({ + 'status': constants.STATUS_AVAILABLE, + 'progress': '100%', + }) + snapshot_update.pop('id', None) + self.db.share_snapshot_update(context, snapshot_id, + snapshot_update) + except Exception: + # NOTE(vponomaryov): set size as 1 because design expects size + # to be set, it also will allow us to handle delete/unmanage + # operations properly with this errored snapshot according to + # quotas. + self.db.share_snapshot_update( + context, snapshot_id, + {'status': constants.STATUS_MANAGE_ERROR, 'size': 1}) + raise + def _update_quota_usages(self, context, project_id, usages): user_id = context.user_id for resource, usage in usages.items(): @@ -1383,6 +1457,60 @@ class ShareManager(manager.SchedulerDependentManager): self.db.share_instance_delete(context, share_instance['id']) LOG.info(_LI("Share %s: unmanaged successfully."), share_id) + @add_hooks + @utils.require_driver_initialized + def unmanage_snapshot(self, context, snapshot_id): + status = {'status': constants.STATUS_UNMANAGE_ERROR} + if self.driver.driver_handles_share_servers: + msg = _("Unmanage snapshot is not supported for " + "driver_handles_share_servers=True mode.") + self.db.share_snapshot_update(context, snapshot_id, status) + LOG.error(_LE("Share snapshot cannot be unmanaged: %s."), + msg) + return + + context = context.elevated() + snapshot_ref = self.db.share_snapshot_get(context, snapshot_id) + share_server = self._get_share_server(context, + snapshot_ref['share']) + + snapshot_instance = self.db.share_snapshot_instance_get( + context, snapshot_ref.instance['id'], with_share_data=True + ) + + project_id = snapshot_ref['project_id'] + + if share_server: + msg = _("Unmanage snapshot is not supported for " + "share snapshots with share servers.") + self.db.share_snapshot_update(context, snapshot_id, status) + LOG.error(_LE("Share snapshot cannot be unmanaged: %s."), + msg) + return + + try: + self.driver.unmanage_snapshot(snapshot_instance) + except exception.UnmanageInvalidShareSnapshot as e: + self.db.share_snapshot_update(context, snapshot_id, status) + LOG.error(_LE("Share snapshot cannot be unmanaged: %s."), e) + return + + try: + reservations = QUOTAS.reserve( + context, + project_id=project_id, + snapshots=-1, + snapshot_gigabytes=-snapshot_ref['size']) + QUOTAS.commit(context, reservations, project_id=project_id) + except Exception as e: + # Note(imalinovskiy): + # Quota reservation errors here are not fatal, because + # unmanage is administrator API and he/she could update user + # quota usages later if it's required. + LOG.warning(_LW("Failed to update quota usages: %s."), e) + + self.db.share_snapshot_destroy(context, snapshot_id) + @add_hooks @utils.require_driver_initialized def delete_share_instance(self, context, share_instance_id): @@ -1451,10 +1579,8 @@ class ShareManager(manager.SchedulerDependentManager): context, snapshot_instance, share_server=share_server) if model_update: - model_dict = model_update.to_dict() self.db.share_snapshot_instance_update( - context, snapshot_instance_id, model_dict) - + context, snapshot_instance_id, model_update) except Exception: with excutils.save_and_reraise_exception(): self.db.share_snapshot_instance_update( diff --git a/manila/share/rpcapi.py b/manila/share/rpcapi.py index d8c568c2b4..8d019fb8d8 100644 --- a/manila/share/rpcapi.py +++ b/manila/share/rpcapi.py @@ -51,6 +51,7 @@ class ShareAPI(object): delete_share_replica() promote_share_replica() update_share_replica() + 1.9 - Add manage_snapshot() and unmanage_snapshot() methods """ BASE_RPC_API_VERSION = '1.0' @@ -59,7 +60,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.8') + self.client = rpc.get_client(target, version_cap='1.9') def create_share_instance(self, context, share_instance, host, request_spec, filter_properties, @@ -87,6 +88,22 @@ class ShareAPI(object): call_context = self.client.prepare(server=host, version='1.1') call_context.cast(context, 'unmanage_share', share_id=share['id']) + def manage_snapshot(self, context, snapshot, host, + driver_options=None): + new_host = utils.extract_host(host) + call_context = self.client.prepare(server=new_host, version='1.9') + call_context.cast(context, + 'manage_snapshot', + snapshot_id=snapshot['id'], + driver_options=driver_options) + + def unmanage_snapshot(self, context, snapshot, host): + new_host = utils.extract_host(host) + call_context = self.client.prepare(server=new_host, version='1.9') + call_context.cast(context, + 'unmanage_snapshot', + snapshot_id=snapshot['id']) + def delete_share_instance(self, context, share_instance): host = utils.extract_host(share_instance['host']) call_context = self.client.prepare(server=host, version='1.4') diff --git a/manila/tests/api/fakes.py b/manila/tests/api/fakes.py index fcae81b87f..8f5e23a1f0 100644 --- a/manila/tests/api/fakes.py +++ b/manila/tests/api/fakes.py @@ -179,6 +179,18 @@ def app(): mapper['/v2'] = router_v2.APIRouter() return mapper +fixture_reset_status_with_different_roles_v1 = ( + { + 'role': 'admin', + 'valid_code': 202, + 'valid_status': constants.STATUS_ERROR, + }, + { + 'role': 'member', + 'valid_code': 403, + 'valid_status': constants.STATUS_AVAILABLE, + }, +) fixture_reset_status_with_different_roles = ( { diff --git a/manila/tests/api/v1/test_share_snapshots.py b/manila/tests/api/v1/test_share_snapshots.py index 4fac14416d..83c8b03fa2 100644 --- a/manila/tests/api/v1/test_share_snapshots.py +++ b/manila/tests/api/v1/test_share_snapshots.py @@ -13,8 +13,6 @@ # License for the specific language governing permissions and limitations # under the License. -import datetime - import ddt import mock from oslo_serialization import jsonutils @@ -31,6 +29,7 @@ from manila import test from manila.tests.api.contrib import stubs from manila.tests.api import fakes from manila.tests import db_utils +from manila.tests import fake_share @ddt.ddt @@ -77,39 +76,18 @@ class ShareSnapshotAPITest(test.TestCase): stubs.stub_snapshot_create) body = { 'snapshot': { - 'share_id': 100, + 'share_id': 'fakeshareid', 'force': False, - 'name': 'fake_share_name', - 'description': 'fake_share_description', + 'name': 'displaysnapname', + 'description': 'displaysnapdesc', } } req = fakes.HTTPRequest.blank('/snapshots') res_dict = self.controller.create(req, body) - expected = { - 'snapshot': { - 'id': 200, - 'share_id': 100, - 'share_size': 1, - 'created_at': datetime.datetime(1, 1, 1, 1, 1, 1), - 'status': 'fakesnapstatus', - 'name': 'fake_share_name', - 'size': 1, - 'description': 'fake_share_description', - 'share_proto': 'fakesnapproto', - 'links': [ - { - 'href': 'http://localhost/v1/fake/snapshots/200', - 'rel': 'self', - }, - { - 'href': 'http://localhost/fake/snapshots/200', - 'rel': 'bookmark', - }, - ], - } - } + expected = fake_share.expected_snapshot(id=200) + self.assertEqual(expected, res_dict) @ddt.data(0, False) @@ -162,29 +140,7 @@ class ShareSnapshotAPITest(test.TestCase): def test_snapshot_show(self): req = fakes.HTTPRequest.blank('/snapshots/200') res_dict = self.controller.show(req, 200) - expected = { - 'snapshot': { - 'id': 200, - 'share_id': 'fakeshareid', - 'share_size': 1, - 'created_at': datetime.datetime(1, 1, 1, 1, 1, 1), - 'status': 'fakesnapstatus', - 'name': 'displaysnapname', - 'size': 1, - 'description': 'displaysnapdesc', - 'share_proto': 'fakesnapproto', - 'links': [ - { - 'href': 'http://localhost/v1/fake/snapshots/200', - 'rel': 'self', - }, - { - 'href': 'http://localhost/fake/snapshots/200', - 'rel': 'bookmark', - }, - ], - } - } + expected = fake_share.expected_snapshot(id=200) self.assertEqual(expected, res_dict) def test_snapshot_show_nofound(self): @@ -222,15 +178,7 @@ class ShareSnapshotAPITest(test.TestCase): self.assertEqual(expected, res_dict) def _snapshot_list_summary_with_search_opts(self, use_admin_context): - search_opts = { - 'name': 'fake_name', - 'status': 'fake_status', - 'share_id': 'fake_share_id', - 'sort_key': 'fake_sort_key', - 'sort_dir': 'fake_sort_dir', - 'offset': '1', - 'limit': '1', - } + search_opts = fake_share.search_opts() # fake_key should be filtered for non-admin url = '/snapshots?fake_key=fake_value' for k, v in search_opts.items(): @@ -275,15 +223,7 @@ class ShareSnapshotAPITest(test.TestCase): self._snapshot_list_summary_with_search_opts(use_admin_context=True) def _snapshot_list_detail_with_search_opts(self, use_admin_context): - search_opts = { - 'name': 'fake_name', - 'status': 'fake_status', - 'share_id': 'fake_share_id', - 'sort_key': 'fake_sort_key', - 'sort_dir': 'fake_sort_dir', - 'limit': '1', - 'offset': '1', - } + search_opts = fake_share.search_opts() # fake_key should be filtered for non-admin url = '/shares/detail?fake_key=fake_value' for k, v in search_opts.items(): @@ -348,32 +288,8 @@ class ShareSnapshotAPITest(test.TestCase): env = {'QUERY_STRING': 'name=Share+Test+Name'} req = fakes.HTTPRequest.blank('/shares/detail', environ=env) res_dict = self.controller.detail(req) - expected = { - 'snapshots': [ - { - 'id': 2, - 'share_id': 'fakeshareid', - 'share_size': 1, - 'size': 1, - 'created_at': datetime.datetime(1, 1, 1, 1, 1, 1), - 'status': 'fakesnapstatus', - 'name': 'displaysnapname', - 'description': 'displaysnapdesc', - 'share_proto': 'fakesnapproto', - 'links': [ - { - 'href': 'http://localhost/v1/fake/snapshots/' - '2', - 'rel': 'self', - }, - { - 'href': 'http://localhost/fake/snapshots/2', - 'rel': 'bookmark', - }, - ], - }, - ] - } + expected_s = fake_share.expected_snapshot(id=2) + expected = {'snapshots': [expected_s['snapshot']]} self.assertEqual(expected, res_dict) def test_snapshot_list_status_none(self): @@ -443,26 +359,22 @@ class ShareSnapshotAdminActionsAPITest(test.TestCase): def _get_context(self, role): return getattr(self, '%s_context' % role) - def _setup_snapshot_data(self, snapshot=None, version='2.7'): + def _setup_snapshot_data(self, snapshot=None): if snapshot is None: share = db_utils.create_share() snapshot = db_utils.create_snapshot( status=constants.STATUS_AVAILABLE, share_id=share['id']) - req = fakes.HTTPRequest.blank('/v2/fake/snapshots/%s/action' % - snapshot['id'], version=version) + req = fakes.HTTPRequest.blank('/v1/fake/snapshots/%s/action' % + snapshot['id']) return snapshot, req def _reset_status(self, ctxt, model, req, db_access_method, - valid_code, valid_status=None, body=None, version='2.7'): - if float(version) > 2.6: - action_name = 'reset_status' - else: - action_name = 'os-reset_status' + valid_code, valid_status=None, body=None): + action_name = 'os-reset_status' if body is None: body = {action_name: {'status': constants.STATUS_ERROR}} req.method = 'POST' req.headers['content-type'] = 'application/json' - req.headers['X-Openstack-Manila-Api-Version'] = version req.body = six.b(jsonutils.dumps(body)) req.environ['manila.context'] = ctxt @@ -480,39 +392,31 @@ class ShareSnapshotAdminActionsAPITest(test.TestCase): actual_model = db_access_method(ctxt, model['id']) self.assertEqual(valid_status, actual_model['status']) - @ddt.data(*fakes.fixture_reset_status_with_different_roles) + @ddt.data(*fakes.fixture_reset_status_with_different_roles_v1) @ddt.unpack def test_snapshot_reset_status_with_different_roles(self, role, valid_code, - valid_status, version): + valid_status): ctxt = self._get_context(role) - snapshot, req = self._setup_snapshot_data(version=version) + snapshot, req = self._setup_snapshot_data() self._reset_status(ctxt, snapshot, req, db.share_snapshot_get, - valid_code, valid_status, version=version) + valid_code, valid_status) @ddt.data( - ({'os-reset_status': {'x-status': 'bad'}}, '2.6'), - ({'reset_status': {'x-status': 'bad'}}, '2.7'), - ({'os-reset_status': {'status': 'invalid'}}, '2.6'), - ({'reset_status': {'status': 'invalid'}}, '2.7'), + {'os-reset_status': {'x-status': 'bad'}}, + {'os-reset_status': {'status': 'invalid'}}, ) - @ddt.unpack - def test_snapshot_invalid_reset_status_body(self, body, version): - snapshot, req = self._setup_snapshot_data(version=version) + def test_snapshot_invalid_reset_status_body(self, body): + snapshot, req = self._setup_snapshot_data() self._reset_status(self.admin_context, snapshot, req, db.share_snapshot_get, 400, - constants.STATUS_AVAILABLE, body, version=version) + constants.STATUS_AVAILABLE, body) - def _force_delete(self, ctxt, model, req, db_access_method, valid_code, - version='2.7'): - if float(version) > 2.6: - action_name = 'force_delete' - else: - action_name = 'os-force_delete' + def _force_delete(self, ctxt, model, req, db_access_method, valid_code): + action_name = 'os-force_delete' req.method = 'POST' req.headers['content-type'] = 'application/json' - req.headers['X-Openstack-Manila-Api-Version'] = version req.body = six.b(jsonutils.dumps({action_name: {}})) req.environ['manila.context'] = ctxt @@ -521,15 +425,17 @@ class ShareSnapshotAdminActionsAPITest(test.TestCase): # Validate response self.assertEqual(valid_code, resp.status_int) - @ddt.data(*fakes.fixture_force_delete_with_different_roles) + @ddt.data( + {'role': 'admin', 'resp_code': 202}, + {'role': 'member', 'resp_code': 403}, + ) @ddt.unpack - def test_snapshot_force_delete_with_different_roles(self, role, resp_code, - version): + def test_snapshot_force_delete_with_different_roles(self, role, resp_code): ctxt = self._get_context(role) - snapshot, req = self._setup_snapshot_data(version=version) + snapshot, req = self._setup_snapshot_data() self._force_delete(ctxt, snapshot, req, db.share_snapshot_get, - resp_code, version=version) + resp_code) def test_snapshot_force_delete_missing(self): ctxt = self._get_context('admin') diff --git a/manila/tests/api/v2/test_share_snapshots.py b/manila/tests/api/v2/test_share_snapshots.py new file mode 100644 index 0000000000..1f9231ce2f --- /dev/null +++ b/manila/tests/api/v2/test_share_snapshots.py @@ -0,0 +1,629 @@ +# Copyright 2015 EMC Corporation +# 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_serialization import jsonutils +import six +import webob + +from manila.api.v2 import share_snapshots +from manila.common import constants +from manila import context +from manila import db +from manila import exception +from manila import policy +from manila.share import api as share_api +from manila import test +from manila.tests.api.contrib import stubs +from manila.tests.api import fakes +from manila.tests import db_utils +from manila.tests import fake_share + +MIN_MANAGE_SNAPSHOT_API_VERSION = '2.12' + + +def get_fake_manage_body(share_id=None, provider_location=None, + driver_options=None, **kwargs): + fake_snapshot = { + 'share_id': share_id, + 'provider_location': provider_location, + 'driver_options': driver_options, + } + fake_snapshot.update(kwargs) + return {'snapshot': fake_snapshot} + + +@ddt.ddt +class ShareSnapshotAPITest(test.TestCase): + """Share Snapshot API Test.""" + + def setUp(self): + super(self.__class__, self).setUp() + self.controller = share_snapshots.ShareSnapshotsController() + + self.mock_object(share_api.API, 'get', stubs.stub_share_get) + self.mock_object(share_api.API, 'get_all_snapshots', + stubs.stub_snapshot_get_all_by_project) + self.mock_object(share_api.API, 'get_snapshot', + stubs.stub_snapshot_get) + self.mock_object(share_api.API, 'snapshot_update', + stubs.stub_snapshot_update) + self.snp_example = { + 'share_id': 100, + 'size': 12, + 'force': False, + 'display_name': 'updated_snapshot_name', + 'display_description': 'updated_snapshot_description', + } + + def test_snapshot_create(self): + self.mock_object(share_api.API, 'create_snapshot', + stubs.stub_snapshot_create) + body = { + 'snapshot': { + 'share_id': 'fakeshareid', + 'force': False, + 'name': 'displaysnapname', + 'description': 'displaysnapdesc', + } + } + req = fakes.HTTPRequest.blank('/snapshots') + + res_dict = self.controller.create(req, body) + + expected = fake_share.expected_snapshot(id=200) + + self.assertEqual(expected, res_dict) + + @ddt.data(0, False) + def test_snapshot_create_no_support(self, snapshot_support): + self.mock_object(share_api.API, 'create_snapshot') + self.mock_object( + share_api.API, + 'get', + mock.Mock(return_value={'snapshot_support': snapshot_support})) + body = { + 'snapshot': { + 'share_id': 100, + 'force': False, + 'name': 'fake_share_name', + 'description': 'fake_share_description', + } + } + req = fakes.HTTPRequest.blank('/snapshots') + + self.assertRaises( + webob.exc.HTTPUnprocessableEntity, + self.controller.create, req, body) + + self.assertFalse(share_api.API.create_snapshot.called) + + def test_snapshot_create_no_body(self): + body = {} + req = fakes.HTTPRequest.blank('/snapshots') + self.assertRaises(webob.exc.HTTPUnprocessableEntity, + self.controller.create, + req, + body) + + def test_snapshot_delete(self): + self.mock_object(share_api.API, 'delete_snapshot', + stubs.stub_snapshot_delete) + req = fakes.HTTPRequest.blank('/snapshots/200') + resp = self.controller.delete(req, 200) + self.assertEqual(202, resp.status_int) + + def test_snapshot_delete_nofound(self): + self.mock_object(share_api.API, 'get_snapshot', + stubs.stub_snapshot_get_notfound) + req = fakes.HTTPRequest.blank('/snapshots/200') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.delete, + req, + 200) + + def test_snapshot_show(self): + req = fakes.HTTPRequest.blank('/snapshots/200') + res_dict = self.controller.show(req, 200) + expected = fake_share.expected_snapshot(id=200) + self.assertEqual(expected, res_dict) + + def test_snapshot_show_nofound(self): + self.mock_object(share_api.API, 'get_snapshot', + stubs.stub_snapshot_get_notfound) + req = fakes.HTTPRequest.blank('/snapshots/200') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, + req, '200') + + def test_snapshot_list_summary(self): + self.mock_object(share_api.API, 'get_all_snapshots', + stubs.stub_snapshot_get_all_by_project) + req = fakes.HTTPRequest.blank('/snapshots') + res_dict = self.controller.index(req) + expected = { + 'snapshots': [ + { + 'name': 'displaysnapname', + 'id': 2, + 'links': [ + { + 'href': 'http://localhost/v1/fake/' + 'snapshots/2', + 'rel': 'self' + }, + { + 'href': 'http://localhost/fake/snapshots/2', + 'rel': 'bookmark' + } + ], + } + ] + } + self.assertEqual(expected, res_dict) + + def _snapshot_list_summary_with_search_opts(self, use_admin_context): + search_opts = fake_share.search_opts() + # fake_key should be filtered for non-admin + url = '/snapshots?fake_key=fake_value' + for k, v in search_opts.items(): + url = url + '&' + k + '=' + v + req = fakes.HTTPRequest.blank(url, use_admin_context=use_admin_context) + + snapshots = [ + {'id': 'id1', 'display_name': 'n1', 'status': 'fake_status', }, + {'id': 'id2', 'display_name': 'n2', 'status': 'fake_status', }, + {'id': 'id3', 'display_name': 'n3', 'status': 'fake_status', }, + ] + self.mock_object(share_api.API, 'get_all_snapshots', + mock.Mock(return_value=snapshots)) + + result = self.controller.index(req) + + search_opts_expected = { + 'display_name': search_opts['name'], + 'status': search_opts['status'], + 'share_id': search_opts['share_id'], + } + if use_admin_context: + search_opts_expected.update({'fake_key': 'fake_value'}) + share_api.API.get_all_snapshots.assert_called_once_with( + req.environ['manila.context'], + sort_key=search_opts['sort_key'], + sort_dir=search_opts['sort_dir'], + search_opts=search_opts_expected, + ) + self.assertEqual(1, len(result['snapshots'])) + self.assertEqual(snapshots[1]['id'], result['snapshots'][0]['id']) + self.assertEqual( + snapshots[1]['display_name'], result['snapshots'][0]['name']) + + def test_snapshot_list_summary_with_search_opts_by_non_admin(self): + self._snapshot_list_summary_with_search_opts(use_admin_context=False) + + def test_snapshot_list_summary_with_search_opts_by_admin(self): + self._snapshot_list_summary_with_search_opts(use_admin_context=True) + + def _snapshot_list_detail_with_search_opts(self, use_admin_context): + search_opts = fake_share.search_opts() + # fake_key should be filtered for non-admin + url = '/shares/detail?fake_key=fake_value' + for k, v in search_opts.items(): + url = url + '&' + k + '=' + v + req = fakes.HTTPRequest.blank(url, use_admin_context=use_admin_context) + + snapshots = [ + {'id': 'id1', 'display_name': 'n1', 'status': 'fake_status', }, + { + 'id': 'id2', + 'display_name': 'n2', + 'status': 'fake_status', + 'share_id': 'fake_share_id', + }, + {'id': 'id3', 'display_name': 'n3', 'status': 'fake_status', }, + ] + + self.mock_object(share_api.API, 'get_all_snapshots', + mock.Mock(return_value=snapshots)) + + result = self.controller.detail(req) + + search_opts_expected = { + 'display_name': search_opts['name'], + 'status': search_opts['status'], + 'share_id': search_opts['share_id'], + } + if use_admin_context: + search_opts_expected.update({'fake_key': 'fake_value'}) + share_api.API.get_all_snapshots.assert_called_once_with( + req.environ['manila.context'], + sort_key=search_opts['sort_key'], + sort_dir=search_opts['sort_dir'], + search_opts=search_opts_expected, + ) + self.assertEqual(1, len(result['snapshots'])) + self.assertEqual(snapshots[1]['id'], result['snapshots'][0]['id']) + self.assertEqual( + snapshots[1]['display_name'], result['snapshots'][0]['name']) + self.assertEqual( + snapshots[1]['status'], result['snapshots'][0]['status']) + self.assertEqual( + snapshots[1]['share_id'], result['snapshots'][0]['share_id']) + + def test_share_list_detail_with_search_opts_by_non_admin(self): + self._snapshot_list_detail_with_search_opts(use_admin_context=False) + + def test_share_list_detail_with_search_opts_by_admin(self): + self._snapshot_list_detail_with_search_opts(use_admin_context=True) + + def test_snapshot_list_detail(self): + env = {'QUERY_STRING': 'name=Share+Test+Name'} + req = fakes.HTTPRequest.blank('/shares/detail', environ=env) + res_dict = self.controller.detail(req) + expected_s = fake_share.expected_snapshot(id=2) + expected = {'snapshots': [expected_s['snapshot']]} + self.assertEqual(expected, res_dict) + + def test_snapshot_updates_description(self): + snp = self.snp_example + body = {"snapshot": snp} + + req = fakes.HTTPRequest.blank('/snapshot/1') + res_dict = self.controller.update(req, 1, body) + self.assertEqual(snp["display_name"], res_dict['snapshot']["name"]) + + def test_snapshot_updates_display_descr(self): + snp = self.snp_example + body = {"snapshot": snp} + + req = fakes.HTTPRequest.blank('/snapshot/1') + res_dict = self.controller.update(req, 1, body) + + self.assertEqual(snp["display_description"], + res_dict['snapshot']["description"]) + + def test_share_not_updates_size(self): + snp = self.snp_example + body = {"snapshot": snp} + + req = fakes.HTTPRequest.blank('/snapshot/1') + res_dict = self.controller.update(req, 1, body) + + self.assertNotEqual(snp["size"], res_dict['snapshot']["size"]) + + +@ddt.ddt +class ShareSnapshotAdminActionsAPITest(test.TestCase): + + def setUp(self): + super(self.__class__, self).setUp() + self.controller = share_snapshots.ShareSnapshotsController() + self.flags(rpc_backend='manila.openstack.common.rpc.impl_fake') + self.admin_context = context.RequestContext('admin', 'fake', True) + self.member_context = context.RequestContext('fake', 'fake') + + self.resource_name = self.controller.resource_name + self.manage_request = fakes.HTTPRequest.blank( + '/snapshots/manage', use_admin_context=True, + version=MIN_MANAGE_SNAPSHOT_API_VERSION) + self.snapshot_id = 'fake' + self.unmanage_request = fakes.HTTPRequest.blank( + '/snapshots/%s/unmanage' % self.snapshot_id, + use_admin_context=True, + version=MIN_MANAGE_SNAPSHOT_API_VERSION) + + def _get_context(self, role): + return getattr(self, '%s_context' % role) + + def _setup_snapshot_data(self, snapshot=None, version='2.7'): + if snapshot is None: + share = db_utils.create_share() + snapshot = db_utils.create_snapshot( + status=constants.STATUS_AVAILABLE, share_id=share['id']) + req = fakes.HTTPRequest.blank('/v2/fake/snapshots/%s/action' % + snapshot['id'], version=version) + return snapshot, req + + def _reset_status(self, ctxt, model, req, db_access_method, + valid_code, valid_status=None, body=None, version='2.7'): + if float(version) > 2.6: + action_name = 'reset_status' + else: + action_name = 'os-reset_status' + if body is None: + body = {action_name: {'status': constants.STATUS_ERROR}} + req.method = 'POST' + req.headers['content-type'] = 'application/json' + req.headers['X-Openstack-Manila-Api-Version'] = version + req.body = six.b(jsonutils.dumps(body)) + req.environ['manila.context'] = ctxt + + resp = req.get_response(fakes.app()) + + # validate response code and model status + self.assertEqual(valid_code, resp.status_int) + + if valid_code == 404: + self.assertRaises(exception.NotFound, + db_access_method, + ctxt, + model['id']) + else: + actual_model = db_access_method(ctxt, model['id']) + self.assertEqual(valid_status, actual_model['status']) + + @ddt.data(*fakes.fixture_reset_status_with_different_roles) + @ddt.unpack + def test_snapshot_reset_status_with_different_roles(self, role, valid_code, + valid_status, version): + ctxt = self._get_context(role) + snapshot, req = self._setup_snapshot_data(version=version) + + self._reset_status(ctxt, snapshot, req, db.share_snapshot_get, + valid_code, valid_status, version=version) + + @ddt.data( + ({'os-reset_status': {'x-status': 'bad'}}, '2.6'), + ({'reset_status': {'x-status': 'bad'}}, '2.7'), + ({'os-reset_status': {'status': 'invalid'}}, '2.6'), + ({'reset_status': {'status': 'invalid'}}, '2.7'), + ) + @ddt.unpack + def test_snapshot_invalid_reset_status_body(self, body, version): + snapshot, req = self._setup_snapshot_data(version=version) + + self._reset_status(self.admin_context, snapshot, req, + db.share_snapshot_get, 400, + constants.STATUS_AVAILABLE, body, version=version) + + def _force_delete(self, ctxt, model, req, db_access_method, valid_code, + version='2.7'): + if float(version) > 2.6: + action_name = 'force_delete' + else: + action_name = 'os-force_delete' + req.method = 'POST' + req.headers['content-type'] = 'application/json' + req.headers['X-Openstack-Manila-Api-Version'] = version + req.body = six.b(jsonutils.dumps({action_name: {}})) + req.environ['manila.context'] = ctxt + + resp = req.get_response(fakes.app()) + + # Validate response + self.assertEqual(valid_code, resp.status_int) + + @ddt.data(*fakes.fixture_force_delete_with_different_roles) + @ddt.unpack + def test_snapshot_force_delete_with_different_roles(self, role, resp_code, + version): + ctxt = self._get_context(role) + snapshot, req = self._setup_snapshot_data(version=version) + + self._force_delete(ctxt, snapshot, req, db.share_snapshot_get, + resp_code, version=version) + + def test_snapshot_force_delete_missing(self): + ctxt = self._get_context('admin') + snapshot, req = self._setup_snapshot_data(snapshot={'id': 'fake'}) + + self._force_delete(ctxt, snapshot, req, db.share_snapshot_get, 404) + + @ddt.data( + {}, + {'snapshots': {}}, + {'snapshot': get_fake_manage_body(share_id='xxxxxxxx')}, + {'snapshot': get_fake_manage_body(provider_location='xxxxxxxx')} + ) + def test_snapshot_manage_invalid_body(self, body): + self.mock_policy_check = self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True)) + self.assertRaises(webob.exc.HTTPUnprocessableEntity, + self.controller.manage, + self.manage_request, + body) + self.mock_policy_check.assert_called_once_with( + self.manage_request.environ['manila.context'], + self.resource_name, 'manage_snapshot') + + @ddt.data( + get_fake_manage_body(name='foo', description='bar'), + get_fake_manage_body(display_name='foo', description='bar'), + get_fake_manage_body(name='foo', display_description='bar'), + get_fake_manage_body(display_name='foo', display_description='bar'), + get_fake_manage_body(display_name='foo', display_description='bar'), + ) + def test_snapshot_manage(self, data): + self.mock_policy_check = self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True)) + data['snapshot']['share_id'] = 'fake' + data['snapshot']['provider_location'] = 'fake_volume_snapshot_id' + data['snapshot']['driver_options'] = {} + return_snapshot = {'id': 'fake_snap'} + self.mock_object( + share_api.API, 'manage_snapshot', mock.Mock( + return_value=return_snapshot)) + share_snapshot = { + 'share_id': 'fake', + 'provider_location': 'fake_volume_snapshot_id', + 'display_name': 'foo', + 'display_description': 'bar', + } + + actual_result = self.controller.manage(self.manage_request, data) + + share_api.API.manage_snapshot.assert_called_once_with( + mock.ANY, share_snapshot, data['snapshot']['driver_options']) + self.assertEqual(return_snapshot['id'], + actual_result['snapshot']['id']) + self.mock_policy_check.assert_called_once_with( + self.manage_request.environ['manila.context'], + self.resource_name, 'manage_snapshot') + + @ddt.data(exception.ShareNotFound(share_id='fake'), + exception.ShareSnapshotNotFound(snapshot_id='fake'), + exception.ManageInvalidShareSnapshot(reason='error')) + def test_manage_exception(self, exception_type): + self.mock_policy_check = self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True)) + body = get_fake_manage_body( + share_id='fake', provider_location='fake_volume_snapshot_id', + driver_options={}) + self.mock_object( + share_api.API, 'manage_snapshot', mock.Mock( + side_effect=exception_type)) + + if isinstance(exception_type, exception.ManageInvalidShareSnapshot): + http_ex = webob.exc.HTTPConflict + else: + http_ex = webob.exc.HTTPNotFound + self.assertRaises(http_ex, + self.controller.manage, + self.manage_request, body) + self.mock_policy_check.assert_called_once_with( + self.manage_request.environ['manila.context'], + self.resource_name, 'manage_snapshot') + + @ddt.data('1.0', '2.6', '2.11') + def test_manage_version_not_found(self, version): + body = get_fake_manage_body( + share_id='fake', provider_location='fake_volume_snapshot_id', + driver_options={}) + fake_req = fakes.HTTPRequest.blank( + '/snapshots/manage', use_admin_context=True, + version=version) + + self.assertRaises(exception.VersionNotFoundForAPIMethod, + self.controller.manage, + fake_req, body) + + def test_snapshot_unmanage_share_server(self): + self.mock_policy_check = self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True)) + share = {'status': constants.STATUS_AVAILABLE, 'id': 'bar_id', + 'share_server_id': 'fake_server_id'} + self.mock_object(share_api.API, 'get', mock.Mock(return_value=share)) + snapshot = {'status': constants.STATUS_AVAILABLE, 'id': 'foo_id', + 'share_id': 'bar_id'} + self.mock_object(share_api.API, 'get_snapshot', + mock.Mock(return_value=snapshot)) + + self.assertRaises(webob.exc.HTTPForbidden, + self.controller.unmanage, + self.unmanage_request, + snapshot['id']) + self.controller.share_api.get_snapshot.assert_called_once_with( + self.unmanage_request.environ['manila.context'], snapshot['id']) + self.controller.share_api.get.assert_called_once_with( + self.unmanage_request.environ['manila.context'], share['id']) + self.mock_policy_check.assert_called_once_with( + self.unmanage_request.environ['manila.context'], + self.resource_name, 'unmanage_snapshot') + + @ddt.data(*constants.TRANSITIONAL_STATUSES) + def test_snapshot_unmanage_with_transitional_state(self, status): + self.mock_policy_check = self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True)) + share = {'status': constants.STATUS_AVAILABLE, 'id': 'bar_id'} + self.mock_object(share_api.API, 'get', mock.Mock(return_value=share)) + snapshot = {'status': status, 'id': 'foo_id', 'share_id': 'bar_id'} + self.mock_object( + self.controller.share_api, 'get_snapshot', + mock.Mock(return_value=snapshot)) + self.assertRaises( + webob.exc.HTTPForbidden, + self.controller.unmanage, self.unmanage_request, snapshot['id']) + + self.controller.share_api.get_snapshot.assert_called_once_with( + self.unmanage_request.environ['manila.context'], snapshot['id']) + self.controller.share_api.get.assert_called_once_with( + self.unmanage_request.environ['manila.context'], share['id']) + self.mock_policy_check.assert_called_once_with( + self.unmanage_request.environ['manila.context'], + self.resource_name, 'unmanage_snapshot') + + def test_snapshot_unmanage(self): + self.mock_policy_check = self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True)) + share = {'status': constants.STATUS_AVAILABLE, 'id': 'bar_id', + 'host': 'fake_host'} + self.mock_object(share_api.API, 'get', mock.Mock(return_value=share)) + snapshot = {'status': constants.STATUS_AVAILABLE, 'id': 'foo_id', + 'share_id': 'bar_id'} + self.mock_object(share_api.API, 'get_snapshot', + mock.Mock(return_value=snapshot)) + self.mock_object(share_api.API, 'unmanage_snapshot', mock.Mock()) + + actual_result = self.controller.unmanage(self.unmanage_request, + snapshot['id']) + + self.assertEqual(202, actual_result.status_int) + self.controller.share_api.get_snapshot.assert_called_once_with( + self.unmanage_request.environ['manila.context'], snapshot['id']) + share_api.API.unmanage_snapshot.assert_called_once_with( + mock.ANY, snapshot, 'fake_host') + self.mock_policy_check.assert_called_once_with( + self.unmanage_request.environ['manila.context'], + self.resource_name, 'unmanage_snapshot') + + def test_unmanage_share_not_found(self): + self.mock_policy_check = self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True)) + self.mock_object( + share_api.API, 'get', mock.Mock( + side_effect=exception.ShareNotFound(share_id='fake'))) + snapshot = {'status': constants.STATUS_AVAILABLE, 'id': 'foo_id', + 'share_id': 'bar_id'} + self.mock_object(share_api.API, 'get_snapshot', + mock.Mock(return_value=snapshot)) + self.mock_object(share_api.API, 'unmanage_snapshot', mock.Mock()) + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.unmanage, + self.unmanage_request, 'foo_id') + self.mock_policy_check.assert_called_once_with( + self.unmanage_request.environ['manila.context'], + self.resource_name, 'unmanage_snapshot') + + def test_unmanage_snapshot_not_found(self): + self.mock_policy_check = self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True)) + share = {'status': constants.STATUS_AVAILABLE, 'id': 'bar_id'} + self.mock_object(share_api.API, 'get', mock.Mock(return_value=share)) + self.mock_object( + share_api.API, 'get_snapshot', mock.Mock( + side_effect=exception.ShareSnapshotNotFound( + snapshot_id='foo_id'))) + self.mock_object(share_api.API, 'unmanage_snapshot', mock.Mock()) + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.unmanage, + self.unmanage_request, 'foo_id') + self.mock_policy_check.assert_called_once_with( + self.unmanage_request.environ['manila.context'], + self.resource_name, 'unmanage_snapshot') + + @ddt.data('1.0', '2.6', '2.11') + def test_unmanage_version_not_found(self, version): + snapshot_id = 'fake' + fake_req = fakes.HTTPRequest.blank( + '/snapshots/%s/unmanage' % snapshot_id, + use_admin_context=True, + version=version) + + self.assertRaises(exception.VersionNotFoundForAPIMethod, + self.controller.unmanage, + fake_req, 'fake') diff --git a/manila/tests/db/migrations/alembic/migrations_data_checks.py b/manila/tests/db/migrations/alembic/migrations_data_checks.py index 8a3d665960..3a3098cfbe 100644 --- a/manila/tests/db/migrations/alembic/migrations_data_checks.py +++ b/manila/tests/db/migrations/alembic/migrations_data_checks.py @@ -552,3 +552,57 @@ class NetworkAllocationsNewLabelColumnChecks(BaseMigrationChecks): for col_name in ('label', 'network_type', 'segmentation_id', 'ip_version', 'cidr'): self.test_case.assertFalse(hasattr(na, col_name)) + + +@map_to_migration('eb6d5544cbbd') +class ShareSnapshotInstanceNewProviderLocationColumnChecks( + BaseMigrationChecks): + table_name = 'share_snapshot_instances' + + def setup_upgrade_data(self, engine): + # Setup shares + share_data = {'id': 'new_share_id'} + s_table = utils.load_table('shares', engine) + engine.execute(s_table.insert(share_data)) + + # Setup share instances + share_instance_data = { + 'id': 'new_share_instance_id', + 'share_id': share_data['id'] + } + si_table = utils.load_table('share_instances', engine) + engine.execute(si_table.insert(share_instance_data)) + + # Setup share snapshots + share_snapshot_data = { + 'id': 'new_snapshot_id', + 'share_id': share_data['id']} + snap_table = utils.load_table('share_snapshots', engine) + engine.execute(snap_table.insert(share_snapshot_data)) + + # Setup snapshot instances + snapshot_instance_data = { + 'id': 'new_snapshot_instance_id', + 'snapshot_id': share_snapshot_data['id'], + 'share_instance_id': share_instance_data['id'] + } + snap_i_table = utils.load_table('share_snapshot_instances', engine) + engine.execute(snap_i_table.insert(snapshot_instance_data)) + + def check_upgrade(self, engine, data): + ss_table = utils.load_table(self.table_name, engine) + db_result = engine.execute(ss_table.select()) + self.test_case.assertTrue(db_result.rowcount > 0) + for ss in db_result: + self.test_case.assertTrue(hasattr(ss, 'provider_location')) + self.test_case.assertEqual('new_snapshot_instance_id', ss.id) + self.test_case.assertEqual('new_snapshot_id', ss.snapshot_id) + + def check_downgrade(self, engine): + ss_table = utils.load_table(self.table_name, engine) + db_result = engine.execute(ss_table.select()) + self.test_case.assertTrue(db_result.rowcount > 0) + for ss in db_result: + self.test_case.assertFalse(hasattr(ss, 'provider_location')) + self.test_case.assertEqual('new_snapshot_instance_id', ss.id) + self.test_case.assertEqual('new_snapshot_id', ss.snapshot_id) diff --git a/manila/tests/db_utils.py b/manila/tests/db_utils.py index dad5626ecb..6d40296e4e 100644 --- a/manila/tests/db_utils.py +++ b/manila/tests/db_utils.py @@ -129,7 +129,8 @@ def create_snapshot(**kwargs): 'share_id': share['id'] if with_share else None, 'user_id': 'fake', 'project_id': 'fake', - 'status': 'creating' + 'status': 'creating', + 'provider_location': 'fake', } return _create_db_row(db.share_snapshot_create, snapshot, kwargs) diff --git a/manila/tests/fake_share.py b/manila/tests/fake_share.py index 3b88618147..0d2fee6f1f 100644 --- a/manila/tests/fake_share.py +++ b/manila/tests/fake_share.py @@ -52,6 +52,48 @@ def fake_snapshot(**kwargs): return db_fakes.FakeModel(snapshot) +def expected_snapshot(id='fake_snapshot_id', **kwargs): + self_link = 'http://localhost/v1/fake/snapshots/%s' % id + bookmark_link = 'http://localhost/fake/snapshots/%s' % id + snapshot = { + 'id': id, + 'share_id': 'fakeshareid', + 'created_at': datetime.datetime(1, 1, 1, 1, 1, 1), + 'status': 'fakesnapstatus', + 'name': 'displaysnapname', + 'description': 'displaysnapdesc', + 'share_size': 1, + 'size': 1, + 'share_proto': 'fakesnapproto', + 'links': [ + { + 'href': self_link, + 'rel': 'self', + }, + { + 'href': bookmark_link, + 'rel': 'bookmark', + }, + ], + } + snapshot.update(kwargs) + return {'snapshot': snapshot} + + +def search_opts(**kwargs): + search_opts = { + 'name': 'fake_name', + 'status': 'fake_status', + 'share_id': 'fake_share_id', + 'sort_key': 'fake_sort_key', + 'sort_dir': 'fake_sort_dir', + 'offset': '1', + 'limit': '1', + } + search_opts.update(kwargs) + return search_opts + + def fake_access(**kwargs): access = { 'id': 'fakeaccid', diff --git a/manila/tests/policy.json b/manila/tests/policy.json index 6f3d8abbe5..8339561940 100644 --- a/manila/tests/policy.json +++ b/manila/tests/policy.json @@ -60,6 +60,8 @@ "share_snapshot:force_delete": "rule:admin_api", "share_snapshot:reset_status": "rule:admin_api", + "share_snapshot:manage_snapshot": "rule:admin_api", + "share_snapshot:unmanage_snapshot": "rule:admin_api", "share_network:create": "", "share_network:index": "", diff --git a/manila/tests/share/drivers/test_generic.py b/manila/tests/share/drivers/test_generic.py index 23efbf7ff0..af66060f42 100644 --- a/manila/tests/share/drivers/test_generic.py +++ b/manila/tests/share/drivers/test_generic.py @@ -2013,6 +2013,46 @@ class GenericShareDriverTestCase(test.TestCase): self.assertEqual(FAKE_COLLATED_INFO, result) + def test_manage_snapshot_not_found(self): + snapshot_instance = {'id': 'snap_instance_id', + 'provider_location': 'vol_snap_id'} + driver_options = {} + self.mock_object( + self._driver.volume_api, 'get_snapshot', + mock.Mock(side_effect=exception.VolumeSnapshotNotFound( + snapshot_id='vol_snap_id'))) + + self.assertRaises(exception.ManageInvalidShareSnapshot, + self._driver.manage_existing_snapshot, + snapshot_instance, + driver_options) + self._driver.volume_api.get_snapshot.assert_called_once_with( + self._context, 'vol_snap_id') + + def test_manage_snapshot_valid(self): + snapshot_instance = {'id': 'snap_instance_id', + 'provider_location': 'vol_snap_id'} + volume_snapshot = {'id': 'vol_snap_id', 'size': 1} + self.mock_object(self._driver.volume_api, 'get_snapshot', + mock.Mock(return_value=volume_snapshot)) + ret_manage = self._driver.manage_existing_snapshot( + snapshot_instance, {}) + + self.assertEqual({'provider_location': 'vol_snap_id', + 'size': 1}, ret_manage) + + self._driver.volume_api.get_snapshot.assert_called_once_with( + self._context, 'vol_snap_id') + + def test_unmanage_snapshot(self): + snapshot_instance = {'id': 'snap_instance_id', + 'provider_location': 'vol_snap_id'} + self.mock_object(self._driver.private_storage, 'delete') + self._driver.unmanage_snapshot(snapshot_instance) + + self._driver.private_storage.delete.assert_called_once_with( + 'snap_instance_id') + @generic.ensure_server def fake(driver_instance, context, share_server=None): diff --git a/manila/tests/share/test_driver.py b/manila/tests/share/test_driver.py index aa9eb64767..dfe204f2a0 100644 --- a/manila/tests/share/test_driver.py +++ b/manila/tests/share/test_driver.py @@ -218,15 +218,23 @@ class ShareDriverTestCase(test.TestCase): share_driver.teardown_server, 'fake_share_server_details') + def _assert_is_callable(self, obj, attr): + self.assertTrue(callable(getattr(obj, attr))) + @ddt.data('manage_existing', 'unmanage') def test_drivers_methods_needed_by_manage_functionality(self, method): share_driver = self._instantiate_share_driver(None, False) - def assert_is_callable(obj, attr): - self.assertTrue(callable(getattr(obj, attr))) + self._assert_is_callable(share_driver, method) - assert_is_callable(share_driver, method) + @ddt.data('manage_existing_snapshot', + 'unmanage_snapshot') + def test_drivers_methods_needed_by_manage_snapshot_functionality( + self, method): + share_driver = self._instantiate_share_driver(None, False) + + self._assert_is_callable(share_driver, method) @ddt.data(True, False) def test_get_share_server_pools(self, value): diff --git a/manila/tests/share/test_manager.py b/manila/tests/share/test_manager.py index 5961121c8b..0c9584e723 100644 --- a/manila/tests/share/test_manager.py +++ b/manila/tests/share/test_manager.py @@ -1240,7 +1240,7 @@ class ShareManagerTestCase(test.TestCase): def _fake_create_snapshot(self, snapshot, **kwargs): snapshot['progress'] = '99%' - return snapshot + return snapshot.to_dict() self.mock_object(self.share_manager.driver, "create_snapshot", _fake_create_snapshot) @@ -1249,7 +1249,6 @@ class ShareManagerTestCase(test.TestCase): share_id = share['id'] snapshot = db_utils.create_snapshot(share_id=share_id) snapshot_id = snapshot['id'] - self.share_manager.create_snapshot(self.context, share_id, snapshot_id) self.assertEqual(share_id, @@ -3614,6 +3613,220 @@ class ShareManagerTestCase(test.TestCase): manager._migrate_share_generic, self.context, share, host) + def test_manage_snapshot_invalid_driver_mode(self): + self.mock_object(self.share_manager, 'driver') + self.share_manager.driver.driver_handles_share_servers = True + share = db_utils.create_share() + snapshot = db_utils.create_snapshot(share_id=share['id']) + driver_options = {'fake': 'fake'} + + self.assertRaises( + exception.InvalidDriverMode, + self.share_manager.manage_snapshot, self.context, + snapshot['id'], driver_options) + + def test_manage_snapshot_invalid_snapshot(self): + fake_share_server = 'fake_share_server' + self.mock_object(self.share_manager, 'driver') + self.share_manager.driver.driver_handles_share_servers = False + mock_get_share_server = self.mock_object( + self.share_manager, + '_get_share_server', + mock.Mock(return_value=fake_share_server)) + share = db_utils.create_share() + snapshot = db_utils.create_snapshot(share_id=share['id']) + driver_options = {'fake': 'fake'} + mock_get = self.mock_object(self.share_manager.db, + 'share_snapshot_get', + mock.Mock(return_value=snapshot)) + + self.assertRaises( + exception.InvalidShareSnapshot, + self.share_manager.manage_snapshot, self.context, + snapshot['id'], driver_options) + + mock_get.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['id']) + mock_get_share_server.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['share']) + + def test_manage_snapshot_driver_exception(self): + CustomException = type('CustomException', (Exception,), {}) + self.mock_object(self.share_manager, 'driver') + self.share_manager.driver.driver_handles_share_servers = False + mock_manage = self.mock_object(self.share_manager.driver, + 'manage_existing_snapshot', + mock.Mock(side_effect=CustomException)) + mock_get_share_server = self.mock_object(self.share_manager, + '_get_share_server', + mock.Mock(return_value=None)) + share = db_utils.create_share() + snapshot = db_utils.create_snapshot(share_id=share['id']) + driver_options = {} + mock_get = self.mock_object(self.share_manager.db, + 'share_snapshot_get', + mock.Mock(return_value=snapshot)) + + self.assertRaises( + CustomException, + self.share_manager.manage_snapshot, + self.context, snapshot['id'], driver_options) + + mock_manage.assert_called_once_with(mock.ANY, driver_options) + mock_get.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['id']) + mock_get_share_server.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['share']) + + @ddt.data( + {'size': 1}, + {'size': 2, 'name': 'fake'}, + {'size': 3}) + def test_manage_snapshot_valid_snapshot(self, driver_data): + mock_get_share_server = self.mock_object(self.share_manager, + '_get_share_server', + mock.Mock(return_value=None)) + self.mock_object(self.share_manager.db, 'share_snapshot_update') + self.mock_object(self.share_manager, 'driver') + self.mock_object(self.share_manager, '_update_quota_usages') + self.share_manager.driver.driver_handles_share_servers = False + mock_manage = self.mock_object( + self.share_manager.driver, + "manage_existing_snapshot", + mock.Mock(return_value=driver_data)) + size = driver_data['size'] + share = db_utils.create_share(size=size) + snapshot = db_utils.create_snapshot(share_id=share['id'], size=size) + snapshot_id = snapshot['id'] + driver_options = {} + mock_get = self.mock_object(self.share_manager.db, + 'share_snapshot_get', + mock.Mock(return_value=snapshot)) + + self.share_manager.manage_snapshot(self.context, snapshot_id, + driver_options) + + mock_manage.assert_called_once_with(mock.ANY, driver_options) + valid_snapshot_data = { + 'status': constants.STATUS_AVAILABLE} + valid_snapshot_data.update(driver_data) + self.share_manager.db.share_snapshot_update.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + snapshot_id, valid_snapshot_data) + self.share_manager._update_quota_usages.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + snapshot['project_id'], + {'snapshots': 1, 'snapshot_gigabytes': size}) + mock_get_share_server.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['share']) + mock_get.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot_id) + + def test_unmanage_snapshot_invalid_driver_mode(self): + self.mock_object(self.share_manager, 'driver') + self.share_manager.driver.driver_handles_share_servers = True + share = db_utils.create_share() + snapshot = db_utils.create_snapshot(share_id=share['id']) + self.mock_object(self.share_manager.db, 'share_snapshot_update') + + ret = self.share_manager.unmanage_snapshot(self.context, + snapshot['id']) + self.assertIsNone(ret) + self.share_manager.db.share_snapshot_update.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + snapshot['id'], + {'status': constants.STATUS_UNMANAGE_ERROR}) + + def test_unmanage_snapshot_invalid_snapshot(self): + self.mock_object(self.share_manager, 'driver') + self.share_manager.driver.driver_handles_share_servers = False + mock_get_share_server = self.mock_object( + self.share_manager, + '_get_share_server', + mock.Mock(return_value='fake_share_server')) + self.mock_object(self.share_manager.db, 'share_snapshot_update') + share = db_utils.create_share() + snapshot = db_utils.create_snapshot(share_id=share['id']) + mock_get = self.mock_object(self.share_manager.db, + 'share_snapshot_get', + mock.Mock(return_value=snapshot)) + + ret = self.share_manager.unmanage_snapshot(self.context, + snapshot['id']) + + self.assertIsNone(ret) + self.share_manager.db.share_snapshot_update.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + snapshot['id'], + {'status': constants.STATUS_UNMANAGE_ERROR}) + mock_get.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['id']) + mock_get_share_server.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['share']) + + def test_unmanage_snapshot_invalid_share(self): + self.mock_object(self.share_manager, 'driver') + self.share_manager.driver.driver_handles_share_servers = False + mock_unmanage = mock.Mock( + side_effect=exception.UnmanageInvalidShareSnapshot(reason="fake")) + self.mock_object(self.share_manager.driver, "unmanage_snapshot", + mock_unmanage) + mock_get_share_server = self.mock_object( + self.share_manager, + '_get_share_server', + mock.Mock(return_value=None)) + self.mock_object(self.share_manager.db, 'share_snapshot_update') + share = db_utils.create_share() + snapshot = db_utils.create_snapshot(share_id=share['id']) + mock_get = self.mock_object(self.share_manager.db, + 'share_snapshot_get', + mock.Mock(return_value=snapshot)) + + self.share_manager.unmanage_snapshot(self.context, snapshot['id']) + + self.share_manager.db.share_snapshot_update.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['id'], + {'status': constants.STATUS_UNMANAGE_ERROR}) + self.share_manager.driver.unmanage_snapshot.assert_called_once_with( + mock.ANY) + mock_get.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['id']) + mock_get_share_server.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['share']) + + @ddt.data(False, True) + def test_unmanage_snapshot_valid_snapshot(self, quota_error): + if quota_error: + self.mock_object(quota.QUOTAS, 'reserve', mock.Mock( + side_effect=exception.ManilaException(message='error'))) + mock_log_warning = self.mock_object(manager.LOG, 'warning') + self.mock_object(self.share_manager, 'driver') + self.share_manager.driver.driver_handles_share_servers = False + self.mock_object(self.share_manager.driver, "unmanage_snapshot") + mock_get_share_server = self.mock_object( + self.share_manager, + '_get_share_server', + mock.Mock(return_value=None)) + self.mock_object(self.share_manager.db, 'share_snapshot_destroy') + share = db_utils.create_share() + snapshot = db_utils.create_snapshot(share_id=share['id']) + mock_get = self.mock_object(self.share_manager.db, + 'share_snapshot_get', + mock.Mock(return_value=snapshot)) + + self.share_manager.unmanage_snapshot(self.context, snapshot['id']) + + self.share_manager.driver.unmanage_snapshot.assert_called_once_with( + mock.ANY) + self.share_manager.db.share_snapshot_destroy.assert_called_once_with( + mock.ANY, snapshot['id']) + mock_get.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['id']) + mock_get_share_server.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), snapshot['share']) + if quota_error: + self.assertTrue(mock_log_warning.called) + @ddt.ddt class HookWrapperTestCase(test.TestCase): diff --git a/manila/tests/share/test_rpcapi.py b/manila/tests/share/test_rpcapi.py index 9c09835a38..9fc6f20984 100644 --- a/manila/tests/share/test_rpcapi.py +++ b/manila/tests/share/test_rpcapi.py @@ -280,6 +280,21 @@ class ShareRpcAPITestCase(test.TestCase): version='1.8', share_replica=self.fake_share_replica) + def test_manage_snapshot(self): + self._test_share_api('manage_snapshot', + rpc_method='cast', + version='1.9', + snapshot=self.fake_snapshot, + host='fake_host', + driver_options={'volume_snapshot_id': 'fake'}) + + def test_unmanage_snapshot(self): + self._test_share_api('unmanage_snapshot', + rpc_method='cast', + version='1.9', + snapshot=self.fake_snapshot, + 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 1d520b4e54..c0a77df335 100644 --- a/manila/tests/test_exception.py +++ b/manila/tests/test_exception.py @@ -227,6 +227,20 @@ class ManilaExceptionResponseCode400(test.TestCase): self.assertEqual(400, e.code) self.assertIn(reason, e.msg) + def test_manage_invalid_share_snapshot(self): + # Verify response code for exception.ManageInvalidShareSnapshot + reason = "fake_reason" + e = exception.ManageInvalidShareSnapshot(reason=reason) + self.assertEqual(400, e.code) + self.assertIn(reason, e.msg) + + def test_unmanage_invalid_share_snapshot(self): + # Verify response code for exception.UnmanageInvalidShareSnapshot + reason = "fake_reason" + e = exception.UnmanageInvalidShareSnapshot(reason=reason) + self.assertEqual(400, e.code) + self.assertIn(reason, e.msg) + class ManilaExceptionResponseCode403(test.TestCase): @@ -490,6 +504,13 @@ class ManilaExceptionResponseCode404(test.TestCase): self.assertEqual(404, e.code) self.assertIn(share_id, e.msg) + def test_share_not_found(self): + # verify response code for exception.ShareNotFound + share_id = "fake_share_id" + e = exception.ShareNotFound(share_id=share_id) + self.assertEqual(404, e.code) + self.assertIn(share_id, e.msg) + class ManilaExceptionResponseCode413(test.TestCase): diff --git a/manila_tempest_tests/config.py b/manila_tempest_tests/config.py index 8183d1cd02..3f8c08ad7f 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.11", + default="2.12", help="The maximum api microversion is configured to be the " "value of the latest microversion supported by Manila."), cfg.StrOpt("region", @@ -128,11 +128,6 @@ ShareGroup = [ help="Whether to suppress errors with clean up operation " "or not. There are cases when we may want to skip " "such errors and catch only test errors."), - cfg.BoolOpt("run_manage_unmanage_tests", - default=False, - help="Defines whether to run manage/unmanage tests or not. " - "These test may leave orphaned resources, so be careful " - "enabling this opt."), # Switching ON/OFF test suites filtered by features cfg.BoolOpt("run_quota_tests", @@ -161,6 +156,16 @@ ShareGroup = [ cfg.BoolOpt("run_migration_tests", default=False, help="Enable or disable migration tests."), + cfg.BoolOpt("run_manage_unmanage_tests", + default=False, + help="Defines whether to run manage/unmanage tests or not. " + "These test may leave orphaned resources, so be careful " + "enabling this opt."), + cfg.BoolOpt("run_manage_unmanage_snapshot_tests", + default=False, + help="Defines whether to run manage/unmanage snapshot tests " + "or not. These tests may leave orphaned resources, so be " + "careful enabling this opt."), cfg.StrOpt("image_with_share_tools", default="manila-service-image", diff --git a/manila_tempest_tests/services/share/v2/json/shares_client.py b/manila_tempest_tests/services/share/v2/json/shares_client.py index 7a454fa708..5187145f25 100644 --- a/manila_tempest_tests/services/share/v2/json/shares_client.py +++ b/manila_tempest_tests/services/share/v2/json/shares_client.py @@ -437,6 +437,113 @@ class SharesV2Client(shares_client.SharesClient): self.expected_success(202, resp.status) return body +############### + + def create_snapshot(self, share_id, name=None, description=None, + force=False, version=LATEST_MICROVERSION): + if name is None: + name = data_utils.rand_name("tempest-created-share-snap") + if description is None: + description = data_utils.rand_name( + "tempest-created-share-snap-desc") + post_body = { + "snapshot": { + "name": name, + "force": force, + "description": description, + "share_id": share_id, + } + } + body = json.dumps(post_body) + resp, body = self.post("snapshots", body, version=version) + self.expected_success(202, resp.status) + return self._parse_resp(body) + + def get_snapshot(self, snapshot_id, version=LATEST_MICROVERSION): + resp, body = self.get("snapshots/%s" % snapshot_id, version=version) + self.expected_success(200, resp.status) + return self._parse_resp(body) + + def list_snapshots(self, detailed=False, params=None, + version=LATEST_MICROVERSION): + """Get list of share snapshots w/o filters.""" + uri = 'snapshots/detail' if detailed else 'snapshots' + uri += '?%s' % urlparse.urlencode(params) if params else '' + resp, body = self.get(uri, version=version) + self.expected_success(200, resp.status) + return self._parse_resp(body) + + def list_snapshots_with_detail(self, params=None, + version=LATEST_MICROVERSION): + """Get detailed list of share snapshots w/o filters.""" + return self.list_snapshots(detailed=True, params=params, + version=version) + + def delete_snapshot(self, snap_id, version=LATEST_MICROVERSION): + resp, body = self.delete("snapshots/%s" % snap_id, version=version) + self.expected_success(202, resp.status) + return body + + def wait_for_snapshot_status(self, snapshot_id, status, + version=LATEST_MICROVERSION): + """Waits for a snapshot to reach a given status.""" + body = self.get_snapshot(snapshot_id, version=version) + snapshot_name = body['name'] + snapshot_status = body['status'] + start = int(time.time()) + + while snapshot_status != status: + time.sleep(self.build_interval) + body = self.get_snapshot(snapshot_id, version=version) + snapshot_status = body['status'] + if 'error' in snapshot_status: + raise (share_exceptions. + SnapshotBuildErrorException(snapshot_id=snapshot_id)) + + if int(time.time()) - start >= self.build_timeout: + message = ('Share Snapshot %s failed to reach %s status ' + 'within the required time (%s s).' % + (snapshot_name, status, self.build_timeout)) + raise exceptions.TimeoutException(message) + + def manage_snapshot(self, share_id, provider_location, + name=None, description=None, + version=LATEST_MICROVERSION, + driver_options=None): + if name is None: + name = data_utils.rand_name("tempest-manage-snapshot") + if description is None: + description = data_utils.rand_name("tempest-manage-snapshot-desc") + post_body = { + "snapshot": { + "share_id": share_id, + "provider_location": provider_location, + "name": name, + "description": description, + "driver_options": driver_options if driver_options else {}, + } + } + url = 'snapshots/manage' + body = json.dumps(post_body) + resp, body = self.post(url, body, version=version) + self.expected_success(202, resp.status) + return self._parse_resp(body) + + def unmanage_snapshot(self, snapshot_id, version=LATEST_MICROVERSION, + body=None): + url = 'snapshots' + action_name = 'action' + if body is None: + body = json.dumps({'unmanage': {}}) + resp, body = self.post( + "%(url)s/%(snapshot_id)s/%(action_name)s" % { + 'url': url, 'snapshot_id': snapshot_id, + 'action_name': action_name}, + body, + version=version) + self.expected_success(202, resp.status) + return body + ############### def _get_access_action_name(self, version, action): diff --git a/manila_tempest_tests/tests/api/admin/test_snapshot_manage.py b/manila_tempest_tests/tests/api/admin/test_snapshot_manage.py new file mode 100644 index 0000000000..4bd76494b9 --- /dev/null +++ b/manila_tempest_tests/tests/api/admin/test_snapshot_manage.py @@ -0,0 +1,143 @@ +# Copyright 2015 EMC Corporation. +# 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 six +from tempest import config +from tempest import test +from tempest_lib.common.utils import data_utils +from tempest_lib import exceptions as lib_exc +import testtools + +from manila_tempest_tests.tests.api import base + +CONF = config.CONF + + +class ManageNFSSnapshotTest(base.BaseSharesAdminTest): + protocol = 'nfs' + + # NOTE(vponomaryov): be careful running these tests using generic driver + # because cinder volume snapshots won't be deleted. + + @classmethod + @base.skip_if_microversion_lt("2.12") + @testtools.skipIf( + CONF.share.multitenancy_enabled, + "Only for driver_handles_share_servers = False driver mode.") + @testtools.skipUnless( + CONF.share.run_manage_unmanage_snapshot_tests, + "Manage/unmanage snapshot tests are disabled.") + def resource_setup(cls): + super(ManageNFSSnapshotTest, cls).resource_setup() + if cls.protocol not in CONF.share.enable_protocols: + message = "%s tests are disabled" % cls.protocol + raise cls.skipException(message) + + # Create share type + cls.st_name = data_utils.rand_name("tempest-manage-st-name") + cls.extra_specs = { + 'storage_protocol': CONF.share.capability_storage_protocol, + 'driver_handles_share_servers': False, + 'snapshot_support': six.text_type( + CONF.share.capability_snapshot_support), + } + + cls.st = cls.create_share_type( + name=cls.st_name, + cleanup_in_class=True, + extra_specs=cls.extra_specs) + + creation_data = {'kwargs': { + 'share_type_id': cls.st['share_type']['id'], + 'share_protocol': cls.protocol, + }} + + # Data for creating shares + data = [creation_data] + shares_created = cls.create_shares(data) + + cls.snapshot = None + cls.shares = [] + # Load all share data (host, etc.) + for share in shares_created: + cls.shares.append(cls.shares_v2_client.get_share(share['id'])) + # Create snapshot + snap_name = data_utils.rand_name("tempest-snapshot-name") + snap_desc = data_utils.rand_name( + "tempest-snapshot-description") + snap = cls.create_snapshot_wait_for_active( + share['id'], snap_name, snap_desc) + cls.snapshot = cls.shares_v2_client.get_snapshot(snap['id']) + # Unmanage snapshot + cls.shares_v2_client.unmanage_snapshot(snap['id']) + cls.shares_client.wait_for_resource_deletion( + snapshot_id=snap['id']) + + def _test_manage(self, snapshot, version=CONF.share.max_api_microversion): + name = ("Name for 'managed' snapshot that had ID %s" % + snapshot['id']) + description = "Description for 'managed' snapshot" + + # Manage snapshot + share_id = snapshot['share_id'] + snapshot = self.shares_v2_client.manage_snapshot( + share_id, + snapshot['provider_location'], + name=name, + description=description, + driver_options={} + ) + + # Add managed snapshot to cleanup queue + self.method_resources.insert( + 0, {'type': 'snapshot', 'id': snapshot['id'], + 'client': self.shares_v2_client}) + + # Wait for success + self.shares_v2_client.wait_for_snapshot_status(snapshot['id'], + 'available') + + # Verify data of managed snapshot + get_snapshot = self.shares_v2_client.get_snapshot(snapshot['id']) + self.assertEqual(name, get_snapshot['name']) + self.assertEqual(description, get_snapshot['description']) + self.assertEqual(snapshot['share_id'], get_snapshot['share_id']) + self.assertEqual(snapshot['provider_location'], + get_snapshot['provider_location']) + + # Delete snapshot + self.shares_v2_client.delete_snapshot(get_snapshot['id']) + self.shares_client.wait_for_resource_deletion( + snapshot_id=get_snapshot['id']) + self.assertRaises(lib_exc.NotFound, + self.shares_v2_client.get_snapshot, + get_snapshot['id']) + + @test.attr(type=["gate", "smoke"]) + def test_manage(self): + # Manage snapshot + self._test_manage(snapshot=self.snapshot) + + +class ManageCIFSSnapshotTest(ManageNFSSnapshotTest): + protocol = 'cifs' + + +class ManageGLUSTERFSSnapshotTest(ManageNFSSnapshotTest): + protocol = 'glusterfs' + + +class ManageHDFSSnapshotTest(ManageNFSSnapshotTest): + protocol = 'hdfs' diff --git a/manila_tempest_tests/tests/api/admin/test_snapshot_manage_negative.py b/manila_tempest_tests/tests/api/admin/test_snapshot_manage_negative.py new file mode 100644 index 0000000000..c2d780491d --- /dev/null +++ b/manila_tempest_tests/tests/api/admin/test_snapshot_manage_negative.py @@ -0,0 +1,109 @@ +# Copyright 2015 EMC Corporation. +# 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 six +from tempest import config +from tempest import test +from tempest_lib.common.utils import data_utils +from tempest_lib import exceptions as lib_exc +import testtools + +from manila_tempest_tests.tests.api import base + +CONF = config.CONF + + +class ManageNFSSnapshotNegativeTest(base.BaseSharesAdminTest): + protocol = 'nfs' + + @classmethod + @base.skip_if_microversion_lt("2.12") + @testtools.skipIf( + CONF.share.multitenancy_enabled, + "Only for driver_handles_share_servers = False driver mode.") + @testtools.skipUnless( + CONF.share.run_manage_unmanage_snapshot_tests, + "Manage/unmanage snapshot tests are disabled.") + def resource_setup(cls): + super(ManageNFSSnapshotNegativeTest, cls).resource_setup() + if cls.protocol not in CONF.share.enable_protocols: + message = "%s tests are disabled" % cls.protocol + raise cls.skipException(message) + + # Create share type + cls.st_name = data_utils.rand_name("tempest-manage-st-name") + cls.extra_specs = { + 'storage_protocol': CONF.share.capability_storage_protocol, + 'driver_handles_share_servers': False, + 'snapshot_support': six.text_type( + CONF.share.capability_snapshot_support), + } + + cls.st = cls.create_share_type( + name=cls.st_name, + cleanup_in_class=True, + extra_specs=cls.extra_specs) + + # Create share + cls.share = cls.create_share( + share_type_id=cls.st['share_type']['id'], + share_protocol=cls.protocol + ) + + @test.attr(type=["gate", "smoke", "negative", ]) + def test_manage_not_found(self): + # Manage snapshot fails + self.assertRaises(lib_exc.NotFound, + self.shares_v2_client.manage_snapshot, + 'fake-share-id', + 'fake-vol-snap-id', + driver_options={}) + + @test.attr(type=["gate", "smoke", "negative", ]) + def test_manage_already_exists(self): + # Manage already existing snapshot fails + + # Create snapshot + snap = self.create_snapshot_wait_for_active(self.share['id']) + get_snap = self.shares_v2_client.get_snapshot(snap['id']) + self.assertEqual(self.share['id'], get_snap['share_id']) + self.assertIsNotNone(get_snap['provider_location']) + + # Manage snapshot fails + self.assertRaises(lib_exc.Conflict, + self.shares_v2_client.manage_snapshot, + self.share['id'], + get_snap['provider_location'], + driver_options={}) + + # Delete snapshot + self.shares_v2_client.delete_snapshot(get_snap['id']) + self.shares_client.wait_for_resource_deletion( + snapshot_id=get_snap['id']) + self.assertRaises(lib_exc.NotFound, + self.shares_v2_client.get_snapshot, + get_snap['id']) + + +class ManageCIFSSnapshotNegativeTest(ManageNFSSnapshotNegativeTest): + protocol = 'cifs' + + +class ManageGLUSTERFSSnapshotNegativeTest(ManageNFSSnapshotNegativeTest): + protocol = 'glusterfs' + + +class ManageHDFSSnapshotNegativeTest(ManageNFSSnapshotNegativeTest): + protocol = 'hdfs' diff --git a/manila_tempest_tests/tests/api/base.py b/manila_tempest_tests/tests/api/base.py index f9a678d6da..8ee5ddd074 100644 --- a/manila_tempest_tests/tests/api/base.py +++ b/manila_tempest_tests/tests/api/base.py @@ -79,6 +79,7 @@ def network_synchronized(f): skip_if_microversion_not_supported = utils.skip_if_microversion_not_supported +skip_if_microversion_lt = utils.skip_if_microversion_lt class BaseSharesTest(test.BaseTestCase): @@ -104,6 +105,13 @@ class BaseSharesTest(test.BaseTestCase): raise self.skipException( "Microversion '%s' is not supported." % microversion) + def skip_if_microversion_lt(self, microversion): + if utils.is_microversion_lt(CONF.share.max_api_microversion, + microversion): + raise self.skipException( + "Microversion must be greater than or equal to '%s'." % + microversion) + @classmethod def get_client_with_isolated_creds(cls, name=None, diff --git a/manila_tempest_tests/utils.py b/manila_tempest_tests/utils.py index 94d8cd3a47..dea51ab515 100644 --- a/manila_tempest_tests/utils.py +++ b/manila_tempest_tests/utils.py @@ -81,6 +81,15 @@ def skip_if_microversion_not_supported(microversion): return lambda f: f +def skip_if_microversion_lt(microversion): + """Decorator for tests that are microversion-specific.""" + if is_microversion_lt(CONF.share.max_api_microversion, microversion): + reason = ("Skipped. Test requires microversion greater than or " + "equal to '%s'." % microversion) + return testtools.skip(reason) + return lambda f: f + + def rand_ip(): """This uses the TEST-NET-3 range of reserved IP addresses. diff --git a/releasenotes/notes/manage-unmanage-snapshot-bd92164472638f44.yaml b/releasenotes/notes/manage-unmanage-snapshot-bd92164472638f44.yaml new file mode 100644 index 0000000000..1d45879a3d --- /dev/null +++ b/releasenotes/notes/manage-unmanage-snapshot-bd92164472638f44.yaml @@ -0,0 +1,3 @@ +--- +features: + - Manage and unmanage snapshot.