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
This commit is contained in:
parent
4c5955930f
commit
1067c44663
@ -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
|
||||
|
197
nova/api/openstack/placement/handlers/resource_class.py
Normal file
197
nova/api/openstack/placement/handlers/resource_class.py
Normal file
@ -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
|
@ -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
|
||||
]
|
||||
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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: /
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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))
|
||||
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user