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
+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.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
+4
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):
@@ -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)
@@ -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
+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_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'],
@@ -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:
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))
+4
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))
@@ -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))
@@ -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