OpenStack resource provider inventory allocation service
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

479 lines
18 KiB

  1. # Licensed under the Apache License, Version 2.0 (the "License"); you may
  2. # not use this file except in compliance with the License. You may obtain
  3. # a copy of the License at
  4. #
  5. # http://www.apache.org/licenses/LICENSE-2.0
  6. #
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  9. # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  10. # License for the specific language governing permissions and limitations
  11. # under the License.
  12. """Utility methods for placement API."""
  13. import functools
  14. import jsonschema
  15. from oslo_log import log as logging
  16. from oslo_middleware import request_id
  17. from oslo_serialization import jsonutils
  18. from oslo_utils import timeutils
  19. from oslo_utils import uuidutils
  20. import webob
  21. from placement import errors
  22. # NOTE(cdent): avoid cyclical import conflict between util and
  23. # microversion
  24. import placement.microversion
  25. LOG = logging.getLogger(__name__)
  26. # Error code handling constants
  27. ENV_ERROR_CODE = 'placement.error_code'
  28. ERROR_CODE_MICROVERSION = (1, 23)
  29. # NOTE(cdent): This registers a FormatChecker on the jsonschema
  30. # module. Do not delete this code! Although it appears that nothing
  31. # is using the decorated method it is being used in JSON schema
  32. # validations to check uuid formatted strings.
  33. @jsonschema.FormatChecker.cls_checks('uuid')
  34. def _validate_uuid_format(instance):
  35. return uuidutils.is_uuid_like(instance)
  36. def check_accept(*types):
  37. """If accept is set explicitly, try to follow it.
  38. If there is no match for the incoming accept header
  39. send a 406 response code.
  40. If accept is not set send our usual content-type in
  41. response.
  42. """
  43. def decorator(f):
  44. @functools.wraps(f)
  45. def decorated_function(req):
  46. if req.accept:
  47. best_matches = req.accept.acceptable_offers(types)
  48. if not best_matches:
  49. type_string = ', '.join(types)
  50. raise webob.exc.HTTPNotAcceptable(
  51. 'Only %(type)s is provided' % {'type': type_string},
  52. json_formatter=json_error_formatter)
  53. return f(req)
  54. return decorated_function
  55. return decorator
  56. def extract_json(body, schema):
  57. """Extract JSON from a body and validate with the provided schema."""
  58. try:
  59. data = jsonutils.loads(body)
  60. except ValueError as exc:
  61. raise webob.exc.HTTPBadRequest(
  62. 'Malformed JSON: %(error)s' % {'error': exc},
  63. json_formatter=json_error_formatter)
  64. try:
  65. jsonschema.validate(data, schema,
  66. format_checker=jsonschema.FormatChecker())
  67. except jsonschema.ValidationError as exc:
  68. raise webob.exc.HTTPBadRequest(
  69. 'JSON does not validate: %(error)s' % {'error': exc},
  70. json_formatter=json_error_formatter)
  71. return data
  72. def inventory_url(environ, resource_provider, resource_class=None):
  73. url = '%s/inventories' % resource_provider_url(environ, resource_provider)
  74. if resource_class:
  75. url = '%s/%s' % (url, resource_class)
  76. return url
  77. def json_error_formatter(body, status, title, environ):
  78. """A json_formatter for webob exceptions.
  79. Follows API-WG guidelines at
  80. http://specs.openstack.org/openstack/api-wg/guidelines/errors.html
  81. """
  82. # Shortcut to microversion module, to avoid wraps below.
  83. microversion = placement.microversion
  84. # Clear out the html that webob sneaks in.
  85. body = webob.exc.strip_tags(body)
  86. # Get status code out of status message. webob's error formatter
  87. # only passes entire status string.
  88. status_code = int(status.split(None, 1)[0])
  89. error_dict = {
  90. 'status': status_code,
  91. 'title': title,
  92. 'detail': body
  93. }
  94. # Version may not be set if we have experienced an error before it
  95. # is set.
  96. want_version = environ.get(microversion.MICROVERSION_ENVIRON)
  97. if want_version and want_version.matches(ERROR_CODE_MICROVERSION):
  98. error_dict['code'] = environ.get(ENV_ERROR_CODE, errors.DEFAULT)
  99. # If the request id middleware has had a chance to add an id,
  100. # put it in the error response.
  101. if request_id.ENV_REQUEST_ID in environ:
  102. error_dict['request_id'] = environ[request_id.ENV_REQUEST_ID]
  103. # When there is a no microversion in the environment and a 406,
  104. # microversion parsing failed so we need to include microversion
  105. # min and max information in the error response.
  106. if status_code == 406 and microversion.MICROVERSION_ENVIRON not in environ:
  107. error_dict['max_version'] = microversion.max_version_string()
  108. error_dict['min_version'] = microversion.min_version_string()
  109. return {'errors': [error_dict]}
  110. def pick_last_modified(last_modified, obj):
  111. """Choose max of last_modified and obj.updated_at or obj.created_at.
  112. If updated_at is not implemented in `obj` use the current time in UTC.
  113. """
  114. current_modified = (obj.updated_at or obj.created_at)
  115. if current_modified is None:
  116. # The object was not loaded from the DB, it was created in
  117. # the current context.
  118. current_modified = timeutils.utcnow(with_timezone=True)
  119. if last_modified:
  120. last_modified = max(last_modified, current_modified)
  121. else:
  122. last_modified = current_modified
  123. return last_modified
  124. def require_content(content_type):
  125. """Decorator to require a content type in a handler."""
  126. def decorator(f):
  127. @functools.wraps(f)
  128. def decorated_function(req):
  129. if req.content_type != content_type:
  130. # webob's unset content_type is the empty string so
  131. # set it the error message content to 'None' to make
  132. # a useful message in that case. This also avoids a
  133. # KeyError raised when webob.exc eagerly fills in a
  134. # Template for output we will never use.
  135. if not req.content_type:
  136. req.content_type = 'None'
  137. raise webob.exc.HTTPUnsupportedMediaType(
  138. 'The media type %(bad_type)s is not supported, '
  139. 'use %(good_type)s' %
  140. {'bad_type': req.content_type,
  141. 'good_type': content_type},
  142. json_formatter=json_error_formatter)
  143. else:
  144. return f(req)
  145. return decorated_function
  146. return decorator
  147. def resource_class_url(environ, resource_class):
  148. """Produce the URL for a resource class.
  149. If SCRIPT_NAME is present, it is the mount point of the placement
  150. WSGI app.
  151. """
  152. prefix = environ.get('SCRIPT_NAME', '')
  153. return '%s/resource_classes/%s' % (prefix, resource_class.name)
  154. def resource_provider_url(environ, resource_provider):
  155. """Produce the URL for a resource provider.
  156. If SCRIPT_NAME is present, it is the mount point of the placement
  157. WSGI app.
  158. """
  159. prefix = environ.get('SCRIPT_NAME', '')
  160. return '%s/resource_providers/%s' % (prefix, resource_provider.uuid)
  161. def trait_url(environ, trait):
  162. """Produce the URL for a trait.
  163. If SCRIPT_NAME is present, it is the mount point of the placement
  164. WSGI app.
  165. """
  166. prefix = environ.get('SCRIPT_NAME', '')
  167. return '%s/traits/%s' % (prefix, trait.name)
  168. def validate_query_params(req, schema):
  169. try:
  170. # NOTE(Kevin_Zheng): The webob package throws UnicodeError when
  171. # param cannot be decoded. Catch this and raise HTTP 400.
  172. jsonschema.validate(dict(req.GET), schema,
  173. format_checker=jsonschema.FormatChecker())
  174. except (jsonschema.ValidationError, UnicodeDecodeError) as exc:
  175. raise webob.exc.HTTPBadRequest(
  176. 'Invalid query string parameters: %(exc)s' %
  177. {'exc': exc})
  178. def wsgi_path_item(environ, name):
  179. """Extract the value of a named field in a URL.
  180. Return None if the name is not present or there are no path items.
  181. """
  182. # NOTE(cdent): For the time being we don't need to urldecode
  183. # the value as the entire placement API has paths that accept no
  184. # encoded values.
  185. try:
  186. return environ['wsgiorg.routing_args'][1][name]
  187. except (KeyError, IndexError):
  188. return None
  189. def normalize_resources_qs_param(qs):
  190. """Given a query string parameter for resources, validate it meets the
  191. expected format and return a dict of amounts, keyed by resource class name.
  192. The expected format of the resources parameter looks like so:
  193. $RESOURCE_CLASS_NAME:$AMOUNT,$RESOURCE_CLASS_NAME:$AMOUNT
  194. So, if the user was looking for resource providers that had room for an
  195. instance that will consume 2 vCPUs, 1024 MB of RAM and 50GB of disk space,
  196. they would use the following query string:
  197. ?resources=VCPU:2,MEMORY_MB:1024,DISK_GB:50
  198. The returned value would be:
  199. {
  200. "VCPU": 2,
  201. "MEMORY_MB": 1024,
  202. "DISK_GB": 50,
  203. }
  204. :param qs: The value of the 'resources' query string parameter
  205. :raises `webob.exc.HTTPBadRequest` if the parameter's value isn't in the
  206. expected format.
  207. """
  208. if qs.strip() == "":
  209. msg = ('Badly formed resources parameter. Expected resources '
  210. 'query string parameter in form: '
  211. '?resources=VCPU:2,MEMORY_MB:1024. Got: empty string.')
  212. raise webob.exc.HTTPBadRequest(msg)
  213. result = {}
  214. resource_tuples = qs.split(',')
  215. for rt in resource_tuples:
  216. try:
  217. rc_name, amount = rt.split(':')
  218. except ValueError:
  219. msg = ('Badly formed resources parameter. Expected resources '
  220. 'query string parameter in form: '
  221. '?resources=VCPU:2,MEMORY_MB:1024. Got: %s.')
  222. msg = msg % rt
  223. raise webob.exc.HTTPBadRequest(msg)
  224. try:
  225. amount = int(amount)
  226. except ValueError:
  227. msg = ('Requested resource %(resource_name)s expected positive '
  228. 'integer amount. Got: %(amount)s.')
  229. msg = msg % {
  230. 'resource_name': rc_name,
  231. 'amount': amount,
  232. }
  233. raise webob.exc.HTTPBadRequest(msg)
  234. if amount < 1:
  235. msg = ('Requested resource %(resource_name)s requires '
  236. 'amount >= 1. Got: %(amount)d.')
  237. msg = msg % {
  238. 'resource_name': rc_name,
  239. 'amount': amount,
  240. }
  241. raise webob.exc.HTTPBadRequest(msg)
  242. result[rc_name] = amount
  243. return result
  244. def valid_trait(trait, allow_forbidden):
  245. """Return True if the provided trait is the expected form.
  246. When allow_forbidden is True, then a leading '!' is acceptable.
  247. """
  248. if trait.startswith('!') and not allow_forbidden:
  249. return False
  250. return True
  251. def normalize_traits_qs_param(val, allow_forbidden=False):
  252. """Parse a traits query string parameter value.
  253. Note that this method doesn't know or care about the query parameter key,
  254. which may currently be of the form `required`, `required123`, etc., but
  255. which may someday also include `preferred`, etc.
  256. This method currently does no format validation of trait strings, other
  257. than to ensure they're not zero-length.
  258. :param val: A traits query parameter value: a comma-separated string of
  259. trait names.
  260. :param allow_forbidden: If True, accept forbidden traits (that is, traits
  261. prefixed by '!') as a valid form when notifying
  262. the caller that the provided value is not properly
  263. formed.
  264. :return: A set of trait names.
  265. :raises `webob.exc.HTTPBadRequest` if the val parameter is not in the
  266. expected format.
  267. """
  268. ret = set(substr.strip() for substr in val.split(','))
  269. expected_form = 'HW_CPU_X86_VMX,CUSTOM_MAGIC'
  270. if allow_forbidden:
  271. expected_form = 'HW_CPU_X86_VMX,!CUSTOM_MAGIC'
  272. if not all(trait and valid_trait(trait, allow_forbidden) for trait in ret):
  273. msg = ("Invalid query string parameters: Expected 'required' "
  274. "parameter value of the form: %(form)s. "
  275. "Got: %(val)s") % {'form': expected_form, 'val': val}
  276. raise webob.exc.HTTPBadRequest(msg)
  277. return ret
  278. def normalize_member_of_qs_params(req, suffix=''):
  279. """Given a webob.Request object, validate that the member_of querystring
  280. parameters are correct. We begin supporting multiple member_of params in
  281. microversion 1.24 and forbidden aggregates in microversion 1.32.
  282. :param req: webob.Request object
  283. :return: A tuple of
  284. required_aggs: A list containing sets of UUIDs of required
  285. aggregates to filter on
  286. forbidden_aggs: A set of UUIDs of forbidden aggregates to filter on
  287. :raises `webob.exc.HTTPBadRequest` if the microversion requested is <1.24
  288. and the request contains multiple member_of querystring params
  289. :raises `webob.exc.HTTPBadRequest` if the microversion requested is <1.32
  290. and the request contains forbidden format of member_of querystring
  291. params with '!' prefix
  292. :raises `webob.exc.HTTPBadRequest` if the val parameter is not in the
  293. expected format.
  294. """
  295. want_version = req.environ[placement.microversion.MICROVERSION_ENVIRON]
  296. multi_member_of = want_version.matches((1, 24))
  297. allow_forbidden = want_version.matches((1, 32))
  298. if not multi_member_of and len(req.GET.getall('member_of' + suffix)) > 1:
  299. raise webob.exc.HTTPBadRequest(
  300. 'Multiple member_of%s parameters are not supported' % suffix)
  301. required_aggs = []
  302. forbidden_aggs = set()
  303. for value in req.GET.getall('member_of' + suffix):
  304. required, forbidden = normalize_member_of_qs_param(value)
  305. if required:
  306. required_aggs.append(required)
  307. if forbidden:
  308. if not allow_forbidden:
  309. raise webob.exc.HTTPBadRequest(
  310. 'Forbidden member_of%s parameters are not supported '
  311. 'in the specified microversion' % suffix)
  312. forbidden_aggs |= forbidden
  313. return required_aggs, forbidden_aggs
  314. def normalize_member_of_qs_param(value):
  315. """Parse a member_of query string parameter value.
  316. Valid values are one of either
  317. - a single UUID
  318. - the prefix '!' followed by a single UUID
  319. - the prefix 'in:' or '!in:' followed by two or more
  320. comma-separated UUIDs.
  321. :param value: A member_of query parameter
  322. :return: A tuple of:
  323. required: A set of aggregate UUIDs at least one of which is required
  324. forbidden: A set of aggregate UUIDs all of which are forbidden
  325. :raises `webob.exc.HTTPBadRequest` if the value parameter is not in the
  326. expected format.
  327. """
  328. if "," in value and not (
  329. value.startswith("in:") or value.startswith("!in:")):
  330. msg = ("Multiple values for 'member_of' must be prefixed with the "
  331. "'in:' or '!in:' keyword using the valid microversion. "
  332. "Got: %s") % value
  333. raise webob.exc.HTTPBadRequest(msg)
  334. required = forbidden = set()
  335. if value.startswith('!in:'):
  336. forbidden = set(value[4:].split(','))
  337. elif value.startswith('!'):
  338. forbidden = set([value[1:]])
  339. elif value.startswith('in:'):
  340. required = set(value[3:].split(','))
  341. else:
  342. required = set([value])
  343. # Make sure the values are actually UUIDs.
  344. for aggr_uuid in (required | forbidden):
  345. if not uuidutils.is_uuid_like(aggr_uuid):
  346. msg = ("Invalid query string parameters: Expected 'member_of' "
  347. "parameter to contain valid UUID(s). Got: %s") % aggr_uuid
  348. raise webob.exc.HTTPBadRequest(msg)
  349. return required, forbidden
  350. def normalize_in_tree_qs_params(value):
  351. """Parse a in_tree query string parameter value.
  352. :param value: in_tree query parameter: A UUID of a resource provider.
  353. :return: A UUID of a resource provider.
  354. :raises `webob.exc.HTTPBadRequest` if the val parameter is not in the
  355. expected format.
  356. """
  357. ret = value.strip()
  358. if not uuidutils.is_uuid_like(ret):
  359. msg = ("Invalid query string parameters: Expected 'in_tree' "
  360. "parameter to be a format of uuid. "
  361. "Got: %(val)s") % {'val': value}
  362. raise webob.exc.HTTPBadRequest(msg)
  363. return ret
  364. def run_once(message, logger, cleanup=None):
  365. """This is a utility function decorator to ensure a function
  366. is run once and only once in an interpreter instance.
  367. The decorated function object can be reset by calling its
  368. reset function. All exceptions raised by the wrapped function,
  369. logger and cleanup function will be propagated to the caller.
  370. """
  371. def outer_wrapper(func):
  372. @functools.wraps(func)
  373. def wrapper(*args, **kwargs):
  374. if not wrapper.called:
  375. # Note(sean-k-mooney): the called state is always
  376. # updated even if the wrapped function completes
  377. # by raising an exception. If the caller catches
  378. # the exception it is their responsibility to call
  379. # reset if they want to re-execute the wrapped function.
  380. try:
  381. return func(*args, **kwargs)
  382. finally:
  383. wrapper.called = True
  384. else:
  385. logger(message)
  386. wrapper.called = False
  387. def reset(wrapper, *args, **kwargs):
  388. # Note(sean-k-mooney): we conditionally call the
  389. # cleanup function if one is provided only when the
  390. # wrapped function has been called previously. We catch
  391. # and reraise any exception that may be raised and update
  392. # the called state in a finally block to ensure its
  393. # always updated if reset is called.
  394. try:
  395. if cleanup and wrapper.called:
  396. return cleanup(*args, **kwargs)
  397. finally:
  398. wrapper.called = False
  399. wrapper.reset = functools.partial(reset, wrapper)
  400. return wrapper
  401. return outer_wrapper