From 4e3fa390b13d7c0509e72f43c4cf24ca1a6bd14d Mon Sep 17 00:00:00 2001 From: Mike Perez Date: Tue, 17 Sep 2013 02:41:59 -0700 Subject: [PATCH] Add volume metadata to v2 This exposes volume metadata in the Cinder v2 API. This already exists in v1 and was originally suppose to exist back with Grizzly, but appears to have slipped. Closes-Bug: #1227979 Change-Id: I512dad591d7d491eca54a230d3cc290d9a349e6f --- cinder/api/v2/router.py | 15 + cinder/api/v2/volume_metadata.py | 163 +++++++ cinder/tests/api/v2/test_volume_metadata.py | 451 ++++++++++++++++++++ 3 files changed, 629 insertions(+) create mode 100644 cinder/api/v2/volume_metadata.py create mode 100644 cinder/tests/api/v2/test_volume_metadata.py diff --git a/cinder/api/v2/router.py b/cinder/api/v2/router.py index bcd668470..34eb81933 100644 --- a/cinder/api/v2/router.py +++ b/cinder/api/v2/router.py @@ -27,6 +27,7 @@ from cinder.api.v2 import limits from cinder.api.v2 import snapshot_metadata from cinder.api.v2 import snapshots from cinder.api.v2 import types +from cinder.api.v2 import volume_metadata from cinder.api.v2 import volumes from cinder.api import versions from cinder.openstack.common import log as logging @@ -78,3 +79,17 @@ class APIRouter(cinder.api.openstack.APIRouter): controller=snapshot_metadata_controller, parent_resource=dict(member_name='snapshot', collection_name='snapshots')) + self.resources['volume_metadata'] = \ + volume_metadata.create_resource() + volume_metadata_controller = self.resources['volume_metadata'] + + mapper.resource("volume_metadata", "metadata", + controller=volume_metadata_controller, + parent_resource=dict(member_name='volume', + collection_name='volumes')) + + mapper.connect("metadata", + "/{project_id}/volumes/{volume_id}/metadata", + controller=volume_metadata_controller, + action='update_all', + conditions={"method": ['PUT']}) diff --git a/cinder/api/v2/volume_metadata.py b/cinder/api/v2/volume_metadata.py new file mode 100644 index 000000000..b9449da0c --- /dev/null +++ b/cinder/api/v2/volume_metadata.py @@ -0,0 +1,163 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation. +# All Rights Reserved. +# +# 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 webob + +from cinder.api import common +from cinder.api.openstack import wsgi +from cinder import exception +from cinder import volume + + +class Controller(object): + """The volume metadata API controller for the OpenStack API.""" + + def __init__(self): + self.volume_api = volume.API() + super(Controller, self).__init__() + + def _get_metadata(self, context, volume_id): + try: + volume = self.volume_api.get(context, volume_id) + meta = self.volume_api.get_volume_metadata(context, volume) + except exception.VolumeNotFound: + msg = _('volume does not exist') + raise webob.exc.HTTPNotFound(explanation=msg) + return meta + + @wsgi.serializers(xml=common.MetadataTemplate) + def index(self, req, volume_id): + """Returns the list of metadata for a given volume.""" + context = req.environ['cinder.context'] + return {'metadata': self._get_metadata(context, volume_id)} + + @wsgi.serializers(xml=common.MetadataTemplate) + @wsgi.deserializers(xml=common.MetadataDeserializer) + def create(self, req, volume_id, body): + try: + metadata = body['metadata'] + except (KeyError, TypeError): + msg = _("Malformed request body") + raise webob.exc.HTTPBadRequest(explanation=msg) + + context = req.environ['cinder.context'] + + new_metadata = self._update_volume_metadata(context, + volume_id, + metadata, + delete=False) + + return {'metadata': new_metadata} + + @wsgi.serializers(xml=common.MetaItemTemplate) + @wsgi.deserializers(xml=common.MetaItemDeserializer) + def update(self, req, volume_id, id, body): + try: + meta_item = body['meta'] + except (TypeError, KeyError): + expl = _('Malformed request body') + raise webob.exc.HTTPBadRequest(explanation=expl) + + if id not in meta_item: + expl = _('Request body and URI mismatch') + raise webob.exc.HTTPBadRequest(explanation=expl) + + if len(meta_item) > 1: + expl = _('Request body contains too many items') + raise webob.exc.HTTPBadRequest(explanation=expl) + + context = req.environ['cinder.context'] + self._update_volume_metadata(context, + volume_id, + meta_item, + delete=False) + + return {'meta': meta_item} + + @wsgi.serializers(xml=common.MetadataTemplate) + @wsgi.deserializers(xml=common.MetadataDeserializer) + def update_all(self, req, volume_id, body): + try: + metadata = body['metadata'] + except (TypeError, KeyError): + expl = _('Malformed request body') + raise webob.exc.HTTPBadRequest(explanation=expl) + + context = req.environ['cinder.context'] + new_metadata = self._update_volume_metadata(context, + volume_id, + metadata, + delete=True) + + return {'metadata': new_metadata} + + def _update_volume_metadata(self, context, + volume_id, metadata, + delete=False): + try: + volume = self.volume_api.get(context, volume_id) + return self.volume_api.update_volume_metadata(context, + volume, + metadata, + delete) + except exception.VolumeNotFound: + msg = _('volume does not exist') + raise webob.exc.HTTPNotFound(explanation=msg) + + except (ValueError, AttributeError): + msg = _("Malformed request body") + raise webob.exc.HTTPBadRequest(explanation=msg) + + except exception.InvalidVolumeMetadata as error: + raise webob.exc.HTTPBadRequest(explanation=error.msg) + + except exception.InvalidVolumeMetadataSize as error: + raise webob.exc.HTTPRequestEntityTooLarge(explanation=error.msg) + + @wsgi.serializers(xml=common.MetaItemTemplate) + def show(self, req, volume_id, id): + """Return a single metadata item.""" + context = req.environ['cinder.context'] + data = self._get_metadata(context, volume_id) + + try: + return {'meta': {id: data[id]}} + except KeyError: + msg = _("Metadata item was not found") + raise webob.exc.HTTPNotFound(explanation=msg) + + def delete(self, req, volume_id, id): + """Deletes an existing metadata.""" + context = req.environ['cinder.context'] + + metadata = self._get_metadata(context, volume_id) + + if id not in metadata: + msg = _("Metadata item was not found") + raise webob.exc.HTTPNotFound(explanation=msg) + + try: + volume = self.volume_api.get(context, volume_id) + self.volume_api.delete_volume_metadata(context, volume, id) + except exception.VolumeNotFound: + msg = _('volume does not exist') + raise webob.exc.HTTPNotFound(explanation=msg) + return webob.Response(status_int=200) + + +def create_resource(): + return wsgi.Resource(Controller()) diff --git a/cinder/tests/api/v2/test_volume_metadata.py b/cinder/tests/api/v2/test_volume_metadata.py new file mode 100644 index 000000000..16ea3b7ce --- /dev/null +++ b/cinder/tests/api/v2/test_volume_metadata.py @@ -0,0 +1,451 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation. +# All Rights Reserved. +# +# 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 uuid + +from oslo.config import cfg +import webob + +from cinder.api import extensions +from cinder.api.v2 import volume_metadata +from cinder.api.v2 import volumes +from cinder import db +from cinder import exception +from cinder.openstack.common import jsonutils +from cinder import test +from cinder.tests.api import fakes +from cinder.tests.api.v2 import stubs +from cinder.volume import api as volume_api + + +CONF = cfg.CONF + + +def return_create_volume_metadata_max(context, volume_id, metadata, delete): + return stub_max_volume_metadata() + + +def return_create_volume_metadata(context, volume_id, metadata, delete): + return stub_volume_metadata() + + +def return_volume_metadata(context, volume_id): + if not isinstance(volume_id, str) or not len(volume_id) == 36: + msg = 'id %s must be a uuid in return volume metadata' % volume_id + raise Exception(msg) + return stub_volume_metadata() + + +def return_empty_volume_metadata(context, volume_id): + return {} + + +def delete_volume_metadata(context, volume_id, key): + pass + + +def stub_volume_metadata(): + metadata = { + "key1": "value1", + "key2": "value2", + "key3": "value3", + } + return metadata + + +def stub_max_volume_metadata(): + metadata = {"metadata": {}} + for num in range(CONF.quota_metadata_items): + metadata['metadata']['key%i' % num] = "blah" + return metadata + + +def return_volume(context, volume_id): + return {'id': '0cc3346e-9fef-4445-abe6-5d2b2690ec64', + 'name': 'fake', + 'metadata': {}} + + +def return_volume_nonexistent(context, volume_id): + raise exception.VolumeNotFound('bogus test message') + + +def fake_update_volume_metadata(self, context, volume, diff): + pass + + +class volumeMetaDataTest(test.TestCase): + + def setUp(self): + super(volumeMetaDataTest, self).setUp() + self.volume_api = volume_api.API() + fakes.stub_out_key_pair_funcs(self.stubs) + self.stubs.Set(db, 'volume_get', return_volume) + self.stubs.Set(db, 'volume_metadata_get', + return_volume_metadata) + self.stubs.Set(db, 'service_get_all_by_topic', + stubs.stub_service_get_all_by_topic) + + self.stubs.Set(self.volume_api, 'update_volume_metadata', + fake_update_volume_metadata) + + self.ext_mgr = extensions.ExtensionManager() + self.ext_mgr.extensions = {} + self.volume_controller = volumes.VolumeController(self.ext_mgr) + self.controller = volume_metadata.Controller() + self.req_id = str(uuid.uuid4()) + self.url = '/v2/fake/volumes/%s/metadata' % self.req_id + + vol = {"size": 100, + "display_name": "Volume Test Name", + "display_description": "Volume Test Desc", + "availability_zone": "zone1:host1", + "metadata": {}} + body = {"volume": vol} + req = fakes.HTTPRequest.blank('/v2/volumes') + self.volume_controller.create(req, body) + + def test_index(self): + req = fakes.HTTPRequest.blank(self.url) + res_dict = self.controller.index(req, self.req_id) + + expected = { + 'metadata': { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }, + } + self.assertEqual(expected, res_dict) + + def test_index_nonexistent_volume(self): + self.stubs.Set(db, 'volume_metadata_get', + return_volume_nonexistent) + req = fakes.HTTPRequest.blank(self.url) + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.index, req, self.url) + + def test_index_no_data(self): + self.stubs.Set(db, 'volume_metadata_get', + return_empty_volume_metadata) + req = fakes.HTTPRequest.blank(self.url) + res_dict = self.controller.index(req, self.req_id) + expected = {'metadata': {}} + self.assertEqual(expected, res_dict) + + def test_show(self): + req = fakes.HTTPRequest.blank(self.url + '/key2') + res_dict = self.controller.show(req, self.req_id, 'key2') + expected = {'meta': {'key2': 'value2'}} + self.assertEqual(expected, res_dict) + + def test_show_nonexistent_volume(self): + self.stubs.Set(db, 'volume_metadata_get', + return_volume_nonexistent) + req = fakes.HTTPRequest.blank(self.url + '/key2') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, req, self.req_id, 'key2') + + def test_show_meta_not_found(self): + self.stubs.Set(db, 'volume_metadata_get', + return_empty_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key6') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, req, self.req_id, 'key6') + + def test_delete(self): + self.stubs.Set(db, 'volume_metadata_get', + return_volume_metadata) + self.stubs.Set(db, 'volume_metadata_delete', + delete_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key2') + req.method = 'DELETE' + res = self.controller.delete(req, self.req_id, 'key2') + + self.assertEqual(200, res.status_int) + + def test_delete_nonexistent_volume(self): + self.stubs.Set(db, 'volume_get', + return_volume_nonexistent) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'DELETE' + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.delete, req, self.req_id, 'key1') + + def test_delete_meta_not_found(self): + self.stubs.Set(db, 'volume_metadata_get', + return_empty_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key6') + req.method = 'DELETE' + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.delete, req, self.req_id, 'key6') + + def test_create(self): + self.stubs.Set(db, 'volume_metadata_get', + return_empty_volume_metadata) + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + + req = fakes.HTTPRequest.blank('/v2/volume_metadata') + req.method = 'POST' + req.content_type = "application/json" + body = {"metadata": {"key9": "value9"}} + req.body = jsonutils.dumps(body) + res_dict = self.controller.create(req, self.req_id, body) + self.assertEqual(body, res_dict) + + def test_create_empty_body(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'POST' + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.create, req, self.req_id, None) + + def test_create_item_empty_key(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {"": "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.create, req, self.req_id, body) + + def test_create_item_key_too_long(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {("a" * 260): "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.create, + req, self.req_id, body) + + def test_create_nonexistent_volume(self): + self.stubs.Set(db, 'volume_get', + return_volume_nonexistent) + self.stubs.Set(db, 'volume_metadata_get', + return_volume_metadata) + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + + req = fakes.HTTPRequest.blank('/v2/volume_metadata') + req.method = 'POST' + req.content_type = "application/json" + body = {"metadata": {"key9": "value9"}} + req.body = jsonutils.dumps(body) + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.create, req, self.req_id, body) + + def test_update_all(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'PUT' + req.content_type = "application/json" + expected = { + 'metadata': { + 'key10': 'value10', + 'key99': 'value99', + }, + } + req.body = jsonutils.dumps(expected) + res_dict = self.controller.update_all(req, self.req_id, expected) + + self.assertEqual(expected, res_dict) + + def test_update_all_empty_container(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'PUT' + req.content_type = "application/json" + expected = {'metadata': {}} + req.body = jsonutils.dumps(expected) + res_dict = self.controller.update_all(req, self.req_id, expected) + + self.assertEqual(expected, res_dict) + + def test_update_all_malformed_container(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'PUT' + req.content_type = "application/json" + expected = {'meta': {}} + req.body = jsonutils.dumps(expected) + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.update_all, req, self.req_id, + expected) + + def test_update_all_malformed_data(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'PUT' + req.content_type = "application/json" + expected = {'metadata': ['asdf']} + req.body = jsonutils.dumps(expected) + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.update_all, req, self.req_id, + expected) + + def test_update_all_nonexistent_volume(self): + self.stubs.Set(db, 'volume_get', return_volume_nonexistent) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'PUT' + req.content_type = "application/json" + body = {'metadata': {'key10': 'value10'}} + req.body = jsonutils.dumps(body) + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.update_all, req, '100', body) + + def test_update_item(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {"key1": "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + res_dict = self.controller.update(req, self.req_id, 'key1', body) + expected = {'meta': {'key1': 'value1'}} + self.assertEqual(expected, res_dict) + + def test_update_item_nonexistent_volume(self): + self.stubs.Set(db, 'volume_get', + return_volume_nonexistent) + req = fakes.HTTPRequest.blank('/v2/fake/volumes/asdf/metadata/key1') + req.method = 'PUT' + body = {"meta": {"key1": "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.update, req, self.req_id, 'key1', + body) + + def test_update_item_empty_body(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.update, req, self.req_id, 'key1', + None) + + def test_update_item_empty_key(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {"": "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.update, req, self.req_id, '', body) + + def test_update_item_key_too_long(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {("a" * 260): "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.update, + req, self.req_id, ("a" * 260), body) + + def test_update_item_value_too_long(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {"key1": ("a" * 260)}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.update, + req, self.req_id, "key1", body) + + def test_update_item_too_many_keys(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {"key1": "value1", "key2": "value2"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.update, req, self.req_id, 'key1', + body) + + def test_update_item_body_uri_mismatch(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url + '/bad') + req.method = 'PUT' + body = {"meta": {"key1": "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.update, req, self.req_id, 'bad', + body) + + def test_invalid_metadata_items_on_create(self): + self.stubs.Set(db, 'volume_metadata_update', + return_create_volume_metadata) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'POST' + req.headers["content-type"] = "application/json" + + #test for long key + data = {"metadata": {"a" * 260: "value1"}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.create, req, self.req_id, data) + + #test for long value + data = {"metadata": {"key": "v" * 260}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.create, req, self.req_id, data) + + #test for empty key. + data = {"metadata": {"": "value1"}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.create, req, self.req_id, data) -- 2.45.2