diff --git a/api-ref/source/index.rst b/api-ref/source/index.rst index ba549828966d..765c21a339ce 100644 --- a/api-ref/source/index.rst +++ b/api-ref/source/index.rst @@ -31,6 +31,7 @@ the `API guide `_. .. include:: os-instance-actions.inc .. include:: os-interface.inc .. include:: os-server-password.inc +.. include:: os-server-shares.inc .. include:: os-volume-attachments.inc .. include:: flavors.inc .. include:: os-flavor-access.inc diff --git a/api-ref/source/os-server-shares.inc b/api-ref/source/os-server-shares.inc new file mode 100644 index 000000000000..0230e422814c --- /dev/null +++ b/api-ref/source/os-server-shares.inc @@ -0,0 +1,163 @@ +.. -*- rst -*- + +=================================================================== + Servers with shares attachments (servers, shares) +=================================================================== + +Attaches shares that are created through the Manila share API to server +instances. Also, lists share attachments for a server, shows +details for a share attachment, and detaches a share (New in version 2.97). + +List share attachments for an instance +======================================= + +.. rest_method:: GET /servers/{server_id}/shares + +List share attachments for an instance. + +Normal response codes: 200 + +Error response codes: badrequest(400), forbidden(403), itemNotFound(404) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - server_id: server_id_path + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - shares: shares_body + - share_id: share_id_body + - status: share_status_body + - tag: share_tag_body + + +**Example List share attachments for an instance: JSON response** + +.. literalinclude:: ../../doc/api_samples/os-server-shares/v2.97/server-shares-list-resp.json + :language: javascript + + +Attach a share to an instance +============================== + +.. rest_method:: POST /servers/{server_id}/shares + +Attach a share to an instance. + +Normal response codes: 201 + +Error response codes: badRequest(400), forbidden(403), itemNotFound(404), conflict(409) + +.. note:: This action is only valid when the server is in ``STOPPED`` state. + +.. note:: This action also needs specific configurations, check the documentation requirements to configure + your environment and support this feature. + +Request +------- + +.. rest_parameters:: parameters.yaml + + - server_id: server_id_path + - share_id: share_id_body + - tag: share_tag_body + +**Example Attach a share to an instance: JSON request** + +.. literalinclude:: ../../doc/api_samples/os-server-shares/v2.97/server-shares-create-req.json + :language: javascript + + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - shares: shares_body + - share_id: share_id_body + - status: share_status_body + - tag: share_tag_body + +**Example Attach a share to an instance: JSON response** + +.. literalinclude:: ../../doc/api_samples/os-server-shares/v2.97/server-shares-create-resp.json + :language: javascript + + +Show a detail of a share attachment +==================================== + +.. rest_method:: GET /servers/{server_id}/shares/{share_id} + +Show a detail of a share attachment. + +Normal response codes: 200 + +Error response codes: badRequest(400), forbidden(403), itemNotFound(404) + + +Request +------- + +.. rest_parameters:: parameters.yaml + + - server_id: server_id_path + - share_id: share_id_path + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - share: share_body + - uuid: share_uuid_body + - share_id: share_id_body + - status: share_status_body + - tag: share_tag_body + - export_location: share_export_location_body + +.. note:: Optional fields can only be seen by admins. + +**Example Show a detail of a share attachment: JSON response** + +.. literalinclude:: ../../doc/api_samples/os-server-shares/v2.97/server-shares-show-resp.json + :language: javascript + +**Example Show a detail of a share attachment with admin rights: JSON response** + +.. literalinclude:: ../../doc/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json + :language: javascript + + +Detach a share from an instance +================================ + +.. rest_method:: DELETE /servers/{server_id}/shares/{share_id} + +Detach a share from an instance. + +Normal response codes: 200 + +Error response codes: badRequest(400), forbidden(403), itemNotFound(404), conflict(409) + +.. note:: This action is only valid when the server is in ``STOPPED`` or ``ERROR`` state. + + +Request +------- + +.. rest_parameters:: parameters.yaml + + - server_id: server_id_path + - share_id: share_id_path + +Response +-------- + +No body is returned on successful request. diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index ec0d1dab1201..95af05bd83c6 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -311,6 +311,12 @@ service_id_path_2_53_no_version: in: path required: true type: string +share_id_path: + description: | + The UUID of the attached share. + in: path + required: true + type: string snapshot_id_path: description: | The UUID of the snapshot. @@ -3742,13 +3748,13 @@ hosts.availability_zone_none: type: none hours: description: | - The duration that the server exists (in hours). + The duration that the server exists (in hours). in: body required: true type: float hours_optional: description: | - The duration that the server exists (in hours). + The duration that the server exists (in hours). in: body required: false type: float @@ -6809,6 +6815,56 @@ set_metadata: in: body required: true type: object +share_body: + description: | + A dictionary representation of a share attachment containing the fields + ``uuid``, ``serverId``, ``status``, ``tag`` and ``export_location``. + in: body + required: true + type: object +share_export_location_body: + description: | + The export location used to attach the share to the underlying host. + in: body + required: false + type: string +share_id_body: + description: | + The UUID of the attached share. + in: body + required: true + type: string +share_status_body: + description: | + Status of the Share: + + - attaching: The share is being attached to the VM by the compute node. + - detaching: The share is being detached from the VM by the compute node. + - inactive: The share is attached but inactive because the VM is stopped. + - active: The share is attached, and the VM is running. + - error: The share is in an error state. + in: body + required: true + type: string +share_tag_body: + description: | + The device tag to be used by users to mount the share within the instance, + if not provided then the share UUID will be used automatically. + in: body + required: true + type: string +share_uuid_body: + description: | + The UUID of the share attachment. + in: body + required: false + type: string +shares_body: + description: | + The list of share attachments. + in: body + required: true + type: array shelve: description: | The action. diff --git a/doc/api_samples/os-server-shares/v2.97/server-shares-admin-create-resp.json b/doc/api_samples/os-server-shares/v2.97/server-shares-admin-create-resp.json new file mode 100644 index 000000000000..ecd27e166856 --- /dev/null +++ b/doc/api_samples/os-server-shares/v2.97/server-shares-admin-create-resp.json @@ -0,0 +1,9 @@ +{ + "share": { + "uuid": "68ba1762-fd6d-4221-8311-f3193dd93404", + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "attaching", + "export_location": "10.0.0.50:/mnt/foo", + "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986" + } +} diff --git a/doc/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json b/doc/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json new file mode 100644 index 000000000000..dbacd2b3da8e --- /dev/null +++ b/doc/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json @@ -0,0 +1,9 @@ +{ + "share": { + "uuid": "68ba1762-fd6d-4221-8311-f3193dd93404", + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "inactive", + "export_location": "10.0.0.50:/mnt/foo", + "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986" + } +} diff --git a/doc/api_samples/os-server-shares/v2.97/server-shares-create-req.json b/doc/api_samples/os-server-shares/v2.97/server-shares-create-req.json new file mode 100644 index 000000000000..ec8949bac7ae --- /dev/null +++ b/doc/api_samples/os-server-shares/v2.97/server-shares-create-req.json @@ -0,0 +1,6 @@ +{ + "share": { + "share_id": "3cdf5132-64f2-4584-876a-bd296ae7eabd", + "tag": "my-share" + } +} diff --git a/doc/api_samples/os-server-shares/v2.97/server-shares-create-resp.json b/doc/api_samples/os-server-shares/v2.97/server-shares-create-resp.json new file mode 100644 index 000000000000..b826f1333d2a --- /dev/null +++ b/doc/api_samples/os-server-shares/v2.97/server-shares-create-resp.json @@ -0,0 +1,7 @@ +{ + "share": { + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "attaching", + "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986" + } +} diff --git a/doc/api_samples/os-server-shares/v2.97/server-shares-list-resp.json b/doc/api_samples/os-server-shares/v2.97/server-shares-list-resp.json new file mode 100644 index 000000000000..f039f5661848 --- /dev/null +++ b/doc/api_samples/os-server-shares/v2.97/server-shares-list-resp.json @@ -0,0 +1,9 @@ +{ + "shares": [ + { + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "inactive", + "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986" + } + ] +} diff --git a/doc/api_samples/os-server-shares/v2.97/server-shares-show-resp.json b/doc/api_samples/os-server-shares/v2.97/server-shares-show-resp.json new file mode 100644 index 000000000000..b83978afaff3 --- /dev/null +++ b/doc/api_samples/os-server-shares/v2.97/server-shares-show-resp.json @@ -0,0 +1,7 @@ +{ + "share": { + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "inactive", + "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986" + } +} diff --git a/doc/api_samples/versions/v21-version-get-resp.json b/doc/api_samples/versions/v21-version-get-resp.json index e1b7acede1c3..197144bc28bb 100644 --- a/doc/api_samples/versions/v21-version-get-resp.json +++ b/doc/api_samples/versions/v21-version-get-resp.json @@ -19,7 +19,7 @@ } ], "status": "CURRENT", - "version": "2.96", + "version": "2.97", "min_version": "2.1", "updated": "2013-07-23T11:33:21Z" } diff --git a/doc/api_samples/versions/versions-get-resp.json b/doc/api_samples/versions/versions-get-resp.json index d36325e9b623..cdb5504f3d43 100644 --- a/doc/api_samples/versions/versions-get-resp.json +++ b/doc/api_samples/versions/versions-get-resp.json @@ -22,7 +22,7 @@ } ], "status": "CURRENT", - "version": "2.96", + "version": "2.97", "min_version": "2.1", "updated": "2013-07-23T11:33:21Z" } diff --git a/nova/api/openstack/api_version_request.py b/nova/api/openstack/api_version_request.py index 18355ae95bc6..a90b1afad197 100644 --- a/nova/api/openstack/api_version_request.py +++ b/nova/api/openstack/api_version_request.py @@ -257,6 +257,14 @@ REST_API_VERSION_HISTORY = """REST API Version History: * 2.95 - Evacuate will now stop instance at destination. * 2.96 - Add support for returning pinned_availability_zone in ``server show`` and ``server list --long`` responses. + * 2.97 - Adds new API ``GET /servers/{server_id}/shares`` which shows + shares attachments of a given server. + ``GET /servers/{server_id}/shares/{share_id} which gives details + about a share attachment. + ``POST /servers/{server_id}/shares/{share_id} which create an + attachment. + ``DELETE /servers/{server_id}/shares/{share_id} which delete an + attachment. """ # The minimum and maximum versions of the API supported @@ -265,7 +273,7 @@ REST_API_VERSION_HISTORY = """REST API Version History: # Note(cyeoh): This only applies for the v2.1 API once microversions # support is fully merged. It does not affect the V2 API. _MIN_API_VERSION = '2.1' -_MAX_API_VERSION = '2.96' +_MAX_API_VERSION = '2.97' DEFAULT_API_VERSION = _MIN_API_VERSION # Almost all proxy APIs which are related to network, images and baremetal diff --git a/nova/api/openstack/compute/evacuate.py b/nova/api/openstack/compute/evacuate.py index 3f86c105441f..cbf7cded6a77 100644 --- a/nova/api/openstack/compute/evacuate.py +++ b/nova/api/openstack/compute/evacuate.py @@ -161,6 +161,10 @@ class EvacuateController(wsgi.Controller): raise exc.HTTPBadRequest(explanation=e.format_message()) except exception.UnsupportedRPCVersion as e: raise exc.HTTPConflict(explanation=e.format_message()) + except ( + exception.ForbiddenSharesNotSupported, + exception.ForbiddenWithShare) as e: + raise exc.HTTPConflict(explanation=e.format_message()) if (not api_version_request.is_supported(req, min_version='2.14') and CONF.api.enable_instance_password): diff --git a/nova/api/openstack/compute/migrate_server.py b/nova/api/openstack/compute/migrate_server.py index b4401b0b5377..f23f63ddc3ed 100644 --- a/nova/api/openstack/compute/migrate_server.py +++ b/nova/api/openstack/compute/migrate_server.py @@ -81,6 +81,11 @@ class MigrateServerController(wsgi.Controller): exception.ExtendedResourceRequestOldCompute, ) as e: raise exc.HTTPBadRequest(explanation=e.format_message()) + except ( + exception.ForbiddenSharesNotSupported, + exception.ForbiddenWithShare, + ) as e: + raise exc.HTTPConflict(explanation=e.format_message()) @wsgi.response(202) @wsgi.expected_errors((400, 403, 404, 409)) @@ -156,6 +161,11 @@ class MigrateServerController(wsgi.Controller): except exception.InstanceInvalidState as state_error: common.raise_http_conflict_for_instance_invalid_state(state_error, 'os-migrateLive', id) + except ( + exception.ForbiddenSharesNotSupported, + exception.ForbiddenWithShare, + ) as e: + raise exc.HTTPConflict(explanation=e.format_message()) def _get_force_param_for_live_migration(self, body, host): force = body["os-migrateLive"].get("force", False) diff --git a/nova/api/openstack/compute/rest_api_version_history.rst b/nova/api/openstack/compute/rest_api_version_history.rst index 033ad3c82aef..374e218265a5 100644 --- a/nova/api/openstack/compute/rest_api_version_history.rst +++ b/nova/api/openstack/compute/rest_api_version_history.rst @@ -1255,3 +1255,16 @@ behavior. The ``server show`` and ``server list --long`` responses now include the pinned availability zone as well. + +.. _microversion 2.97: + +2.97 +---- + +This microversion introduces the new Manila Share Attachment feature, +streamlining the process of attaching and mounting Manila file shares to +instances. It includes a new set of APIs to easily add, remove, list, and +display shares. For detailed insights and usage instructions, please refer +to the `manage-shares documentation`_. + +.. _manage-shares documentation: https://docs.openstack.org/nova/latest/admin/manage-shares.html diff --git a/nova/api/openstack/compute/routes.py b/nova/api/openstack/compute/routes.py index 91f068daef17..293e32fbdef2 100644 --- a/nova/api/openstack/compute/routes.py +++ b/nova/api/openstack/compute/routes.py @@ -72,6 +72,7 @@ from nova.api.openstack.compute import server_groups from nova.api.openstack.compute import server_metadata from nova.api.openstack.compute import server_migrations from nova.api.openstack.compute import server_password +from nova.api.openstack.compute import server_shares from nova.api.openstack.compute import server_tags from nova.api.openstack.compute import server_topology from nova.api.openstack.compute import servers @@ -310,6 +311,8 @@ server_remote_consoles_controller = functools.partial(_create_controller, server_security_groups_controller = functools.partial(_create_controller, security_groups.ServerSecurityGroupController, []) +server_shares_controller = functools.partial(_create_controller, + server_shares.ServerSharesController, []) server_tags_controller = functools.partial(_create_controller, server_tags.ServerTagsController, []) @@ -825,6 +828,14 @@ ROUTE_LIST = ( ('/servers/{server_id}/os-security-groups', { 'GET': [server_security_groups_controller, 'index'] }), + ('/servers/{server_id}/shares', { + 'GET': [server_shares_controller, 'index'], + 'POST': [server_shares_controller, 'create'], + }), + ('/servers/{server_id}/shares/{id}', { + 'GET': [server_shares_controller, 'show'], + 'DELETE': [server_shares_controller, 'delete'], + }), ('/servers/{server_id}/tags', { 'GET': [server_tags_controller, 'index'], 'PUT': [server_tags_controller, 'update_all'], diff --git a/nova/api/openstack/compute/schemas/server_shares.py b/nova/api/openstack/compute/schemas/server_shares.py new file mode 100644 index 000000000000..3a22c5476c2c --- /dev/null +++ b/nova/api/openstack/compute/schemas/server_shares.py @@ -0,0 +1,95 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.api.validation import parameter_types + +create = { + 'title': 'Server shares', + 'type': 'object', + 'properties': { + 'share': { + 'type': 'object', + 'properties': { + 'share_id': parameter_types.share_id, + 'tag': parameter_types.share_tag, + }, + 'required': ['share_id'], + 'additionalProperties': False + } + }, + 'required': ['share'], + 'additionalProperties': False +} + +index_query = { + 'type': 'object', + 'properties': {}, + 'additionalProperties': False +} + +show_query = { + 'type': 'object', + 'properties': {}, + 'additionalProperties': False +} + +# "share": { +# "uuid": "68ba1762-fd6d-4221-8311-f3193dd93404", +# "export_location": "10.0.0.50:/mnt/foo", +# "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", +# "status": "inactive", +# "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986" +# } + +share_response = { + 'title': 'Server share', + 'type': 'object', + 'properties': { + 'share': { + 'type': 'object', + 'properties': { + 'uuid': parameter_types.share_id, + 'export_location': parameter_types.share_export_location, + 'share_id': parameter_types.share_id, + 'status': parameter_types.share_status, + 'tag': parameter_types.share_tag, + }, + 'required': ['share_id', 'status', 'tag'], + 'additionalProperties': False + } + }, + 'required': ['share'], + 'additionalProperties': False +} + +share_list_response = { + 'title': 'Server shares', + 'type': 'object', + 'properties': { + 'shares': { + 'type': 'array', + 'items': { + 'properties': { + 'uuid': parameter_types.share_id, + 'export_location': parameter_types.share_export_location, + 'share_id': parameter_types.share_id, + 'status': parameter_types.share_status, + 'tag': parameter_types.share_tag, + }, + 'required': ['share_id', 'status', 'tag'], + 'additionalProperties': False + } + }, + }, + 'required': ['shares'], + 'additionalProperties': False +} diff --git a/nova/api/openstack/compute/server_shares.py b/nova/api/openstack/compute/server_shares.py new file mode 100644 index 000000000000..8dc1282db50c --- /dev/null +++ b/nova/api/openstack/compute/server_shares.py @@ -0,0 +1,262 @@ +# 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 webob + +from oslo_db import exception as db_exc +from oslo_utils import uuidutils + +from nova.api.openstack import common +from nova.api.openstack.compute.schemas import server_shares as schema +from nova.api.openstack.compute.views import server_shares +from nova.api.openstack import wsgi +from nova.api import validation +from nova.compute import api as compute +from nova.compute import vm_states +from nova import context as nova_context +from nova import exception +from nova import objects +from nova.objects import fields +from nova.policies import server_shares as ss_policies +from nova.share import manila +from nova.virt import hardware as hw + + +def _get_instance_mapping(context, server_id): + try: + return objects.InstanceMapping.get_by_instance_uuid(context, server_id) + except exception.InstanceMappingNotFound as e: + raise webob.exc.HTTPNotFound(explanation=e.format_message()) + + +class ServerSharesController(wsgi.Controller): + _view_builder_class = server_shares.ViewBuilder + + def __init__(self): + super(ServerSharesController, self).__init__() + self.compute_api = compute.API() + self.manila = manila.API() + + def _get_instance_from_server_uuid(self, context, server_id): + instance = common.get_instance(self.compute_api, context, server_id) + return instance + + def _check_instance_in_valid_state(self, context, server_id, action): + instance = self._get_instance_from_server_uuid(context, server_id) + if ( + (action == "create share" and + instance.vm_state not in vm_states.STOPPED) or + (action == "delete share" and + instance.vm_state not in vm_states.STOPPED and + instance.vm_state not in vm_states.ERROR) + ): + exc = exception.InstanceInvalidState( + attr="vm_state", + instance_uuid=instance.uuid, + state=instance.vm_state, + method=action, + ) + common.raise_http_conflict_for_instance_invalid_state( + exc, action, server_id + ) + return instance + + @wsgi.Controller.api_version("2.97") + @wsgi.response(200) + @wsgi.expected_errors((400, 403, 404)) + @validation.query_schema(schema.index_query) + @validation.response_body_schema(schema.share_list_response) + def index(self, req, server_id): + context = req.environ["nova.context"] + # Get instance mapping to query the required cell database + im = _get_instance_mapping(context, server_id) + context.can(ss_policies.POLICY_ROOT % 'index', + target={'project_id': im.project_id}) + + with nova_context.target_cell(context, im.cell_mapping) as cctxt: + # Ensure the instance exists + self._get_instance_from_server_uuid(cctxt, server_id) + db_shares = objects.ShareMappingList.get_by_instance_uuid( + cctxt, server_id + ) + + return self._view_builder._list_view(db_shares) + + @wsgi.Controller.api_version("2.97") + @wsgi.response(201) + @wsgi.expected_errors((400, 403, 404, 409)) + @validation.schema(schema.create, min_version='2.97') + @validation.response_body_schema(schema.share_response) + def create(self, req, server_id, body): + def _try_create_share_mapping(context, share_mapping): + """Block the request if the share is already created. + Prevent race conditions of requests that would hit the + share_mapping.create() almost at the same time. + Prevent user from using the same tag twice on the same instance. + """ + try: + objects.ShareMapping.get_by_instance_uuid_and_share_id(context, + share_mapping.instance_uuid, share_mapping.share_id + ) + raise exception.ShareMappingAlreadyExists( + share_id=share_mapping.share_id, tag=share_mapping.tag + ) + except exception.ShareNotFound: + pass + + try: + share_mapping.create() + except db_exc.DBDuplicateEntry: + raise exception.ShareMappingAlreadyExists( + share_id=share_mapping.share_id, tag=share_mapping.tag + ) + + def _check_manila_share(manila_share_data): + """Check that the targeted share in manila has + correct export location, status 'available' and a supported + protocol. + """ + if manila_share_data.status != 'available': + raise exception.ShareStatusIncorect( + share_id=share_id, status=manila_share_data.status + ) + + if manila_share_data.export_location is None: + raise exception.ShareMissingExportLocation(share_id=share_id) + + if ( + manila_share_data.share_proto + not in fields.ShareMappingProto.ALL + ): + raise exception.ShareProtocolNotSupported( + share_proto=manila_share_data.share_proto + ) + + context = req.environ["nova.context"] + # Get instance mapping to query the required cell database + im = _get_instance_mapping(context, server_id) + context.can( + ss_policies.POLICY_ROOT % 'create', + target={'project_id': im.project_id} + ) + + share_dict = body['share'] + share_id = share_dict.get('share_id') + with nova_context.target_cell(context, im.cell_mapping) as cctxt: + instance = self._check_instance_in_valid_state( + cctxt, + server_id, + "create share" + ) + + try: + hw.check_shares_supported(cctxt, instance) + + manila_share_data = self.manila.get(cctxt, share_id) + _check_manila_share(manila_share_data) + + share_mapping = objects.ShareMapping(cctxt) + share_mapping.uuid = uuidutils.generate_uuid() + share_mapping.instance_uuid = server_id + share_mapping.share_id = manila_share_data.id + share_mapping.status = fields.ShareMappingStatus.ATTACHING + share_mapping.tag = share_dict.get('tag', manila_share_data.id) + share_mapping.export_location = ( + manila_share_data.export_location) + share_mapping.share_proto = manila_share_data.share_proto + + _try_create_share_mapping(cctxt, share_mapping) + self.compute_api.allow_share(cctxt, instance, share_mapping) + + view = self._view_builder._show_view(cctxt, share_mapping) + + except (exception.ShareNotFound) as e: + raise webob.exc.HTTPNotFound(explanation=e.format_message()) + except (exception.ShareStatusIncorect) as e: + raise webob.exc.HTTPConflict(explanation=e.format_message()) + except (exception.ShareMissingExportLocation) as e: + raise webob.exc.HTTPConflict(explanation=e.format_message()) + except (exception.ShareProtocolNotSupported) as e: + raise webob.exc.HTTPConflict(explanation=e.format_message()) + except (exception.ShareMappingAlreadyExists) as e: + raise webob.exc.HTTPConflict(explanation=e.format_message()) + except (exception.ForbiddenSharesNotSupported) as e: + raise webob.exc.HTTPForbidden(explanation=e.format_message()) + except (exception.ForbiddenSharesNotConfiguredCorrectly) as e: + raise webob.exc.HTTPConflict(explanation=e.format_message()) + + return view + + @wsgi.Controller.api_version("2.97") + @wsgi.response(200) + @wsgi.expected_errors((400, 403, 404)) + @validation.query_schema(schema.show_query) + @validation.response_body_schema(schema.share_response) + def show(self, req, server_id, id): + context = req.environ["nova.context"] + # Get instance mapping to query the required cell database + im = _get_instance_mapping(context, server_id) + context.can( + ss_policies.POLICY_ROOT % 'show', + target={'project_id': im.project_id} + ) + + with nova_context.target_cell(context, im.cell_mapping) as cctxt: + try: + # Ensure the instance exists + self._get_instance_from_server_uuid(cctxt, server_id) + share = objects.ShareMapping.get_by_instance_uuid_and_share_id( + cctxt, + server_id, + id + ) + + view = self._view_builder._show_view(cctxt, share) + + except (exception.ShareNotFound) as e: + raise webob.exc.HTTPNotFound(explanation=e.format_message()) + + return view + + @wsgi.Controller.api_version("2.97") + @wsgi.response(200) + @wsgi.expected_errors((400, 403, 404, 409)) + def delete(self, req, server_id, id): + context = req.environ["nova.context"] + # Get instance mapping to query the required cell database + im = _get_instance_mapping(context, server_id) + context.can( + ss_policies.POLICY_ROOT % 'delete', + target={'project_id': im.project_id} + ) + + with nova_context.target_cell(context, im.cell_mapping) as cctxt: + instance = self._check_instance_in_valid_state( + cctxt, + server_id, + "delete share" + ) + try: + # Ensure the instance exists + self._get_instance_from_server_uuid(cctxt, server_id) + share_mapping = ( + objects.ShareMapping.get_by_instance_uuid_and_share_id( + cctxt, server_id, id + ) + ) + + share_mapping.status = fields.ShareMappingStatus.DETACHING + share_mapping.save() + self.compute_api.deny_share(cctxt, instance, share_mapping) + + except (exception.ShareNotFound) as e: + raise webob.exc.HTTPNotFound(explanation=e.format_message()) diff --git a/nova/api/openstack/compute/servers.py b/nova/api/openstack/compute/servers.py index b7500d28298d..b27bf6502bcb 100644 --- a/nova/api/openstack/compute/servers.py +++ b/nova/api/openstack/compute/servers.py @@ -1099,6 +1099,10 @@ class ServersController(wsgi.Controller): except exception.Invalid: msg = _("Invalid instance image.") raise exc.HTTPBadRequest(explanation=msg) + except ( + exception.ForbiddenSharesNotSupported, + exception.ForbiddenWithShare) as e: + raise exc.HTTPConflict(explanation=e.format_message()) @wsgi.response(204) @wsgi.expected_errors((404, 409)) diff --git a/nova/api/openstack/compute/shelve.py b/nova/api/openstack/compute/shelve.py index a471786da182..0894af3d4ba6 100644 --- a/nova/api/openstack/compute/shelve.py +++ b/nova/api/openstack/compute/shelve.py @@ -59,6 +59,10 @@ class ShelveController(wsgi.Controller): 'shelve', id) except exception.ForbiddenPortsWithAccelerator as e: raise exc.HTTPBadRequest(explanation=e.format_message()) + except ( + exception.ForbiddenSharesNotSupported, + exception.ForbiddenWithShare) as e: + raise exc.HTTPConflict(explanation=e.format_message()) @wsgi.response(202) @wsgi.expected_errors((400, 404, 409)) diff --git a/nova/api/openstack/compute/suspend_server.py b/nova/api/openstack/compute/suspend_server.py index 3afbeaaac5e0..54a3bfa3307f 100644 --- a/nova/api/openstack/compute/suspend_server.py +++ b/nova/api/openstack/compute/suspend_server.py @@ -49,6 +49,10 @@ class SuspendServerController(wsgi.Controller): 'suspend', id) except exception.ForbiddenPortsWithAccelerator as e: raise exc.HTTPBadRequest(explanation=e.format_message()) + except ( + exception.ForbiddenSharesNotSupported, + exception.ForbiddenWithShare) as e: + raise exc.HTTPConflict(explanation=e.format_message()) @wsgi.response(202) @wsgi.expected_errors((404, 409)) diff --git a/nova/api/openstack/compute/views/server_shares.py b/nova/api/openstack/compute/views/server_shares.py new file mode 100644 index 000000000000..4e4fb9f50d5a --- /dev/null +++ b/nova/api/openstack/compute/views/server_shares.py @@ -0,0 +1,46 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.api.openstack import common +from nova.api.openstack.compute.views import servers + + +class ViewBuilder(common.ViewBuilder): + _collection_name = 'shares' + + def __init__(self): + super(ViewBuilder, self).__init__() + self._server_builder = servers.ViewBuilder() + + def _list_view(self, db_shares): + shares = {'shares': []} + for db_share in db_shares: + share = { + 'share_id': db_share.share_id, + 'status': db_share.status, + 'tag': db_share.tag, + } + shares['shares'].append(share) + return shares + + def _show_view(self, context, db_share): + share = {'share': { + 'share_id': db_share.share_id, + 'status': db_share.status, + 'tag': db_share.tag, + }} + + if context.is_admin: + share['share']['export_location'] = db_share.export_location + share['share']['uuid'] = db_share.uuid + + return share diff --git a/nova/api/validation/parameter_types.py b/nova/api/validation/parameter_types.py index bdb3ad3c8378..dd693bf3f28e 100644 --- a/nova/api/validation/parameter_types.py +++ b/nova/api/validation/parameter_types.py @@ -357,6 +357,23 @@ image_id = { 'type': 'string', 'format': 'uuid' } +share_id = { + 'type': 'string', 'format': 'uuid' +} + +share_tag = { + 'type': 'string', 'minLength': 1, 'maxLength': 255, + 'pattern': '^[a-zA-Z0-9-]*$' +} + +share_export_location = { + 'type': 'string' +} + +share_status = { + 'type': 'string', + 'enum': ['active', 'inactive', 'attaching', 'detaching', 'error'] +} image_id_or_empty_string = { 'oneOf': [ diff --git a/nova/exception.py b/nova/exception.py index 26292f6cb091..41c9492bffaa 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -721,14 +721,24 @@ class ShareNotFound(NotFound): msg_fmt = _("Share %(share_id)s could not be found.") +class ShareStatusIncorect(NotFound): + msg_fmt = _("Share %(share_id)s is in '%(status)s' instead of " + "'available' status.") + + class ShareMappingAlreadyExists(NotFound): - msg_fmt = _("Share %(share_id)s already associated to this server.") + msg_fmt = _("Share '%(share_id)s' or tag '%(tag)s' already associated " + "to this server.") class ShareProtocolNotSupported(NotFound): msg_fmt = _("Share protocol %(share_proto)s is not supported.") +class ShareMissingExportLocation(NotFound): + msg_fmt = _("Share %(share_id)s export location is missing.") + + class ShareError(NovaException): msg_fmt = _("Share %(share_id)s used by instance %(instance_uuid)s " "is in error state.") diff --git a/nova/policies/__init__.py b/nova/policies/__init__.py index d5c485cfa3da..8895c1de0000 100644 --- a/nova/policies/__init__.py +++ b/nova/policies/__init__.py @@ -56,6 +56,7 @@ from nova.policies import server_external_events from nova.policies import server_groups from nova.policies import server_metadata from nova.policies import server_password +from nova.policies import server_shares from nova.policies import server_tags from nova.policies import server_topology from nova.policies import servers @@ -114,6 +115,7 @@ def list_rules(): server_groups.list_rules(), server_metadata.list_rules(), server_password.list_rules(), + server_shares.list_rules(), server_tags.list_rules(), server_topology.list_rules(), servers.list_rules(), diff --git a/nova/policies/server_shares.py b/nova/policies/server_shares.py new file mode 100644 index 000000000000..0b6249ee8459 --- /dev/null +++ b/nova/policies/server_shares.py @@ -0,0 +1,70 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_policy import policy + +from nova.policies import base + + +POLICY_ROOT = 'os_compute_api:os-server-shares:%s' + + +server_shares_policies = [ + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'index', + check_str=base.PROJECT_READER, + description="List all shares for given server", + operations=[ + { + 'method': 'GET', + 'path': '/servers/{server_id}/shares' + } + ], + scope_types=['project']), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'create', + check_str=base.PROJECT_MEMBER, + description="Attach a share to the specified server", + operations=[ + { + 'method': 'POST', + 'path': '/servers/{server_id}/shares' + } + ], + scope_types=['project']), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'show', + check_str=base.PROJECT_READER, + description="Show a share configured for the specified server", + operations=[ + { + 'method': 'GET', + 'path': '/servers/{server_id}/shares/{share_id}' + } + ], + scope_types=['project']), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'delete', + check_str=base.PROJECT_MEMBER, + description="Detach a share to the specified server", + operations=[ + { + 'method': 'DELETE', + 'path': '/servers/{server_id}/shares/{share_id}' + } + ], + scope_types=['project']), +] + + +def list_rules(): + return server_shares_policies diff --git a/nova/share/manila.py b/nova/share/manila.py index a0b48aa4b9c2..1f126462ca2d 100644 --- a/nova/share/manila.py +++ b/nova/share/manila.py @@ -203,12 +203,15 @@ class API(object): def filter_export_locations(export_locations): # Return the preferred path otherwise choose the first one paths = [] - for export_location in export_locations: - if export_location.is_preferred: - return export_location.path - else: - paths.append(export_location.path) - return paths[0] + try: + for export_location in export_locations: + if export_location.is_preferred: + return export_location.path + else: + paths.append(export_location.path) + return paths[0] + except (IndexError, NameError): + return None client = _manilaclient(context, admin=False) LOG.debug("Get share id:'%s' data from manila", share_id) diff --git a/nova/tests/fixtures/manila.py b/nova/tests/fixtures/manila.py index d7fca52d0440..0a85e60652fb 100644 --- a/nova/tests/fixtures/manila.py +++ b/nova/tests/fixtures/manila.py @@ -80,6 +80,29 @@ class ManilaFixture(fixtures.Fixture): manila_share, export_location ) + def fake_get_share_status_error(self, context, share_id): + manila_share = ManilaShare(share_id) + manila_share.status = "error" + export_location = "10.0.0.50:/mnt/foo" + return nova.share.manila.Share.from_manila_share( + manila_share, export_location + ) + + def fake_get_share_export_location_missing(self, context, share_id): + manila_share = ManilaShare(share_id) + export_location = None + return nova.share.manila.Share.from_manila_share( + manila_share, export_location + ) + + def fake_get_share_unknown_protocol(self, context, share_id): + manila_share = ManilaShare(share_id) + manila_share.share_protocol = "CIFS" + export_location = "10.0.0.50:/mnt/foo" + return nova.share.manila.Share.from_manila_share( + manila_share, export_location + ) + def fake_get_cephfs(self, context, share_id): manila_share = ManilaShare(share_id, "CEPHFS") export_location = "10.0.0.50:/mnt/foo" diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-admin-create-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-admin-create-resp.json.tpl new file mode 100644 index 000000000000..6db23430f105 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-admin-create-resp.json.tpl @@ -0,0 +1,10 @@ +{ + "share": + { + "uuid": "%(uuid)s", + "share_id": "%(share_id)s", + "status": "attaching", + "export_location": "10.0.0.50:/mnt/foo", + "tag": "%(share_id)s" + } +} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json.tpl new file mode 100644 index 000000000000..795c3a555ddd --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json.tpl @@ -0,0 +1,10 @@ +{ + "share": + { + "uuid": "%(uuid)s", + "share_id": "%(share_id)s", + "status": "inactive", + "export_location": "10.0.0.50:/mnt/foo", + "tag": "%(share_id)s" + } +} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-req.json.tpl new file mode 100644 index 000000000000..e101a7817b00 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-req.json.tpl @@ -0,0 +1,5 @@ +{ + "share": { + "share_id": "%(share_id)s" + } +} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-resp.json.tpl new file mode 100644 index 000000000000..e6261d88c39a --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-resp.json.tpl @@ -0,0 +1,8 @@ +{ + "share": + { + "share_id": "%(share_id)s", + "status": "attaching", + "tag": "%(share_id)s" + } +} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-tag-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-tag-req.json.tpl new file mode 100644 index 000000000000..23d8009a0798 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-tag-req.json.tpl @@ -0,0 +1,6 @@ +{ + "share": { + "share_id": "%(share_id)s", + "tag": "%(tag)s" + } +} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-delete-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-delete-req.json.tpl new file mode 120000 index 000000000000..73b0584014f7 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-delete-req.json.tpl @@ -0,0 +1 @@ +server-shares-create-req.json.tpl \ No newline at end of file diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-list-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-list-resp.json.tpl new file mode 100644 index 000000000000..3c6ee5f3f4b5 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-list-resp.json.tpl @@ -0,0 +1,9 @@ +{ + "shares": [ + { + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "inactive", + "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986" + } + ] +} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-show-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-show-resp.json.tpl new file mode 100644 index 000000000000..8b05ba444426 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-show-resp.json.tpl @@ -0,0 +1,8 @@ +{ + "share": + { + "share_id": "%(share_id)s", + "status": "inactive", + "tag": "%(share_id)s" + } +} diff --git a/nova/tests/functional/api_sample_tests/test_server_shares.py b/nova/tests/functional/api_sample_tests/test_server_shares.py new file mode 100644 index 000000000000..322c1ee9a321 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/test_server_shares.py @@ -0,0 +1,482 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.compute import api as compute +from nova import exception +from nova.tests import fixtures as nova_fixtures +from nova.tests.functional.api import client +from nova.tests.functional.api_sample_tests import test_servers +from oslo_utils.fixture import uuidsentinel +from unittest import mock + + +class ServerSharesBase(test_servers.ServersSampleBase): + sample_dir = 'os-server-shares' + microversion = '2.97' + scenarios = [('v2_97', {'api_major_version': 'v2.1'})] + + def setUp(self): + super(ServerSharesBase, self).setUp() + self.manila_fixture = self.useFixture(nova_fixtures.ManilaFixture()) + self.compute_api = compute.API() + + def _get_create_subs(self): + return {'share_id': 'e8debdc0-447a-4376-a10a-4cd9122d7986', + 'uuid': '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}' + '-[0-9a-f]{4}-[0-9a-f]{12}', + } + + def create_server_ok(self, requested_flavor=None): + flavor = self._create_flavor(extra_spec=requested_flavor) + server = self._create_server(networks='auto', flavor_id=flavor) + self._stop_server(server) + return server['id'] + + def create_server_not_stopped(self): + server = self._create_server(networks='auto') + return server['id'] + + def _post_server_shares(self): + """Verify the response status and returns the UUID of the + newly created server with shares. + """ + uuid = self.create_server_ok() + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + + self._verify_response( + 'server-shares-create-resp', subs, response, 201) + + return uuid + + +class ServerSharesJsonTest(ServerSharesBase): + def test_server_shares_create(self): + """Verify we can create a share mapping. + """ + self._post_server_shares() + + def test_server_shares_create_fails_if_already_created(self): + """Verify we cannot create a share mapping already created. + """ + uuid = self._post_server_shares() + # Following mock simulate a race condition between 2 requests that + # would hit the share_mapping.create() almost at the same time. + with mock.patch( + "nova.db.main.api.share_mapping_get_by_instance_uuid_and_share_id" + ) as mock_db: + mock_db.return_value = None + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self.assertEqual(409, response.status_code) + self.assertIn('already associated to this server', response.text) + + def test_server_shares_create_with_tag_fails_if_already_created(self): + """Verify we cannot create a share mapping with a new tag if it is + already created. + """ + uuid = self._post_server_shares() + subs = self._get_create_subs() + subs['tag'] = "my-tag" + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-tag-req", subs + ) + self.assertEqual(409, response.status_code) + self.assertIn( + "Share 'e8debdc0-447a-4376-a10a-4cd9122d7986' or " + "tag 'my-tag' already associated to this server.", + response.text, + ) + + def test_server_shares_create_fails_instance_not_stopped(self): + """Verify we cannot create a share if instance is not stopped. + """ + uuid = self.create_server_not_stopped() + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self.assertEqual(409, response.status_code) + self.assertIn('while it is in vm_state active', response.text) + + def test_server_shares_create_fails_incorrect_configuration(self): + """Verify we cannot create a share we don't have the + appropriate configuration. + """ + with mock.patch.dict(self.compute.driver.capabilities, + supports_mem_backing_file=False): + self.compute.stop() + self.compute.start() + uuid = self.create_server_ok() + subs = self._get_create_subs() + response = self._do_post('servers/%s/shares' % uuid, + 'server-shares-create-req', subs) + self.assertEqual(409, response.status_code) + self.assertIn( + 'Feature not supported because either compute or ' + 'instance are not configured correctly.', response.text + ) + + def test_server_shares_create_fails_cannot_allow_policy(self): + """Verify we raise an exception if we get a timeout to apply policy""" + uuid = self.create_server_ok() + subs = self._get_create_subs() + # simulate that manila does not set the requested access in time and + # nova times out waiting for it. + self.manila_fixture.mock_get_access.return_value = None + self.manila_fixture.mock_get_access.side_effect = None + self.flags(share_apply_policy_timeout=2, group='manila') + + # Here we are using CastAsCallFixture so we got an exception from + # nova api. This should not happen without the fixture. + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self.assertEqual(500, response.status_code) + self.assertIn( + "nova.exception.ShareAccessGrantError", + response.text, + ) + + def test_server_shares_create_with_alternative_flavor(self): + """Verify we can create a share with the proper flavor. + """ + with mock.patch.dict(self.compute.driver.capabilities, + supports_mem_backing_file=False): + self.compute.stop() + self.compute.start() + uuid = self.create_server_ok( + requested_flavor={"hw:mem_page_size": "large"} + ) + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self.assertEqual(201, response.status_code) + + def test_server_shares_create_fails_share_not_found(self): + """Verify we can not create a share if the share does not + exists. + """ + self.manila_fixture.mock_get.side_effect = exception.ShareNotFound( + share_id='fake_uuid') + uuid = self.create_server_ok() + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self.assertEqual(404, response.status_code) + self.assertIn("Share fake_uuid could not be found", response.text) + + def test_server_shares_create_unknown_instance(self): + """Verify creating a share on an unknown instance reports an error. + """ + self.create_server_ok() + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuidsentinel.fake_uuid, + "server-shares-create-req", + subs, + ) + self.assertEqual(404, response.status_code) + self.assertIn("could not be found", response.text) + + def test_server_shares_create_fails_share_in_error(self): + """Verify creating a share which is in error reports an error. + """ + uuid = self.create_server_ok() + subs = self._get_create_subs() + self.manila_fixture.mock_get.side_effect = ( + self.manila_fixture.fake_get_share_status_error + ) + + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self.assertEqual(409, response.status_code) + self.assertIn( + "Share e8debdc0-447a-4376-a10a-4cd9122d7986 is in 'error' " + "instead of 'available' status.", + response.text, + ) + + def test_server_shares_create_fails_export_location_missing(self): + """Verify creating a share without export location reports an error. + """ + uuid = self.create_server_ok() + subs = self._get_create_subs() + self.manila_fixture.mock_get.side_effect = ( + self.manila_fixture.fake_get_share_export_location_missing + ) + + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self.assertEqual(409, response.status_code) + self.assertIn( + "Share e8debdc0-447a-4376-a10a-4cd9122d7986 export location is " + "missing.", + response.text, + ) + + def test_server_shares_create_fails_unknown_protocol(self): + """Verify creating a share with an unknown protocol reports an error. + """ + uuid = self.create_server_ok() + subs = self._get_create_subs() + self.manila_fixture.mock_get.side_effect = ( + self.manila_fixture.fake_get_share_unknown_protocol + ) + + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self.assertEqual(409, response.status_code) + self.assertIn("Share protocol CIFS is not supported.", response.text) + + def test_server_shares_index(self): + """Verify we can list shares. + """ + uuid = self._post_server_shares() + subs = self._get_create_subs() + response = self._do_get("servers/%s/shares" % uuid) + self._verify_response("server-shares-list-resp", subs, response, 200) + + def test_server_shares_index_unknown_instance(self): + """Verify getting shares on an unknown instance reports an error. + """ + response = self._do_get('servers/%s/shares' % uuidsentinel.fake_uuid) + self.assertEqual(404, response.status_code) + self.assertIn( + "could not be found", + response.text + ) + + def test_server_shares_show(self): + """Verify we can show a share. + """ + uuid = self._post_server_shares() + subs = self._get_create_subs() + response = self._do_get( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self._verify_response("server-shares-show-resp", subs, response, 200) + + def test_server_shares_show_fails_share_not_found(self): + """Verify we can not show a share if the share does not + exists. + """ + uuid = self.create_server_ok() + subs = self._get_create_subs() + response = self._do_get( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self.assertEqual(404, response.status_code) + self.assertIn( + "Share e8debdc0-447a-4376-a10a-4cd9122d7986 could not be found", + response.text + ) + + def test_server_shares_show_unknown_instance(self): + """Verify showing a share on an unknown instance reports an error. + """ + self._post_server_shares() + subs = self._get_create_subs() + response = self._do_get( + "servers/%s/shares/%s" % (uuidsentinel.fake_uuid, subs["share_id"]) + ) + self.assertEqual(404, response.status_code) + self.assertIn( + "could not be found", + response.text + ) + + def test_server_shares_delete(self): + """Verify we can delete share. + """ + uuid = self._post_server_shares() + subs = self._get_create_subs() + response = self._do_delete( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self.assertEqual(200, response.status_code) + + # Check share is not anymore available + response = self._do_get( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self.assertEqual(404, response.status_code) + + def test_server_shares_delete_instance(self): + """Verify we can delete an instance and its associated share is + deleted as well. + """ + uuid = self._post_server_shares() + subs = self._get_create_subs() + + # Check share is created + response = self._do_get( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self._verify_response("server-shares-show-resp", subs, response, 200) + + # Delete the instance + response = self._do_delete( + "servers/%s" % (uuid) + ) + self.assertEqual(204, response.status_code) + + # Check share is not anymore available + response = self._do_get( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self.assertEqual(404, response.status_code) + + def test_server_shares_delete_fails_share_not_found(self): + """Verify we have an error if we want to remove an unknown share. + """ + uuid = self._post_server_shares() + response = self._do_delete( + "servers/%s/shares/%s" % (uuid, uuidsentinel.wrong_share_id) + ) + self.assertEqual(404, response.status_code) + + def test_server_shares_delete_fails_instance_not_stopped(self): + """Verify we cannot remove a share if the instance is not stopped. + """ + uuid = self._post_server() + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-delete-req", subs + ) + response = self._do_delete( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self.assertEqual(409, response.status_code) + + def test_server_shares_delete_unknown_instance(self): + """Verify deleting a share on an unknown instance reports an error. + """ + uuid = self._post_server_shares() + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-delete-req", subs + ) + response = self._do_delete( + "servers/%s/shares/%s" % (uuidsentinel.fake_uuid, subs["share_id"]) + ) + self.assertEqual(404, response.status_code) + self.assertIn( + "could not be found", + response.text + ) + + def test_server_shares_delete_fails_cannot_deny_policy(self): + """Verify we raise an exception if we cannot deny the policy. + """ + uuid = self._post_server_shares() + subs = self._get_create_subs() + self.manila_fixture.mock_deny.return_value = None + self.manila_fixture.mock_deny.side_effect = ( + exception.ShareAccessRemovalError( + share_id=subs["share_id"], + reason="Resource could not be found.", + ) + ) + + # Here we are using CastAsCallFixture so we got an exception from + # nova api. This should not happen without the fixture. + response = self._do_delete( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self.assertEqual(500, response.status_code) + self.assertIn('nova.exception.ShareAccessRemovalError', response.text) + + +class ServerSharesJsonAdminTest(ServerSharesBase): + ADMIN_API = True + + def _post_server_shares(self): + """Verify the response status and returns the UUID of the + newly created server with shares. + """ + uuid = self.create_server_ok() + subs = self._get_create_subs() + response = self._do_post( + "servers/%s/shares" % uuid, "server-shares-create-req", subs + ) + self._verify_response( + 'server-shares-admin-create-resp', subs, response, 201) + + return uuid + + def test_server_shares_create(self): + """Verify we can create a share mapping. + """ + self._post_server_shares() + + def test_server_shares_show(self): + """Verify we can show a share as admin and thus have more + information. + """ + uuid = self._post_server_shares() + subs = self._get_create_subs() + response = self._do_get( + "servers/%s/shares/%s" % (uuid, subs["share_id"]) + ) + self._verify_response( + "server-shares-admin-show-resp", subs, response, 200 + ) + + def _block_action(self, body): + uuid = self._post_server_shares() + + ex = self.assertRaises( + client.OpenStackApiException, + self.api.post_server_action, + uuid, + body + ) + + self.assertEqual(409, ex.response.status_code) + self.assertIn( + "Feature not supported with instances that have shares.", + ex.response.text + ) + + def test_shelve_server_with_share_fails(self): + self._block_action({"shelve": None}) + + def test_suspend_server_with_share_fails(self): + self._block_action({"suspend": None}) + + def test_evacuate_server_with_share_fails(self): + self._block_action({"evacuate": {}}) + + def test_resize_server_with_share_fails(self): + self._block_action({"resize": {"flavorRef": "2"}}) + + def test_migrate_server_with_share_fails(self): + self._block_action({"migrate": None}) + + def test_live_migrate_server_with_share_fails(self): + self._block_action( + {"os-migrateLive": { + "host": None, + "block_migration": "auto" + } + } + ) diff --git a/nova/tests/unit/api/openstack/compute/test_server_shares.py b/nova/tests/unit/api/openstack/compute/test_server_shares.py new file mode 100644 index 000000000000..ab154d41b050 --- /dev/null +++ b/nova/tests/unit/api/openstack/compute/test_server_shares.py @@ -0,0 +1,411 @@ +# 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 webob + +from nova.api.openstack.compute import server_shares +from nova.compute import vm_states +from nova import context +from nova.db.main import models +from nova import objects +from nova.tests.unit.api.openstack import fakes +from nova.tests.unit.compute.test_compute import BaseTestCase +from nova.tests.unit import fake_instance + +from nova.tests import fixtures as nova_fixtures +from oslo_utils import timeutils + +from unittest import mock + +UUID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' +NON_EXISTING_UUID = '123' + + +def return_server(compute_api, context, instance_id, expected_attrs=None): + return fake_instance.fake_instance_obj(context, vm_state=vm_states.ACTIVE) + + +def return_invalid_server(compute_api, context, instance_id, + expected_attrs=None): + return fake_instance.fake_instance_obj(context, + vm_state=vm_states.BUILDING) + + +class ServerSharesTest(BaseTestCase): + wsgi_api_version = '2.97' + + def setUp(self): + super(ServerSharesTest, self).setUp() + self.controller = server_shares.ServerSharesController() + inst_map = objects.InstanceMapping( + project_id=fakes.FAKE_PROJECT_ID, + user_id=fakes.FAKE_USER_ID, + cell_mapping=objects.CellMappingList.get_all( + context.get_admin_context())[1]) + self.stub_out('nova.objects.InstanceMapping.get_by_instance_uuid', + lambda s, c, u: inst_map) + self.req = fakes.HTTPRequest.blank( + '/servers/%s/shares' % (UUID), + use_admin_context=False, version=self.wsgi_api_version) + self.manila_fixture = self.useFixture(nova_fixtures.ManilaFixture()) + + def fake_get_instance(self): + ctxt = self.req.environ['nova.context'] + return fake_instance.fake_instance_obj( + ctxt, + uuid=fakes.FAKE_UUID, + flavor = objects.Flavor(id=1, name='flavor1', + memory_mb=256, vcpus=1, + root_gb=1, ephemeral_gb=1, + flavorid='1', + swap=0, rxtx_factor=1.0, + vcpu_weight=1, + disabled=False, + is_public=True, + extra_specs={ + 'virtiofs': 'required', + 'mem_backing_file': 'required' + }, + projects=[]), + vm_state=vm_states.STOPPED) + + @mock.patch( + 'nova.virt.hardware.check_shares_supported', return_value=None + ) + @mock.patch('nova.db.main.api.share_mapping_get_by_instance_uuid') + @mock.patch('nova.api.openstack.common.get_instance') + def test_index( + self, mock_get_instance, mock_db_get_shares, mock_shares_support + ): + timeutils.set_time_override() + NOW = timeutils.utcnow() + instance = self.fake_get_instance() + mock_get_instance.return_value = instance + + fake_db_shares = [ + { + 'created_at': NOW, + 'updated_at': None, + 'deleted_at': None, + 'deleted': False, + "id": 1, + "uuid": "33a8e0cb-5f82-409a-b310-89c41f8bf023", + "instance_uuid": "48c16a1a-183f-4052-9dac-0e4fc1e498ae", + "share_id": "48c16a1a-183f-4052-9dac-0e4fc1e498ad", + "status": "active", + "tag": "foo", + "export_location": "10.0.0.50:/mnt/foo", + "share_proto": "NFS", + }, + { + 'created_at': NOW, + 'updated_at': None, + 'deleted_at': None, + 'deleted': False, + "id": 2, + "uuid": "33a8e0cb-5f82-409a-b310-89c41f8bf024", + "instance_uuid": "48c16a1a-183f-4052-9dac-0e4fc1e498ae", + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "active", + "tag": "bar", + "export_location": "10.0.0.50:/mnt/bar", + "share_proto": "NFS", + } + ] + + fake_shares = { + "shares": [ + { + "share_id": "48c16a1a-183f-4052-9dac-0e4fc1e498ad", + "status": "active", + "tag": "foo", + }, + { + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "active", + "tag": "bar", + } + ] + } + + mock_db_get_shares.return_value = fake_db_shares + output = self.controller.index(self.req, instance.uuid) + mock_db_get_shares.assert_called_once_with(mock.ANY, instance.uuid) + self.assertEqual(output, fake_shares) + + @mock.patch('nova.compute.api.API.allow_share') + @mock.patch( + 'nova.virt.hardware.check_shares_supported', return_value=None + ) + @mock.patch( + 'nova.db.main.api.share_mapping_get_by_instance_uuid_and_share_id' + ) + @mock.patch('nova.db.main.api.share_mapping_update') + @mock.patch('nova.api.openstack.common.get_instance') + def test_create( + self, + mock_get_instance, + mock_db_update_share, + mock_db_get_share, + mock_shares_support, + mock_allow + ): + instance = self.fake_get_instance() + + mock_get_instance.return_value = instance + + fake_db_share = { + 'created_at': None, + 'updated_at': None, + 'deleted_at': None, + 'deleted': False, + "id": 1, + "uuid": "7ddcf3ae-82d4-4f93-996a-2b6cbcb42c2b", + "instance_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "attaching", + "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "export_location": "10.0.0.50:/mnt/foo", + "share_proto": "NFS", + } + + body = { + 'share': { + 'share_id': 'e8debdc0-447a-4376-a10a-4cd9122d7986' + }} + + mock_db_update_share.return_value = fake_db_share + mock_db_get_share.side_effect = [None, fake_db_share] + self.controller.create(self.req, instance.uuid, body=body) + + mock_allow.assert_called_once() + self.assertIsInstance( + mock_allow.call_args.args[1], objects.instance.Instance) + self.assertEqual(mock_allow.call_args.args[1].uuid, instance.uuid) + self.assertIsInstance( + mock_allow.call_args.args[2], objects.share_mapping.ShareMapping) + self.assertEqual( + mock_allow.call_args.args[2].share_id, fake_db_share['share_id']) + + mock_db_update_share.assert_called_once_with( + mock.ANY, + mock.ANY, + instance.uuid, + fake_db_share['share_id'], + 'attaching', + fake_db_share['tag'], + fake_db_share['export_location'], + fake_db_share['share_proto'], + ) + + @mock.patch('nova.compute.api.API.allow_share') + @mock.patch( + 'nova.virt.hardware.check_shares_supported', return_value=None + ) + @mock.patch( + 'nova.db.main.api.share_mapping_get_by_instance_uuid_and_share_id' + ) + @mock.patch('nova.db.main.api.share_mapping_update') + @mock.patch('nova.api.openstack.common.get_instance') + def test_create_share_with_new_tag( + self, + mock_get_instance, + mock_db_update_share, + mock_db_get_share, + mock_shares_support, + mock_allow + ): + instance = self.fake_get_instance() + + mock_get_instance.return_value = instance + + fake_db_share = { + 'created_at': None, + 'updated_at': None, + 'deleted_at': None, + 'deleted': False, + "id": 1, + "uuid": "7ddcf3ae-82d4-4f93-996a-2b6cbcb42c2b", + "instance_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "status": "attaching", + "tag": "e8debdc0-447a-4376-a10a-4cd9122d7986", + "export_location": "10.0.0.50:/mnt/foo", + "share_proto": "NFS", + } + + body = { + 'share': { + 'share_id': 'e8debdc0-447a-4376-a10a-4cd9122d7986' + }} + + mock_db_update_share.return_value = fake_db_share + mock_db_get_share.side_effect = [None, fake_db_share] + self.controller.create(self.req, instance.uuid, body=body) + + mock_allow.assert_called_once() + self.assertIsInstance( + mock_allow.call_args.args[1], objects.instance.Instance) + self.assertEqual(mock_allow.call_args.args[1].uuid, instance.uuid) + self.assertIsInstance( + mock_allow.call_args.args[2], objects.share_mapping.ShareMapping) + self.assertEqual( + mock_allow.call_args.args[2].share_id, fake_db_share['share_id']) + + mock_db_update_share.assert_called_once_with( + mock.ANY, + mock.ANY, + instance.uuid, + fake_db_share['share_id'], + 'attaching', + fake_db_share['tag'], + fake_db_share['export_location'], + fake_db_share['share_proto'], + ) + + # Change the tag of the share + body['share']['tag'] = 'my-tag' + mock_db_update_share.return_value['tag'] = "my-tag" + mock_db_get_share.side_effect = [ + fake_db_share, + mock_db_update_share.return_value, + ] + + exc = self.assertRaises( + webob.exc.HTTPConflict, + self.controller.create, + self.req, + instance.uuid, + body=body, + ) + + self.assertIn( + "Share 'e8debdc0-447a-4376-a10a-4cd9122d7986' or tag 'my-tag' " + "already associated to this server", + str(exc)) + + @mock.patch( + 'nova.virt.hardware.check_shares_supported', return_value=None + ) + @mock.patch('nova.api.openstack.common.get_instance') + def test_create_passing_a_share_with_an_error( + self, + mock_get_instance, + mock_shares_support, + ): + instance = self.fake_get_instance() + + mock_get_instance.return_value = instance + + body = { + 'share': { + 'share_id': 'e8debdc0-447a-4376-a10a-4cd9122d7986' + }} + + self.manila_fixture.mock_get.side_effect = ( + self.manila_fixture.fake_get_share_status_error + ) + + exc = self.assertRaises( + webob.exc.HTTPConflict, + self.controller.create, + self.req, + instance.uuid, + body=body, + ) + self.assertEqual( + str(exc), + "Share e8debdc0-447a-4376-a10a-4cd9122d7986 is in 'error' " + "instead of 'available' status.", + ) + + @mock.patch( + 'nova.virt.hardware.check_shares_supported', return_value=None + ) + @mock.patch('nova.api.openstack.common.get_instance') + def test_create_passing_unknown_protocol( + self, + mock_get_instance, + mock_shares_support, + ): + instance = self.fake_get_instance() + + mock_get_instance.return_value = instance + + body = { + 'share': { + 'share_id': 'e8debdc0-447a-4376-a10a-4cd9122d7986' + }} + + self.manila_fixture.mock_get.side_effect = ( + self.manila_fixture.fake_get_share_unknown_protocol + ) + + exc = self.assertRaises( + webob.exc.HTTPConflict, + self.controller.create, + self.req, + instance.uuid, + body=body, + ) + self.assertEqual( + str(exc), + "Share protocol CIFS is not supported." + ) + + @mock.patch('nova.compute.api.API.deny_share') + @mock.patch( + 'nova.virt.hardware.check_shares_supported', return_value=None + ) + @mock.patch('nova.db.main.api.' + 'share_mapping_delete_by_instance_uuid_and_share_id') + @mock.patch('nova.db.main.api.' + 'share_mapping_get_by_instance_uuid_and_share_id') + @mock.patch('nova.api.openstack.common.get_instance') + def test_delete( + self, + mock_get_instance, + mock_db_get_shares, + mock_db_delete_share, + mock_shares_support, + mock_deny + ): + instance = self.fake_get_instance() + + mock_get_instance.return_value = instance + + fake_db_share = models.ShareMapping() + fake_db_share.created_at = None + fake_db_share.updated_at = None + fake_db_share.deleted_at = None + fake_db_share.deleted = False + fake_db_share.id = 1 + fake_db_share.uuid = "33a8e0cb-5f82-409a-b310-89c41f8bf023" + fake_db_share.instance_uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + fake_db_share.share_id = "e8debdc0-447a-4376-a10a-4cd9122d7986" + fake_db_share.status = "inactive" + fake_db_share.tag = "e8debdc0-447a-4376-a10a-4cd9122d7986" + fake_db_share.export_location = "10.0.0.50:/mnt/foo" + fake_db_share.share_proto = "NFS" + + mock_db_get_shares.return_value = fake_db_share + self.controller.delete( + self.req, instance.uuid, fake_db_share.share_id) + + mock_deny.assert_called_once() + self.assertIsInstance( + mock_deny.call_args.args[1], objects.instance.Instance) + self.assertEqual(mock_deny.call_args.args[1].uuid, instance.uuid) + self.assertIsInstance( + mock_deny.call_args.args[2], objects.share_mapping.ShareMapping) + self.assertEqual( + mock_deny.call_args.args[2].share_id, fake_db_share['share_id']) diff --git a/nova/tests/unit/fake_policy.py b/nova/tests/unit/fake_policy.py index be3c07dad824..1423a426355a 100644 --- a/nova/tests/unit/fake_policy.py +++ b/nova/tests/unit/fake_policy.py @@ -159,6 +159,10 @@ policy_data = """ "os_compute_api:os-server-password:show": "", "os_compute_api:os-server-password:clear": "", "os_compute_api:os-server-external-events:create": "", + "os_compute_api:os-server-shares:index": "", + "os_compute_api:os-server-shares:create": "", + "os_compute_api:os-server-shares:show": "", + "os_compute_api:os-server-shares:delete": "", "os_compute_api:os-server-tags:index": "", "os_compute_api:os-server-tags:show": "", "os_compute_api:os-server-tags:update": "", diff --git a/nova/tests/unit/test_policy.py b/nova/tests/unit/test_policy.py index 26cbf709eac3..56562b720f8f 100644 --- a/nova/tests/unit/test_policy.py +++ b/nova/tests/unit/test_policy.py @@ -488,6 +488,10 @@ class RealRolePolicyTestCase(test.NoDBTestCase): "os_compute_api:os-instance-actions:list", "os_compute_api:os-instance-actions:show", "os_compute_api:os-server-password:show", +"os_compute_api:os-server-shares:index", +"os_compute_api:os-server-shares:create", +"os_compute_api:os-server-shares:show", +"os_compute_api:os-server-shares:delete", "os_compute_api:os-server-tags:index", "os_compute_api:os-server-tags:show", "os_compute_api:os-floating-ips:list",