manila/manila/api/v1/shares.py

556 lines
21 KiB
Python

# Copyright 2013 NetApp
# All Rights Reserved.
#
# 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.
"""The shares api."""
import ast
import re
import string
from oslo_log import log
from oslo_utils import strutils
from oslo_utils import uuidutils
import six
import webob
from webob import exc
from manila.api import common
from manila.api.openstack import wsgi
from manila.api.views import shares as share_views
from manila import db
from manila import exception
from manila.i18n import _
from manila.i18n import _LI
from manila import share
from manila.share import share_types
LOG = log.getLogger(__name__)
class ShareMixin(object):
"""Mixin class for Share API Controllers."""
def _update(self, *args, **kwargs):
db.share_update(*args, **kwargs)
def _get(self, *args, **kwargs):
return self.share_api.get(*args, **kwargs)
def _delete(self, *args, **kwargs):
return self.share_api.delete(*args, **kwargs)
def _migrate(self, *args, **kwargs):
return self.share_api.migrate_share(*args, **kwargs)
def show(self, req, id):
"""Return data about the given share."""
context = req.environ['manila.context']
try:
share = self.share_api.get(context, id)
except exception.NotFound:
raise exc.HTTPNotFound()
return self._view_builder.detail(req, share)
def delete(self, req, id):
"""Delete a share."""
context = req.environ['manila.context']
LOG.info(_LI("Delete share with id: %s"), id, context=context)
try:
share = self.share_api.get(context, id)
# NOTE(ameade): If the share is in a consistency group, we require
# it's id be specified as a param.
if share.get('consistency_group_id'):
consistency_group_id = req.params.get('consistency_group_id')
if (share.get('consistency_group_id') and
not consistency_group_id):
msg = _("Must provide 'consistency_group_id' as a request "
"parameter when deleting a share in a consistency "
"group.")
raise exc.HTTPBadRequest(explanation=msg)
elif consistency_group_id != share.get('consistency_group_id'):
msg = _("The specified 'consistency_group_id' does not "
"match the consistency group id of the share.")
raise exc.HTTPBadRequest(explanation=msg)
self.share_api.delete(context, share)
except exception.NotFound:
raise exc.HTTPNotFound()
except exception.InvalidShare as e:
raise exc.HTTPForbidden(explanation=six.text_type(e))
except exception.Conflict as e:
raise exc.HTTPConflict(explanation=six.text_type(e))
return webob.Response(status_int=202)
def _migrate_share(self, req, id, body):
"""Migrate a share to the specified host."""
context = req.environ['manila.context']
try:
share = self.share_api.get(context, id)
except exception.NotFound:
msg = _("Share %s not found.") % id
raise exc.HTTPNotFound(explanation=msg)
params = body.get('migrate_share', body.get('os-migrate_share'))
try:
host = params['host']
except KeyError:
raise exc.HTTPBadRequest(explanation=_("Must specify 'host'"))
force_host_copy = params.get('force_host_copy', False)
try:
force_host_copy = strutils.bool_from_string(force_host_copy,
strict=True)
except ValueError:
raise exc.HTTPBadRequest(
explanation=_("Bad value for 'force_host_copy'"))
try:
self.share_api.migrate_share(context, share, host, force_host_copy)
except exception.Conflict as e:
raise exc.HTTPConflict(explanation=six.text_type(e))
return webob.Response(status_int=202)
def index(self, req):
"""Returns a summary list of shares."""
return self._get_shares(req, is_detail=False)
def detail(self, req):
"""Returns a detailed list of shares."""
return self._get_shares(req, is_detail=True)
def _get_shares(self, req, is_detail):
"""Returns a list of shares, transformed through view builder."""
context = req.environ['manila.context']
search_opts = {}
search_opts.update(req.GET)
# Remove keys that are not related to share attrs
search_opts.pop('limit', None)
search_opts.pop('offset', None)
sort_key = search_opts.pop('sort_key', 'created_at')
sort_dir = search_opts.pop('sort_dir', 'desc')
# Deserialize dicts
if 'metadata' in search_opts:
search_opts['metadata'] = ast.literal_eval(search_opts['metadata'])
if 'extra_specs' in search_opts:
search_opts['extra_specs'] = ast.literal_eval(
search_opts['extra_specs'])
# NOTE(vponomaryov): Manila stores in DB key 'display_name', but
# allows to use both keys 'name' and 'display_name'. It is leftover
# from Cinder v1 and v2 APIs.
if 'name' in search_opts:
search_opts['display_name'] = search_opts.pop('name')
if sort_key == 'name':
sort_key = 'display_name'
common.remove_invalid_options(
context, search_opts, self._get_share_search_options())
shares = self.share_api.get_all(
context, search_opts=search_opts, sort_key=sort_key,
sort_dir=sort_dir)
limited_list = common.limited(shares, req)
if is_detail:
shares = self._view_builder.detail_list(req, limited_list)
else:
shares = self._view_builder.summary_list(req, limited_list)
return shares
def _get_share_search_options(self):
"""Return share search options allowed by non-admin."""
# NOTE(vponomaryov): share_server_id depends on policy, allow search
# by it for non-admins in case policy changed.
# Also allow search by extra_specs in case policy
# for it allows non-admin access.
return (
'display_name', 'status', 'share_server_id', 'volume_type_id',
'share_type_id', 'snapshot_id', 'host', 'share_network_id',
'is_public', 'metadata', 'extra_specs', 'sort_key', 'sort_dir',
'consistency_group_id', 'cgsnapshot_id'
)
def update(self, req, id, body):
"""Update a share."""
context = req.environ['manila.context']
if not body or 'share' not in body:
raise exc.HTTPUnprocessableEntity()
share_data = body['share']
valid_update_keys = (
'display_name',
'display_description',
'is_public',
)
update_dict = {key: share_data[key]
for key in valid_update_keys
if key in share_data}
try:
share = self.share_api.get(context, id)
except exception.NotFound:
raise exc.HTTPNotFound()
share = self.share_api.update(context, share, update_dict)
share.update(update_dict)
return self._view_builder.detail(req, share)
def create(self, req, body):
# Remove consistency group attributes
body.get('share', {}).pop('consistency_group_id', None)
share = self._create(req, body)
return share
def _create(self, req, body):
"""Creates a new share."""
context = req.environ['manila.context']
if not self.is_valid_body(body, 'share'):
raise exc.HTTPUnprocessableEntity()
share = body['share']
# NOTE(rushiagr): Manila API allows 'name' instead of 'display_name'.
if share.get('name'):
share['display_name'] = share.get('name')
del share['name']
# NOTE(rushiagr): Manila API allows 'description' instead of
# 'display_description'.
if share.get('description'):
share['display_description'] = share.get('description')
del share['description']
size = share['size']
share_proto = share['share_proto'].upper()
msg = (_LI("Create %(share_proto)s share of %(size)s GB") %
{'share_proto': share_proto, 'size': size})
LOG.info(msg, context=context)
availability_zone = share.get('availability_zone')
if availability_zone:
try:
db.availability_zone_get(context, availability_zone)
except exception.AvailabilityZoneNotFound as e:
raise exc.HTTPNotFound(explanation=six.text_type(e))
kwargs = {
'availability_zone': availability_zone,
'metadata': share.get('metadata'),
'is_public': share.get('is_public', False),
'consistency_group_id': share.get('consistency_group_id')
}
snapshot_id = share.get('snapshot_id')
if snapshot_id:
snapshot = self.share_api.get_snapshot(context, snapshot_id)
else:
snapshot = None
kwargs['snapshot'] = snapshot
share_network_id = share.get('share_network_id')
if snapshot:
# Need to check that share_network_id from snapshot's
# parents share equals to share_network_id from args.
# If share_network_id is empty than update it with
# share_network_id of parent share.
parent_share = self.share_api.get(context, snapshot['share_id'])
parent_share_net_id = parent_share.instance['share_network_id']
if share_network_id:
if share_network_id != parent_share_net_id:
msg = "Share network ID should be the same as snapshot's" \
" parent share's or empty"
raise exc.HTTPBadRequest(explanation=msg)
elif parent_share_net_id:
share_network_id = parent_share_net_id
if share_network_id:
try:
self.share_api.get_share_network(
context,
share_network_id)
except exception.ShareNetworkNotFound as e:
raise exc.HTTPNotFound(explanation=six.text_type(e))
kwargs['share_network_id'] = share_network_id
display_name = share.get('display_name')
display_description = share.get('display_description')
if 'share_type' in share and 'volume_type' in share:
msg = 'Cannot specify both share_type and volume_type'
raise exc.HTTPBadRequest(explanation=msg)
req_share_type = share.get('share_type', share.get('volume_type'))
if req_share_type:
try:
if not uuidutils.is_uuid_like(req_share_type):
kwargs['share_type'] = \
share_types.get_share_type_by_name(
context, req_share_type)
else:
kwargs['share_type'] = share_types.get_share_type(
context, req_share_type)
except exception.ShareTypeNotFound:
msg = _("Share type not found.")
raise exc.HTTPNotFound(explanation=msg)
elif not snapshot:
def_share_type = share_types.get_default_share_type()
if def_share_type:
kwargs['share_type'] = def_share_type
new_share = self.share_api.create(context,
share_proto,
size,
display_name,
display_description,
**kwargs)
return self._view_builder.detail(req, new_share)
@staticmethod
def _validate_common_name(access):
"""Validate common name passed by user.
'access' is used as the certificate's CN (common name)
to which access is allowed or denied by the backend.
The standard allows for just about any string in the
common name. The meaning of a string depends on its
interpretation and is limited to 64 characters.
"""
if len(access) == 0 or len(access) > 64:
exc_str = _('Invalid CN (common name). Must be 1-64 chars long')
raise webob.exc.HTTPBadRequest(explanation=exc_str)
@staticmethod
def _validate_username(access):
valid_username_re = '[\w\.\-_\`;\'\{\}\[\]\\\\]{4,32}$'
username = access
if not re.match(valid_username_re, username):
exc_str = ('Invalid user or group name. Must be 4-32 characters '
'and consist of alphanumeric characters and '
'special characters ]{.-_\'`;}[\\')
raise webob.exc.HTTPBadRequest(explanation=exc_str)
@staticmethod
def _validate_ip_range(ip_range):
ip_range = ip_range.split('/')
exc_str = ('Supported ip format examples:\n'
'\t10.0.0.2, 10.0.0.0/24')
if len(ip_range) > 2:
raise webob.exc.HTTPBadRequest(explanation=exc_str)
if len(ip_range) == 2:
try:
prefix = int(ip_range[1])
if prefix < 0 or prefix > 32:
raise ValueError()
except ValueError:
msg = 'IP prefix should be in range from 0 to 32'
raise webob.exc.HTTPBadRequest(explanation=msg)
ip_range = ip_range[0].split('.')
if len(ip_range) != 4:
raise webob.exc.HTTPBadRequest(explanation=exc_str)
for item in ip_range:
try:
if 0 <= int(item) <= 255:
continue
raise ValueError()
except ValueError:
raise webob.exc.HTTPBadRequest(explanation=exc_str)
@staticmethod
def _validate_cephx_id(cephx_id):
if not cephx_id:
raise webob.exc.HTTPBadRequest(explanation=_(
'Ceph IDs may not be empty'))
# This restriction may be lifted in Ceph in the future:
# http://tracker.ceph.com/issues/14626
if not set(cephx_id) <= set(string.printable):
raise webob.exc.HTTPBadRequest(explanation=_(
'Ceph IDs must consist of ASCII printable characters'))
# Periods are technically permitted, but we restrict them here
# to avoid confusion where users are unsure whether they should
# include the "client." prefix: otherwise they could accidentally
# create "client.client.foobar".
if '.' in cephx_id:
raise webob.exc.HTTPBadRequest(explanation=_(
'Ceph IDs may not contain periods'))
def _allow_access(self, req, id, body, enable_ceph=False):
"""Add share access rule."""
context = req.environ['manila.context']
access_data = body.get('allow_access', body.get('os-allow_access'))
share = self.share_api.get(context, id)
access_type = access_data['access_type']
access_to = access_data['access_to']
if access_type == 'ip':
self._validate_ip_range(access_to)
elif access_type == 'user':
self._validate_username(access_to)
elif access_type == 'cert':
self._validate_common_name(access_to.strip())
elif access_type == "cephx" and enable_ceph:
self._validate_cephx_id(access_to)
else:
if enable_ceph:
exc_str = _("Only 'ip', 'user', 'cert' or 'cephx' access "
"types are supported.")
else:
exc_str = _("Only 'ip', 'user' or 'cert' access types "
"are supported.")
raise webob.exc.HTTPBadRequest(explanation=exc_str)
try:
access = self.share_api.allow_access(
context, share, access_type, access_to,
access_data.get('access_level'))
except exception.ShareAccessExists as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
return {'access': access}
def _deny_access(self, req, id, body):
"""Remove share access rule."""
context = req.environ['manila.context']
access_id = body.get(
'deny_access', body.get('os-deny_access'))['access_id']
try:
access = self.share_api.access_get(context, access_id)
if access.share_id != id:
raise exception.NotFound()
share = self.share_api.get(context, id)
except exception.NotFound as error:
raise webob.exc.HTTPNotFound(explanation=six.text_type(error))
self.share_api.deny_access(context, share, access)
return webob.Response(status_int=202)
def _access_list(self, req, id, body):
"""list share access rules."""
context = req.environ['manila.context']
share = self.share_api.get(context, id)
access_list = self.share_api.access_get_all(context, share)
return {'access_list': access_list}
def _extend(self, req, id, body):
"""Extend size of a share."""
context = req.environ['manila.context']
share, size = self._get_valid_resize_parameters(
context, id, body, 'os-extend')
try:
self.share_api.extend(context, share, size)
except (exception.InvalidInput, exception.InvalidShare) as e:
raise webob.exc.HTTPBadRequest(explanation=six.text_type(e))
except exception.ShareSizeExceedsAvailableQuota as e:
raise webob.exc.HTTPForbidden(explanation=six.text_type(e))
return webob.Response(status_int=202)
def _shrink(self, req, id, body):
"""Shrink size of a share."""
context = req.environ['manila.context']
share, size = self._get_valid_resize_parameters(
context, id, body, 'os-shrink')
try:
self.share_api.shrink(context, share, size)
except (exception.InvalidInput, exception.InvalidShare) as e:
raise webob.exc.HTTPBadRequest(explanation=six.text_type(e))
return webob.Response(status_int=202)
def _get_valid_resize_parameters(self, context, id, body, action):
try:
share = self.share_api.get(context, id)
except exception.NotFound as e:
raise webob.exc.HTTPNotFound(explanation=six.text_type(e))
try:
size = int(body.get(action,
body.get(action.split('os-')[-1]))['new_size'])
except (KeyError, ValueError, TypeError):
msg = _("New share size must be specified as an integer.")
raise webob.exc.HTTPBadRequest(explanation=msg)
return share, size
class ShareController(wsgi.Controller, ShareMixin, wsgi.AdminActionsMixin):
"""The Shares API v1 controller for the OpenStack API."""
resource_name = 'share'
_view_builder_class = share_views.ViewBuilder
def __init__(self):
super(self.__class__, self).__init__()
self.share_api = share.API()
@wsgi.action('os-reset_status')
def share_reset_status(self, req, id, body):
"""Reset status of a share."""
return self._reset_status(req, id, body)
@wsgi.action('os-force_delete')
def share_force_delete(self, req, id, body):
"""Delete a share, bypassing the check for status."""
return self._force_delete(req, id, body)
@wsgi.action('os-allow_access')
def allow_access(self, req, id, body):
"""Add share access rule."""
return self._allow_access(req, id, body)
@wsgi.action('os-deny_access')
def deny_access(self, req, id, body):
"""Remove share access rule."""
return self._deny_access(req, id, body)
@wsgi.action('os-access_list')
def access_list(self, req, id, body):
"""List share access rules."""
return self._access_list(req, id, body)
@wsgi.action('os-extend')
def extend(self, req, id, body):
"""Extend size of a share."""
return self._extend(req, id, body)
@wsgi.action('os-shrink')
def shrink(self, req, id, body):
"""Shrink size of a share."""
return self._shrink(req, id, body)
def create_resource():
return wsgi.Resource(ShareController())