From 5e508a09b392130813d543b6f121129ffc1ce7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Ribaud?= Date: Tue, 22 Mar 2022 15:15:21 +0100 Subject: [PATCH] Attach Manila shares via virtiofs (API) This patch introduce the REST API modifications to attach/detach a share and list/show share mappings. Manila is the OpenStack Shared Filesystems service. These series of patches implement changes required in Nova to allow the shares provided by Manila to be associated with and attached to instances using virtiofs. Implements: blueprint libvirt-virtiofs-attach-manila-shares Change-Id: I0255a5697cd4ea148bd91c4f6fd183841d69a333 --- api-ref/source/index.rst | 1 + api-ref/source/os-server-shares.inc | 163 ++++++ api-ref/source/parameters.yaml | 60 ++- .../server-shares-admin-create-resp.json | 9 + .../v2.97/server-shares-admin-show-resp.json | 9 + .../v2.97/server-shares-create-req.json | 6 + .../v2.97/server-shares-create-resp.json | 7 + .../v2.97/server-shares-list-resp.json | 9 + .../v2.97/server-shares-show-resp.json | 7 + .../versions/v21-version-get-resp.json | 2 +- .../versions/versions-get-resp.json | 2 +- nova/api/openstack/api_version_request.py | 10 +- nova/api/openstack/compute/evacuate.py | 4 + nova/api/openstack/compute/migrate_server.py | 10 + .../compute/rest_api_version_history.rst | 13 + nova/api/openstack/compute/routes.py | 11 + .../compute/schemas/server_shares.py | 95 ++++ nova/api/openstack/compute/server_shares.py | 262 ++++++++++ nova/api/openstack/compute/servers.py | 4 + nova/api/openstack/compute/shelve.py | 4 + nova/api/openstack/compute/suspend_server.py | 4 + .../openstack/compute/views/server_shares.py | 46 ++ nova/api/validation/parameter_types.py | 17 + nova/exception.py | 12 +- nova/policies/__init__.py | 2 + nova/policies/server_shares.py | 70 +++ nova/share/manila.py | 15 +- nova/tests/fixtures/manila.py | 23 + .../server-shares-admin-create-resp.json.tpl | 10 + .../server-shares-admin-show-resp.json.tpl | 10 + .../v2.97/server-shares-create-req.json.tpl | 5 + .../v2.97/server-shares-create-resp.json.tpl | 8 + .../server-shares-create-tag-req.json.tpl | 6 + .../v2.97/server-shares-delete-req.json.tpl | 1 + .../v2.97/server-shares-list-resp.json.tpl | 9 + .../v2.97/server-shares-show-resp.json.tpl | 8 + .../api_sample_tests/test_server_shares.py | 482 ++++++++++++++++++ .../openstack/compute/test_server_shares.py | 411 +++++++++++++++ nova/tests/unit/fake_policy.py | 4 + nova/tests/unit/test_policy.py | 4 + 40 files changed, 1823 insertions(+), 12 deletions(-) create mode 100644 api-ref/source/os-server-shares.inc create mode 100644 doc/api_samples/os-server-shares/v2.97/server-shares-admin-create-resp.json create mode 100644 doc/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json create mode 100644 doc/api_samples/os-server-shares/v2.97/server-shares-create-req.json create mode 100644 doc/api_samples/os-server-shares/v2.97/server-shares-create-resp.json create mode 100644 doc/api_samples/os-server-shares/v2.97/server-shares-list-resp.json create mode 100644 doc/api_samples/os-server-shares/v2.97/server-shares-show-resp.json create mode 100644 nova/api/openstack/compute/schemas/server_shares.py create mode 100644 nova/api/openstack/compute/server_shares.py create mode 100644 nova/api/openstack/compute/views/server_shares.py create mode 100644 nova/policies/server_shares.py create mode 100644 nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-admin-create-resp.json.tpl create mode 100644 nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-admin-show-resp.json.tpl create mode 100644 nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-req.json.tpl create mode 100644 nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-resp.json.tpl create mode 100644 nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-create-tag-req.json.tpl create mode 120000 nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-delete-req.json.tpl create mode 100644 nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-list-resp.json.tpl create mode 100644 nova/tests/functional/api_sample_tests/api_samples/os-server-shares/v2.97/server-shares-show-resp.json.tpl create mode 100644 nova/tests/functional/api_sample_tests/test_server_shares.py create mode 100644 nova/tests/unit/api/openstack/compute/test_server_shares.py diff --git a/api-ref/source/index.rst b/api-ref/source/index.rst index ba54982896..765c21a339 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 0000000000..0230e42281 --- /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 ec0d1dab12..95af05bd83 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 0000000000..ecd27e1668 --- /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 0000000000..dbacd2b3da --- /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 0000000000..ec8949bac7 --- /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 0000000000..b826f1333d --- /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 0000000000..f039f56618 --- /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 0000000000..b83978afaf --- /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 e1b7acede1..197144bc28 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 d36325e9b6..cdb5504f3d 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 18355ae95b..a90b1afad1 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 3f86c10544..cbf7cded6a 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 b4401b0b53..f23f63ddc3 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 033ad3c82a..374e218265 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 91f068daef..293e32fbde 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 0000000000..3a22c5476c --- /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 0000000000..8dc1282db5 --- /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 b7500d2829..b27bf6502b 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 a471786da1..0894af3d4b 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 3afbeaaac5..54a3bfa330 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 0000000000..4e4fb9f50d --- /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 bdb3ad3c83..dd693bf3f2 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 26292f6cb0..41c9492bff 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 d5c485cfa3..8895c1de00 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 0000000000..0b6249ee84 --- /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 a0b48aa4b9..1f126462ca 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 d7fca52d04..0a85e60652 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 0000000000..6db23430f1 --- /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 0000000000..795c3a555d --- /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 0000000000..e101a7817b --- /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 0000000000..e6261d88c3 --- /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 0000000000..23d8009a07 --- /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 0000000000..73b0584014 --- /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 0000000000..3c6ee5f3f4 --- /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 0000000000..8b05ba4444 --- /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 0000000000..322c1ee9a3 --- /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 0000000000..ab154d41b0 --- /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 be3c07dad8..1423a42635 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 26cbf709ea..56562b720f 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",