Add support for resource_providers urls

GET a list of /resource_providers
POST to /resource_providers for a new one
GET, DELETE, PUT a single /resource_providers/{uuid}

Uses jsonschema to validate input when creating or updating.
A 'uuid' FormatChecker is added in the util module to verify
the format of uuids in request bodies. Note the lengthy comment
adjacent explaining its magic.

The olso_context RequestContext is subclassed to allow it to
be an engine_facade transaction_context_provider. A database
fixture is added to the gabbi tests to create and dispose the
placement database per each gabbi testsuite (i.e. each YAML
file).

A resource_provider_url is added to util.py: given a
resource_provider object, give the url, accounting for any
prefix in SCRIPT_NAME

With the addition of working endpoints, basic_http.yaml
is extended with more complete testing of basic http behaviors.

Tests for resource_providers are in resource-provider.yaml.

Change-Id: I799d839101af78b4d89aca175f647efc2b56c401
Partially-Implements: blueprint generic-resource-pools
This commit is contained in:
Chris Dent 2016-08-08 17:14:01 +00:00
parent 4e923eb9a6
commit 125cfc97fb
9 changed files with 615 additions and 6 deletions

View File

@ -12,6 +12,7 @@
from oslo_context import context
from oslo_db.sqlalchemy import enginefacade
from oslo_log import log as logging
from oslo_middleware import request_id
import webob.dec
@ -55,6 +56,11 @@ class NoAuthMiddleware(Middleware):
return self.application
@enginefacade.transaction_context_provider
class RequestContext(context.RequestContext):
pass
class PlacementKeystoneContext(Middleware):
"""Make a request context from keystone headers."""
@ -62,7 +68,7 @@ class PlacementKeystoneContext(Middleware):
def __call__(self, req):
req_id = req.environ.get(request_id.ENV_REQUEST_ID)
ctx = context.RequestContext.from_environ(
ctx = RequestContext.from_environ(
req.environ, request_id=req_id)
if ctx.user is None:

View File

@ -17,15 +17,19 @@ from oslo_middleware import request_id
from nova.api.openstack.placement import auth
from nova.api.openstack.placement import handler
from nova.api.openstack.placement import microversion
from nova import objects
# TODO(cdent): register objects here as this is our startup place,
# but only once we start using them.
# TODO(cdent): NAME points to the config project being used, so for
# now this is "nova" but we probably want "placement" eventually.
NAME = "nova"
# Make sure that objects are registered for this running of the
# placement API.
objects.register_all()
def deploy(conf, project_name):
"""Assemble the middleware pipeline leading to the placement app."""
if conf.auth_strategy == 'noauth2':

View File

@ -26,15 +26,30 @@ method.
import routes
import webob
from nova.api.openstack.placement.handlers import resource_provider
from nova.api.openstack.placement.handlers import root
from nova.api.openstack.placement import util
from nova import exception
# URLs and Handlers
# NOTE(cdent): When adding URLs here, do not use regex patterns in
# the path parameters (e.g. {uuid:[0-9a-zA-Z-]+}) as that will lead
# to 404s that are controlled outside of the individual resources
# and thus do not include specific information on the why of the 404.
ROUTE_DECLARATIONS = {
'/': {
'GET': root.home,
},
'/resource_providers': {
'GET': resource_provider.list_resource_providers,
'POST': resource_provider.create_resource_provider
},
'/resource_providers/{uuid}': {
'GET': resource_provider.get_resource_provider,
'DELETE': resource_provider.delete_resource_provider,
'PUT': resource_provider.update_resource_provider
},
}
@ -56,7 +71,7 @@ def dispatch(environ, start_response, mapper):
def handle_405(environ, start_response):
"""Return a 405 response as required.
"""Return a 405 response when method is not allowed.
If _methods are in routing_args, send an allow header listing
the methods that are possible on the provided URL.
@ -111,4 +126,12 @@ class PlacementHandler(object):
raise webob.exc.HTTPForbidden(
'admin required',
json_formatter=util.json_error_formatter)
return dispatch(environ, start_response, self._map)
try:
return dispatch(environ, start_response, self._map)
# Trap the small number of nova exceptions that aren't
# caught elsewhere and transform them into webob.exc.
# These are common exceptions raised when making calls against
# nova.objects in the handlers.
except exception.NotFound as exc:
raise webob.exc.HTTPNotFound(
exc, json_formatter=util.json_error_formatter)

View File

@ -0,0 +1,222 @@
# 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 providers."""
import copy
import jsonschema
from oslo_db import exception as db_exc
from oslo_serialization import jsonutils
from oslo_utils import uuidutils
import webob
from nova.api.openstack.placement import util
from nova import exception
from nova import objects
POST_RESOURCE_PROVIDER_SCHEMA = {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"uuid": {
"type": "string",
"format": "uuid"
}
},
"required": [
"name"
],
"additionalProperties": False,
}
# Remove uuid to create the schema for PUTting a resource provider
PUT_RESOURCE_PROVIDER_SCHEMA = copy.deepcopy(POST_RESOURCE_PROVIDER_SCHEMA)
PUT_RESOURCE_PROVIDER_SCHEMA['properties'].pop('uuid')
def _extract_resource_provider(body, schema):
"""Extract and validate resource provider from JSON body."""
try:
data = jsonutils.loads(body)
except ValueError as exc:
raise webob.exc.HTTPBadRequest(
'Malformed JSON: %s' % exc,
json_formatter=util.json_error_formatter)
try:
jsonschema.validate(data, schema,
format_checker=jsonschema.FormatChecker())
except jsonschema.ValidationError as exc:
raise webob.exc.HTTPBadRequest(
'JSON does not validate: %s' % exc,
json_formatter=util.json_error_formatter)
return data
def _serialize_links(environ, resource_provider):
url = util.resource_provider_url(environ, resource_provider)
links = [{'rel': 'self', 'href': url}]
for rel in ('aggregates', 'inventories', 'usages'):
links.append({'rel': rel, 'href': '%s/%s' % (url, rel)})
return links
def _serialize_provider(environ, resource_provider):
data = {
'uuid': resource_provider.uuid,
'name': resource_provider.name,
'generation': resource_provider.generation,
'links': _serialize_links(environ, resource_provider)
}
return data
def _serialize_providers(environ, resource_providers):
output = []
for provider in resource_providers:
provider_data = _serialize_provider(environ, provider)
output.append(provider_data)
return {"resource_providers": output}
@webob.dec.wsgify
@util.require_content('application/json')
def create_resource_provider(req):
"""POST to create a resource provider.
On success return a 201 response with an empty body and a location
header pointing to the newly created resource provider.
"""
context = req.environ['placement.context']
data = _extract_resource_provider(req.body,
POST_RESOURCE_PROVIDER_SCHEMA)
try:
uuid = data.get('uuid', uuidutils.generate_uuid())
resource_provider = objects.ResourceProvider(
context, name=data['name'], uuid=uuid)
resource_provider.create()
except db_exc.DBDuplicateEntry as exc:
raise webob.exc.HTTPConflict(
'Conflicting resource provider already exists: %s' % exc,
json_formatter=util.json_error_formatter)
except exception.ObjectActionError as exc:
raise webob.exc.HTTPBadRequest(
'Unable to create resource provider %s: %s' % (uuid, exc),
json_formatter=util.json_error_formatter)
req.response.location = util.resource_provider_url(
req.environ, resource_provider)
req.response.status = 201
req.response.content_type = None
return req.response
@webob.dec.wsgify
def delete_resource_provider(req):
"""DELETE to destroy a single resource provider.
On success return a 204 and an empty body.
"""
uuid = util.wsgi_path_item(req.environ, 'uuid')
context = req.environ['placement.context']
# The containing application will catch a not found here.
resource_provider = objects.ResourceProvider.get_by_uuid(
context, uuid)
try:
resource_provider.destroy()
except exception.ResourceProviderInUse as exc:
raise webob.exc.HTTPConflict(
'Unable to delete resource provider %s: %s' % (uuid, exc),
json_formatter=util.json_error_formatter)
req.response.status = 204
req.response.content_type = None
return req.response
@webob.dec.wsgify
@util.check_accept('application/json')
def get_resource_provider(req):
"""Get a single resource provider.
On success return a 200 with an application/json body representing
the resource provider.
"""
uuid = util.wsgi_path_item(req.environ, 'uuid')
# The containing application will catch a not found here.
context = req.environ['placement.context']
resource_provider = objects.ResourceProvider.get_by_uuid(
context, uuid)
req.response.body = jsonutils.dumps(
_serialize_provider(req.environ, resource_provider))
req.response.content_type = 'application/json'
return req.response
@webob.dec.wsgify
@util.check_accept('application/json')
def list_resource_providers(req):
"""GET a list of resource providers.
On success return a 200 and an application/json body representing
a collection of resource providers.
"""
context = req.environ['placement.context']
resource_providers = objects.ResourceProviderList.get_all_by_filters(
context)
response = req.response
response.body = jsonutils.dumps(_serialize_providers(
req.environ, resource_providers))
response.content_type = 'application/json'
return response
@webob.dec.wsgify
@util.require_content('application/json')
def update_resource_provider(req):
"""PUT to update a single resource provider.
On success return a 200 response with a representation of the updated
resource provider.
"""
uuid = util.wsgi_path_item(req.environ, 'uuid')
context = req.environ['placement.context']
# The containing application will catch a not found here.
resource_provider = objects.ResourceProvider.get_by_uuid(
context, uuid)
data = _extract_resource_provider(req.body,
PUT_RESOURCE_PROVIDER_SCHEMA)
resource_provider.name = data['name']
try:
resource_provider.save()
except db_exc.DBDuplicateEntry as exc:
raise webob.exc.HTTPConflict(
'Conflicting resource provider already exists: %s' % exc,
json_formatter=util.json_error_formatter)
except exception.ObjectActionError as exc:
raise webob.exc.HTTPBadRequest(
'Unable to save resource provider %s: %s' % (uuid, exc),
json_formatter=util.json_error_formatter)
req.response.body = jsonutils.dumps(
_serialize_provider(req.environ, resource_provider))
req.response.status = 200
req.response.content_type = 'application/json'
return req.response

