Merge "Attach Manila shares via virtiofs (API)"

This commit is contained in:
Zuul
2024-12-06 18:38:52 +00:00
committed by Gerrit Code Review
40 changed files with 1823 additions and 12 deletions
+1
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-instance-actions.inc
.. include:: os-interface.inc .. include:: os-interface.inc
.. include:: os-server-password.inc .. include:: os-server-password.inc
.. include:: os-server-shares.inc
.. include:: os-volume-attachments.inc .. include:: os-volume-attachments.inc
.. include:: flavors.inc .. include:: flavors.inc
.. include:: os-flavor-access.inc .. include:: os-flavor-access.inc
+163
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.
+58 -2
View File
@@ -311,6 +311,12 @@ service_id_path_2_53_no_version:
in: path in: path
required: true required: true
type: string type: string
share_id_path:
description: |
The UUID of the attached share.
in: path
required: true
type: string
snapshot_id_path: snapshot_id_path:
description: | description: |
The UUID of the snapshot. The UUID of the snapshot.
@@ -3742,13 +3748,13 @@ hosts.availability_zone_none:
type: none type: none
hours: hours:
description: | description: |
The duration that the server exists (in hours). The duration that the server exists (in hours).
in: body in: body
required: true required: true
type: float type: float
hours_optional: hours_optional:
description: | description: |
The duration that the server exists (in hours). The duration that the server exists (in hours).
in: body in: body
required: false required: false
type: float type: float
@@ -6809,6 +6815,56 @@ set_metadata:
in: body in: body
required: true required: true
type: object 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: shelve:
description: | description: |
The action. The action.
@@ -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"
}
}
@@ -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"
}
}
@@ -0,0 +1,6 @@
{
"share": {
"share_id": "3cdf5132-64f2-4584-876a-bd296ae7eabd",
"tag": "my-share"
}
}
@@ -0,0 +1,7 @@
{
"share": {
"share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986",
"status": "attaching",
"tag": "e8debdc0-447a-4376-a10a-4cd9122d7986"
}
}
@@ -0,0 +1,9 @@
{
"shares": [
{
"share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986",
"status": "inactive",
"tag": "e8debdc0-447a-4376-a10a-4cd9122d7986"
}
]
}
@@ -0,0 +1,7 @@
{
"share": {
"share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986",
"status": "inactive",
"tag": "e8debdc0-447a-4376-a10a-4cd9122d7986"
}
}
@@ -19,7 +19,7 @@
} }
], ],
"status": "CURRENT", "status": "CURRENT",
"version": "2.96", "version": "2.97",
"min_version": "2.1", "min_version": "2.1",
"updated": "2013-07-23T11:33:21Z" "updated": "2013-07-23T11:33:21Z"
} }
@@ -22,7 +22,7 @@
} }
], ],
"status": "CURRENT", "status": "CURRENT",
"version": "2.96", "version": "2.97",
"min_version": "2.1", "min_version": "2.1",
"updated": "2013-07-23T11:33:21Z" "updated": "2013-07-23T11:33:21Z"
} }
+9 -1
View File
@@ -257,6 +257,14 @@ REST_API_VERSION_HISTORY = """REST API Version History:
* 2.95 - Evacuate will now stop instance at destination. * 2.95 - Evacuate will now stop instance at destination.
* 2.96 - Add support for returning pinned_availability_zone in * 2.96 - Add support for returning pinned_availability_zone in
``server show`` and ``server list --long`` responses. ``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 # 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 # Note(cyeoh): This only applies for the v2.1 API once microversions
# support is fully merged. It does not affect the V2 API. # support is fully merged. It does not affect the V2 API.
_MIN_API_VERSION = '2.1' _MIN_API_VERSION = '2.1'
_MAX_API_VERSION = '2.96' _MAX_API_VERSION = '2.97'
DEFAULT_API_VERSION = _MIN_API_VERSION DEFAULT_API_VERSION = _MIN_API_VERSION
# Almost all proxy APIs which are related to network, images and baremetal # Almost all proxy APIs which are related to network, images and baremetal
+4
View File
@@ -161,6 +161,10 @@ class EvacuateController(wsgi.Controller):
raise exc.HTTPBadRequest(explanation=e.format_message()) raise exc.HTTPBadRequest(explanation=e.format_message())
except exception.UnsupportedRPCVersion as e: except exception.UnsupportedRPCVersion as e:
raise exc.HTTPConflict(explanation=e.format_message()) 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 if (not api_version_request.is_supported(req, min_version='2.14') and
CONF.api.enable_instance_password): CONF.api.enable_instance_password):
@@ -81,6 +81,11 @@ class MigrateServerController(wsgi.Controller):
exception.ExtendedResourceRequestOldCompute, exception.ExtendedResourceRequestOldCompute,
) as e: ) as e:
raise exc.HTTPBadRequest(explanation=e.format_message()) 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.response(202)
@wsgi.expected_errors((400, 403, 404, 409)) @wsgi.expected_errors((400, 403, 404, 409))
@@ -156,6 +161,11 @@ class MigrateServerController(wsgi.Controller):
except exception.InstanceInvalidState as state_error: except exception.InstanceInvalidState as state_error:
common.raise_http_conflict_for_instance_invalid_state(state_error, common.raise_http_conflict_for_instance_invalid_state(state_error,
'os-migrateLive', id) '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): def _get_force_param_for_live_migration(self, body, host):
force = body["os-migrateLive"].get("force", False) force = body["os-migrateLive"].get("force", False)
@@ -1255,3 +1255,16 @@ behavior.
The ``server show`` and ``server list --long`` responses now include the The ``server show`` and ``server list --long`` responses now include the
pinned availability zone as well. 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
+11
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_metadata
from nova.api.openstack.compute import server_migrations from nova.api.openstack.compute import server_migrations
from nova.api.openstack.compute import server_password 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_tags
from nova.api.openstack.compute import server_topology from nova.api.openstack.compute import server_topology
from nova.api.openstack.compute import servers 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, server_security_groups_controller = functools.partial(_create_controller,
security_groups.ServerSecurityGroupController, []) security_groups.ServerSecurityGroupController, [])
server_shares_controller = functools.partial(_create_controller,
server_shares.ServerSharesController, [])
server_tags_controller = functools.partial(_create_controller, server_tags_controller = functools.partial(_create_controller,
server_tags.ServerTagsController, []) server_tags.ServerTagsController, [])
@@ -825,6 +828,14 @@ ROUTE_LIST = (
('/servers/{server_id}/os-security-groups', { ('/servers/{server_id}/os-security-groups', {
'GET': [server_security_groups_controller, 'index'] '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', { ('/servers/{server_id}/tags', {
'GET': [server_tags_controller, 'index'], 'GET': [server_tags_controller, 'index'],
'PUT': [server_tags_controller, 'update_all'], 'PUT': [server_tags_controller, 'update_all'],
@@ -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
}
+262
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())
+4
View File
@@ -1099,6 +1099,10 @@ class ServersController(wsgi.Controller):
except exception.Invalid: except exception.Invalid:
msg = _("Invalid instance image.") msg = _("Invalid instance image.")
raise exc.HTTPBadRequest(explanation=msg) raise exc.HTTPBadRequest(explanation=msg)
except (
exception.ForbiddenSharesNotSupported,
exception.ForbiddenWithShare) as e:
raise exc.HTTPConflict(explanation=e.format_message())
@wsgi.response(204) @wsgi.response(204)
@wsgi.expected_errors((404, 409)) @wsgi.expected_errors((404, 409))
+4
View File
@@ -59,6 +59,10 @@ class ShelveController(wsgi.Controller):
'shelve', id) 'shelve', id)
except exception.ForbiddenPortsWithAccelerator as e: except exception.ForbiddenPortsWithAccelerator as e:
raise exc.HTTPBadRequest(explanation=e.format_message()) 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.response(202)
@wsgi.expected_errors((400, 404, 409)) @wsgi.expected_errors((400, 404, 409))
@@ -49,6 +49,10 @@ class SuspendServerController(wsgi.Controller):
'suspend', id) 'suspend', id)
except exception.ForbiddenPortsWithAccelerator as e: except exception.ForbiddenPortsWithAccelerator as e:
raise exc.HTTPBadRequest(explanation=e.format_message()) 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.response(202)
@wsgi.expected_errors((404, 409)) @wsgi.expected_errors((404, 409))
@@ -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
+17
View File
@@ -357,6 +357,23 @@ image_id = {
'type': 'string', 'format': 'uuid' '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 = { image_id_or_empty_string = {
'oneOf': [ 'oneOf': [
+11 -1
View File
@@ -721,14 +721,24 @@ class ShareNotFound(NotFound):
msg_fmt = _("Share %(share_id)s could not be found.") 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): 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): class ShareProtocolNotSupported(NotFound):
msg_fmt = _("Share protocol %(share_proto)s is not supported.") 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): class ShareError(NovaException):
msg_fmt = _("Share %(share_id)s used by instance %(instance_uuid)s " msg_fmt = _("Share %(share_id)s used by instance %(instance_uuid)s "
"is in error state.") "is in error state.")
+2
View File
@@ -56,6 +56,7 @@ from nova.policies import server_external_events
from nova.policies import server_groups from nova.policies import server_groups
from nova.policies import server_metadata from nova.policies import server_metadata
from nova.policies import server_password from nova.policies import server_password
from nova.policies import server_shares
from nova.policies import server_tags from nova.policies import server_tags
from nova.policies import server_topology from nova.policies import server_topology
from nova.policies import servers from nova.policies import servers
@@ -114,6 +115,7 @@ def list_rules():
server_groups.list_rules(), server_groups.list_rules(),
server_metadata.list_rules(), server_metadata.list_rules(),
server_password.list_rules(), server_password.list_rules(),
server_shares.list_rules(),
server_tags.list_rules(), server_tags.list_rules(),
server_topology.list_rules(), server_topology.list_rules(),
servers.list_rules(), servers.list_rules(),
+70
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
+9 -6
View File
@@ -203,12 +203,15 @@ class API(object):
def filter_export_locations(export_locations): def filter_export_locations(export_locations):
# Return the preferred path otherwise choose the first one # Return the preferred path otherwise choose the first one
paths = [] paths = []
for export_location in export_locations: try:
if export_location.is_preferred: for export_location in export_locations:
return export_location.path if export_location.is_preferred:
else: return export_location.path
paths.append(export_location.path) else:
return paths[0] paths.append(export_location.path)
return paths[0]
except (IndexError, NameError):
return None
client = _manilaclient(context, admin=False) client = _manilaclient(context, admin=False)
LOG.debug("Get share id:'%s' data from manila", share_id) LOG.debug("Get share id:'%s' data from manila", share_id)
+23
View File
@@ -80,6 +80,29 @@ class ManilaFixture(fixtures.Fixture):
manila_share, export_location 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): def fake_get_cephfs(self, context, share_id):
manila_share = ManilaShare(share_id, "CEPHFS") manila_share = ManilaShare(share_id, "CEPHFS")
export_location = "10.0.0.50:/mnt/foo" export_location = "10.0.0.50:/mnt/foo"
@@ -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"
}
}
@@ -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"
}
}
@@ -0,0 +1,5 @@
{
"share": {
"share_id": "%(share_id)s"
}
}
@@ -0,0 +1,8 @@
{
"share":
{
"share_id": "%(share_id)s",
"status": "attaching",
"tag": "%(share_id)s"
}
}
@@ -0,0 +1,6 @@
{
"share": {
"share_id": "%(share_id)s",
"tag": "%(tag)s"
}
}
@@ -0,0 +1 @@
server-shares-create-req.json.tpl
@@ -0,0 +1,9 @@
{
"shares": [
{
"share_id": "e8debdc0-447a-4376-a10a-4cd9122d7986",
"status": "inactive",
"tag": "e8debdc0-447a-4376-a10a-4cd9122d7986"
}
]
}
@@ -0,0 +1,8 @@
{
"share":
{
"share_id": "%(share_id)s",
"status": "inactive",
"tag": "%(share_id)s"
}
}
@@ -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"
}
}
)
@@ -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'])
+4
View File
@@ -159,6 +159,10 @@ policy_data = """
"os_compute_api:os-server-password:show": "", "os_compute_api:os-server-password:show": "",
"os_compute_api:os-server-password:clear": "", "os_compute_api:os-server-password:clear": "",
"os_compute_api:os-server-external-events:create": "", "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:index": "",
"os_compute_api:os-server-tags:show": "", "os_compute_api:os-server-tags:show": "",
"os_compute_api:os-server-tags:update": "", "os_compute_api:os-server-tags:update": "",
+4
View File
@@ -488,6 +488,10 @@ class RealRolePolicyTestCase(test.NoDBTestCase):
"os_compute_api:os-instance-actions:list", "os_compute_api:os-instance-actions:list",
"os_compute_api:os-instance-actions:show", "os_compute_api:os-instance-actions:show",
"os_compute_api:os-server-password: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:index",
"os_compute_api:os-server-tags:show", "os_compute_api:os-server-tags:show",
"os_compute_api:os-floating-ips:list", "os_compute_api:os-floating-ips:list",