Deleting volume metadata keys with a single request
Deleting multiple volume metadata keys with a single request to improve performance. This patch uses etags to avoid the lost update problem with volume metadata. To inject the response's `Etag` header the index method returns webob.Response object with this tag. APIImpact Related blueprint: delete-multiple-metadata-keys Change-Id: I575635258c10f299181b8e4cdb51a7ad1f1be764
This commit is contained in:
parent
bf8bd9f358
commit
54ae45264e
cinder
api
tests/unit/api/v3
releasenotes/notes
@ -62,7 +62,8 @@ REST_API_VERSION_HISTORY = """
|
||||
* 3.12 - Add volumes summary API.
|
||||
* 3.13 - Add generic volume groups API.
|
||||
* 3.14 - Add group snapshot and create group from src APIs.
|
||||
|
||||
* 3.15 - Inject the response's `Etag` header to avoid the lost update
|
||||
problem with volume metadata.
|
||||
"""
|
||||
|
||||
# The minimum and maximum versions of the API supported
|
||||
@ -70,7 +71,7 @@ REST_API_VERSION_HISTORY = """
|
||||
# minimum version of the API supported.
|
||||
# Explicitly using /v1 or /v2 enpoints will still work
|
||||
_MIN_API_VERSION = "3.0"
|
||||
_MAX_API_VERSION = "3.14"
|
||||
_MAX_API_VERSION = "3.15"
|
||||
_LEGACY_API_VERSION1 = "1.0"
|
||||
_LEGACY_API_VERSION2 = "2.0"
|
||||
|
||||
|
@ -184,5 +184,9 @@ user documentation.
|
||||
Added create/delete/update/list/show APIs for generic volume groups.
|
||||
|
||||
3.14
|
||||
----
|
||||
Added group snapshots and create group from src APIs.
|
||||
---
|
||||
|
||||
3.15
|
||||
Added injecting the response's `Etag` header to avoid the lost update
|
||||
problem with volume metadata.
|
||||
|
@ -24,7 +24,6 @@ import cinder.api.openstack
|
||||
from cinder.api.v2 import limits
|
||||
from cinder.api.v2 import snapshot_metadata
|
||||
from cinder.api.v2 import types
|
||||
from cinder.api.v2 import volume_metadata
|
||||
from cinder.api.v3 import backups
|
||||
from cinder.api.v3 import clusters
|
||||
from cinder.api.v3 import consistencygroups
|
||||
@ -36,6 +35,7 @@ from cinder.api.v3 import messages
|
||||
from cinder.api.v3 import snapshot_manage
|
||||
from cinder.api.v3 import snapshots
|
||||
from cinder.api.v3 import volume_manage
|
||||
from cinder.api.v3 import volume_metadata
|
||||
from cinder.api.v3 import volumes
|
||||
from cinder.api import versions
|
||||
|
||||
|
80
cinder/api/v3/volume_metadata.py
Normal file
80
cinder/api/v3/volume_metadata.py
Normal file
@ -0,0 +1,80 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""The volume metadata V3 api."""
|
||||
|
||||
import hashlib
|
||||
|
||||
from oslo_serialization import jsonutils
|
||||
import six
|
||||
import webob
|
||||
|
||||
from cinder.api.openstack import wsgi
|
||||
from cinder.api.v2 import volume_metadata as volume_meta_v2
|
||||
from cinder import exception
|
||||
|
||||
|
||||
METADATA_MICRO_VERSION = '3.15'
|
||||
|
||||
|
||||
class Controller(volume_meta_v2.Controller):
|
||||
"""The volume metadata API controller for the OpenStack API."""
|
||||
def _validate_etag(self, req, volume_id):
|
||||
if not req.if_match:
|
||||
return True
|
||||
context = req.environ['cinder.context']
|
||||
metadata = self._get_metadata(context, volume_id)
|
||||
data = jsonutils.dumps({"metadata": metadata})
|
||||
if six.PY3:
|
||||
data = data.encode('utf-8')
|
||||
checksum = hashlib.md5(data).hexdigest()
|
||||
return checksum in req.if_match.etags
|
||||
|
||||
def _ensure_min_version(self, req, allowed_version):
|
||||
version = req.api_version_request
|
||||
if not version.matches(allowed_version, None):
|
||||
raise exception.VersionNotFoundForAPIMethod(version=version)
|
||||
|
||||
@wsgi.extends
|
||||
def index(self, req, volume_id):
|
||||
self._ensure_min_version(req, METADATA_MICRO_VERSION)
|
||||
metadata = super(Controller, self).index(req, volume_id)
|
||||
resp = webob.Response()
|
||||
data = jsonutils.dumps(metadata)
|
||||
if six.PY3:
|
||||
data = data.encode('utf-8')
|
||||
resp.headers['Etag'] = hashlib.md5(data).hexdigest()
|
||||
resp.body = data
|
||||
return resp
|
||||
|
||||
@wsgi.extends
|
||||
def update(self, req, volume_id, id, body):
|
||||
self._ensure_min_version(req, METADATA_MICRO_VERSION)
|
||||
if not self._validate_etag(req, volume_id):
|
||||
return webob.Response(status_int=412)
|
||||
return super(Controller, self).update(req, volume_id,
|
||||
id, body)
|
||||
|
||||
@wsgi.extends
|
||||
def update_all(self, req, volume_id, body):
|
||||
self._ensure_min_version(req, METADATA_MICRO_VERSION)
|
||||
if not self._validate_etag(req, volume_id):
|
||||
return webob.Response(status_int=412)
|
||||
return super(Controller, self).update_all(req, volume_id,
|
||||
body)
|
||||
|
||||
|
||||
def create_resource():
|
||||
return wsgi.Resource(Controller())
|
240
cinder/tests/unit/api/v3/test_volume_metadata.py
Normal file
240
cinder/tests/unit/api/v3/test_volume_metadata.py
Normal file
@ -0,0 +1,240 @@
|
||||
# Copyright 2016 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
|
||||
|
||||
import mock
|
||||
from oslo_config import cfg
|
||||
from oslo_serialization import jsonutils
|
||||
import six
|
||||
|
||||
from cinder.api import extensions
|
||||
from cinder.api.v3 import volume_metadata
|
||||
from cinder.api.v3 import volumes
|
||||
from cinder import db
|
||||
from cinder import exception
|
||||
from cinder import test
|
||||
from cinder.tests.unit.api import fakes
|
||||
from cinder.tests.unit.api.v2 import stubs
|
||||
from cinder.tests.unit import fake_constants as fake
|
||||
from cinder.tests.unit import fake_volume
|
||||
from cinder import volume
|
||||
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, meta_type):
|
||||
return stub_volume_metadata()
|
||||
|
||||
|
||||
def return_new_volume_metadata(context, volume_id, metadata,
|
||||
delete, meta_type):
|
||||
return stub_new_volume_metadata()
|
||||
|
||||
|
||||
def return_create_volume_metadata_insensitive(context, snapshot_id,
|
||||
metadata, delete,
|
||||
meta_type):
|
||||
return stub_volume_metadata_insensitive()
|
||||
|
||||
|
||||
def return_volume_metadata(context, volume_id):
|
||||
return stub_volume_metadata()
|
||||
|
||||
|
||||
def return_empty_volume_metadata(context, volume_id):
|
||||
return {}
|
||||
|
||||
|
||||
def return_empty_container_metadata(context, volume_id, metadata,
|
||||
delete, meta_type):
|
||||
return {}
|
||||
|
||||
|
||||
def stub_volume_metadata():
|
||||
metadata = {
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def stub_new_volume_metadata():
|
||||
metadata = {
|
||||
'key10': 'value10',
|
||||
'key99': 'value99',
|
||||
'KEY20': 'value20',
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def stub_volume_metadata_insensitive():
|
||||
metadata = {
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
"KEY4": "value4",
|
||||
}
|
||||
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 get_volume(*args, **kwargs):
|
||||
vol = {'name': 'fake',
|
||||
'metadata': {}}
|
||||
return fake_volume.fake_volume_obj(args[0], **vol)
|
||||
|
||||
|
||||
def return_volume_nonexistent(*args, **kwargs):
|
||||
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()
|
||||
self.mock_object(volume.api.API, 'get', get_volume)
|
||||
self.mock_object(db, 'volume_metadata_get',
|
||||
return_volume_metadata)
|
||||
self.patch(
|
||||
'cinder.db.service_get_all', autospec=True,
|
||||
return_value=stubs.stub_service_get_all_by_topic(None, None))
|
||||
|
||||
self.mock_object(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/%s/volumes/%s/metadata' % (
|
||||
fake.PROJECT_ID, 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, version="3.15")
|
||||
data = self.controller.index(req, self.req_id)
|
||||
|
||||
expected = {
|
||||
'metadata': {
|
||||
'key1': 'value1',
|
||||
'key2': 'value2',
|
||||
'key3': 'value3',
|
||||
},
|
||||
}
|
||||
expected = jsonutils.dumps(expected)
|
||||
if six.PY3:
|
||||
expected = expected.encode('utf-8')
|
||||
self.assertEqual(expected, data.body)
|
||||
|
||||
def test_index_nonexistent_volume(self):
|
||||
self.mock_object(db, 'volume_metadata_get',
|
||||
return_volume_nonexistent)
|
||||
req = fakes.HTTPRequest.blank(self.url, version="3.15")
|
||||
self.assertRaises(exception.VolumeNotFound,
|
||||
self.controller.index, req, self.url)
|
||||
|
||||
def test_index_no_data(self):
|
||||
self.mock_object(db, 'volume_metadata_get',
|
||||
return_empty_volume_metadata)
|
||||
req = fakes.HTTPRequest.blank(self.url, version="3.15")
|
||||
data = self.controller.index(req, self.req_id)
|
||||
expected = {'metadata': {}}
|
||||
expected = jsonutils.dumps(expected)
|
||||
if six.PY3:
|
||||
expected = expected.encode('utf-8')
|
||||
self.assertEqual(expected, data.body)
|
||||
|
||||
def test_validate_etag_true(self):
|
||||
self.mock_object(db, 'volume_metadata_get',
|
||||
mock.Mock(return_value={'key1': 'vanue1',
|
||||
'key2': 'value2'}))
|
||||
req = fakes.HTTPRequest.blank(self.url, version="3.15")
|
||||
req.environ['cinder.context'] = mock.Mock()
|
||||
req.if_match.etags = ['d5103bf7b26ff0310200d110da3ed186']
|
||||
self.assertTrue(self.controller._validate_etag(req, self.req_id))
|
||||
|
||||
@mock.patch.object(db, 'volume_metadata_update')
|
||||
def test_update_all(self, metadata_update):
|
||||
fake_volume = {'id': self.req_id, 'status': 'available'}
|
||||
fake_context = mock.Mock()
|
||||
metadata_update.side_effect = return_new_volume_metadata
|
||||
req = fakes.HTTPRequest.blank(self.url, version="3.15")
|
||||
req.method = 'PUT'
|
||||
req.content_type = "application/json"
|
||||
expected = {
|
||||
'metadata': {
|
||||
'key10': 'value10',
|
||||
'key99': 'value99',
|
||||
'KEY20': 'value20',
|
||||
},
|
||||
}
|
||||
req.body = jsonutils.dump_as_bytes(expected)
|
||||
req.environ['cinder.context'] = fake_context
|
||||
|
||||
with mock.patch.object(self.controller.volume_api,
|
||||
'get') as get_volume:
|
||||
get_volume.return_value = fake_volume
|
||||
res_dict = self.controller.update_all(req, self.req_id, expected)
|
||||
self.assertEqual(expected, res_dict)
|
||||
get_volume.assert_called_once_with(fake_context, self.req_id)
|
||||
|
||||
@mock.patch.object(db, 'volume_metadata_update')
|
||||
def test_update_item(self, metadata_update):
|
||||
fake_volume = {'id': self.req_id, 'status': 'available'}
|
||||
fake_context = mock.Mock()
|
||||
metadata_update.side_effect = return_create_volume_metadata
|
||||
req = fakes.HTTPRequest.blank(self.url + '/key1', version="3.15")
|
||||
req.method = 'PUT'
|
||||
body = {"meta": {"key1": "value1"}}
|
||||
req.body = jsonutils.dump_as_bytes(body)
|
||||
req.headers["content-type"] = "application/json"
|
||||
req.environ['cinder.context'] = fake_context
|
||||
|
||||
with mock.patch.object(self.controller.volume_api,
|
||||
'get') as get_volume:
|
||||
get_volume.return_value = fake_volume
|
||||
res_dict = self.controller.update(req, self.req_id, 'key1', body)
|
||||
expected = {'meta': {'key1': 'value1'}}
|
||||
self.assertEqual(expected, res_dict)
|
||||
get_volume.assert_called_once_with(fake_context, self.req_id)
|
@ -0,0 +1,3 @@
|
||||
---
|
||||
features:
|
||||
- Added using etags to avoid the lost update problem during deleting volume metadata.
|
Loading…
x
Reference in New Issue
Block a user