View File

@ -12,7 +12,9 @@
"""Utility methods for placement API."""
import functools
import jsonschema
from oslo_middleware import request_id
from oslo_utils import uuidutils
import webob
# NOTE(cdent): avoid cyclical import conflict between util and
@ -20,6 +22,18 @@ import webob
import nova.api.openstack.placement.microversion
# NOTE(cdent): This registers a FormatChecker on the jsonschema
# module. Do not delete this code! Although it appears that nothing
# is using the decorated method it is being used in JSON schema
# validations to check uuid formatted strings. The addition of a uuid
# format checker is an implicit result of loading the util module.
# Since util.json_error_formatter # needs to be imported when jsonschema
# is doing validation this works.
@jsonschema.FormatChecker.cls_checks('uuid')
def _validate_uuid_format(instance):
return uuidutils.is_uuid_like(instance)
def check_accept(*types):
"""If accept is set explicitly, try to follow it.
@ -95,6 +109,16 @@ def require_content(content_type):
return decorator
def resource_provider_url(environ, resource_provider):
"""Produce the URL for a resource provider.
If SCRIPT_NAME is present, it is the mount point of the placement
WSGI app.
"""
prefix = environ.get('SCRIPT_NAME', '')
return '%s/resource_providers/%s' % (prefix, resource_provider.uuid)
def wsgi_path_item(environ, name):
"""Extract the value of a named field in a URL.

