From 1067c44663df2e6ebf03236ce6c00a935350ce20 Mon Sep 17 00:00:00 2001 From: Jay Pipes Date: Mon, 31 Oct 2016 15:33:40 -0400 Subject: [PATCH] placement: REST API for resource classes This patch adds support for a REST API for CRUD operations on custom resource classes: GET /resource_classes: return all resource classes POST /resource_classes: create a new custom resource class PUT /resource_classes/{name}: update name of custom resource class DELETE /resource_classes/{name}: deletes a custom resource class GET /resource_classes/{name}: get a single resource class Change-Id: I99e7bcfe27938e5e4d50ac3005690ac1255d4c5e blueprint: custom-resource-classes --- nova/api/openstack/placement/handler.py | 10 + .../placement/handlers/resource_class.py | 197 ++++++++++++++++++ nova/api/openstack/placement/microversion.py | 1 + .../placement/rest_api_version_history.rst | 18 ++ nova/api/openstack/placement/util.py | 10 + .../api/openstack/placement/fixtures.py | 1 + .../placement/gabbits/microversion.yaml | 4 +- .../placement/gabbits/resource-classes.yaml | 197 ++++++++++++++++++ .../openstack/placement/test_microversion.py | 2 +- .../unit/api/openstack/placement/test_util.py | 17 ++ ...tom-resource-classes-a3f2175772983b0a.yaml | 10 + 11 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 nova/api/openstack/placement/handlers/resource_class.py create mode 100644 nova/tests/functional/api/openstack/placement/gabbits/resource-classes.yaml create mode 100644 releasenotes/notes/placement-rest-custom-resource-classes-a3f2175772983b0a.yaml diff --git a/nova/api/openstack/placement/handler.py b/nova/api/openstack/placement/handler.py index 7c8733ec68ce..2c9750f1887b 100644 --- a/nova/api/openstack/placement/handler.py +++ b/nova/api/openstack/placement/handler.py @@ -31,6 +31,7 @@ from oslo_log import log as logging from nova.api.openstack.placement.handlers import aggregate from nova.api.openstack.placement.handlers import allocation from nova.api.openstack.placement.handlers import inventory +from nova.api.openstack.placement.handlers import resource_class from nova.api.openstack.placement.handlers import resource_provider from nova.api.openstack.placement.handlers import root from nova.api.openstack.placement.handlers import usage @@ -58,6 +59,15 @@ ROUTE_DECLARATIONS = { '': { 'GET': root.home, }, + '/resource_classes': { + 'GET': resource_class.list_resource_classes, + 'POST': resource_class.create_resource_class + }, + '/resource_classes/{name}': { + 'GET': resource_class.get_resource_class, + 'PUT': resource_class.update_resource_class, + 'DELETE': resource_class.delete_resource_class, + }, '/resource_providers': { 'GET': resource_provider.list_resource_providers, 'POST': resource_provider.create_resource_provider diff --git a/nova/api/openstack/placement/handlers/resource_class.py b/nova/api/openstack/placement/handlers/resource_class.py new file mode 100644 index 000000000000..adcf3c2edd5c --- /dev/null +++ b/nova/api/openstack/placement/handlers/resource_class.py @@ -0,0 +1,197 @@ +# 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. +"""Placement API handlers for resource classes.""" + +import copy + +from oslo_serialization import jsonutils +import webob + +from nova.api.openstack.placement import microversion +from nova.api.openstack.placement import util +from nova import exception +from nova.i18n import _ +from nova import objects + + +POST_RC_SCHEMA_V1_2 = { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^CUSTOM\_[A-Z0-9_]+$", + }, + }, + "required": [ + "name" + ], + "additionalProperties": False, +} +PUT_RC_SCHEMA_V1_2 = copy.deepcopy(POST_RC_SCHEMA_V1_2) + + +def _serialize_links(environ, rc): + url = util.resource_class_url(environ, rc) + links = [{'rel': 'self', 'href': url}] + return links + + +def _serialize_resource_class(environ, rc): + data = { + 'name': rc.name, + 'links': _serialize_links(environ, rc) + } + return data + + +def _serialize_resource_classes(environ, rcs): + output = [] + for rc in rcs: + data = _serialize_resource_class(environ, rc) + output.append(data) + return {"resource_classes": output} + + +@webob.dec.wsgify +@microversion.version_handler(1.2) +@util.require_content('application/json') +def create_resource_class(req): + """POST to create a resource class. + + On success return a 201 response with an empty body and a location + header pointing to the newly created resource class. + """ + context = req.environ['placement.context'] + data = util.extract_json(req.body, POST_RC_SCHEMA_V1_2) + + try: + rc = objects.ResourceClass(context, name=data['name']) + rc.create() + except exception.ResourceClassExists: + raise webob.exc.HTTPConflict( + _('Conflicting resource class already exists: %(name)s') % + {'name': data['name']}, + json_formatter=util.json_error_formatter) + + req.response.location = util.resource_class_url(req.environ, rc) + req.response.status = 201 + req.response.content_type = None + return req.response + + +@webob.dec.wsgify +@microversion.version_handler(1.2) +def delete_resource_class(req): + """DELETE to destroy a single resource class. + + On success return a 204 and an empty body. + """ + name = util.wsgi_path_item(req.environ, 'name') + context = req.environ['placement.context'] + # The containing application will catch a not found here. + rc = objects.ResourceClass.get_by_name(context, name) + try: + rc.destroy() + except exception.ResourceClassCannotDeleteStandard as exc: + raise webob.exc.HTTPBadRequest( + _('Cannot delete standard resource class %(rp_name)s: %(error)s') % + {'rp_name': name, 'error': exc}, + json_formatter=util.json_error_formatter) + except exception.ResourceClassInUse as exc: + raise webob.exc.HTTPConflict( + _('Unable to delete resource class %(rp_name)s: %(error)s') % + {'rp_name': name, 'error': exc}, + json_formatter=util.json_error_formatter) + req.response.status = 204 + req.response.content_type = None + return req.response + + +@webob.dec.wsgify +@microversion.version_handler(1.2) +@util.check_accept('application/json') +def get_resource_class(req): + """Get a single resource class. + + On success return a 200 with an application/json body representing + the resource class. + """ + name = util.wsgi_path_item(req.environ, 'name') + context = req.environ['placement.context'] + # The containing application will catch a not found here. + rc = objects.ResourceClass.get_by_name(context, name) + + req.response.body = jsonutils.dumps( + _serialize_resource_class(req.environ, rc) + ) + req.response.content_type = 'application/json' + return req.response + + +@webob.dec.wsgify +@microversion.version_handler(1.2) +@util.check_accept('application/json') +def list_resource_classes(req): + """GET a list of resource classes. + + On success return a 200 and an application/json body representing + a collection of resource classes. + """ + context = req.environ['placement.context'] + rcs = objects.ResourceClassList.get_all(context) + + response = req.response + response.body = jsonutils.dumps( + _serialize_resource_classes(req.environ, rcs) + ) + response.content_type = 'application/json' + return response + + +@webob.dec.wsgify +@microversion.version_handler(1.2) +@util.require_content('application/json') +def update_resource_class(req): + """PUT to update a single resource class. + + On success return a 200 response with a representation of the updated + resource class. + """ + name = util.wsgi_path_item(req.environ, 'name') + context = req.environ['placement.context'] + + data = util.extract_json(req.body, PUT_RC_SCHEMA_V1_2) + + # The containing application will catch a not found here. + rc = objects.ResourceClass.get_by_name(context, name) + + rc.name = data['name'] + + try: + rc.save() + except exception.ResourceClassExists: + raise webob.exc.HTTPConflict( + _('Resource class already exists: %(name)s') % + {'name': name}, + json_formatter=util.json_error_formatter) + except exception.ResourceClassCannotUpdateStandard: + raise webob.exc.HTTPBadRequest( + _('Cannot update standard resource class %(rp_name)s') % + {'rp_name': name}, + json_formatter=util.json_error_formatter) + + req.response.body = jsonutils.dumps( + _serialize_resource_class(req.environ, rc) + ) + req.response.status = 200 + req.response.content_type = 'application/json' + return req.response diff --git a/nova/api/openstack/placement/microversion.py b/nova/api/openstack/placement/microversion.py index 9618a01bdd3d..2b786fce67f2 100644 --- a/nova/api/openstack/placement/microversion.py +++ b/nova/api/openstack/placement/microversion.py @@ -36,6 +36,7 @@ VERSIONED_METHODS = collections.defaultdict(list) VERSIONS = [ '1.0', '1.1', # initial support for aggregate.get_aggregates and set_aggregates + '1.2', # Adds /resource-classes resource endpoint ] diff --git a/nova/api/openstack/placement/rest_api_version_history.rst b/nova/api/openstack/placement/rest_api_version_history.rst index 9fc3d8028738..1c07cb696dd0 100644 --- a/nova/api/openstack/placement/rest_api_version_history.rst +++ b/nova/api/openstack/placement/rest_api_version_history.rst @@ -26,3 +26,21 @@ resource providers with ``GET`` and ``PUT`` methods on one new route: * /resource_providers/{uuid}/aggregates + +1.2 Custom resource classes +--------------------------- + +Placement API version 1.2 adds basic operations allowing an admin to create, +list and delete custom resource classes. + +The following new routes are added: + +* GET /resource_classes: return all resource classes +* POST /resource_classes: create a new custom resource class +* PUT /resource_classes/{name}: update name of custom resource class +* DELETE /resource_classes/{name}: deletes a custom resource class +* GET /resource_classes/{name}: get a single resource class + +Custom resource classes must begin with the prefix "CUSTOM\_" and contain only +the letters A through Z, the numbers 0 through 9 and the underscore "\_" +character. diff --git a/nova/api/openstack/placement/util.py b/nova/api/openstack/placement/util.py index 2fa4987ad62e..eff0765c0259 100644 --- a/nova/api/openstack/placement/util.py +++ b/nova/api/openstack/placement/util.py @@ -135,6 +135,16 @@ def require_content(content_type): return decorator +def resource_class_url(environ, resource_class): + """Produce the URL for a resource class. + + If SCRIPT_NAME is present, it is the mount point of the placement + WSGI app. + """ + prefix = environ.get('SCRIPT_NAME', '') + return '%s/resource_classes/%s' % (prefix, resource_class.name) + + def resource_provider_url(environ, resource_provider): """Produce the URL for a resource provider. diff --git a/nova/tests/functional/api/openstack/placement/fixtures.py b/nova/tests/functional/api/openstack/placement/fixtures.py index ec5694e4a247..a55be910469b 100644 --- a/nova/tests/functional/api/openstack/placement/fixtures.py +++ b/nova/tests/functional/api/openstack/placement/fixtures.py @@ -68,6 +68,7 @@ class APIFixture(fixture.GabbiFixture): os.environ['RP_UUID'] = uuidutils.generate_uuid() os.environ['RP_NAME'] = uuidutils.generate_uuid() + os.environ['CUSTOM_RES_CLASS'] = 'CUSTOM_IRON_NFV' def stop_fixture(self): self.api_db_fixture.cleanup() diff --git a/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml b/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml index 5cb89e76f79f..32a4f4fb9c7b 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml @@ -37,13 +37,13 @@ tests: response_strings: - "Unacceptable version header: 0.5" -- name: latest microversion is 1.1 +- name: latest microversion is 1.2 GET: / request_headers: openstack-api-version: placement latest response_headers: vary: /OpenStack-API-Version/ - openstack-api-version: placement 1.1 + openstack-api-version: placement 1.2 - name: other accept header bad version GET: / diff --git a/nova/tests/functional/api/openstack/placement/gabbits/resource-classes.yaml b/nova/tests/functional/api/openstack/placement/gabbits/resource-classes.yaml new file mode 100644 index 000000000000..80c1cc95432c --- /dev/null +++ b/nova/tests/functional/api/openstack/placement/gabbits/resource-classes.yaml @@ -0,0 +1,197 @@ +fixtures: + - APIFixture + +defaults: + request_headers: + x-auth-token: admin + OpenStack-API-Version: placement latest + +tests: + +- name: test microversion masks entire resource-classes endpoint with 404 + GET: /resource_classes + request_headers: + OpenStack-API-Version: placement 1.1 + content-type: application/json + status: 404 + +- name: test microversion mask when wrong content type + desc: we want to get a 404 before a 415 + POST: /resource_classes + request_headers: + OpenStack-API-Version: placement 1.1 + content-type: text/plain + data: data + status: 404 + +- name: test wrong content type + desc: we want to get a 415 when bad content type + POST: /resource_classes + request_headers: + OpenStack-API-Version: placement 1.2 + content-type: text/plain + data: data + status: 415 + +- name: what is at resource classes + GET: /resource_classes + response_json_paths: + response_json_paths: + $.resource_classes.`len`: 10 # Number of standard resource classes + $.resource_classes[0].name: VCPU + +- name: non admin forbidden + GET: /resource_classes + request_headers: + x-auth-token: user + accept: application/json + status: 403 + response_json_paths: + $.errors[0].title: Forbidden + +- name: non admin forbidden non json + GET: /resource_classes + request_headers: + x-auth-token: user + accept: text/plain + status: 403 + response_strings: + - admin required + +- name: post illegal characters in name + POST: /resource_classes + request_headers: + content-type: application/json + data: + name: CUSTOM_Illegal&@!Name? + status: 400 + response_strings: + - JSON does not validate + +- name: post new resource class + POST: /resource_classes + request_headers: + content-type: application/json + data: + name: $ENVIRON['CUSTOM_RES_CLASS'] + status: 201 + response_headers: + location: //resource_classes/$ENVIRON['CUSTOM_RES_CLASS']/ + response_forbidden_headers: + - content-type + +- name: try to create same again + POST: /resource_classes + request_headers: + content-type: application/json + data: + name: $ENVIRON['CUSTOM_RES_CLASS'] + status: 409 + response_strings: + - Conflicting resource class already exists + +- name: confirm the correct post + GET: /resource_classes/$ENVIRON['CUSTOM_RES_CLASS'] + request_headers: + content-type: application/json + response_json_paths: + $.name: $ENVIRON['CUSTOM_RES_CLASS'] + $.links[?rel = "self"].href: /resource_classes/$ENVIRON['CUSTOM_RES_CLASS'] + +- name: get resource class works with no accept + GET: /resource_classes/$ENVIRON['CUSTOM_RES_CLASS'] + request_headers: + content-type: application/json + response_headers: + content-type: /application/json/ + response_json_paths: + $.name: $ENVIRON['CUSTOM_RES_CLASS'] + +- name: list resource classes after addition of custom res class + GET: /resource_classes + response_json_paths: + $.resource_classes.`len`: 11 # 10 standard plus 1 custom + $.resource_classes[10].name: $ENVIRON['CUSTOM_RES_CLASS'] + $.resource_classes[10].links[?rel = "self"].href: /resource_classes/$ENVIRON['CUSTOM_RES_CLASS'] + +- name: update standard resource class + PUT: /resource_classes/VCPU + request_headers: + content-type: application/json + data: + name: VCPU_ALTERNATE + status: 400 + response_strings: + - JSON does not validate + +- name: update custom resource class to standard resource class name + PUT: /resource_classes/$ENVIRON['CUSTOM_RES_CLASS'] + request_headers: + content-type: application/json + data: + name: VCPU + status: 400 + response_strings: + - JSON does not validate + +- name: post another custom resource class + POST: /resource_classes + request_headers: + content-type: application/json + data: + name: CUSTOM_NFV_FOO + status: 201 + +- name: update custom resource class to already existing custom resource class name + PUT: /resource_classes/CUSTOM_NFV_FOO + request_headers: + content-type: application/json + data: + name: $ENVIRON['CUSTOM_RES_CLASS'] + status: 409 + response_strings: + - Resource class already exists + +- name: update custom resource class + PUT: /resource_classes/$ENVIRON['CUSTOM_RES_CLASS'] + request_headers: + content-type: application/json + data: + name: CUSTOM_NFV_BAR + status: 200 + response_json_paths: + $.name: CUSTOM_NFV_BAR + $.links[?rel = "self"].href: /resource_classes/CUSTOM_NFV_BAR + +- name: delete standard resource class + DELETE: /resource_classes/VCPU + status: 400 + response_strings: + - Cannot delete standard resource class + +- name: delete custom resource class + DELETE: /resource_classes/CUSTOM_NFV_BAR + status: 204 + +- name: 404 on deleted resource class + DELETE: $LAST_URL + status: 404 + +- name: post malformed json as json + POST: /resource_classes + request_headers: + content-type: application/json + data: '{"foo": }' + status: 400 + response_strings: + - 'Malformed JSON:' + +- name: post bad resource class name IRON_NFV + POST: /resource_classes + request_headers: + content-type: application/json + data: + name: IRON_NFV # Doesn't start with CUSTOM_ + status: 400 + response_strings: + - JSON does not validate diff --git a/nova/tests/unit/api/openstack/placement/test_microversion.py b/nova/tests/unit/api/openstack/placement/test_microversion.py index f20364da0e28..da2311494209 100644 --- a/nova/tests/unit/api/openstack/placement/test_microversion.py +++ b/nova/tests/unit/api/openstack/placement/test_microversion.py @@ -57,7 +57,7 @@ class TestMicroversionIntersection(test.NoDBTestCase): # if you add two different versions of method 'foobar' the # number only goes up by one if no other version foobar yet # exists. This operates as a simple sanity check. - TOTAL_VERSIONED_METHODS = 0 + TOTAL_VERSIONED_METHODS = 5 def test_methods_versioned(self): methods_data = microversion.VERSIONED_METHODS diff --git a/nova/tests/unit/api/openstack/placement/test_util.py b/nova/tests/unit/api/openstack/placement/test_util.py index b9f01a013901..b849d3cd2815 100644 --- a/nova/tests/unit/api/openstack/placement/test_util.py +++ b/nova/tests/unit/api/openstack/placement/test_util.py @@ -264,6 +264,9 @@ class TestPlacementURLs(test.NoDBTestCase): self.resource_provider = objects.ResourceProvider( name=uuidsentinel.rp_name, uuid=uuidsentinel.rp_uuid) + self.resource_class = objects.ResourceClass( + name='CUSTOM_BAREMETAL_GOLD', + id=1000) def test_resource_provider_url(self): environ = {} @@ -294,3 +297,17 @@ class TestPlacementURLs(test.NoDBTestCase): % (uuidsentinel.rp_uuid, resource_class)) self.assertEqual(expected_url, util.inventory_url( environ, self.resource_provider, resource_class)) + + def test_resource_class_url(self): + environ = {} + expected_url = '/resource_classes/CUSTOM_BAREMETAL_GOLD' + self.assertEqual(expected_url, util.resource_class_url( + environ, self.resource_class)) + + def test_resource_class_url_prefix(self): + # SCRIPT_NAME represents the mount point of a WSGI + # application when it is hosted at a path/prefix. + environ = {'SCRIPT_NAME': '/placement'} + expected_url = '/placement/resource_classes/CUSTOM_BAREMETAL_GOLD' + self.assertEqual(expected_url, util.resource_class_url( + environ, self.resource_class)) diff --git a/releasenotes/notes/placement-rest-custom-resource-classes-a3f2175772983b0a.yaml b/releasenotes/notes/placement-rest-custom-resource-classes-a3f2175772983b0a.yaml new file mode 100644 index 000000000000..a2da22e8d611 --- /dev/null +++ b/releasenotes/notes/placement-rest-custom-resource-classes-a3f2175772983b0a.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + A new administrator-only resource endpoint was added to the OpenStack + Placement REST API for managing custom resource classes. Custom resource + classes are specific to a deployment and represent types of quantitative + resources that are not interoperable between OpenStack clouds. See the + `Placement REST API Version History`_ documentation for usage details. + + .. _Placement REST API Version History: http://docs.openstack.org/developer/nova/placement.html#id3