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_context import context
from oslo_db.sqlalchemy import enginefacade
from oslo_log import log as logging from oslo_log import log as logging
from oslo_middleware import request_id from oslo_middleware import request_id
import webob.dec import webob.dec
@ -55,6 +56,11 @@ class NoAuthMiddleware(Middleware):
return self.application return self.application
@enginefacade.transaction_context_provider
class RequestContext(context.RequestContext):
pass
class PlacementKeystoneContext(Middleware): class PlacementKeystoneContext(Middleware):
"""Make a request context from keystone headers.""" """Make a request context from keystone headers."""
@ -62,7 +68,7 @@ class PlacementKeystoneContext(Middleware):
def __call__(self, req): def __call__(self, req):
req_id = req.environ.get(request_id.ENV_REQUEST_ID) 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) req.environ, request_id=req_id)
if ctx.user is None: 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 auth
from nova.api.openstack.placement import handler from nova.api.openstack.placement import handler
from nova.api.openstack.placement import microversion 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 # TODO(cdent): NAME points to the config project being used, so for
# now this is "nova" but we probably want "placement" eventually. # now this is "nova" but we probably want "placement" eventually.
NAME = "nova" NAME = "nova"
# Make sure that objects are registered for this running of the
# placement API.
objects.register_all()
def deploy(conf, project_name): def deploy(conf, project_name):
"""Assemble the middleware pipeline leading to the placement app.""" """Assemble the middleware pipeline leading to the placement app."""
if conf.auth_strategy == 'noauth2': if conf.auth_strategy == 'noauth2':

View File

@ -26,15 +26,30 @@ method.
import routes import routes
import webob import webob
from nova.api.openstack.placement.handlers import resource_provider
from nova.api.openstack.placement.handlers import root from nova.api.openstack.placement.handlers import root
from nova.api.openstack.placement import util from nova.api.openstack.placement import util
from nova import exception
# URLs and Handlers # 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 = { ROUTE_DECLARATIONS = {
'/': { '/': {
'GET': root.home, '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): 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 If _methods are in routing_args, send an allow header listing
the methods that are possible on the provided URL. the methods that are possible on the provided URL.
@ -111,4 +126,12 @@ class PlacementHandler(object):
raise webob.exc.HTTPForbidden( raise webob.exc.HTTPForbidden(
'admin required', 'admin required',
json_formatter=util.json_error_formatter) 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.""" """Utility methods for placement API."""
import functools import functools
import jsonschema
from oslo_middleware import request_id from oslo_middleware import request_id
from oslo_utils import uuidutils
import webob import webob
# NOTE(cdent): avoid cyclical import conflict between util and # NOTE(cdent): avoid cyclical import conflict between util and
@ -20,6 +22,18 @@ import webob
import nova.api.openstack.placement.microversion 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): def check_accept(*types):
"""If accept is set explicitly, try to follow it. """If accept is set explicitly, try to follow it.
@ -95,6 +109,16 @@ def require_content(content_type):
return decorator 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): def wsgi_path_item(environ, name):
"""Extract the value of a named field in a URL. """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 # License for the specific language governing permissions and limitations
# under the License. # under the License.
import os
from gabbi import fixture from gabbi import fixture
from oslo_utils import uuidutils
from nova.api.openstack.placement import deploy from nova.api.openstack.placement import deploy
from nova import conf from nova import conf
from nova import config from nova import config
from nova.tests import fixtures
CONF = conf.CONF CONF = conf.CONF
@ -32,10 +36,33 @@ class APIFixture(fixture.GabbiFixture):
def start_fixture(self): def start_fixture(self):
self.conf = CONF 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, config.parse_args([], default_config_files=None, configure_db=False,
init_rpc=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): def stop_fixture(self):
self.placement_db_fixture.cleanup()
self.api_db_fixture.cleanup()
self.main_db_fixture.cleanup()
if self.conf: if self.conf:
self.conf.reset() self.conf.reset()

View File

@ -26,6 +26,10 @@ tests:
response_json_paths: response_json_paths:
$.errors[0].request_id: /req-[a-fA-F0-9-]+/ $.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 - name: 405 on bad method at root
DELETE: / DELETE: /
status: 405 status: 405
@ -37,3 +41,76 @@ tests:
- name: 200 at home - name: 200 at home
GET: / GET: /
status: 200 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 microversion
from nova.api.openstack.placement import util from nova.api.openstack.placement import util
from nova import objects
from nova import test from nova import test
from nova.tests import uuidsentinel
class TestCheckAccept(test.NoDBTestCase): class TestCheckAccept(test.NoDBTestCase):
@ -177,3 +179,27 @@ class TestRequireContent(test.NoDBTestCase):
req = webob.Request.blank('/') req = webob.Request.blank('/')
req.content_type = 'application/json' req.content_type = 'application/json'
self.assertTrue(self.handler(req)) 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))