View File

@ -10,11 +10,15 @@
# License for the specific language governing permissions and limitations
# under the License.
import os
from gabbi import fixture
from oslo_utils import uuidutils
from nova.api.openstack.placement import deploy
from nova import conf
from nova import config
from nova.tests import fixtures
CONF = conf.CONF
@ -32,10 +36,33 @@ class APIFixture(fixture.GabbiFixture):
def start_fixture(self):
self.conf = CONF
self.conf.set_override('auth_strategy', 'noauth2')
# Be explicit about all three database connections to avoid
# potential conflicts with config on disk.
self.conf.set_override('connection', "sqlite://", group='database')
self.conf.set_override('connection', "sqlite://",
group='api_database')
self.conf.set_override('connection', "sqlite://",
group='placement_database')
config.parse_args([], default_config_files=None, configure_db=False,
init_rpc=False)
self.conf.set_override('auth_strategy', 'noauth2')
self.placement_db_fixture = fixtures.Database('placement')
# NOTE(cdent): api and main database are not used but we still need
# to manage them to make the fixtures work correctly and not cause
# conflicts with other tests in the same process.
self.api_db_fixture = fixtures.Database('api')
self.main_db_fixture = fixtures.Database('main')
self.placement_db_fixture.reset()
self.api_db_fixture.reset()
self.main_db_fixture.reset()
os.environ['RP_UUID'] = uuidutils.generate_uuid()
os.environ['RP_NAME'] = uuidutils.generate_uuid()
def stop_fixture(self):
self.placement_db_fixture.cleanup()
self.api_db_fixture.cleanup()
self.main_db_fixture.cleanup()
if self.conf:
self.conf.reset()

