From 2e1f4e191b806c6e5802c0b8a95548c707e047d9 Mon Sep 17 00:00:00 2001 From: "Andrei V. Ostapenko" Date: Thu, 28 Nov 2013 14:42:44 +0200 Subject: [PATCH] Adds an ability to manage share metadata Now on share creating, we can specify metadata and it will be saved in database. Also support of setting, deleting and updating metadata info was added. Adds model for share metadata Adds migration Adds api extension for share metadata Adds relative api functions Partially implements bp manila-client-enhancements Change-Id: Id421323dd9e947d536b577092f1875f178d9aba5 --- manila/api/v1/router.py | 15 + manila/api/v1/share_metadata.py | 161 +++++++ manila/api/v1/shares.py | 2 + manila/api/views/shares.py | 7 + manila/db/api.py | 15 + manila/db/sqlalchemy/api.py | 132 +++++- .../versions/006_add_share_metadata.py | 61 +++ manila/db/sqlalchemy/models.py | 16 +- manila/exception.py | 13 + manila/share/api.py | 57 ++- manila/test.py | 9 + manila/tests/api/v1/test_share_metadata.py | 446 ++++++++++++++++++ manila/tests/api/v1/test_shares.py | 2 + manila/tests/policy.json | 6 +- manila/tests/test_migrations.py | 40 ++ manila/tests/test_share.py | 30 ++ manila/tests/test_share_api.py | 32 ++ 17 files changed, 1040 insertions(+), 4 deletions(-) create mode 100644 manila/api/v1/share_metadata.py create mode 100644 manila/db/sqlalchemy/migrate_repo/versions/006_add_share_metadata.py create mode 100644 manila/tests/api/v1/test_share_metadata.py 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'))