From 2ec1a8715f23acbdb38b04e39eabc7a80e19fa2b Mon Sep 17 00:00:00 2001 From: Sundar Nadathur Date: Fri, 12 Jul 2019 00:29:48 -0700 Subject: [PATCH] P5: Basic changes for API layer. Introduce changes to enable v2 APIs, needed for Nova integ. . Create v2/ dir under cyborg/api/controllers/. . Refactor common files and code in cyborg/api/controllers to share between v1 and v2. . Enable service version discovery and microversions by following [1]. [1] https://docs.openstack.org/api-guide/compute/microversions.html Change-Id: Ic73cd84cc1199ec150795a1a389eae73eda3555e --- cyborg/api/config.py | 2 +- cyborg/api/controllers/base.py | 3 + cyborg/api/controllers/link.py | 14 +- cyborg/api/controllers/root.py | 11 +- cyborg/api/controllers/{v1 => }/types.py | 12 +- cyborg/api/controllers/{v1 => }/utils.py | 0 cyborg/api/controllers/v1/accelerators.py | 2 +- cyborg/api/controllers/v1/deployables.py | 2 +- cyborg/api/controllers/v2/__init__.py | 72 +++++++ .../api/controllers/v2/api_version_request.py | 181 ++++++++++++++++++ cyborg/common/exception.py | 5 + .../tests/unit/api/controllers/v2/test_api.py | 30 +++ 12 files changed, 317 insertions(+), 17 deletions(-) rename cyborg/api/controllers/{v1 => }/types.py (93%) rename cyborg/api/controllers/{v1 => }/utils.py (100%) create mode 100644 cyborg/api/controllers/v2/__init__.py create mode 100644 cyborg/api/controllers/v2/api_version_request.py create mode 100644 cyborg/tests/unit/api/controllers/v2/test_api.py diff --git a/cyborg/api/config.py b/cyborg/api/config.py index 32a0d276..4de3106c 100644 --- a/cyborg/api/config.py +++ b/cyborg/api/config.py @@ -29,7 +29,7 @@ app = { 'debug': False, 'acl_public_routes': [ '/', - '/v1' + '/v2' ] } diff --git a/cyborg/api/controllers/base.py b/cyborg/api/controllers/base.py index d9367e4f..5c80301c 100644 --- a/cyborg/api/controllers/base.py +++ b/cyborg/api/controllers/base.py @@ -21,6 +21,9 @@ from pecan import rest import wsme from wsme import types as wtypes +API_V1 = 'v1' +API_V2 = 'v2' + class APIBase(wtypes.Base): created_at = wsme.wsattr(datetime.datetime, readonly=True) diff --git a/cyborg/api/controllers/link.py b/cyborg/api/controllers/link.py index fe39c69f..35c02bd1 100644 --- a/cyborg/api/controllers/link.py +++ b/cyborg/api/controllers/link.py @@ -23,8 +23,12 @@ def build_url(resource, resource_args, bookmark=False, base_url=None): if base_url is None: base_url = pecan.request.public_url - template = '%(url)s/%(res)s' if bookmark else '%(url)s/v1/%(res)s' - template += '%(args)s' if resource_args.startswith('?') else '/%(args)s' + # TODO(Sundar) Return version etc. similar to other projects. + template = '%(url)s/accelerator/%(res)s' \ + if bookmark else '%(url)s/accelerator/' + base.API_V2 + '/%(res)s' + if resource_args: + template += ('%(args)s' if resource_args.startswith('?') + else '/%(args)s') return template % {'url': base_url, 'res': resource, 'args': resource_args} @@ -46,3 +50,9 @@ class Link(base.APIBase): href = build_url(resource, resource_args, bookmark=bookmark, base_url=url) return Link(href=href, rel=rel_name, type=type) + + @staticmethod + def make_link_dict(resource, resource_args, rel='self'): + href = build_url(resource, resource_args) + link = {"href": href, "rel": rel} + return link diff --git a/cyborg/api/controllers/root.py b/cyborg/api/controllers/root.py index 3361bfef..967472bc 100644 --- a/cyborg/api/controllers/root.py +++ b/cyborg/api/controllers/root.py @@ -18,13 +18,10 @@ from pecan import rest from wsme import types as wtypes from cyborg.api.controllers import base -from cyborg.api.controllers import v1 +from cyborg.api.controllers import v2 from cyborg.api import expose -VERSION1 = 'v1' - - class Root(base.APIBase): name = wtypes.text """The name of the API""" @@ -47,13 +44,13 @@ class Root(base.APIBase): class RootController(rest.RestController): - _versions = [VERSION1] + _versions = [base.API_V1, base.API_V2] """All supported API versions""" - _default_version = VERSION1 + _default_version = base.API_V2 """The default API version""" - v1 = v1.Controller() + v2 = v2.Controller() @expose.expose(Root) def get(self): diff --git a/cyborg/api/controllers/v1/types.py b/cyborg/api/controllers/types.py similarity index 93% rename from cyborg/api/controllers/v1/types.py rename to cyborg/api/controllers/types.py index a5d5f5d9..3e861236 100644 --- a/cyborg/api/controllers/v1/types.py +++ b/cyborg/api/controllers/types.py @@ -30,9 +30,11 @@ class FilterType(wtypes.UserType): name = 'filtertype' basetype = wtypes.text - _supported_fields = wtypes.Enum(wtypes.text, 'parent_id', 'root_id', - 'name', 'num_accelerators', 'device_id', - 'limit', 'marker', + # TODO(Sundar): Ensure v1 and v2 APIs coexist. + _supported_fields = wtypes.Enum(wtypes.text, 'parent_uuid', 'root_uuid', + 'vendor', 'host', 'board', 'availability', + 'assignable', 'interface_type', + 'instance_uuid', 'limit', 'marker', 'sort_key', 'sort_dir') field = wsme.wsattr(_supported_fields, mandatory=True) @@ -45,8 +47,8 @@ class FilterType(wtypes.UserType): @classmethod def sample(cls): - return cls(field='name', - value='FPGA') + return cls(field='interface_type', + value='pci') def as_dict(self): d = dict() diff --git a/cyborg/api/controllers/v1/utils.py b/cyborg/api/controllers/utils.py similarity index 100% rename from cyborg/api/controllers/v1/utils.py rename to cyborg/api/controllers/utils.py diff --git a/cyborg/api/controllers/v1/accelerators.py b/cyborg/api/controllers/v1/accelerators.py index 88ab3003..672a922b 100644 --- a/cyborg/api/controllers/v1/accelerators.py +++ b/cyborg/api/controllers/v1/accelerators.py @@ -20,8 +20,8 @@ from wsme import types as wtypes from cyborg.api.controllers import base from cyborg.api.controllers import link +from cyborg.api.controllers import types from cyborg.api.controllers.v1 import deployables -from cyborg.api.controllers.v1 import types from cyborg.api import expose from cyborg.common import policy diff --git a/cyborg/api/controllers/v1/deployables.py b/cyborg/api/controllers/v1/deployables.py index bb8f8c8d..8efcbbb2 100644 --- a/cyborg/api/controllers/v1/deployables.py +++ b/cyborg/api/controllers/v1/deployables.py @@ -23,7 +23,7 @@ from oslo_serialization import jsonutils from cyborg.agent.rpcapi import AgentAPI from cyborg.api.controllers import base from cyborg.api.controllers import link -from cyborg.api.controllers.v1 import types +from cyborg.api.controllers import types from cyborg.api import expose from cyborg.common import policy from cyborg import objects diff --git a/cyborg/api/controllers/v2/__init__.py b/cyborg/api/controllers/v2/__init__.py new file mode 100644 index 00000000..ccd5fbf1 --- /dev/null +++ b/cyborg/api/controllers/v2/__init__.py @@ -0,0 +1,72 @@ +# Copyright 2019 Intel, Inc. +# 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. + +"""Version 2 of the Cyborg API""" + +import pecan +from pecan import rest +from wsme import types as wtypes + +from cyborg.api.controllers import base +from cyborg.api.controllers import link +from cyborg.api.controllers.v2 import api_version_request +from cyborg.api import expose + + +class V2(base.APIBase): + """The representation of the version 2 of the API.""" + + id = wtypes.text + """The ID of the version""" + + links = [link.Link] + """Links to the accelerator resource""" + + max_version = wtypes.text + """Highest microversion supported""" + + min_version = wtypes.text + """Lowest microversion supported""" + + status = wtypes.text + """Status""" + + @staticmethod + def convert(): + v2 = V2() + v2.id = 'v2.0' + v2.max_version = api_version_request.max_api_version().get_string() + v2.min_version = api_version_request.min_api_version().get_string() + v2.status = 'CURRENT' + v2.links = [ + link.Link.make_link('self', pecan.request.public_url, + '', ''), + ] + return v2 + + +class Controller(rest.RestController): + """Version 2 API controller root""" + + # Enabled in later patches. + # device_profiles = device_profiles.DeviceProfilesController() + # accelerator_requests = arqs.ARQsController() + + @expose.expose(V2) + def get(self): + return V2.convert() + + +__all__ = ('Controller',) diff --git a/cyborg/api/controllers/v2/api_version_request.py b/cyborg/api/controllers/v2/api_version_request.py new file mode 100644 index 00000000..14598f0f --- /dev/null +++ b/cyborg/api/controllers/v2/api_version_request.py @@ -0,0 +1,181 @@ +# Copyright 2019 Intel, Inc. +# +# 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 re + +from cyborg.common import exception + +# Define the minimum and maximum version of the API across all of the +# REST API. The format of the version is: +# X.Y where: +# +# - X will only be changed if a significant backwards incompatible API +# change is made which affects the API as whole. That is, something +# that is only very very rarely incremented. +# +# - Y when you make any change to the API. Note that this includes +# semantic changes which may not affect the input or output formats or +# even originate in the API code layer. We are not distinguishing +# between backwards compatible and backwards incompatible changes in +# the versioning system. It must be made clear in the documentation as +# to what is a backwards compatible change and what is a backwards +# incompatible one. + +# +# You must update the API version history string below with a one or +# two line description as well as update rest_api_version_history.rst +REST_API_VERSION_HISTORY = """REST API Version History: + + * 2.0 - Initial version. +""" + +# The minimum and maximum versions of the API supported +# The default api version request is defined to be the +# minimum version of the API supported. +# 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.0" +_MAX_API_VERSION = "2.0" +DEFAULT_API_VERSION = _MIN_API_VERSION + + +# NOTE(Sundar): min and max versions declared as functions so we can +# mock them for unittests. Do not use the constants directly anywhere +# else. +def min_api_version(): + return APIVersionRequest(_MIN_API_VERSION) + + +def max_api_version(): + return APIVersionRequest(_MAX_API_VERSION) + + +def is_supported(req, min_version=_MIN_API_VERSION, + max_version=_MAX_API_VERSION): + """Check if API request version satisfies version restrictions. + + :param req: request object + :param min_version: minimal version of API needed for correct + request processing + :param max_version: maximum version of API needed for correct + request processing + + :returns: True if request satisfies minimal and maximum API version + requirements. False in other case. + """ + + return (APIVersionRequest(max_version) >= req.api_version_request >= + APIVersionRequest(min_version)) + + +class APIVersionRequest(object): + """This class represents an API Version Request with convenience + methods for manipulation and comparison of version + numbers that we need to do to implement microversions. + """ + + def __init__(self, version_string=None): + """Create an API version request object. + + :param version_string: String representation of APIVersionRequest. + Correct format is 'X.Y', where 'X' and 'Y' are int values. + None value should be used to create Null APIVersionRequest, + which is equal to 0.0 + """ + self.ver_major = 0 + self.ver_minor = 0 + + if version_string is not None: + match = re.match(r"^([1-9]\d*)\.([1-9]\d*|0)$", + version_string) + if match: + self.ver_major = int(match.group(1)) + self.ver_minor = int(match.group(2)) + else: + raise exception.InvalidAPIVersionString(version=version_string) + + def __str__(self): + """Debug/Logging representation of object.""" + return ("API Version Request Major: %s, Minor: %s" + % (self.ver_major, self.ver_minor)) + + def is_null(self): + return self.ver_major == 0 and self.ver_minor == 0 + + def _format_type_error(self, other): + return TypeError("'%(other)s' should be an instance of '%(cls)s'" % + {"other": other, "cls": self.__class__}) + + def __lt__(self, other): + if not isinstance(other, APIVersionRequest): + raise self._format_type_error(other) + + return ((self.ver_major, self.ver_minor) < + (other.ver_major, other.ver_minor)) + + def __eq__(self, other): + if not isinstance(other, APIVersionRequest): + raise self._format_type_error(other) + + return ((self.ver_major, self.ver_minor) == + (other.ver_major, other.ver_minor)) + + def __gt__(self, other): + if not isinstance(other, APIVersionRequest): + raise self._format_type_error(other) + + return ((self.ver_major, self.ver_minor) > + (other.ver_major, other.ver_minor)) + + def __le__(self, other): + return self < other or self == other + + def __ne__(self, other): + return not self.__eq__(other) + + def __ge__(self, other): + return self > other or self == other + + def matches(self, min_version, max_version): + """Returns whether the version object represents a version + greater than or equal to the minimum version and less than + or equal to the maximum version. + + @param min_version: Minimum acceptable version. + @param max_version: Maximum acceptable version. + @returns: boolean + + If min_version is null then there is no minimum limit. + If max_version is null then there is no maximum limit. + If self is null then raise ValueError + """ + + if self.is_null(): + raise ValueError + if max_version.is_null() and min_version.is_null(): + return True + elif max_version.is_null(): + return min_version <= self + elif min_version.is_null(): + return self <= max_version + else: + return min_version <= self <= max_version + + def get_string(self): + """Converts object to string representation which if used to create + an APIVersionRequest object results in the same version request. + """ + if self.is_null(): + raise ValueError + return "%s.%s" % (self.ver_major, self.ver_minor) diff --git a/cyborg/common/exception.py b/cyborg/common/exception.py index 4c2cc528..701b467a 100644 --- a/cyborg/common/exception.py +++ b/cyborg/common/exception.py @@ -163,6 +163,11 @@ class InvalidJsonType(Invalid): _msg_fmt = _("%(value)s is not JSON serializable.") +class InvalidAPIVersionString(Invalid): + msg_fmt = _("API Version String %(version)s is of invalid format. Must " + "be of format MajorNum.MinorNum.") + + # Cannot be templated as the error syntax varies. # msg needs to be constructed when raised. class InvalidParameterValue(Invalid): diff --git a/cyborg/tests/unit/api/controllers/v2/test_api.py b/cyborg/tests/unit/api/controllers/v2/test_api.py new file mode 100644 index 00000000..537cea4e --- /dev/null +++ b/cyborg/tests/unit/api/controllers/v2/test_api.py @@ -0,0 +1,30 @@ +# Copyright 2019 Intel, Inc. +# 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. + +from cyborg.tests.unit.api.controllers.v2 import base as v2_test + + +class TestAPI(v2_test.APITestV2): + + def setUp(self): + super(TestAPI, self).setUp() + self.headers = self.gen_headers(self.context) + + def test_get_api_v2(self): + data = self.get_json('/', headers=self.headers) + self.assertEqual(data['status'], "CURRENT") + self.assertEqual(data['max_version'], "2.0") + self.assertEqual(data['id'], "v2.0") + self.assertTrue(isinstance(data['links'], list))