Add API support for flavors

This patch adds the control API for flavors. The patch allows users to
create, delete, update and list flavors. This API is considered an
admin-only API, hence it's registered as part of the private endpoints.

DocImpact

Partially-Implements blueprint marconi-queue-flavors

Change-Id: Id29d29940f2ecabab0531edefe018c0dd2f39811
This commit is contained in:
Flavio Percoco 2014-07-11 13:55:38 +02:00
parent 0300181024
commit d8388e89fd
8 changed files with 525 additions and 2 deletions

View File

@ -97,10 +97,14 @@ class TestValidation(v1_1.TestValidation):
url_prefix = URL_PREFIX url_prefix = URL_PREFIX
class TestFlavorsMongoDB(v1_1.TestFlavorsMongoDB):
url_prefix = URL_PREFIX
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# v1.1 only # v1.1 only
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
class TestPing(base.V1_1Base): class TestPing(base.V1_1Base):
config_file = 'wsgi_sqlalchemy.conf' config_file = 'wsgi_sqlalchemy.conf'

View File

@ -0,0 +1,51 @@
# Copyright (c) 2013 Rackspace Hosting, 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.
"""flavors: JSON schema for marconi-queues flavors resources."""
# NOTE(flaper87): capabilities can be anything. These will be unique to
# each storage driver, so we don't perform any further validation at
# the transport layer.
patch_capabilities = {
'type': 'object',
'properties': {
'capabilities': {
'type': 'object'
}
}
}
# NOTE(flaper87): a string valid
patch_pool = {
'type': 'object',
'properties': {
'pool': {
'type': 'string'
},
'additionalProperties': False
}
}
create = {
'type': 'object',
'properties': {
'pool': patch_pool['properties']['pool'],
'capabilities': patch_capabilities['properties']['capabilities']
},
# NOTE(flaper87): capabilities need not be present. Storage drivers
# must provide reasonable defaults.
'required': ['pool'],
'additionalProperties': False
}

View File

@ -58,7 +58,7 @@ class FlavorsController(base.FlavorsBase):
query['n'] = {'$gt': marker} query['n'] = {'$gt': marker}
cursor = self._col.find(query, fields=_field_spec(detailed), cursor = self._col.find(query, fields=_field_spec(detailed),
limit=limit) limit=limit).sort('n', 1)
normalizer = functools.partial(_normalize, detailed=detailed) normalizer = functools.partial(_normalize, detailed=detailed)
return utils.HookedCursor(cursor, normalizer) return utils.HookedCursor(cursor, normalizer)

View File

@ -181,4 +181,4 @@ class ControlDriver(storage.ControlDriverBase):
@property @property
def flavors_controller(self): def flavors_controller(self):
# NOTE(flaper87): Needed to avoid `abc` errors. # NOTE(flaper87): Needed to avoid `abc` errors.
raise NotImplementedError pass

View File

