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
This commit is contained in:
René Ribaud 2022-03-22 15:15:21 +01:00
parent d6f5a30caa
commit 5e508a09b3
40 changed files with 1823 additions and 12 deletions

View File

@ -31,6 +31,7 @@ the `API guide <https://docs.openstack.org/api-guide/compute/index.html>`_.
.. 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

View File

@ -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.

View File

@ -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.

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
{
"share": {
"share_id": "3cdf5132-64f2-4584-876a-bd296ae7eabd",
"tag": "my-share"
}
}

View File

@ -0,0 +1,7 @@
{
"share": {
"share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986",
"status": "attaching",
"tag": "e8debdc0-447a-4376-a10a-4cd9122d7986"
}
}

View File

@ -0,0 +1,9 @@
{
"shares": [
{
"share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986",
"status": "inactive",
"tag": "e8debdc0-447a-4376-a10a-4cd9122d7986"
}
]
}

View File

@ -0,0 +1,7 @@
{
"share": {
"share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986",
"status": "inactive",
"tag": "e8debdc0-447a-4376-a10a-4cd9122d7986"
}
}

View File

@ -19,7 +19,7 @@
}
],
"status": "CURRENT",
"version": "2.96",
"version": "2.97",
"min_version": "2.1",
"updated": "2013-07-23T11:33:21Z"
}

View File

@ -22,7 +22,7 @@
}
],
"status": "CURRENT",
"version": "2.96",
"version": "2.97",
"min_version": "2.1",
"updated": "2013-07-23T11:33:21Z"
}

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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'],

View File

@ -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
}

View File

@ -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())

View File

@ -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))

View File

@ -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))

View File

@ -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))

View File

@ -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

View File

@ -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': [

View File

@ -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.")

View File

@ -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(),

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,5 @@
{
"share": {
"share_id": "%(share_id)s"
}
}

View File

@ -0,0 +1,8 @@
{
"share":
{
"share_id": "%(share_id)s",
"status": "attaching",
"tag": "%(share_id)s"
}
}

View File

@ -0,0 +1,6 @@
{
"share": {
"share_id": "%(share_id)s",
"tag": "%(tag)s"
}
}

View File

@ -0,0 +1 @@
server-shares-create-req.json.tpl

View File

@ -0,0 +1,9 @@
{
"shares": [
{
"share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986",
"status": "inactive",
"tag": "e8debdc0-447a-4376-a10a-4cd9122d7986"
}
]
}

View File

@ -0,0 +1,8 @@
{
"share":
{
"share_id": "%(share_id)s",
"status": "inactive",
"tag": "%(share_id)s"
}
}

View File

@ -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"
}
}
)

View File

@ -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'])

View File

@ -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": "",

View File

@ -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",