View File

@ -26,6 +26,10 @@ tests:
response_json_paths:
$.errors[0].request_id: /req-[a-fA-F0-9-]+/
- name: 404 at no resource provider
GET: /resource_providers/fd0dd55c-6330-463b-876c-31c54e95cb95
status: 404
- name: 405 on bad method at root
DELETE: /
status: 405
@ -37,3 +41,76 @@ tests:
- name: 200 at home
GET: /
status: 200
- name: 405 on bad method on app
DELETE: /resource_providers
status: 405
- name: bad accept resource providers
GET: /resource_providers
request_headers:
accept: text/plain
status: 406
- name: complex accept resource providers
GET: /resource_providers
request_headers:
accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
status: 200
response_json_paths:
$.resource_providers: []
- name: post resource provider wrong content-type
POST: /resource_providers
request_headers:
content-type: text/plain
data: I want a resource provider please
status: 415
- name: post resource provider schema mismatch
POST: /resource_providers
request_headers:
content-type: application/json
data:
transport: car
color: blue
status: 400
- name: post good resource provider
POST: /resource_providers
request_headers:
content-type: application/json
data:
name: $ENVIRON['RP_NAME']
uuid: $ENVIRON['RP_UUID']
status: 201
- name: get resource provider wrong accept
GET: /resource_providers/$ENVIRON['RP_UUID']
request_headers:
accept: text/plain
status: 406
response_strings:
- Only application/json is provided
- name: get resource provider complex accept wild match
desc: like a browser, */* should match
GET: /resource_providers/$ENVIRON['RP_UUID']
request_headers:
accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
response_json_paths:
$.uuid: $ENVIRON['RP_UUID']
- name: get resource provider complex accept no match
desc: no */*, no match
GET: /resource_providers/$ENVIRON['RP_UUID']
request_headers:
accept: text/html,application/xhtml+xml,application/xml;q=0.9
status: 406
- name: put poor format resource provider
PUT: /resource_providers/$ENVIRON['RP_UUID']
request_headers:
content-type: text/plain
data: Why U no provide?
status: 415

View File