@ -13,6 +13,7 @@
# the License. # the License.
from zaqar.queues.transport.wsgi.v1_1 import claims from zaqar.queues.transport.wsgi.v1_1 import claims
from zaqar.queues.transport.wsgi.v1_1 import flavors
from zaqar.queues.transport.wsgi.v1_1 import health from zaqar.queues.transport.wsgi.v1_1 import health
from zaqar.queues.transport.wsgi.v1_1 import homedoc from zaqar.queues.transport.wsgi.v1_1 import homedoc
from zaqar.queues.transport.wsgi.v1_1 import messages from zaqar.queues.transport.wsgi.v1_1 import messages
@ -75,6 +76,7 @@ def public_endpoints(driver):
def private_endpoints(driver): def private_endpoints(driver):
pools_controller = driver._control.pools_controller pools_controller = driver._control.pools_controller
flavors_controller = driver._control.flavors_controller
return [ return [
('/pools', ('/pools',
@ -84,4 +86,8 @@ def private_endpoints(driver):
# Health # Health
('/health', ('/health',
health.Resource(driver._storage)), health.Resource(driver._storage)),
('/flavors',
flavors.Listing(flavors_controller)),
('/flavors/{flavor}',
flavors.Resource(flavors_controller)),
] ]

View File

@ -0,0 +1,185 @@
# Copyright (c) 2014 Red Hat, 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 falcon
import jsonschema
from zaqar.common.schemas import flavors as schema
from zaqar.common import utils as common_utils
from zaqar.openstack.common import log
from zaqar.queues.storage import errors
from zaqar.queues.transport import utils as transport_utils
from zaqar.queues.transport.wsgi import errors as wsgi_errors
from zaqar.queues.transport.wsgi import utils as wsgi_utils
LOG = log.getLogger(__name__)
class Listing(object):
"""A resource to list registered flavors
:param flavors_controller: means to interact with storage
"""
def __init__(self, flavors_controller):
self._ctrl = flavors_controller
def on_get(self, request, response, project_id):
"""Returns a flavor listing as objects embedded in an array:
[
{"href": "", "capabilities": {}, "pool": ""},
...
]
:returns: HTTP | [200, 204]
"""
LOG.debug(u'LIST flavors for project_id %s' % project_id)
store = {}
request.get_param('marker', store=store)
request.get_param_as_int('limit', store=store)
request.get_param_as_bool('detailed', store=store)
results = {}
results['flavors'] = list(self._ctrl.list(project=project_id, **store))
for entry in results['flavors']:
entry['href'] = request.path + '/' + entry.pop('name')
if not results['flavors']:
response.status = falcon.HTTP_204
return
response.content_location = request.relative_uri
response.body = transport_utils.to_json(results)
response.status = falcon.HTTP_200
class Resource(object):
"""A handler for individual flavor.
:param flavors_controller: means to interact with storage
"""
def __init__(self, flavors_controller):
self._ctrl = flavors_controller
validator_type = jsonschema.Draft4Validator
self._validators = {
'create': validator_type(schema.create),
'pool': validator_type(schema.patch_pool),
'capabilities': validator_type(schema.patch_capabilities),
}
def on_get(self, request, response, project_id, flavor):
"""Returns a JSON object for a single flavor entry:
{"pool": "", capabilities: {...}}
:returns: HTTP | [200, 404]
"""
LOG.debug(u'GET flavor - name: %s', flavor)
data = None
detailed = request.get_param_as_bool('detailed') or False
try:
data = self._ctrl.get(flavor,
project=project_id,
detailed=detailed)
except errors.FlavorDoesNotExist as ex:
LOG.debug(ex)
raise falcon.HTTPNotFound()
data['href'] = request.path
# remove the name entry - it isn't needed on GET
del data['name']
response.body = transport_utils.to_json(data)
response.content_location = request.relative_uri
def on_put(self, request, response, project_id, flavor):
"""Registers a new flavor. Expects the following input:
{"pool": "my-pool", "capabilities": {}}
A capabilities object may also be provided.
:returns: HTTP | [201]
"""
# TODO(flaper87): Verify pool exists.
LOG.debug(u'PUT flavor - name: %s', flavor)
data = wsgi_utils.load(request)
wsgi_utils.validate(self._validators['create'], data)
self._ctrl.create(flavor,
pool=data['pool'],
project=project_id,
capabilities=data['capabilities'])
response.status = falcon.HTTP_201
response.location = request.path
def on_delete(self, request, response, project_id, flavor):
"""Deregisters a flavor.
:returns: HTTP | [204]
"""
LOG.debug(u'DELETE flavor - name: %s', flavor)
self._ctrl.delete(flavor, project=project_id)
response.status = falcon.HTTP_204
def on_patch(self, request, response, project_id, flavor):
"""Allows one to update a flavors's pool and/or capabilities.
This method expects the user to submit a JSON object
containing at least one of: 'pool', 'capabilities'. If
none are found, the request is flagged as bad. There is also
strict format checking through the use of
jsonschema. Appropriate errors are returned in each case for
badly formatted input.
:returns: HTTP | [200, 400]
"""
LOG.debug(u'PATCH flavor - name: %s', flavor)
data = wsgi_utils.load(request)
EXPECT = ('pool', 'capabilities')
if not any([(field in data) for field in EXPECT]):
LOG.debug(u'PATCH flavor, bad params')
raise wsgi_errors.HTTPBadRequestBody(
'One of `pool` or `capabilities` needs '
'to be specified'
)
for field in EXPECT:
wsgi_utils.validate(self._validators[field], data)
fields = common_utils.fields(data, EXPECT,
pred=lambda v: v is not None)
try:
self._ctrl.update(flavor, project=project_id, **fields)
except errors.FlavorDoesNotExist as ex:
LOG.exception(ex)
raise falcon.HTTPNotFound()

View File

@ -15,6 +15,7 @@
from zaqar.tests.queues.transport.wsgi.v1_1 import test_auth from zaqar.tests.queues.transport.wsgi.v1_1 import test_auth
from zaqar.tests.queues.transport.wsgi.v1_1 import test_claims from zaqar.tests.queues.transport.wsgi.v1_1 import test_claims
from zaqar.tests.queues.transport.wsgi.v1_1 import test_default_limits from zaqar.tests.queues.transport.wsgi.v1_1 import test_default_limits
from zaqar.tests.queues.transport.wsgi.v1_1 import test_flavors
from zaqar.tests.queues.transport.wsgi.v1_1 import test_health from zaqar.tests.queues.transport.wsgi.v1_1 import test_health
from zaqar.tests.queues.transport.wsgi.v1_1 import test_home from zaqar.tests.queues.transport.wsgi.v1_1 import test_home
from zaqar.tests.queues.transport.wsgi.v1_1 import test_media_type from zaqar.tests.queues.transport.wsgi.v1_1 import test_media_type
@ -42,3 +43,4 @@ TestQueueLifecycleSqlalchemy = l.TestQueueLifecycleSqlalchemy
TestPoolsMongoDB = test_pools.TestPoolsMongoDB TestPoolsMongoDB = test_pools.TestPoolsMongoDB
TestPoolsSqlalchemy = test_pools.TestPoolsSqlalchemy TestPoolsSqlalchemy = test_pools.TestPoolsSqlalchemy
TestValidation = test_validation.TestValidation TestValidation = test_validation.TestValidation
TestFlavorsMongoDB = test_flavors.TestFlavorsMongoDB

View File

@ -0,0 +1,275 @@
# Copyright (c) 2014 Red Hat, 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 contextlib
import uuid
import ddt
import falcon
from zaqar.openstack.common import jsonutils
from zaqar import tests as testing
from zaqar.tests.queues.transport.wsgi import base
@contextlib.contextmanager
def flavor(test, name, pool, capabilities={}):
"""A context manager for constructing a flavor for use in testing.
Deletes the flavor after exiting the context.
:param test: Must expose simulate_* methods
:param name: Name for this flavor
:type name: six.text_type
:type pool: six.text_type
:type capabilities: dict
:returns: (name, uri, capabilities)
:rtype: see above
"""
doc = {'pool': pool, 'capabilities': capabilities}
path = test.url_prefix + '/flavors/' + name
test.simulate_put(path, body=jsonutils.dumps(doc))
try:
yield name, pool, capabilities
finally:
test.simulate_delete(path)
@contextlib.contextmanager
def flavors(test, count, pool):
"""A context manager for constructing flavors for use in testing.
Deletes the flavors after exiting the context.
:param test: Must expose simulate_* methods
:param count: Number of pools to create
:type count: int
:returns: (paths, pool, capabilities)
:rtype: ([six.text_type], [six.text_type], [dict])
"""
base = test.url_prefix + '/flavors/'
args = sorted([(base + str(i), {str(i): i}) for i in range(count)],
key=lambda tup: tup[1])
for path, capabilities in args:
doc = {'pool': pool, 'capabilities': capabilities}
test.simulate_put(path, body=jsonutils.dumps(doc))
try:
yield args
finally:
for path, _ in args:
test.simulate_delete(path)
@ddt.ddt
class FlavorsBaseTest(base.V1_1Base):
def setUp(self):
super(FlavorsBaseTest, self).setUp()
self.flavor = 'test-flavor'
self.doc = {'capabilities': {}, 'pool': 'mypool'}
self.flavor = self.url_prefix + '/flavors/' + self.flavor
self.simulate_put(self.flavor, body=jsonutils.dumps(self.doc))
self.assertEqual(self.srmock.status, falcon.HTTP_201)
def tearDown(self):
super(FlavorsBaseTest, self).tearDown()
self.simulate_delete(self.flavor)
self.assertEqual(self.srmock.status, falcon.HTTP_204)
def test_put_flavor_works(self):
name = str(uuid.uuid1())
with flavor(self, name, self.doc['pool']):
self.assertEqual(self.srmock.status, falcon.HTTP_201)
def test_put_raises_if_missing_fields(self):
path = self.url_prefix + '/flavors/' + str(uuid.uuid1())
self.simulate_put(path, body=jsonutils.dumps({}))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
self.simulate_put(path,
body=jsonutils.dumps({'capabilities': {}}))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
@ddt.data(1, 2**32+1, [])
def test_put_raises_if_invalid_pool(self, pool):
path = self.url_prefix + '/flavors/' + str(uuid.uuid1())
self.simulate_put(path,
body=jsonutils.dumps({'pool': pool}))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
@ddt.data(-1, 'wee', [])
def test_put_raises_if_invalid_capabilities(self, capabilities):
path = self.url_prefix + '/flavors/' + str(uuid.uuid1())
doc = {'pool': 'a', 'capabilities': capabilities}
self.simulate_put(path, body=jsonutils.dumps(doc))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
def test_put_existing_overwrites(self):
# NOTE(cabrera): setUp creates default flavor
expect = self.doc
self.simulate_put(self.flavor,
body=jsonutils.dumps(expect))
self.assertEqual(self.srmock.status, falcon.HTTP_201)
result = self.simulate_get(self.flavor)
self.assertEqual(self.srmock.status, falcon.HTTP_200)
doc = jsonutils.loads(result[0])
self.assertEqual(doc['pool'], expect['pool'])
def test_delete_works(self):
self.simulate_delete(self.flavor)
self.assertEqual(self.srmock.status, falcon.HTTP_204)
self.simulate_get(self.flavor)
self.assertEqual(self.srmock.status, falcon.HTTP_404)
def test_get_nonexisting_raises_404(self):
self.simulate_get(self.url_prefix + '/flavors/nonexisting')
self.assertEqual(self.srmock.status, falcon.HTTP_404)
def _flavor_expect(self, flavor, xhref, xpool):
self.assertIn('href', flavor)
self.assertEqual(flavor['href'], xhref)
self.assertIn('pool', flavor)
self.assertEqual(flavor['pool'], xpool)
def test_get_works(self):
result = self.simulate_get(self.flavor)
self.assertEqual(self.srmock.status, falcon.HTTP_200)
pool = jsonutils.loads(result[0])
self._flavor_expect(pool, self.flavor, self.doc['pool'])
def test_detailed_get_works(self):
result = self.simulate_get(self.flavor,
query_string='?detailed=True')
self.assertEqual(self.srmock.status, falcon.HTTP_200)
pool = jsonutils.loads(result[0])
self._flavor_expect(pool, self.flavor, self.doc['pool'])
self.assertIn('capabilities', pool)
self.assertEqual(pool['capabilities'], {})
def test_patch_raises_if_missing_fields(self):
self.simulate_patch(self.flavor,
body=jsonutils.dumps({'location': 1}))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
def _patch_test(self, doc):
self.simulate_patch(self.flavor,
body=jsonutils.dumps(doc))
self.assertEqual(self.srmock.status, falcon.HTTP_200)
result = self.simulate_get(self.flavor,
query_string='?detailed=True')
self.assertEqual(self.srmock.status, falcon.HTTP_200)
pool = jsonutils.loads(result[0])
self._flavor_expect(pool, self.flavor, doc['pool'])
self.assertEqual(pool['capabilities'], doc['capabilities'])
def test_patch_works(self):
doc = {'pool': 'my-pool', 'capabilities': {'a': 1}}
self._patch_test(doc)
def test_patch_works_with_extra_fields(self):
doc = {'pool': 'my-pool', 'capabilities': {'a': 1},
'location': 100, 'partition': 'taco'}
self._patch_test(doc)
@ddt.data(-1, 2**32+1, [])
def test_patch_raises_400_on_invalid_pool(self, pool):
self.simulate_patch(self.flavor,
body=jsonutils.dumps({'pool': pool}))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
@ddt.data(-1, 'wee', [])
def test_patch_raises_400_on_invalid_capabilities(self, capabilities):
doc = {'capabilities': capabilities}
self.simulate_patch(self.flavor, body=jsonutils.dumps(doc))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
def test_patch_raises_404_if_flavor_not_found(self):
self.simulate_patch(self.url_prefix + '/flavors/notexists',
body=jsonutils.dumps({'pool': 'test'}))
self.assertEqual(self.srmock.status, falcon.HTTP_404)
def test_empty_listing_returns_204(self):
self.simulate_delete(self.flavor)
self.simulate_get(self.url_prefix + '/flavors')
self.assertEqual(self.srmock.status, falcon.HTTP_204)
def _listing_test(self, count=10, limit=10,
marker=None, detailed=False):
# NOTE(cpp-cabrera): delete initial flavor - it will interfere
# with listing tests
self.simulate_delete(self.flavor)
query = '?limit={0}&detailed={1}'.format(limit, detailed)
if marker:
query += '&marker={2}'.format(marker)
with flavors(self, count, self.doc['pool']) as expected:
result = self.simulate_get(self.url_prefix + '/flavors',
query_string=query)
self.assertEqual(self.srmock.status, falcon.HTTP_200)
results = jsonutils.loads(result[0])
self.assertIsInstance(results, dict)
self.assertIn('flavors', results)
flavors_list = results['flavors']
self.assertEqual(len(flavors_list), min(limit, count))
for i, s in enumerate(flavors_list):
expect = expected[i]
path, capabilities = expect[:2]
self._flavor_expect(s, path, self.doc['pool'])
if detailed:
self.assertIn('capabilities', s)
self.assertEqual(s['capabilities'], capabilities)
else:
self.assertNotIn('capabilities', s)
def test_listing_works(self):
self._listing_test()
def test_detailed_listing_works(self):
self._listing_test(detailed=True)
@ddt.data(1, 5, 10, 15)
def test_listing_works_with_limit(self, limit):
self._listing_test(count=15, limit=limit)
def test_listing_marker_is_respected(self):
self.simulate_delete(self.flavor)
with flavors(self, 10, self.doc['pool']) as expected:
result = self.simulate_get(self.url_prefix + '/flavors',
query_string='?marker=3')
self.assertEqual(self.srmock.status, falcon.HTTP_200)
flavor_list = jsonutils.loads(result[0])['flavors']
self.assertEqual(len(flavor_list), 6)
path, capabilities = expected[4][:2]
self._flavor_expect(flavor_list[0], path, self.doc['pool'])
class TestFlavorsMongoDB(FlavorsBaseTest):
config_file = 'wsgi_mongodb.conf'
@testing.requires_mongodb
def setUp(self):
super(TestFlavorsMongoDB, self).setUp()