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

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. 

15 

16import ipaddress 

17import os 

18import re 

19import string 

20from urllib import parse 

21 

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 

28 

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 

36 

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] 

47 

48CONF = cfg.CONF 

49CONF.register_opts(api_common_opts) 

50LOG = log.getLogger(__name__) 

51 

52 

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) 

59 

60 

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 

67 

68 

69def get_pagination_params(request): 

70 """Return marker, limit, offset tuple from request. 

71 

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. 

79 

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 

89 

90 

91def _get_limit_param(request): 

92 """Extract integer limit from request or fail. 

93 

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 

108 

109 

110def _get_marker_param(request): 

111 """Extract marker ID from request or fail.""" 

112 return request.GET['marker'] 

113 

114 

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) 

122 

123 

124def _validate_integer(value, name, min_value=None, max_value=None): 

125 """Make sure that value is a valid integer, potentially within range. 

126 

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

138 

139 

140def _validate_pagination_query(request, max_limit=CONF.osapi_max_limit): 

141 """Validate the given request query and return limit and offset.""" 

142 

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) 

148 

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) 

154 

155 if limit < 0: 

156 msg = _('limit param must be positive') 

157 raise webob.exc.HTTPBadRequest(explanation=msg) 

158 

159 if offset < 0: 

160 msg = _('offset param must be positive') 

161 raise webob.exc.HTTPBadRequest(explanation=msg) 

162 

163 return limit, offset 

164 

165 

166def limited(items, request, max_limit=CONF.osapi_max_limit): 

167 """Return a slice of items according to requested offset and limit. 

168 

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) 

179 

180 limit = min(max_limit, limit or max_limit) 

181 range_end = offset + limit 

182 return items[offset:range_end] 

183 

184 

185def get_sort_params(params, default_key='created_at', default_dir='desc'): 

186 """Retrieves sort key/direction parameters. 

187 

188 Processes the parameters to get the 'sort_key' and 'sort_dir' parameter 

189 values. 

190 

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 

202 

203 

204def remove_version_from_href(href): 

205 """Removes the first api version from the href. 

206 

207 Given: 'http://manila.example.com/v1.1/123' 

208 Returns: 'http://manila.example.com/123' 

209 

210 Given: 'http://www.manila.com/v1.1' 

211 Returns: 'http://www.manila.com' 

212 

213 Given: 'http://manila.example.com/share/v1.1/123' 

214 Returns: 'http://manila.example.com/share/123' 

215 

216 """ 

217 parsed_url = parse.urlsplit(href) 

218 url_parts = parsed_url.path.split('/') 

219 

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 

226 

227 new_path = '/'.join(url_parts) 

228 

229 if new_path == parsed_url.path: 

230 msg = 'href %s does not contain version' % href 

231 LOG.debug(msg) 

232 raise ValueError(msg) 

233 

234 parsed_url = list(parsed_url) 

235 parsed_url[2] = new_path 

236 return parse.urlunsplit(parsed_url) 

237 

238 

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)]) + '&' 

245 

246 return param_str.rstrip('&') 

247 

248 

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) 

255 

256 

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) 

267 

268 

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

277 

278 

279def parse_is_public(is_public): 

280 """Parse is_public into something usable. 

281 

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) 

298 

299 

300class ViewBuilder(object): 

301 """Model API responses as dictionaries.""" 

302 

303 _collection_name = None 

304 _collection_route_name = None 

305 _detail_version_modifiers = [] 

306 

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

317 

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), }] 

323 

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) 

338 

339 return "%s?%s" % (url, dict_to_query_str(params)) 

340 

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

353 

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

367 

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 

383 

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) 

391 

392 def update_versioned_resource_dict(self, request, resource_dict, resource): 

393 """Updates the given resource dict for the given request version. 

394 

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) 

403 

404 @classmethod 

405 def versioned_method(cls, min_ver, max_ver=None, experimental=False): 

406 """Decorator for versioning API methods. 

407 

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

413 

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

420 

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) 

425 

426 return new_func 

427 

428 return decorator 

429 

430 

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] 

444 

445 

446def validate_common_name(access): 

447 """Validate common name passed by user. 

448 

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) 

458 

459 

460''' 

461for the reference specification for AD usernames, reference below links: 

462 

463 1:https://msdn.microsoft.com/en-us/library/bb726984.aspx 

464 2:https://technet.microsoft.com/en-us/library/cc733146.aspx 

465''' 

466 

467 

468def validate_username(access): 

469 sole_periods_spaces_re = r'[\s|\.]+$' 

470 valid_username_re = r'.[^\"\/\\\[\]\:\;\|\=\,\+\*\?\<\>]{3,254}$' 

471 username = access 

472 

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) 

477 

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) 

483 

484 

485def validate_cephx_id(cephx_id): 

486 if not cephx_id: 

487 raise webob.exc.HTTPBadRequest(explanation=_( 

488 'Ceph IDs may not be empty.')) 

489 

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.')) 

495 

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.')) 

503 

504 

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

514 

515 

516def validate_access(*args, **kwargs): 

517 

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

522 

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

538 

539 raise webob.exc.HTTPBadRequest(explanation=exc_str) 

540 

541 

542def validate_integer(value, name, min_value=None, max_value=None): 

543 """Make sure that value is a valid integer, potentially within range. 

544 

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

556 

557 

558def validate_public_share_policy(context, api_params, api='create'): 

559 """Validates if policy allows is_public parameter to be set to True. 

560 

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 

568 

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

579 

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) 

586 

587 return api_params 

588 

589 

590def _get_existing_subnets(context, share_network_id, az): 

591 """Return any existing subnets in the requested AZ. 

592 

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) 

598 

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 ) 

604 

605 

606def validate_subnet_create(context, share_network_id, data, 

607 multiple_subnet_support): 

608 

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) 

615 

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

627 

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) 

636 

637 return share_network, existing_subnets 

638 

639 

640def check_metadata_properties(metadata=None): 

641 if not metadata: 

642 metadata = {} 

643 

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)