@ -0,0 +1,200 @@
fixtures:
- APIFixture
defaults:
request_headers:
x-auth-token: admin
tests:
- name: what is at resource providers
GET: /resource_providers
response_json_paths:
$.resource_providers: []
- name: non admin forbidden
GET: /resource_providers
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_providers
request_headers:
x-auth-token: user
accept: text/plain
status: 403
response_strings:
- admin required
- name: post new resource provider
POST: /resource_providers
request_headers:
content-type: application/json
data:
name: $ENVIRON['RP_NAME']
uuid: $ENVIRON['RP_UUID']
status: 201
response_headers:
location: //resource_providers/[a-f0-9-]+/
response_forbidden_headers:
- content-type
- name: try to create same all again
POST: /resource_providers
request_headers:
content-type: application/json
data:
name: $ENVIRON['RP_NAME']
uuid: $ENVIRON['RP_UUID']
status: 409
response_strings:
- Conflicting resource provider already exists
- name: try to create same name again
POST: /resource_providers
request_headers:
content-type: application/json
data:
name: $ENVIRON['RP_NAME']
uuid: ada30fb5-566d-4fe1-b43b-28a9e988790c
status: 409
response_strings:
- Conflicting resource provider already exists
- name: confirm the correct post
GET: /resource_providers/$ENVIRON['RP_UUID']
request_headers:
content-type: application/json
response_json_paths:
$.uuid: $ENVIRON['RP_UUID']
$.name: $ENVIRON['RP_NAME']
$.generation: 0
$.links[?rel = "self"].href: /resource_providers/$ENVIRON['RP_UUID']
$.links[?rel = "inventories"].href: /resource_providers/$ENVIRON['RP_UUID']/inventories
$.links[?rel = "aggregates"].href: /resource_providers/$ENVIRON['RP_UUID']/aggregates
$.links[?rel = "usages"].href: /resource_providers/$ENVIRON['RP_UUID']/usages
- name: get resource provider works with no accept
GET: /resource_providers/$ENVIRON['RP_UUID']
response_headers:
content-type: /application/json/
response_json_paths:
$.uuid: $ENVIRON['RP_UUID']
- name: list one resource providers
GET: /resource_providers
response_json_paths:
$.resource_providers.`len`: 1
$.resource_providers[0].uuid: $ENVIRON['RP_UUID']
$.resource_providers[0].name: $ENVIRON['RP_NAME']
$.resource_providers[0].generation: 0
$.resource_providers[0].links[?rel = "self"].href: /resource_providers/$ENVIRON['RP_UUID']
$.resource_providers[0].links[?rel = "inventories"].href: /resource_providers/$ENVIRON['RP_UUID']/inventories
$.resource_providers[0].links[?rel = "aggregates"].href: /resource_providers/$ENVIRON['RP_UUID']/aggregates
$.resource_providers[0].links[?rel = "usages"].href: /resource_providers/$ENVIRON['RP_UUID']/usages
- name: update a resource provider
PUT: /resource_providers/$RESPONSE['$.resource_providers[0].uuid']
request_headers:
content-type: application/json
data:
name: new name
status: 200
response_headers:
content-type: /application/json/
response_forbidden_headers:
- location
response_json_paths:
$.generation: 0
$.name: new name
$.uuid: $ENVIRON['RP_UUID']
$.links[?rel = "self"].href: /resource_providers/$ENVIRON['RP_UUID']
- name: check the name from that update
GET: $LAST_URL
response_json_paths:
$.name: new name
- name: update a provider poorly
PUT: $LAST_URL
request_headers:
content-type: application/json
data:
badfield: new name
status: 400
response_strings:
- 'JSON does not validate'
- name: create a new provider
POST: /resource_providers
request_headers:
content-type: application/json
data:
name: cow
status: 201
- name: try to rename that provider to existing name
PUT: $LOCATION
request_headers:
content-type: application/json
data:
name: new name
status: 409
- name: fail to put that provider with uuid
PUT: $LAST_URL
request_headers:
content-type: application/json
data:
name: second new name
uuid: 7d4275fc-8b40-4995-85e2-74fcec2cb3b6
status: 400
response_strings:
- Additional properties are not allowed
- name: delete resource provider
DELETE: $LAST_URL
status: 204
- name: 404 on deleted provider
DELETE: $LAST_URL
status: 404
- name: fail to get a provider
GET: /resource_providers/random_sauce
status: 404
- name: post resource provider no uuid
POST: /resource_providers
request_headers:
content-type: application/json
data:
name: a name
status: 201
response_headers:
location: //resource_providers/[a-f0-9-]+/
- name: post malformed json as json
POST: /resource_providers
request_headers:
content-type: application/json
data: '{"foo": }'
status: 400
response_strings:
- 'Malformed JSON:'
- name: post bad uuid in resource provider
POST: /resource_providers
request_headers:
content-type: application/json
data:
name: my bad rp
uuid: this is not a uuid
status: 400
response_strings:
- "Failed validating 'format'"

View File

@ -18,7 +18,9 @@ import webob
from nova.api.openstack.placement import microversion
from nova.api.openstack.placement import util
from nova import objects
from nova import test
from nova.tests import uuidsentinel
class TestCheckAccept(test.NoDBTestCase):
@ -177,3 +179,27 @@ class TestRequireContent(test.NoDBTestCase):
req = webob.Request.blank('/')
req.content_type = 'application/json'
self.assertTrue(self.handler(req))
class TestPlacementURLs(test.NoDBTestCase):
def setUp(self):
super(TestPlacementURLs, self).setUp()
self.resource_provider = objects.ResourceProvider(
name=uuidsentinel.rp_name,
uuid=uuidsentinel.rp_uuid)
def test_resource_provider_url(self):
environ = {}
expected_url = '/resource_providers/%s' % uuidsentinel.rp_uuid
self.assertEqual(expected_url, util.resource_provider_url(
environ, self.resource_provider))
def test_resource_provider_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_providers/%s'
% uuidsentinel.rp_uuid)
self.assertEqual(expected_url, util.resource_provider_url(
environ, self.resource_provider))