diff --git a/manila/api/v1/router.py b/manila/api/v1/router.py index 9a5a050b9f..7d3f2c349b 100644 --- a/manila/api/v1/router.py +++ b/manila/api/v1/router.py @@ -26,6 +26,7 @@ import manila.api.openstack from manila.api.v1 import limits from manila.api import versions +from manila.api.v1 import share_metadata from manila.api.v1 import share_snapshots from manila.api.v1 import shares @@ -62,6 +63,20 @@ class APIRouter(manila.api.openstack.APIRouter): collection={'detail': 'GET'}, member={'action': 'POST'}) + self.resources['share_metadata'] = share_metadata.create_resource() + share_metadata_controller = self.resources['share_metadata'] + + mapper.resource("share_metadata", "metadata", + controller=share_metadata_controller, + parent_resource=dict(member_name='share', + collection_name='shares')) + + mapper.connect("metadata", + "/{project_id}/shares/{share_id}/metadata", + controller=share_metadata_controller, + action='update_all', + conditions={"method": ['PUT']}) + self.resources['limits'] = limits.create_resource() mapper.resource("limit", "limits", controller=self.resources['limits']) diff --git a/manila/api/v1/share_metadata.py b/manila/api/v1/share_metadata.py new file mode 100644 index 0000000000..37a9fb1d2c --- /dev/null +++ b/manila/api/v1/share_metadata.py @@ -0,0 +1,161 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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 manila.api import common +from manila.api.openstack import wsgi +from manila import exception +from manila import share +from webob import exc + + +class ShareMetadataController(object): + """The share metadata API controller for the OpenStack API.""" + + def __init__(self): + self.share_api = share.API() + super(ShareMetadataController, self).__init__() + + def _get_metadata(self, context, share_id): + try: + share = self.share_api.get(context, share_id) + meta = self.share_api.get_share_metadata(context, share) + except exception.NotFound: + msg = _('share does not exist') + raise exc.HTTPNotFound(explanation=msg) + return meta + + @wsgi.serializers(xml=common.MetadataTemplate) + def index(self, req, share_id): + """Returns the list of metadata for a given share.""" + context = req.environ['manila.context'] + return {'metadata': self._get_metadata(context, share_id)} + + @wsgi.serializers(xml=common.MetadataTemplate) + @wsgi.deserializers(xml=common.MetadataDeserializer) + def create(self, req, share_id, body): + try: + metadata = body['metadata'] + except (KeyError, TypeError): + msg = _("Malformed request body") + raise exc.HTTPBadRequest(explanation=msg) + + context = req.environ['manila.context'] + + new_metadata = self._update_share_metadata(context, + share_id, + metadata, + delete=False) + + return {'metadata': new_metadata} + + @wsgi.serializers(xml=common.MetaItemTemplate) + @wsgi.deserializers(xml=common.MetaItemDeserializer) + def update(self, req, share_id, id, body): + try: + meta_item = body['meta'] + except (TypeError, KeyError): + expl = _('Malformed request body') + raise exc.HTTPBadRequest(explanation=expl) + + if id not in meta_item: + expl = _('Request body and URI mismatch') + raise exc.HTTPBadRequest(explanation=expl) + + if len(meta_item) > 1: + expl = _('Request body contains too many items') + raise exc.HTTPBadRequest(explanation=expl) + + context = req.environ['manila.context'] + self._update_share_metadata(context, + share_id, + meta_item, + delete=False) + + return {'meta': meta_item} + + @wsgi.serializers(xml=common.MetadataTemplate) + @wsgi.deserializers(xml=common.MetadataDeserializer) + def update_all(self, req, share_id, body): + try: + metadata = body['metadata'] + except (TypeError, KeyError): + expl = _('Malformed request body') + raise exc.HTTPBadRequest(explanation=expl) + + context = req.environ['manila.context'] + new_metadata = self._update_share_metadata(context, share_id, + metadata, delete=True) + return {'metadata': new_metadata} + + def _update_share_metadata(self, context, + share_id, metadata, + delete=False): + try: + share = self.share_api.get(context, share_id) + return self.share_api.update_share_metadata(context, + share, + metadata, + delete) + except exception.NotFound: + msg = _('share does not exist') + raise exc.HTTPNotFound(explanation=msg) + + except (ValueError, AttributeError): + msg = _("Malformed request body") + raise exc.HTTPBadRequest(explanation=msg) + + except exception.InvalidShareMetadata as error: + raise exc.HTTPBadRequest(explanation=error.msg) + + except exception.InvalidShareMetadataSize as error: + raise exc.HTTPBadRequest(explanation=error.msg) + + @wsgi.serializers(xml=common.MetaItemTemplate) + def show(self, req, share_id, id): + """Return a single metadata item.""" + context = req.environ['manila.context'] + data = self._get_metadata(context, share_id) + + try: + return {'meta': {id: data[id]}} + except KeyError: + msg = _("Metadata item was not found") + raise exc.HTTPNotFound(explanation=msg) + + def delete(self, req, share_id, id): + """Deletes an existing metadata.""" + context = req.environ['manila.context'] + + metadata = self._get_metadata(context, share_id) + + if id not in metadata: + msg = _("Metadata item was not found") + raise exc.HTTPNotFound(explanation=msg) + + try: + share = self.share_api.get(context, share_id) + self.share_api.delete_share_metadata(context, share, id) + except exception.NotFound: + msg = _('share does not exist') + raise exc.HTTPNotFound(explanation=msg) + return webob.Response(status_int=200) + + +def create_resource(): + return wsgi.Resource(ShareMetadataController()) diff --git a/manila/api/v1/shares.py b/manila/api/v1/shares.py index a33f40109d..527b47e4f3 100644 --- a/manila/api/v1/shares.py +++ b/manila/api/v1/shares.py @@ -205,6 +205,8 @@ class ShareController(wsgi.Controller): kwargs = {} kwargs['availability_zone'] = share.get('availability_zone') + kwargs['metadata'] = share.get('metadata', None) + snapshot_id = share.get('snapshot_id') if snapshot_id: kwargs['snapshot'] = self.share_api.get_snapshot(context, diff --git a/manila/api/views/shares.py b/manila/api/views/shares.py index b97c6f5ea2..89a5719385 100644 --- a/manila/api/views/shares.py +++ b/manila/api/views/shares.py @@ -44,6 +44,12 @@ class ViewBuilder(common.ViewBuilder): def detail(self, request, share): """Detailed view of a single share.""" + metadata = share.get('share_metadata') + if metadata: + metadata = dict((item['key'], item['value']) for item in metadata) + else: + metadata = {} + return { 'share': { 'id': share.get('id'), @@ -56,6 +62,7 @@ class ViewBuilder(common.ViewBuilder): 'snapshot_id': share.get('snapshot_id'), 'share_proto': share.get('share_proto'), 'export_location': share.get('export_location'), + 'metadata': metadata, 'links': self._get_links(request, share['id']) } } diff --git a/manila/db/api.py b/manila/db/api.py index c89ccd7ea8..c633bfe1c3 100644 --- a/manila/db/api.py +++ b/manila/db/api.py @@ -430,3 +430,18 @@ def share_snapshot_data_get_for_project(context, project_id, session=None): #################### + + +def share_metadata_get(context, share_id): + """Get all metadata for a share.""" + return IMPL.share_metadata_get(context, share_id) + + +def share_metadata_delete(context, share_id, key): + """Delete the given metadata item.""" + IMPL.share_metadata_delete(context, share_id, key) + + +def share_metadata_update(context, share, metadata, delete): + """Update metadata if it exists, otherwise create it.""" + IMPL.share_metadata_update(context, share, metadata, delete) diff --git a/manila/db/sqlalchemy/api.py b/manila/db/sqlalchemy/api.py index bd8c44f8a2..078e1ed156 100644 --- a/manila/db/sqlalchemy/api.py +++ b/manila/db/sqlalchemy/api.py @@ -128,6 +128,20 @@ def require_context(f): return wrapper +def require_share_exists(f): + """Decorator to require the specified share to exist. + + Requires the wrapped function to use context and share_id as + their first two arguments. + """ + + def wrapper(context, share_id, *args, **kwargs): + db.share_get(context, share_id) + return f(context, share_id, *args, **kwargs) + wrapper.__name__ = f.__name__ + return wrapper + + def model_query(context, *args, **kwargs): """Query helper that accounts for context's `read_deleted` field. @@ -1037,11 +1051,25 @@ def reservation_expire(context): def _share_get_query(context, session=None): if session is None: session = get_session() - return model_query(context, models.Share, session=session) + return model_query(context, models.Share, session=session).\ + options(joinedload('share_metadata')) + + +def _metadata_refs(metadata_dict, meta_class): + metadata_refs = [] + if metadata_dict: + for k, v in metadata_dict.iteritems(): + metadata_ref = meta_class() + metadata_ref['key'] = k + metadata_ref['value'] = v + metadata_refs.append(metadata_ref) + return metadata_refs @require_context def share_create(context, values): + values['share_metadata'] = _metadata_refs(values.get('metadata'), + models.ShareMetadata) share_ref = models.Share() if not values.get('id'): values['id'] = str(uuid.uuid4()) @@ -1112,6 +1140,12 @@ def share_delete(context, share_id): 'deleted_at': timeutils.utcnow(), 'updated_at': literal_column('updated_at'), 'status': 'deleted'}) + session.query(models.ShareMetadata).\ + filter_by(share_id=share_id).\ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + share_ref.save(session) @@ -1284,3 +1318,99 @@ def share_snapshot_update(context, snapshot_id, values): snapshot_ref.update(values) snapshot_ref.save(session=session) return snapshot_ref + + +################################# + + +@require_context +@require_share_exists +def share_metadata_get(context, share_id): + return _share_metadata_get(context, share_id) + + +@require_context +@require_share_exists +def share_metadata_delete(context, share_id, key): + _share_metadata_get_query(context, share_id).\ + filter_by(key=key).\ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + + +@require_context +@require_share_exists +def share_metadata_update(context, share_id, metadata, delete): + return _share_metadata_update(context, share_id, metadata, delete) + + +def _share_metadata_get_query(context, share_id, session=None): + return model_query(context, models.ShareMetadata, session=session, + read_deleted="no").\ + filter_by(share_id=share_id) + + +@require_context +@require_share_exists +def _share_metadata_get(context, share_id, session=None): + rows = _share_metadata_get_query(context, share_id, + session=session).all() + result = {} + for row in rows: + result[row['key']] = row['value'] + + return result + + +@require_context +@require_share_exists +def _share_metadata_update(context, share_id, metadata, delete, session=None): + if not session: + session = get_session() + + with session.begin(): + # Set existing metadata to deleted if delete argument is True + if delete: + original_metadata = _share_metadata_get(context, share_id, + session=session) + for meta_key, meta_value in original_metadata.iteritems(): + if meta_key not in metadata: + meta_ref = _share_metadata_get_item(context, share_id, + meta_key, + session=session) + meta_ref.update({'deleted': True}) + meta_ref.save(session=session) + + meta_ref = None + + # Now update all existing items with new values, or create new meta + # objects + for meta_key, meta_value in metadata.items(): + + # update the value whether it exists or not + item = {"value": meta_value} + + try: + meta_ref = _share_metadata_get_item(context, share_id, + meta_key, + session=session) + except exception.ShareMetadataNotFound: + meta_ref = models.ShareMetadata() + item.update({"key": meta_key, "share_id": share_id}) + + meta_ref.update(item) + meta_ref.save(session=session) + + return metadata + + +def _share_metadata_get_item(context, share_id, key, session=None): + result = _share_metadata_get_query(context, share_id, session=session).\ + filter_by(key=key).\ + first() + + if not result: + raise exception.ShareMetadataNotFound(metadata_key=key, + share_id=share_id) + return result diff --git a/manila/db/sqlalchemy/migrate_repo/versions/006_add_share_metadata.py b/manila/db/sqlalchemy/migrate_repo/versions/006_add_share_metadata.py new file mode 100644 index 0000000000..7d40408db0 --- /dev/null +++ b/manila/db/sqlalchemy/migrate_repo/versions/006_add_share_metadata.py @@ -0,0 +1,61 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation +# +# 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. + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer +from sqlalchemy import MetaData, String, Table + +from manila.openstack.common.gettextutils import _ +from manila.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +def upgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + shares = Table('shares', meta, autoload=True) + + share_metadata = Table('share_metadata', meta, + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('deleted_at', DateTime), + Column('deleted', Boolean), + Column('id', Integer, primary_key=True, nullable=False), + Column('share_id', String(length=36), ForeignKey('shares.id'), + nullable=False), + Column('key', String(length=255), nullable=False), + Column('value', String(length=1024), nullable=False), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + try: + share_metadata.create() + except Exception: + LOG.exception("Exception while creating table 'share_metadata'") + meta.drop_all(tables=[share_metadata]) + raise + + +def downgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + share_metadata = Table('share_metadata', meta, autoload=True) + try: + share_metadata.drop() + except Exception: + LOG.error(_("share_metadata table not dropped")) + raise diff --git a/manila/db/sqlalchemy/models.py b/manila/db/sqlalchemy/models.py index 64cfc75dcc..fd791f3f7b 100644 --- a/manila/db/sqlalchemy/models.py +++ b/manila/db/sqlalchemy/models.py @@ -21,7 +21,7 @@ SQLAlchemy models for Manila data. """ -from sqlalchemy import Column, Integer, String, Text, schema +from sqlalchemy import Column, Index, Integer, String, Text, schema from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.declarative import declarative_base from sqlalchemy import ForeignKey, DateTime, Boolean, Enum @@ -257,6 +257,20 @@ class Share(BASE, ManilaBase): export_location = Column(String(255)) +class ShareMetadata(BASE, ManilaBase): + """Represents a metadata key/value pair for a share.""" + __tablename__ = 'share_metadata' + id = Column(Integer, primary_key=True) + key = Column(String(255), nullable=False) + value = Column(String(1024), nullable=False) + share_id = Column(String(36), ForeignKey('shares.id'), nullable=False) + share = relationship(Share, backref="share_metadata", + foreign_keys=share_id, + primaryjoin='and_(' + 'ShareMetadata.share_id == Share.id,' + 'ShareMetadata.deleted == False)') + + class ShareAccessMapping(BASE, ManilaBase): """Represents access to NFS.""" STATE_NEW = 'new' diff --git a/manila/exception.py b/manila/exception.py index f03c164c4e..52df4f3ece 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -131,6 +131,7 @@ class ManilaException(Exception): # at least get the core message out if something happened message = self.message + self.msg = message super(ManilaException, self).__init__(message) @@ -478,3 +479,15 @@ class InvalidShareSnapshot(ManilaException): class SwiftConnectionFailed(ManilaException): message = _("Connection to swift failed") + ": %(reason)s" + + +class ShareMetadataNotFound(NotFound): + message = _("Metadata item is not found") + + +class InvalidShareMetadata(Invalid): + message = _("Invalid metadata") + + +class InvalidShareMetadataSize(Invalid): + message = _("Invalid metadata size") diff --git a/manila/share/api.py b/manila/share/api.py index 77b41a14fd..401763aeb8 100644 --- a/manila/share/api.py +++ b/manila/share/api.py @@ -77,9 +77,12 @@ class API(base.Base): super(API, self).__init__(db_driver) def create(self, context, share_proto, size, name, description, - snapshot=None, availability_zone=None): + snapshot=None, availability_zone=None, metadata=None): """Create new share.""" check_policy(context, 'create') + + self._check_metadata_properties(context, metadata) + if snapshot is not None: if snapshot['status'] != 'available': msg = _('status must be available') @@ -151,6 +154,7 @@ class API(base.Base): 'project_id': context.project_id, 'snapshot_id': snapshot_id, 'availability_zone': availability_zone, + 'metadata': metadata, 'status': "creating", 'scheduled_at': timeutils.utcnow(), 'display_name': name, @@ -423,3 +427,54 @@ class API(base.Base): check_policy(context, 'access_get') rule = self.db.share_access_get(context, access_id) return rule + + @wrap_check_policy + def get_share_metadata(self, context, share): + """Get all metadata associated with a share.""" + rv = self.db.share_metadata_get(context, share['id']) + return dict(rv.iteritems()) + + @wrap_check_policy + def delete_share_metadata(self, context, share, key): + """Delete the given metadata item from a share.""" + self.db.share_metadata_delete(context, share['id'], key) + + def _check_metadata_properties(self, context, metadata=None): + if not metadata: + metadata = {} + + for k, v in metadata.iteritems(): + if len(k) == 0: + msg = _("Metadata property key is blank") + LOG.warn(msg) + raise exception.InvalidShareMetadata(message=msg) + if len(k) > 255: + msg = _("Metadata property key is greater than 255 characters") + LOG.warn(msg) + raise exception.InvalidShareMetadataSize(message=msg) + if len(v) > 1024: + msg = _("Metadata property value is " + "greater than 1024 characters") + LOG.warn(msg) + raise exception.InvalidShareMetadataSize(message=msg) + + @wrap_check_policy + def update_share_metadata(self, context, share, metadata, delete=False): + """Updates or creates share metadata. + + If delete is True, metadata items that are not specified in the + `metadata` argument will be deleted. + + """ + orig_meta = self.get_share_metadata(context, share) + if delete: + _metadata = metadata + else: + _metadata = orig_meta.copy() + _metadata.update(metadata) + + self._check_metadata_properties(context, _metadata) + self.db.share_metadata_update(context, share['id'], + _metadata, delete) + + return _metadata diff --git a/manila/test.py b/manila/test.py index 46db4b6fdc..337aa00900 100644 --- a/manila/test.py +++ b/manila/test.py @@ -284,3 +284,12 @@ class TestCase(unittest.TestCase): self.assertFalse(a in b, *args, **kwargs) else: f(a, b, *args, **kwargs) + + def assertIsInstance(self, a, b, *args, **kwargs): + """Python < v2.7 compatibility.""" + try: + f = super(TestCase, self).assertIsInstance + except AttributeError: + self.assertTrue(isinstance(a, b)) + else: + f(a, b, *args, **kwargs) diff --git a/manila/tests/api/v1/test_share_metadata.py b/manila/tests/api/v1/test_share_metadata.py new file mode 100644 index 0000000000..16665f22fe --- /dev/null +++ b/manila/tests/api/v1/test_share_metadata.py @@ -0,0 +1,446 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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 manila.api import extensions +from manila.api.v1 import share_metadata +from manila.api.v1 import shares +import manila.db +from manila import exception +from manila.openstack.common import jsonutils +from manila import test +from manila.tests.api import fakes + + +CONF = cfg.CONF + + +def return_create_share_metadata_max(context, share_id, metadata, delete): + return stub_max_share_metadata() + + +def return_create_share_metadata(context, share_id, metadata, delete): + return stub_share_metadata() + + +def return_share_metadata(context, share_id): + if not isinstance(share_id, str) or not len(share_id) == 36: + msg = 'id %s must be a uuid in return share metadata' % share_id + raise Exception(msg) + return stub_share_metadata() + + +def return_empty_share_metadata(context, share_id): + return {} + + +def delete_share_metadata(context, share_id, key): + pass + + +def stub_share_metadata(): + metadata = { + "key1": "value1", + "key2": "value2", + "key3": "value3", + } + return metadata + + +def stub_max_share_metadata(): + metadata = {"metadata": {}} + for num in range(CONF.quota_metadata_items): + metadata['metadata']['key%i' % num] = "blah" + return metadata + + +def return_share(context, share_id): + return {'id': '0cc3346e-9fef-4445-abe6-5d2b2690ec64', + 'name': 'fake', + 'metadata': {}} + + +def return_share_nonexistent(context, share_id): + raise exception.NotFound('bogus test message') + + +def fake_update_share_metadata(self, context, share, diff): + pass + + +class ShareMetaDataTest(test.TestCase): + + def setUp(self): + super(ShareMetaDataTest, self).setUp() + self.share_api = manila.share.api.API() + fakes.stub_out_key_pair_funcs(self.stubs) + self.stubs.Set(manila.db, 'share_get', return_share) + self.stubs.Set(manila.db, 'share_metadata_get', + return_share_metadata) + + self.stubs.Set(self.share_api, 'update_share_metadata', + fake_update_share_metadata) + + self.share_controller = shares.ShareController() + self.controller = share_metadata.ShareMetadataController() + self.req_id = str(uuid.uuid4()) + self.url = '/shares/%s/metadata' % self.req_id + + sh = {"size": 1, + "name": "Share Test Name", + "share_proto": "nfs", + "display_name": "Updated Desc", + "display_description": "Share Test Desc", + "metadata": {}} + body = {"share": sh} + req = fakes.HTTPRequest.blank('/shares') + self.share_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_share(self): + self.stubs.Set(manila.db, 'share_metadata_get', + return_share_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(manila.db, 'share_metadata_get', + return_empty_share_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_share(self): + self.stubs.Set(manila.db, 'share_metadata_get', + return_share_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(manila.db, 'share_metadata_get', + return_empty_share_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(manila.db, 'share_metadata_get', + return_share_metadata) + self.stubs.Set(manila.db, 'share_metadata_delete', + delete_share_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_share(self): + self.stubs.Set(manila.db, 'share_get', + return_share_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(manila.db, 'share_metadata_get', + return_empty_share_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(manila.db, 'share_metadata_get', + return_empty_share_metadata) + self.stubs.Set(manila.db, 'share_metadata_update', + return_create_share_metadata) + + req = fakes.HTTPRequest.blank('/v1/share_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(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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_share(self): + self.stubs.Set(manila.db, 'share_get', + return_share_nonexistent) + self.stubs.Set(manila.db, 'share_metadata_get', + return_share_metadata) + self.stubs.Set(manila.db, 'share_metadata_update', + return_create_share_metadata) + + req = fakes.HTTPRequest.blank('/v1/share_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(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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_share(self): + self.stubs.Set(manila.db, 'share_get', return_share_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(manila.db, 'share_metadata_update', + return_create_share_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_share(self): + self.stubs.Set(manila.db, 'share_get', + return_share_nonexistent) + req = fakes.HTTPRequest.blank('/v1.1/fake/shares/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(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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.update, + req, self.req_id, ("a" * 260), body) + + def test_update_item_value_too_long(self): + self.stubs.Set(manila.db, 'share_metadata_update', + return_create_share_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {"key1": ("a" * 1025)}} + 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_too_many_keys(self): + self.stubs.Set(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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(manila.db, 'share_metadata_update', + return_create_share_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.HTTPBadRequest, + self.controller.create, req, self.req_id, data) + + #test for long value + data = {"metadata": {"key": "v" * 1025}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPBadRequest, + 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) diff --git a/manila/tests/api/v1/test_shares.py b/manila/tests/api/v1/test_shares.py index 92a04b5588..bc61745d00 100644 --- a/manila/tests/api/v1/test_shares.py +++ b/manila/tests/api/v1/test_shares.py @@ -141,6 +141,7 @@ class ShareApiTest(test.TestCase): 'id': '1', 'created_at': datetime.datetime(1, 1, 1, 1, 1, 1), 'share_proto': 'fakeproto', + 'metadata': {}, 'size': 1, 'snapshot_id': '2', 'status': 'fakestatus', @@ -241,6 +242,7 @@ class ShareApiTest(test.TestCase): 'availability_zone': 'fakeaz', 'name': 'displayname', 'share_proto': 'fakeproto', + 'metadata': {}, 'id': '1', 'snapshot_id': '2', 'created_at': datetime.datetime(1, 1, 1, 1, 1, 1), diff --git a/manila/tests/policy.json b/manila/tests/policy.json index 064c26c310..32083d9481 100644 --- a/manila/tests/policy.json +++ b/manila/tests/policy.json @@ -11,5 +11,9 @@ "share:create_snapshot": [], "share:delete_snapshot": [], "share:get_snapshot": [], - "share:get_all_snapshots": [] + "share:get_all_snapshots": [], + + "share:get_share_metadata": [], + "share:delete_share_metadata": [], + "share:update_share_metadata": [] } diff --git a/manila/tests/test_migrations.py b/manila/tests/test_migrations.py index bb573f9300..04f4dc11d0 100644 --- a/manila/tests/test_migrations.py +++ b/manila/tests/test_migrations.py @@ -369,3 +369,43 @@ class TestMigrations(test.TestCase): LOG.error("Failed to migrate to version %s on engine %s" % (version, engine)) raise + + def test_migration_006(self): + """Test adding share_metadata table works correctly.""" + for (key, engine) in self.engines.items(): + migration_api.version_control(engine, + TestMigrations.REPOSITORY, + migration.INIT_VERSION) + migration_api.upgrade(engine, TestMigrations.REPOSITORY, 5) + metadata = sqlalchemy.schema.MetaData() + metadata.bind = engine + + migration_api.upgrade(engine, TestMigrations.REPOSITORY, 6) + + self.assertTrue(engine.dialect.has_table(engine.connect(), + "share_metadata")) + share_metadata = sqlalchemy.Table('share_metadata', + metadata, + autoload=True) + + self.assertIsInstance(share_metadata.c.created_at.type, + sqlalchemy.types.DATETIME) + self.assertIsInstance(share_metadata.c.updated_at.type, + sqlalchemy.types.DATETIME) + self.assertIsInstance(share_metadata.c.deleted_at.type, + sqlalchemy.types.DATETIME) + self.assertIsInstance(share_metadata.c.deleted.type, + sqlalchemy.types.BOOLEAN) + self.assertIsInstance(share_metadata.c.id.type, + sqlalchemy.types.INTEGER) + self.assertIsInstance(share_metadata.c.share_id.type, + sqlalchemy.types.VARCHAR) + self.assertIsInstance(share_metadata.c.key.type, + sqlalchemy.types.VARCHAR) + self.assertIsInstance(share_metadata.c.value.type, + sqlalchemy.types.VARCHAR) + + migration_api.downgrade(engine, TestMigrations.REPOSITORY, 5) + + self.assertFalse(engine.dialect.has_table(engine.connect(), + "share_metadata")) diff --git a/manila/tests/test_share.py b/manila/tests/test_share.py index 0c1e91d765..b2066c87af 100644 --- a/manila/tests/test_share.py +++ b/manila/tests/test_share.py @@ -113,6 +113,7 @@ class ShareTestCase(test.TestCase): share['snapshot_id'] = snapshot_id share['user_id'] = 'fake' share['project_id'] = 'fake' + share['metadata'] = {'fake_key': 'fake_value'} share['availability_zone'] = CONF.storage_availability_zone share['status'] = status share['host'] = CONF.host @@ -367,3 +368,32 @@ class ShareTestCase(test.TestCase): acs = db.share_access_get(self.context, access_id) self.assertEquals(acs['state'], 'error') + + def test_create_delete_share_with_metadata(self): + """Test share can be created with metadata and deleted.""" + test_meta = {'fake_key': 'fake_value'} + share = self._create_share() + share_id = share['id'] + self.share.create_share(self.context, share_id) + result_meta = { + share.share_metadata[0].key: share.share_metadata[0].value} + self.assertEqual(result_meta, test_meta) + + self.share.delete_share(self.context, share_id) + self.assertRaises(exception.NotFound, + db.share_get, + self.context, + share_id) + + def test_create_share_with_invalid_metadata(self): + """Test share create with too much metadata fails.""" + share_api = manila.share.api.API() + test_meta = {'fake_key': 'fake_value' * 1025} + self.assertRaises(exception.InvalidShareMetadataSize, + share_api.create, + self.context, + 'nfs', + 1, + 'name', + 'description', + metadata=test_meta) diff --git a/manila/tests/test_share_api.py b/manila/tests/test_share_api.py index 4cd5bd1632..230404ea6d 100644 --- a/manila/tests/test_share_api.py +++ b/manila/tests/test_share_api.py @@ -43,6 +43,7 @@ def fake_share(id, **kwargs): 'availability_zone': 'fakeaz', 'status': 'fakestatus', 'display_name': 'fakename', + 'metadata': None, 'display_description': 'fakedesc', 'share_proto': 'nfs', 'export_location': 'fake_location', @@ -527,3 +528,34 @@ class ShareAPITestCase(test.TestCase): 'access_type': 'fakeacctype', 'access_to': 'fakeaccto', 'state': 'fakeerror'}]) + + def test_share_metadata_get(self): + metadata = {'a': 'b', 'c': 'd'} + db_driver.share_create(self.context, {'id': '1', 'metadata': metadata}) + + self.assertEqual(metadata, + db_driver.share_metadata_get(self.context, '1')) + + def test_share_metadata_update(self): + metadata1 = {'a': '1', 'c': '2'} + metadata2 = {'a': '3', 'd': '5'} + should_be = {'a': '3', 'c': '2', 'd': '5'} + + db_driver.share_create(self.context, {'id': '1', + 'metadata': metadata1}) + db_driver.share_metadata_update(self.context, '1', metadata2, False) + + self.assertEqual(should_be, + db_driver.share_metadata_get(self.context, '1')) + + def test_share_metadata_update_delete(self): + metadata1 = {'a': '1', 'c': '2'} + metadata2 = {'a': '3', 'd': '4'} + should_be = metadata2 + + db_driver.share_create(self.context, {'id': '1', + 'metadata': metadata1}) + db_driver.share_metadata_update(self.context, '1', metadata2, True) + + self.assertEqual(should_be, + db_driver.share_metadata_get(self.context, '1'))