Coverage for manila/api/common.py: 95%
315 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
1# Copyright 2010 OpenStack LLC.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
16import ipaddress
17import os
18import re
19import string
20from urllib import parse
22from operator import xor
23from oslo_config import cfg
24from oslo_log import log
25from oslo_utils import strutils
26import webob
27from webob import exc
29from manila.api.openstack import api_version_request as api_version
30from manila.api.openstack import versioned_method
31from manila.common import constants
32from manila.db import api as db_api
33from manila import exception
34from manila.i18n import _
35from manila import policy
37api_common_opts = [
38 cfg.IntOpt(
39 'osapi_max_limit',
40 default=1000,
41 help='The maximum number of items returned in a single response from '
42 'a collection resource.'),
43 cfg.StrOpt(
44 'osapi_share_base_URL',
45 help='Base URL to be presented to users in links to the Share API'),
46]
48CONF = cfg.CONF
49CONF.register_opts(api_common_opts)
50LOG = log.getLogger(__name__)
53# Regex that matches alphanumeric characters, periods, hypens,
54# colons and underscores:
55# ^ assert position at start of the string
56# [\w\.\-\:\_] match expression
57# $ assert position at end of the string
58VALID_KEY_NAME_REGEX = re.compile(r"^[\w\.\-\:\_]+$", re.UNICODE)
61def validate_key_names(key_names_list):
62 """Validate each item of the list to match key name regex."""
63 for key_name in key_names_list:
64 if not VALID_KEY_NAME_REGEX.match(key_name):
65 return False
66 return True
69def get_pagination_params(request):
70 """Return marker, limit, offset tuple from request.
72 :param request: `wsgi.Request` possibly containing 'marker' and 'limit'
73 GET variables. 'marker' is the id of the last element
74 the client has seen, and 'limit' is the maximum number
75 of items to return. If 'limit' is not specified, 0, or
76 > max_limit, we default to max_limit. Negative values
77 for either marker or limit will cause
78 exc.HTTPBadRequest() exceptions to be raised.
80 """
81 params = {}
82 if 'limit' in request.GET:
83 params['limit'] = _get_limit_param(request)
84 if 'marker' in request.GET:
85 params['marker'] = _get_marker_param(request)
86 if 'offset' in request.GET:
87 params['offset'] = _get_offset_param(request)
88 return params
91def _get_limit_param(request):
92 """Extract integer limit from request or fail.
94 Defaults to max_limit if not present and returns max_limit if present
95 'limit' is greater than max_limit.
96 """
97 max_limit = CONF.osapi_max_limit
98 try:
99 limit = int(request.GET['limit'])
100 except ValueError:
101 msg = _('limit param must be an integer')
102 raise webob.exc.HTTPBadRequest(explanation=msg)
103 if limit < 0:
104 msg = _('limit param must be positive')
105 raise webob.exc.HTTPBadRequest(explanation=msg)
106 limit = min(limit, max_limit)
107 return limit
110def _get_marker_param(request):
111 """Extract marker ID from request or fail."""
112 return request.GET['marker']
115def _get_offset_param(request):
116 """Extract offset id from request's dictionary (defaults to 0) or fail."""
117 offset = request.GET['offset']
118 return _validate_integer(offset,
119 'offset',
120 0,
121 constants.DB_MAX_INT)
124def _validate_integer(value, name, min_value=None, max_value=None):
125 """Make sure that value is a valid integer, potentially within range.
127 :param value: the value of the integer
128 :param name: the name of the integer
129 :param min_value: the min_length of the integer
130 :param max_value: the max_length of the integer
131 :return: integer
132 """
133 try:
134 value = strutils.validate_integer(value, name, min_value, max_value)
135 return value
136 except ValueError as e:
137 raise webob.exc.HTTPBadRequest(explanation=str(e))
140def _validate_pagination_query(request, max_limit=CONF.osapi_max_limit):
141 """Validate the given request query and return limit and offset."""
143 try:
144 offset = int(request.GET.get('offset', 0))
145 except ValueError:
146 msg = _('offset param must be an integer')
147 raise webob.exc.HTTPBadRequest(explanation=msg)
149 try:
150 limit = int(request.GET.get('limit', max_limit))
151 except ValueError:
152 msg = _('limit param must be an integer')
153 raise webob.exc.HTTPBadRequest(explanation=msg)
155 if limit < 0:
156 msg = _('limit param must be positive')
157 raise webob.exc.HTTPBadRequest(explanation=msg)
159 if offset < 0:
160 msg = _('offset param must be positive')
161 raise webob.exc.HTTPBadRequest(explanation=msg)
163 return limit, offset
166def limited(items, request, max_limit=CONF.osapi_max_limit):
167 """Return a slice of items according to requested offset and limit.
169 :param items: A sliceable entity
170 :param request: ``wsgi.Request`` possibly containing 'offset' and 'limit'
171 GET variables. 'offset' is where to start in the list,
172 and 'limit' is the maximum number of items to return. If
173 'limit' is not specified, 0, or > max_limit, we default
174 to max_limit. Negative values for either offset or limit
175 will cause exc.HTTPBadRequest() exceptions to be raised.
176 :kwarg max_limit: The maximum number of items to return from 'items'
177 """
178 limit, offset = _validate_pagination_query(request, max_limit)
180 limit = min(max_limit, limit or max_limit)
181 range_end = offset + limit
182 return items[offset:range_end]
185def get_sort_params(params, default_key='created_at', default_dir='desc'):
186 """Retrieves sort key/direction parameters.
188 Processes the parameters to get the 'sort_key' and 'sort_dir' parameter
189 values.
191 :param params: webob.multidict of request parameters (from
192 manila.api.openstack.wsgi.Request.params)
193 :param default_key: default sort key value, will return if no
194 sort key are supplied
195 :param default_dir: default sort dir value, will return if no
196 sort dir are supplied
197 :returns: value of sort key, value of sort dir
198 """
199 sort_key = params.pop('sort_key', default_key)
200 sort_dir = params.pop('sort_dir', default_dir)
201 return sort_key, sort_dir
204def remove_version_from_href(href):
205 """Removes the first api version from the href.
207 Given: 'http://manila.example.com/v1.1/123'
208 Returns: 'http://manila.example.com/123'
210 Given: 'http://www.manila.com/v1.1'
211 Returns: 'http://www.manila.com'
213 Given: 'http://manila.example.com/share/v1.1/123'
214 Returns: 'http://manila.example.com/share/123'
216 """
217 parsed_url = parse.urlsplit(href)
218 url_parts = parsed_url.path.split('/')
220 # NOTE: this should match vX.X or vX
221 expression = re.compile(r'^v([0-9]+|[0-9]+\.[0-9]+)(/.*|$)')
222 for x in range(len(url_parts)):
223 if expression.match(url_parts[x]):
224 del url_parts[x]
225 break
227 new_path = '/'.join(url_parts)
229 if new_path == parsed_url.path:
230 msg = 'href %s does not contain version' % href
231 LOG.debug(msg)
232 raise ValueError(msg)
234 parsed_url = list(parsed_url)
235 parsed_url[2] = new_path
236 return parse.urlunsplit(parsed_url)
239def dict_to_query_str(params):
240 # TODO(throughnothing): we should just use urllib.urlencode instead of this
241 # But currently we don't work with urlencoded url's
242 param_str = ""
243 for key, val in params.items():
244 param_str = param_str + '='.join([str(key), str(val)]) + '&'
246 return param_str.rstrip('&')
249def check_net_id_and_subnet_id(body):
250 if xor('neutron_net_id' in body, 'neutron_subnet_id' in body):
251 msg = _("When creating a new share network subnet you need to "
252 "specify both neutron_net_id and neutron_subnet_id or "
253 "none of them.")
254 raise webob.exc.HTTPBadRequest(explanation=msg)
257def check_share_network_is_active(share_network):
258 network_status = share_network.get('status')
259 if network_status != constants.STATUS_NETWORK_ACTIVE:
260 msg = _("The share network %(id)s used isn't in an 'active' state. "
261 "Current status is %(status)s. The action may be retried "
262 "after the share network has changed its state.") % {
263 'id': share_network['id'],
264 'status': share_network.get('status'),
265 }
266 raise webob.exc.HTTPBadRequest(explanation=msg)
269def check_display_field_length(field, field_name):
270 if field: 270 ↛ exitline 270 didn't return from function 'check_display_field_length' because the condition on line 270 was always true
271 length = len(field)
272 if length > constants.DB_DISPLAY_FIELDS_MAX_LENGTH:
273 raise exception.InvalidInput(
274 reason=("%(field_name)s can only be %(len)d characters long."
275 % {'field_name': field_name,
276 'len': constants.DB_DISPLAY_FIELDS_MAX_LENGTH}))
279def parse_is_public(is_public):
280 """Parse is_public into something usable.
282 :returns:
283 - True: API should list public share group types only
284 - False: API should list private share group types only
285 - None: API should list both public and private share group types
286 """
287 if is_public is None:
288 # preserve default value of showing only public types
289 return True
290 elif str(is_public).lower() == "all":
291 return None
292 else:
293 try:
294 return strutils.bool_from_string(is_public, strict=True)
295 except ValueError:
296 msg = _('Invalid is_public filter [%s]') % is_public
297 raise webob.exc.HTTPBadRequest(explanation=msg)
300class ViewBuilder(object):
301 """Model API responses as dictionaries."""
303 _collection_name = None
304 _collection_route_name = None
305 _detail_version_modifiers = []
307 def _get_project_id(self, request):
308 project_id = request.environ["manila.context"].project_id
309 if '/v1/' in request.url:
310 # project_ids are mandatory in v1 URLs
311 return project_id
312 elif project_id and ("/v2/%s" % project_id in request.url):
313 # project_ids are not mandatory within v2 URLs, but links need
314 # to include them if the request does.
315 return project_id
316 return ''
318 def _get_links(self, request, identifier):
319 return [{"rel": "self",
320 "href": self._get_href_link(request, identifier), },
321 {"rel": "bookmark",
322 "href": self._get_bookmark_link(request, identifier), }]
324 def _get_next_link(self, request, identifier):
325 """Return href string with proper limit and marker params."""
326 params = request.params.copy()
327 params["marker"] = identifier
328 url = ""
329 collection_route_name = (
330 self._collection_route_name
331 or self._collection_name
332 )
333 prefix = self._update_link_prefix(request.application_url,
334 CONF.osapi_share_base_URL)
335 url = os.path.join(prefix,
336 self._get_project_id(request),
337 collection_route_name)
339 return "%s?%s" % (url, dict_to_query_str(params))
341 def _get_href_link(self, request, identifier):
342 """Return an href string pointing to this object."""
343 collection_route_name = (
344 self._collection_route_name
345 or self._collection_name
346 )
347 prefix = self._update_link_prefix(request.application_url,
348 CONF.osapi_share_base_URL)
349 return os.path.join(prefix,
350 self._get_project_id(request),
351 collection_route_name,
352 str(identifier))
354 def _get_bookmark_link(self, request, identifier):
355 """Create a URL that refers to a specific resource."""
356 base_url = remove_version_from_href(request.application_url)
357 base_url = self._update_link_prefix(base_url,
358 CONF.osapi_share_base_URL)
359 collection_route_name = (
360 self._collection_route_name
361 or self._collection_name
362 )
363 return os.path.join(base_url,
364 self._get_project_id(request),
365 collection_route_name,
366 str(identifier))
368 def _get_collection_links(self, request, items, id_key="uuid"):
369 """Retrieve 'next' link, if applicable."""
370 links = []
371 limit = int(request.params.get("limit", 0))
372 if limit and limit == len(items):
373 last_item = items[-1]
374 if id_key in last_item: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true
375 last_item_id = last_item[id_key]
376 else:
377 last_item_id = last_item["id"]
378 links.append({
379 "rel": "next",
380 "href": self._get_next_link(request, last_item_id),
381 })
382 return links
384 def _update_link_prefix(self, orig_url, prefix):
385 if not prefix: 385 ↛ 387line 385 didn't jump to line 387 because the condition on line 385 was always true
386 return orig_url
387 url_parts = list(parse.urlsplit(orig_url))
388 prefix_parts = list(parse.urlsplit(prefix))
389 url_parts[0:2] = prefix_parts[0:2]
390 return parse.urlunsplit(url_parts)
392 def update_versioned_resource_dict(self, request, resource_dict, resource):
393 """Updates the given resource dict for the given request version.
395 This method calls every method, that is applicable to the request
396 version, in _detail_version_modifiers.
397 """
398 for method_name in self._detail_version_modifiers:
399 method = getattr(self, method_name)
400 if request.api_version_request.matches_versioned_method(method):
401 request_context = request.environ['manila.context']
402 method.func(self, request_context, resource_dict, resource)
404 @classmethod
405 def versioned_method(cls, min_ver, max_ver=None, experimental=False):
406 """Decorator for versioning API methods.
408 :param min_ver: string representing minimum version
409 :param max_ver: optional string representing maximum version
410 :param experimental: flag indicating an API is experimental and is
411 subject to change or removal at any time
412 """
414 def decorator(f):
415 obj_min_ver = api_version.APIVersionRequest(min_ver)
416 if max_ver:
417 obj_max_ver = api_version.APIVersionRequest(max_ver)
418 else:
419 obj_max_ver = api_version.APIVersionRequest()
421 # Add to list of versioned methods registered
422 func_name = f.__name__
423 new_func = versioned_method.VersionedMethod(
424 func_name, obj_min_ver, obj_max_ver, experimental, f)
426 return new_func
428 return decorator
431def remove_invalid_options(context, search_options, allowed_search_options):
432 """Remove search options that are not valid for non-admin API/context."""
433 if context.is_admin:
434 # Allow all options
435 return
436 # Otherwise, strip out all unknown options
437 unknown_options = [opt for opt in search_options
438 if opt not in allowed_search_options]
439 bad_options = ", ".join(unknown_options)
440 LOG.debug("Removing options '%(bad_options)s' from query",
441 {"bad_options": bad_options})
442 for opt in unknown_options:
443 del search_options[opt]
446def validate_common_name(access):
447 """Validate common name passed by user.
449 'access' is used as the certificate's CN (common name)
450 to which access is allowed or denied by the backend.
451 The standard allows for just about any string in the
452 common name. The meaning of a string depends on its
453 interpretation and is limited to 64 characters.
454 """
455 if not (0 < len(access) < 65):
456 exc_str = _('Invalid CN (common name). Must be 1-64 chars long.')
457 raise webob.exc.HTTPBadRequest(explanation=exc_str)
460'''
461for the reference specification for AD usernames, reference below links:
463 1:https://msdn.microsoft.com/en-us/library/bb726984.aspx
464 2:https://technet.microsoft.com/en-us/library/cc733146.aspx
465'''
468def validate_username(access):
469 sole_periods_spaces_re = r'[\s|\.]+$'
470 valid_username_re = r'.[^\"\/\\\[\]\:\;\|\=\,\+\*\?\<\>]{3,254}$'
471 username = access
473 if re.match(sole_periods_spaces_re, username): 473 ↛ 474line 473 didn't jump to line 474 because the condition on line 473 was never true
474 exc_str = ('Invalid user or group name,cannot consist solely '
475 'of periods or spaces.')
476 raise webob.exc.HTTPBadRequest(explanation=exc_str)
478 if not re.match(valid_username_re, username):
479 exc_str = ('Invalid user or group name. Must be 4-255 characters '
480 'and consist of alphanumeric characters and '
481 'exclude special characters "/\\[]:;|=,+*?<>')
482 raise webob.exc.HTTPBadRequest(explanation=exc_str)
485def validate_cephx_id(cephx_id):
486 if not cephx_id:
487 raise webob.exc.HTTPBadRequest(explanation=_(
488 'Ceph IDs may not be empty.'))
490 # This restriction may be lifted in Ceph in the future:
491 # http://tracker.ceph.com/issues/14626
492 if not set(cephx_id) <= set(string.printable):
493 raise webob.exc.HTTPBadRequest(explanation=_(
494 'Ceph IDs must consist of ASCII printable characters.'))
496 # Periods are technically permitted, but we restrict them here
497 # to avoid confusion where users are unsure whether they should
498 # include the "client." prefix: otherwise they could accidentally
499 # create "client.client.foobar".
500 if '.' in cephx_id:
501 raise webob.exc.HTTPBadRequest(explanation=_(
502 'Ceph IDs may not contain periods.'))
505def validate_ip(access_to, enable_ipv6):
506 try:
507 if enable_ipv6:
508 validator = ipaddress.ip_network
509 else:
510 validator = ipaddress.IPv4Network
511 validator(str(access_to))
512 except ValueError as e:
513 raise webob.exc.HTTPBadRequest(explanation=str(e))
516def validate_access(*args, **kwargs):
518 access_type = kwargs.get('access_type')
519 access_to = kwargs.get('access_to')
520 enable_ceph = kwargs.get('enable_ceph')
521 enable_ipv6 = kwargs.get('enable_ipv6')
523 if access_type == 'ip':
524 validate_ip(access_to, enable_ipv6)
525 elif access_type == 'user':
526 validate_username(access_to)
527 elif access_type == 'cert':
528 validate_common_name(access_to.strip())
529 elif access_type == "cephx" and enable_ceph:
530 validate_cephx_id(access_to)
531 else:
532 if enable_ceph:
533 exc_str = _("Only 'ip', 'user', 'cert' or 'cephx' access "
534 "types are supported.")
535 else:
536 exc_str = _("Only 'ip', 'user' or 'cert' access types "
537 "are supported.")
539 raise webob.exc.HTTPBadRequest(explanation=exc_str)
542def validate_integer(value, name, min_value=None, max_value=None):
543 """Make sure that value is a valid integer, potentially within range.
545 :param value: the value of the integer
546 :param name: the name of the integer
547 :param min_value: the lowest integer permitted in the range
548 :param max_value: the highest integer permitted in the range
549 :returns: integer
550 """
551 try:
552 value = strutils.validate_integer(value, name, min_value, max_value)
553 return value
554 except ValueError as e:
555 raise webob.exc.HTTPBadRequest(explanation=str(e))
558def validate_public_share_policy(context, api_params, api='create'):
559 """Validates if policy allows is_public parameter to be set to True.
561 :arg api_params - A dictionary of values that may contain 'is_public'
562 :returns api_params with 'is_public' item sanitized if present
563 :raises exception.InvalidParameterValue if is_public is set but is Invalid
564 exception.NotAuthorized if is_public is True but policy prevents it
565 """
566 if 'is_public' not in api_params:
567 return api_params
569 policies = {
570 'create': 'create_public_share',
571 'update': 'set_public_share',
572 }
573 policy_to_check = policies[api]
574 try:
575 api_params['is_public'] = strutils.bool_from_string(
576 api_params['is_public'], strict=True)
577 except ValueError as e:
578 raise exception.InvalidParameterValue(str(e))
580 public_shares_allowed = policy.check_policy(
581 context, 'share', policy_to_check, do_raise=False)
582 if api_params['is_public'] and not public_shares_allowed:
583 message = _("User is not authorized to set 'is_public' to True in the "
584 "request.")
585 raise exception.NotAuthorized(message=message)
587 return api_params
590def _get_existing_subnets(context, share_network_id, az):
591 """Return any existing subnets in the requested AZ.
593 If az is None, the method will search for an existent default subnet.
594 """
595 if az is None:
596 return db_api.share_network_subnet_get_default_subnets(
597 context, share_network_id)
599 return (
600 db_api.share_network_subnets_get_all_by_availability_zone_id(
601 context, share_network_id, az,
602 fallback_to_default=False)
603 )
606def validate_subnet_create(context, share_network_id, data,
607 multiple_subnet_support):
609 check_net_id_and_subnet_id(data)
610 try:
611 share_network = db_api.share_network_get(
612 context, share_network_id)
613 except exception.ShareNetworkNotFound as e:
614 raise exc.HTTPNotFound(explanation=e.msg)
616 availability_zone = data.pop('availability_zone', None)
617 subnet_az = {}
618 if availability_zone: 618 ↛ 626line 618 didn't jump to line 626 because the condition on line 618 was always true
619 try:
620 subnet_az = db_api.availability_zone_get(context,
621 availability_zone)
622 except exception.AvailabilityZoneNotFound:
623 msg = _("The provided availability zone %s does not "
624 "exist.") % availability_zone
625 raise exc.HTTPBadRequest(explanation=msg)
626 data['availability_zone_id'] = subnet_az.get('id')
628 existing_subnets = _get_existing_subnets(
629 context, share_network_id, data['availability_zone_id'])
630 if existing_subnets and not multiple_subnet_support:
631 msg = ("Another share network subnet was found in the "
632 "specified availability zone. Only one share network "
633 "subnet is allowed per availability zone for share "
634 "network %s." % share_network_id)
635 raise exc.HTTPConflict(explanation=msg)
637 return share_network, existing_subnets
640def check_metadata_properties(metadata=None):
641 if not metadata:
642 metadata = {}
644 for k, v in metadata.items():
645 if not k:
646 msg = _("Metadata property key is blank.")
647 LOG.warning(msg)
648 raise exception.InvalidMetadata(message=msg)
649 if len(k) > 255:
650 msg = _("Metadata property key is "
651 "greater than 255 characters.")
652 LOG.warning(msg)
653 raise exception.InvalidMetadataSize(message=msg)
654 if not v:
655 msg = _("Metadata property value is blank.")
656 LOG.warning(msg)
657 raise exception.InvalidMetadata(message=msg)
658 if len(v) > 1023:
659 msg = _("Metadata property value is "
660 "greater than 1023 characters.")
661 LOG.warning(msg)
662 raise exception.InvalidMetadataSize(message=msg)