From: John Griffith Date: Mon, 18 Feb 2013 23:39:06 +0000 (+0000) Subject: Implement metadata options for snapshots X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=667031428bbe750933cd82b07a90116891d74747;p=openstack-build%2Fcinder-build.git Implement metadata options for snapshots This patch implements metadata for snapshots as well as the ability to update/delete that metadata. Implements blueprint: update-snap-metadata Change-Id: Iec2b7a51cdc3dffad41f24807067cc6c8e7d5135 --- diff --git a/cinder/api/v1/router.py b/cinder/api/v1/router.py index 0524b94ae..bb361412c 100644 --- a/cinder/api/v1/router.py +++ b/cinder/api/v1/router.py @@ -24,6 +24,7 @@ WSGI middleware for OpenStack Volume API. from cinder.api import extensions import cinder.api.openstack from cinder.api.v1 import limits +from cinder.api.v1 import snapshot_metadata from cinder.api.v1 import snapshots from cinder.api.v1 import types from cinder.api.v1 import volume_metadata @@ -66,6 +67,15 @@ class APIRouter(cinder.api.openstack.APIRouter): collection={'detail': 'GET'}, member={'action': 'POST'}) + self.resources['snapshot_metadata'] = \ + snapshot_metadata.create_resource() + snapshot_metadata_controller = self.resources['snapshot_metadata'] + + mapper.resource("snapshot_metadata", "metadata", + controller=snapshot_metadata_controller, + parent_resource=dict(member_name='snapshot', + collection_name='snapshots')) + self.resources['limits'] = limits.create_resource() mapper.resource("limit", "limits", controller=self.resources['limits']) diff --git a/cinder/api/v1/snapshot_metadata.py b/cinder/api/v1/snapshot_metadata.py new file mode 100644 index 000000000..6322204ff --- /dev/null +++ b/cinder/api/v1/snapshot_metadata.py @@ -0,0 +1,164 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import webob + +from cinder.api import common +from cinder.api.openstack import wsgi +from cinder import exception +from cinder import volume +from webob import exc + + +class Controller(object): + """ The volume metadata API controller for the OpenStack API """ + + def __init__(self): + self.volume_api = volume.API() + super(Controller, self).__init__() + + def _get_metadata(self, context, snapshot_id): + try: + snapshot = self.volume_api.get_snapshot(context, snapshot_id) + meta = self.volume_api.get_snapshot_metadata(context, snapshot) + except exception.SnapshotNotFound: + msg = _('snapshot does not exist') + raise exc.HTTPNotFound(explanation=msg) + return meta + + @wsgi.serializers(xml=common.MetadataTemplate) + def index(self, req, snapshot_id): + """ Returns the list of metadata for a given snapshot""" + context = req.environ['cinder.context'] + return {'metadata': self._get_metadata(context, snapshot_id)} + + @wsgi.serializers(xml=common.MetadataTemplate) + @wsgi.deserializers(xml=common.MetadataDeserializer) + def create(self, req, snapshot_id, body): + try: + metadata = body['metadata'] + except (KeyError, TypeError): + msg = _("Malformed request body") + raise exc.HTTPBadRequest(explanation=msg) + + context = req.environ['cinder.context'] + + new_metadata = self._update_snapshot_metadata(context, + snapshot_id, + metadata, + delete=False) + + return {'metadata': new_metadata} + + @wsgi.serializers(xml=common.MetaItemTemplate) + @wsgi.deserializers(xml=common.MetaItemDeserializer) + def update(self, req, snapshot_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['cinder.context'] + self._update_snapshot_metadata(context, + snapshot_id, + meta_item, + delete=False) + + return {'meta': meta_item} + + @wsgi.serializers(xml=common.MetadataTemplate) + @wsgi.deserializers(xml=common.MetadataDeserializer) + def update_all(self, req, snapshot_id, body): + try: + metadata = body['metadata'] + except (TypeError, KeyError): + expl = _('Malformed request body') + raise exc.HTTPBadRequest(explanation=expl) + + context = req.environ['cinder.context'] + new_metadata = self._update_snapshot_metadata(context, + snapshot_id, + metadata, + delete=True) + + return {'metadata': new_metadata} + + def _update_snapshot_metadata(self, context, + snapshot_id, metadata, + delete=False): + try: + snapshot = self.volume_api.get_snapshot(context, snapshot_id) + return self.volume_api.update_snapshot_metadata(context, + snapshot, + metadata, + delete) + except exception.SnapshotNotFound: + msg = _('snapshot does not exist') + raise exc.HTTPNotFound(explanation=msg) + + except (ValueError, AttributeError): + msg = _("Malformed request body") + raise exc.HTTPBadRequest(explanation=msg) + + except exception.InvalidVolumeMetadata as error: + raise exc.HTTPBadRequest(explanation=unicode(error)) + + except exception.InvalidVolumeMetadataSize as error: + raise exc.HTTPRequestEntityTooLarge(explanation=unicode(error)) + + @wsgi.serializers(xml=common.MetaItemTemplate) + def show(self, req, snapshot_id, id): + """ Return a single metadata item """ + context = req.environ['cinder.context'] + data = self._get_metadata(context, snapshot_id) + + try: + return {'meta': {id: data[id]}} + except KeyError: + msg = _("Metadata item was not found") + raise exc.HTTPNotFound(explanation=msg) + + def delete(self, req, snapshot_id, id): + """ Deletes an existing metadata """ + context = req.environ['cinder.context'] + + metadata = self._get_metadata(context, snapshot_id) + + if id not in metadata: + msg = _("Metadata item was not found") + raise exc.HTTPNotFound(explanation=msg) + + try: + snapshot = self.volume_api.get_snapshot(context, snapshot_id) + self.volume_api.delete_snapshot_metadata(context, snapshot, id) + except exception.SnapshotNotFound: + msg = _('snapshot does not exist') + raise exc.HTTPNotFound(explanation=msg) + return webob.Response(status_int=200) + + +def create_resource(): + return wsgi.Resource(Controller()) diff --git a/cinder/api/v1/snapshots.py b/cinder/api/v1/snapshots.py index ac044bacf..61f8b9718 100644 --- a/cinder/api/v1/snapshots.py +++ b/cinder/api/v1/snapshots.py @@ -56,6 +56,15 @@ def _translate_snapshot_summary_view(context, snapshot): d['status'] = snapshot['status'] d['size'] = snapshot['volume_size'] + if snapshot.get('snapshot_metadata'): + metadata = snapshot.get('snapshot_metadata') + d['metadata'] = dict((item['key'], item['value']) for item in metadata) + # avoid circular ref when vol is a Volume instance + elif snapshot.get('metadata') and isinstance(snapshot.get('metadata'), + dict): + d['metadata'] = snapshot['metadata'] + else: + d['metadata'] = {} return d @@ -67,6 +76,7 @@ def make_snapshot(elem): elem.set('display_name') elem.set('display_description') elem.set('volume_id') + elem.append(common.MetadataTemplate()) class SnapshotTemplate(xmlutil.TemplateBuilder): @@ -147,12 +157,15 @@ class SnapshotsController(wsgi.Controller): @wsgi.serializers(xml=SnapshotTemplate) def create(self, req, body): """Creates a new snapshot.""" + kwargs = {} context = req.environ['cinder.context'] if not self.is_valid_body(body, 'snapshot'): raise exc.HTTPUnprocessableEntity() snapshot = body['snapshot'] + kwargs['metadata'] = snapshot.get('metadata', None) + volume_id = snapshot['volume_id'] volume = self.volume_api.get(context, volume_id) force = snapshot.get('force', False) @@ -168,13 +181,15 @@ class SnapshotsController(wsgi.Controller): context, volume, snapshot.get('display_name'), - snapshot.get('display_description')) + snapshot.get('display_description'), + **kwargs) else: new_snapshot = self.volume_api.create_snapshot( context, volume, snapshot.get('display_name'), - snapshot.get('display_description')) + snapshot.get('display_description'), + **kwargs) retval = _translate_snapshot_detail_view(context, new_snapshot) diff --git a/cinder/api/v2/snapshot_metadata.py b/cinder/api/v2/snapshot_metadata.py new file mode 100644 index 000000000..6322204ff --- /dev/null +++ b/cinder/api/v2/snapshot_metadata.py @@ -0,0 +1,164 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import webob + +from cinder.api import common +from cinder.api.openstack import wsgi +from cinder import exception +from cinder import volume +from webob import exc + + +class Controller(object): + """ The volume metadata API controller for the OpenStack API """ + + def __init__(self): + self.volume_api = volume.API() + super(Controller, self).__init__() + + def _get_metadata(self, context, snapshot_id): + try: + snapshot = self.volume_api.get_snapshot(context, snapshot_id) + meta = self.volume_api.get_snapshot_metadata(context, snapshot) + except exception.SnapshotNotFound: + msg = _('snapshot does not exist') + raise exc.HTTPNotFound(explanation=msg) + return meta + + @wsgi.serializers(xml=common.MetadataTemplate) + def index(self, req, snapshot_id): + """ Returns the list of metadata for a given snapshot""" + context = req.environ['cinder.context'] + return {'metadata': self._get_metadata(context, snapshot_id)} + + @wsgi.serializers(xml=common.MetadataTemplate) + @wsgi.deserializers(xml=common.MetadataDeserializer) + def create(self, req, snapshot_id, body): + try: + metadata = body['metadata'] + except (KeyError, TypeError): + msg = _("Malformed request body") + raise exc.HTTPBadRequest(explanation=msg) + + context = req.environ['cinder.context'] + + new_metadata = self._update_snapshot_metadata(context, + snapshot_id, + metadata, + delete=False) + + return {'metadata': new_metadata} + + @wsgi.serializers(xml=common.MetaItemTemplate) + @wsgi.deserializers(xml=common.MetaItemDeserializer) + def update(self, req, snapshot_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['cinder.context'] + self._update_snapshot_metadata(context, + snapshot_id, + meta_item, + delete=False) + + return {'meta': meta_item} + + @wsgi.serializers(xml=common.MetadataTemplate) + @wsgi.deserializers(xml=common.MetadataDeserializer) + def update_all(self, req, snapshot_id, body): + try: + metadata = body['metadata'] + except (TypeError, KeyError): + expl = _('Malformed request body') + raise exc.HTTPBadRequest(explanation=expl) + + context = req.environ['cinder.context'] + new_metadata = self._update_snapshot_metadata(context, + snapshot_id, + metadata, + delete=True) + + return {'metadata': new_metadata} + + def _update_snapshot_metadata(self, context, + snapshot_id, metadata, + delete=False): + try: + snapshot = self.volume_api.get_snapshot(context, snapshot_id) + return self.volume_api.update_snapshot_metadata(context, + snapshot, + metadata, + delete) + except exception.SnapshotNotFound: + msg = _('snapshot does not exist') + raise exc.HTTPNotFound(explanation=msg) + + except (ValueError, AttributeError): + msg = _("Malformed request body") + raise exc.HTTPBadRequest(explanation=msg) + + except exception.InvalidVolumeMetadata as error: + raise exc.HTTPBadRequest(explanation=unicode(error)) + + except exception.InvalidVolumeMetadataSize as error: + raise exc.HTTPRequestEntityTooLarge(explanation=unicode(error)) + + @wsgi.serializers(xml=common.MetaItemTemplate) + def show(self, req, snapshot_id, id): + """ Return a single metadata item """ + context = req.environ['cinder.context'] + data = self._get_metadata(context, snapshot_id) + + try: + return {'meta': {id: data[id]}} + except KeyError: + msg = _("Metadata item was not found") + raise exc.HTTPNotFound(explanation=msg) + + def delete(self, req, snapshot_id, id): + """ Deletes an existing metadata """ + context = req.environ['cinder.context'] + + metadata = self._get_metadata(context, snapshot_id) + + if id not in metadata: + msg = _("Metadata item was not found") + raise exc.HTTPNotFound(explanation=msg) + + try: + snapshot = self.volume_api.get_snapshot(context, snapshot_id) + self.volume_api.delete_snapshot_metadata(context, snapshot, id) + except exception.SnapshotNotFound: + msg = _('snapshot does not exist') + raise exc.HTTPNotFound(explanation=msg) + return webob.Response(status_int=200) + + +def create_resource(): + return wsgi.Resource(Controller()) diff --git a/cinder/api/v2/snapshots.py b/cinder/api/v2/snapshots.py index a69cb2e9e..dc992b78b 100644 --- a/cinder/api/v2/snapshots.py +++ b/cinder/api/v2/snapshots.py @@ -56,6 +56,15 @@ def _translate_snapshot_summary_view(context, snapshot): d['status'] = snapshot['status'] d['size'] = snapshot['volume_size'] + if snapshot.get('snapshot_metadata'): + metadata = snapshot.get('snapshot_metadata') + d['metadata'] = dict((item['key'], item['value']) for item in metadata) + # avoid circular ref when vol is a Volume instance + elif snapshot.get('metadata') and isinstance(snapshot.get('metadata'), + dict): + d['metadata'] = snapshot['metadata'] + else: + d['metadata'] = {} return d @@ -67,6 +76,7 @@ def make_snapshot(elem): elem.set('name') elem.set('description') elem.set('volume_id') + elem.append(common.MetadataTemplate()) class SnapshotTemplate(xmlutil.TemplateBuilder): @@ -153,12 +163,15 @@ class SnapshotsController(wsgi.Controller): @wsgi.serializers(xml=SnapshotTemplate) def create(self, req, body): """Creates a new snapshot.""" + kwargs = {} context = req.environ['cinder.context'] if not self.is_valid_body(body, 'snapshot'): raise exc.HTTPUnprocessableEntity() snapshot = body['snapshot'] + kwargs['metadata'] = snapshot.get('metadata', None) + volume_id = snapshot['volume_id'] volume = self.volume_api.get(context, volume_id) force = snapshot.get('force', False) @@ -179,13 +192,15 @@ class SnapshotsController(wsgi.Controller): context, volume, snapshot.get('display_name'), - snapshot.get('description')) + snapshot.get('description'), + **kwargs) else: new_snapshot = self.volume_api.create_snapshot( context, volume, snapshot.get('display_name'), - snapshot.get('description')) + snapshot.get('description'), + **kwargs) retval = _translate_snapshot_detail_view(context, new_snapshot) diff --git a/cinder/db/api.py b/cinder/db/api.py index 0c795118b..4f3b92544 100644 --- a/cinder/db/api.py +++ b/cinder/db/api.py @@ -320,6 +320,24 @@ def snapshot_data_get_for_project(context, project_id, session=None): #################### +def snapshot_metadata_get(context, snapshot_id): + """Get all metadata for a snapshot.""" + return IMPL.snapshot_metadata_get(context, snapshot_id) + + +def snapshot_metadata_delete(context, snapshot_id, key): + """Delete the given metadata item.""" + IMPL.snapshot_metadata_delete(context, snapshot_id, key) + + +def snapshot_metadata_update(context, snapshot_id, metadata, delete): + """Update metadata if it exists, otherwise create it.""" + IMPL.snapshot_metadata_update(context, snapshot_id, metadata, delete) + + +#################### + + def volume_metadata_get(context, volume_id): """Get all metadata for a volume.""" return IMPL.volume_metadata_get(context, volume_id) diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index 14f4b3c78..dceaf45c0 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -1186,6 +1186,8 @@ def volume_metadata_update(context, volume_id, metadata, delete): @require_context def snapshot_create(context, values): + values['snapshot_metadata'] = _metadata_refs(values.get('metadata'), + models.SnapshotMetadata) snapshot_ref = models.Snapshot() if not values.get('id'): values['id'] = str(uuid.uuid4()) @@ -1194,7 +1196,8 @@ def snapshot_create(context, values): session = get_session() with session.begin(): snapshot_ref.save(session=session) - return snapshot_ref + + return snapshot_get(context, values['id'], session=session) @require_admin_context @@ -1265,6 +1268,85 @@ def snapshot_update(context, snapshot_id, values): snapshot_ref.update(values) snapshot_ref.save(session=session) +#################### + + +def _snapshot_metadata_get_query(context, snapshot_id, session=None): + return model_query(context, models.SnapshotMetadata, + session=session, read_deleted="no").\ + filter_by(snapshot_id=snapshot_id) + + +@require_context +@require_snapshot_exists +def snapshot_metadata_get(context, snapshot_id): + rows = _snapshot_metadata_get_query(context, snapshot_id).all() + result = {} + for row in rows: + result[row['key']] = row['value'] + + return result + + +@require_context +@require_snapshot_exists +def snapshot_metadata_delete(context, snapshot_id, key): + _snapshot_metadata_get_query(context, snapshot_id).\ + filter_by(key=key).\ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + + +@require_context +@require_snapshot_exists +def snapshot_metadata_get_item(context, snapshot_id, key, session=None): + result = _snapshot_metadata_get_query(context, + snapshot_id, + session=session).\ + filter_by(key=key).\ + first() + + if not result: + raise exception.SnapshotMetadataNotFound(metadata_key=key, + snapshot_id=snapshot_id) + return result + + +@require_context +@require_snapshot_exists +def snapshot_metadata_update(context, snapshot_id, metadata, delete): + session = get_session() + + # Set existing metadata to deleted if delete argument is True + if delete: + original_metadata = snapshot_metadata_get(context, snapshot_id) + for meta_key, meta_value in original_metadata.iteritems(): + if meta_key not in metadata: + meta_ref = snapshot_metadata_get_item(context, snapshot_id, + meta_key, 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 = snapshot_metadata_get_item(context, snapshot_id, + meta_key, session) + except exception.SnapshotMetadataNotFound as e: + meta_ref = models.SnapshotMetadata() + item.update({"key": meta_key, "snapshot_id": snapshot_id}) + + meta_ref.update(item) + meta_ref.save(session=session) + + return metadata ################### diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/009_add_snapshot_metadata_table.py b/cinder/db/sqlalchemy/migrate_repo/versions/009_add_snapshot_metadata_table.py new file mode 100644 index 000000000..3ccd79a23 --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/009_add_snapshot_metadata_table.py @@ -0,0 +1,60 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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 +from sqlalchemy import Integer, MetaData, String, Table, ForeignKey + +from cinder.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +def upgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + snapshots = Table('snapshots', meta, autoload=True) + + # New table + snapshot_metadata = Table( + 'snapshot_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('snapshot_id', String(length=36), ForeignKey('snapshots.id'), + nullable=False), + Column('key', String(length=255)), + Column('value', String(length=255)), + mysql_engine='InnoDB' + ) + + try: + snapshot_metadata.create() + except Exception: + LOG.error(_("Table |%s| not created!"), repr(snapshot_metadata)) + raise + + +def downgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + snapshot_metadata = Table('snapshot_metadata', + meta, + autoload=True) + try: + snapshot_metadata.drop() + except Exception: + LOG.error(_("snapshot_metadata table not dropped")) diff --git a/cinder/db/sqlalchemy/models.py b/cinder/db/sqlalchemy/models.py index 7d4fce41a..5113e7a2a 100644 --- a/cinder/db/sqlalchemy/models.py +++ b/cinder/db/sqlalchemy/models.py @@ -325,6 +325,22 @@ class Snapshot(BASE, CinderBase): 'Snapshot.deleted == False)') +class SnapshotMetadata(BASE, CinderBase): + """Represents a metadata key/value pair for a snapshot.""" + __tablename__ = 'snapshot_metadata' + id = Column(Integer, primary_key=True) + key = Column(String(255)) + value = Column(String(255)) + snapshot_id = Column(String(36), + ForeignKey('snapshots.id'), + nullable=False) + snapshot = relationship(Snapshot, backref="snapshot_metadata", + foreign_keys=snapshot_id, + primaryjoin='and_(' + 'SnapshotMetadata.snapshot_id == Snapshot.id,' + 'SnapshotMetadata.deleted == False)') + + class IscsiTarget(BASE, CinderBase): """Represents an iscsi target for a given host.""" __tablename__ = 'iscsi_targets' @@ -427,6 +443,7 @@ def register_models(): SMVolume, Volume, VolumeMetadata, + SnapshotMetadata, VolumeTypeExtraSpecs, VolumeTypes, VolumeGlanceMetadata, diff --git a/cinder/exception.py b/cinder/exception.py index c89e1bab1..5d6b5c119 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -259,6 +259,19 @@ class InvalidVolumeMetadataSize(Invalid): message = _("Invalid metadata size") + ": %(reason)s" +class SnapshotMetadataNotFound(NotFound): + message = _("Snapshot %(snapshot_id)s has no metadata with " + "key %(metadata_key)s.") + + +class InvalidSnapshotMetadata(Invalid): + message = _("Invalid metadata") + ": %(reason)s" + + +class InvalidSnapshotMetadataSize(Invalid): + message = _("Invalid metadata size") + ": %(reason)s" + + class VolumeTypeNotFound(NotFound): message = _("Volume type %(volume_type_id)s could not be found.") diff --git a/cinder/tests/api/v1/test_snapshot_metadata.py b/cinder/tests/api/v1/test_snapshot_metadata.py new file mode 100644 index 000000000..3376589a1 --- /dev/null +++ b/cinder/tests/api/v1/test_snapshot_metadata.py @@ -0,0 +1,458 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# 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 webob + +from cinder.api import extensions +from cinder.api.v1 import snapshot_metadata +from cinder.api.v1 import snapshots +import cinder.db +from cinder import exception +from cinder.openstack.common import cfg +from cinder.openstack.common import jsonutils +from cinder import test +from cinder.tests.api import fakes + + +CONF = cfg.CONF + + +def return_create_snapshot_metadata_max(context, + snapshot_id, + metadata, + delete): + return stub_max_snapshot_metadata() + + +def return_create_snapshot_metadata(context, snapshot_id, metadata, delete): + return stub_snapshot_metadata() + + +def return_snapshot_metadata(context, snapshot_id): + if not isinstance(snapshot_id, str) or not len(snapshot_id) == 36: + msg = 'id %s must be a uuid in return snapshot metadata' % snapshot_id + raise Exception(msg) + return stub_snapshot_metadata() + + +def return_empty_snapshot_metadata(context, snapshot_id): + return {} + + +def delete_snapshot_metadata(context, snapshot_id, key): + pass + + +def stub_snapshot_metadata(): + metadata = { + "key1": "value1", + "key2": "value2", + "key3": "value3", + } + return metadata + + +def stub_max_snapshot_metadata(): + metadata = {"metadata": {}} + for num in range(CONF.quota_metadata_items): + metadata['metadata']['key%i' % num] = "blah" + return metadata + + +def return_snapshot(context, snapshot_id): + return {'id': '0cc3346e-9fef-4445-abe6-5d2b2690ec64', + 'name': 'fake', + 'status': 'available', + 'metadata': {}} + + +def return_volume(context, volume_id): + return {'id': 'fake-vol-id', + 'size': 100, + 'name': 'fake', + 'host': 'fake-host', + 'status': 'available', + 'metadata': {}} + + +def return_snapshot_nonexistent(context, snapshot_id): + raise exception.SnapshotNotFound('bogus test message') + + +def fake_update_snapshot_metadata(self, context, snapshot, diff): + pass + + +class SnapshotMetaDataTest(test.TestCase): + + def setUp(self): + super(SnapshotMetaDataTest, self).setUp() + self.volume_api = cinder.volume.api.API() + fakes.stub_out_key_pair_funcs(self.stubs) + self.stubs.Set(cinder.db, 'volume_get', return_volume) + self.stubs.Set(cinder.db, 'snapshot_get', return_snapshot) + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_metadata) + + self.stubs.Set(self.volume_api, 'update_snapshot_metadata', + fake_update_snapshot_metadata) + + self.ext_mgr = extensions.ExtensionManager() + self.ext_mgr.extensions = {} + self.snapshot_controller = snapshots.SnapshotsController(self.ext_mgr) + self.controller = snapshot_metadata.Controller() + self.id = str(uuid.uuid4()) + self.url = '/v1/fake/snapshots/%s/metadata' % self.id + + snap = {"volume_size": 100, + "volume_id": "fake-vol-id", + "display_name": "Volume Test Name", + "display_description": "Volume Test Desc", + "availability_zone": "zone1:host1", + "host": "fake-host", + "metadata": {}} + body = {"snapshot": snap} + req = fakes.HTTPRequest.blank('/v1/snapshots') + self.snapshot_controller.create(req, body) + + def test_index(self): + req = fakes.HTTPRequest.blank(self.url) + res_dict = self.controller.index(req, self.id) + + expected = { + 'metadata': { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }, + } + self.assertEqual(expected, res_dict) + + def test_index_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_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(cinder.db, 'snapshot_metadata_get', + return_empty_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url) + res_dict = self.controller.index(req, self.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.id, 'key2') + expected = {'meta': {'key2': 'value2'}} + self.assertEqual(expected, res_dict) + + def test_show_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_nonexistent) + req = fakes.HTTPRequest.blank(self.url + '/key2') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, req, self.id, 'key2') + + def test_show_meta_not_found(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_empty_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key6') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, req, self.id, 'key6') + + def test_delete(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_metadata) + self.stubs.Set(cinder.db, 'snapshot_metadata_delete', + delete_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key2') + req.method = 'DELETE' + res = self.controller.delete(req, self.id, 'key2') + + self.assertEqual(200, res.status_int) + + def test_delete_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_get', + return_snapshot_nonexistent) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'DELETE' + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.delete, req, self.id, 'key1') + + def test_delete_meta_not_found(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_empty_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key6') + req.method = 'DELETE' + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.delete, req, self.id, 'key6') + + def test_create(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_empty_snapshot_metadata) + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + + req = fakes.HTTPRequest.blank('/v1/snapshot_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.id, body) + self.assertEqual(body, res_dict) + + def test_create_empty_body(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, None) + + def test_create_item_empty_key(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, body) + + def test_create_item_key_too_long(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, body) + + def test_create_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_get', + return_snapshot_nonexistent) + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_metadata) + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + + req = fakes.HTTPRequest.blank('/v1/snapshot_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.id, body) + + def test_update_all(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, expected) + + self.assertEqual(expected, res_dict) + + def test_update_all_empty_container(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, expected) + + self.assertEqual(expected, res_dict) + + def test_update_all_malformed_container(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, expected) + + def test_update_all_malformed_data(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, expected) + + def test_update_all_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_get', return_snapshot_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(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, 'key1', body) + expected = {'meta': {'key1': 'value1'}} + self.assertEqual(expected, res_dict) + + def test_update_item_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_get', + return_snapshot_nonexistent) + req = fakes.HTTPRequest.blank( + '/v1.1/fake/snapshots/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.id, 'key1', body) + + def test_update_item_empty_body(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, 'key1', None) + + def test_update_item_empty_key(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, '', body) + + def test_update_item_key_too_long(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {("a" * 260): "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.update, + req, self.id, ("a" * 260), body) + + def test_update_item_value_too_long(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {"key1": ("a" * 260)}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.update, + req, self.id, "key1", body) + + def test_update_item_too_many_keys(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, 'key1', body) + + def test_update_item_body_uri_mismatch(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, 'bad', body) + + def test_invalid_metadata_items_on_create(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'POST' + req.headers["content-type"] = "application/json" + + #test for long key + data = {"metadata": {"a" * 260: "value1"}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.create, req, self.id, data) + + #test for long value + data = {"metadata": {"key": "v" * 260}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.create, req, self.id, data) + + #test for empty key. + data = {"metadata": {"": "value1"}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.create, req, self.id, data) diff --git a/cinder/tests/api/v1/test_snapshots.py b/cinder/tests/api/v1/test_snapshots.py index 4bfe3e612..6a13179ac 100644 --- a/cinder/tests/api/v1/test_snapshots.py +++ b/cinder/tests/api/v1/test_snapshots.py @@ -46,11 +46,14 @@ def _get_default_snapshot_param(): 'display_description': 'Default description', } -def stub_snapshot_create(self, context, volume_id, name, description): +def stub_snapshot_create(self, context, + volume_id, name, + description, metadata): snapshot = _get_default_snapshot_param() snapshot['volume_id'] = volume_id snapshot['display_name'] = name snapshot['display_description'] = description + snapshot['metadata'] = metadata return snapshot @@ -145,6 +148,7 @@ class SnapshotApiTest(test.TestCase): 'created_at': None, 'display_name': 'Updated Test Name', 'display_description': 'Default description', + 'metadata': {}, }} self.assertEquals(expected, res_dict) diff --git a/cinder/tests/api/v2/test_snapshot_metadata.py b/cinder/tests/api/v2/test_snapshot_metadata.py new file mode 100644 index 000000000..51bf9dcbc --- /dev/null +++ b/cinder/tests/api/v2/test_snapshot_metadata.py @@ -0,0 +1,458 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# 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 webob + +from cinder.api import extensions +from cinder.api.v2 import snapshot_metadata +from cinder.api.v2 import snapshots +import cinder.db +from cinder import exception +from cinder.openstack.common import cfg +from cinder.openstack.common import jsonutils +from cinder import test +from cinder.tests.api import fakes + + +CONF = cfg.CONF + + +def return_create_snapshot_metadata_max(context, + snapshot_id, + metadata, + delete): + return stub_max_snapshot_metadata() + + +def return_create_snapshot_metadata(context, snapshot_id, metadata, delete): + return stub_snapshot_metadata() + + +def return_snapshot_metadata(context, snapshot_id): + if not isinstance(snapshot_id, str) or not len(snapshot_id) == 36: + msg = 'id %s must be a uuid in return snapshot metadata' % snapshot_id + raise Exception(msg) + return stub_snapshot_metadata() + + +def return_empty_snapshot_metadata(context, snapshot_id): + return {} + + +def delete_snapshot_metadata(context, snapshot_id, key): + pass + + +def stub_snapshot_metadata(): + metadata = { + "key1": "value1", + "key2": "value2", + "key3": "value3", + } + return metadata + + +def stub_max_snapshot_metadata(): + metadata = {"metadata": {}} + for num in range(CONF.quota_metadata_items): + metadata['metadata']['key%i' % num] = "blah" + return metadata + + +def return_snapshot(context, snapshot_id): + return {'id': '0cc3346e-9fef-4445-abe6-5d2b2690ec64', + 'name': 'fake', + 'status': 'available', + 'metadata': {}} + + +def return_volume(context, volume_id): + return {'id': 'fake-vol-id', + 'size': 100, + 'name': 'fake', + 'host': 'fake-host', + 'status': 'available', + 'metadata': {}} + + +def return_snapshot_nonexistent(context, snapshot_id): + raise exception.SnapshotNotFound('bogus test message') + + +def fake_update_snapshot_metadata(self, context, snapshot, diff): + pass + + +class SnapshotMetaDataTest(test.TestCase): + + def setUp(self): + super(SnapshotMetaDataTest, self).setUp() + self.volume_api = cinder.volume.api.API() + fakes.stub_out_key_pair_funcs(self.stubs) + self.stubs.Set(cinder.db, 'volume_get', return_volume) + self.stubs.Set(cinder.db, 'snapshot_get', return_snapshot) + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_metadata) + + self.stubs.Set(self.volume_api, 'update_snapshot_metadata', + fake_update_snapshot_metadata) + + self.ext_mgr = extensions.ExtensionManager() + self.ext_mgr.extensions = {} + self.snapshot_controller = snapshots.SnapshotsController(self.ext_mgr) + self.controller = snapshot_metadata.Controller() + self.id = str(uuid.uuid4()) + self.url = '/v2/fake/snapshots/%s/metadata' % self.id + + snap = {"volume_size": 100, + "volume_id": "fake-vol-id", + "display_name": "Volume Test Name", + "display_description": "Volume Test Desc", + "availability_zone": "zone1:host1", + "host": "fake-host", + "metadata": {}} + body = {"snapshot": snap} + req = fakes.HTTPRequest.blank('/v2/snapshots') + self.snapshot_controller.create(req, body) + + def test_index(self): + req = fakes.HTTPRequest.blank(self.url) + res_dict = self.controller.index(req, self.id) + + expected = { + 'metadata': { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }, + } + self.assertEqual(expected, res_dict) + + def test_index_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_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(cinder.db, 'snapshot_metadata_get', + return_empty_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url) + res_dict = self.controller.index(req, self.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.id, 'key2') + expected = {'meta': {'key2': 'value2'}} + self.assertEqual(expected, res_dict) + + def test_show_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_nonexistent) + req = fakes.HTTPRequest.blank(self.url + '/key2') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, req, self.id, 'key2') + + def test_show_meta_not_found(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_empty_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key6') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, req, self.id, 'key6') + + def test_delete(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_metadata) + self.stubs.Set(cinder.db, 'snapshot_metadata_delete', + delete_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key2') + req.method = 'DELETE' + res = self.controller.delete(req, self.id, 'key2') + + self.assertEqual(200, res.status_int) + + def test_delete_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_get', + return_snapshot_nonexistent) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'DELETE' + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.delete, req, self.id, 'key1') + + def test_delete_meta_not_found(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_empty_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key6') + req.method = 'DELETE' + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.delete, req, self.id, 'key6') + + def test_create(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_empty_snapshot_metadata) + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + + req = fakes.HTTPRequest.blank('/v2/snapshot_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.id, body) + self.assertEqual(body, res_dict) + + def test_create_empty_body(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, None) + + def test_create_item_empty_key(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, body) + + def test_create_item_key_too_long(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, body) + + def test_create_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_get', + return_snapshot_nonexistent) + self.stubs.Set(cinder.db, 'snapshot_metadata_get', + return_snapshot_metadata) + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + + req = fakes.HTTPRequest.blank('/v2/snapshot_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.id, body) + + def test_update_all(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, expected) + + self.assertEqual(expected, res_dict) + + def test_update_all_empty_container(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, expected) + + self.assertEqual(expected, res_dict) + + def test_update_all_malformed_container(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, expected) + + def test_update_all_malformed_data(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, expected) + + def test_update_all_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_get', return_snapshot_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(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, 'key1', body) + expected = {'meta': {'key1': 'value1'}} + self.assertEqual(expected, res_dict) + + def test_update_item_nonexistent_snapshot(self): + self.stubs.Set(cinder.db, 'snapshot_get', + return_snapshot_nonexistent) + req = fakes.HTTPRequest.blank( + '/v2/fake/snapshots/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.id, 'key1', body) + + def test_update_item_empty_body(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, 'key1', None) + + def test_update_item_empty_key(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, '', body) + + def test_update_item_key_too_long(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {("a" * 260): "value1"}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.update, + req, self.id, ("a" * 260), body) + + def test_update_item_value_too_long(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url + '/key1') + req.method = 'PUT' + body = {"meta": {"key1": ("a" * 260)}} + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.update, + req, self.id, "key1", body) + + def test_update_item_too_many_keys(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, 'key1', body) + + def test_update_item_body_uri_mismatch(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_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.id, 'bad', body) + + def test_invalid_metadata_items_on_create(self): + self.stubs.Set(cinder.db, 'snapshot_metadata_update', + return_create_snapshot_metadata) + req = fakes.HTTPRequest.blank(self.url) + req.method = 'POST' + req.headers["content-type"] = "application/json" + + #test for long key + data = {"metadata": {"a" * 260: "value1"}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.create, req, self.id, data) + + #test for long value + data = {"metadata": {"key": "v" * 260}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPRequestEntityTooLarge, + self.controller.create, req, self.id, data) + + #test for empty key. + data = {"metadata": {"": "value1"}} + req.body = jsonutils.dumps(data) + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.create, req, self.id, data) diff --git a/cinder/tests/api/v2/test_snapshots.py b/cinder/tests/api/v2/test_snapshots.py index 49cc6dee8..d32e01017 100644 --- a/cinder/tests/api/v2/test_snapshots.py +++ b/cinder/tests/api/v2/test_snapshots.py @@ -48,11 +48,14 @@ def _get_default_snapshot_param(): } -def stub_snapshot_create(self, context, volume_id, name, description): +def stub_snapshot_create(self, context, + volume_id, name, + description, metadata): snapshot = _get_default_snapshot_param() snapshot['volume_id'] = volume_id snapshot['display_name'] = name snapshot['display_description'] = description + snapshot['metadata'] = metadata return snapshot @@ -160,6 +163,7 @@ class SnapshotApiTest(test.TestCase): 'created_at': None, 'name': 'Updated Test Name', 'description': 'Default description', + 'metadata': {}, } } self.assertEquals(expected, res_dict) diff --git a/cinder/tests/test_migrations.py b/cinder/tests/test_migrations.py index f50ef7f6c..847eb4bf9 100644 --- a/cinder/tests/test_migrations.py +++ b/cinder/tests/test_migrations.py @@ -585,3 +585,45 @@ class TestMigrations(test.TestCase): self.assertFalse(engine.dialect.has_table(engine.connect(), "backups")) + + def test_migration_009(self): + """Test adding snapshot_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, 8) + metadata = sqlalchemy.schema.MetaData() + metadata.bind = engine + + migration_api.upgrade(engine, TestMigrations.REPOSITORY, 9) + + self.assertTrue(engine.dialect.has_table(engine.connect(), + "snapshot_metadata")) + snapshot_metadata = sqlalchemy.Table('snapshot_metadata', + metadata, + autoload=True) + + self.assertTrue(isinstance(snapshot_metadata.c.created_at.type, + sqlalchemy.types.DATETIME)) + self.assertTrue(isinstance(snapshot_metadata.c.updated_at.type, + sqlalchemy.types.DATETIME)) + self.assertTrue(isinstance(snapshot_metadata.c.deleted_at.type, + sqlalchemy.types.DATETIME)) + self.assertTrue(isinstance(snapshot_metadata.c.deleted.type, + sqlalchemy.types.BOOLEAN)) + self.assertTrue(isinstance(snapshot_metadata.c.deleted.type, + sqlalchemy.types.BOOLEAN)) + self.assertTrue(isinstance(snapshot_metadata.c.id.type, + sqlalchemy.types.INTEGER)) + self.assertTrue(isinstance(snapshot_metadata.c.snapshot_id.type, + sqlalchemy.types.VARCHAR)) + self.assertTrue(isinstance(snapshot_metadata.c.key.type, + sqlalchemy.types.VARCHAR)) + self.assertTrue(isinstance(snapshot_metadata.c.value.type, + sqlalchemy.types.VARCHAR)) + + migration_api.downgrade(engine, TestMigrations.REPOSITORY, 8) + + self.assertFalse(engine.dialect.has_table(engine.connect(), + "snapshot_metadata")) diff --git a/cinder/volume/api.py b/cinder/volume/api.py index 285afa2a6..47af1a706 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -25,13 +25,11 @@ import functools from oslo.config import cfg from cinder.db import base -from cinder.db.sqlalchemy import models from cinder import exception from cinder import flags from cinder.image import glance from cinder.openstack.common import excutils from cinder.openstack.common import log as logging -from cinder.openstack.common import rpc from cinder.openstack.common import timeutils import cinder.policy from cinder import quota @@ -484,14 +482,16 @@ class API(base.Base): connector, force) - def _create_snapshot(self, context, volume, name, description, - force=False): + def _create_snapshot(self, context, + volume, name, description, + force=False, metadata=None): check_policy(context, 'create_snapshot', volume) if ((not force) and (volume['status'] != "available")): msg = _("must be available") raise exception.InvalidVolume(reason=msg) + self._check_metadata_properties(context, metadata) options = {'volume_id': volume['id'], 'user_id': context.user_id, 'project_id': context.project_id, @@ -499,20 +499,25 @@ class API(base.Base): 'progress': '0%', 'volume_size': volume['size'], 'display_name': name, - 'display_description': description} + 'display_description': description, + 'metadata': metadata} snapshot = self.db.snapshot_create(context, options) self.volume_rpcapi.create_snapshot(context, volume, snapshot) return snapshot - def create_snapshot(self, context, volume, name, description): + def create_snapshot(self, context, + volume, name, + description, metadata=None): return self._create_snapshot(context, volume, name, description, - False) + False, metadata) - def create_snapshot_force(self, context, volume, name, description): + def create_snapshot_force(self, context, + volume, name, + description, metadata=None): return self._create_snapshot(context, volume, name, description, - True) + True, metadata) @wrap_check_policy def delete_snapshot(self, context, snapshot, force=False): @@ -589,6 +594,45 @@ class API(base.Base): return i['value'] return None + def get_snapshot_metadata(self, context, snapshot): + """Get all metadata associated with a snapshot.""" + rv = self.db.snapshot_metadata_get(context, snapshot['id']) + return dict(rv.iteritems()) + + def delete_snapshot_metadata(self, context, snapshot, key): + """Delete the given metadata item from a snapshot.""" + self.db.snapshot_metadata_delete(context, snapshot['id'], key) + + def update_snapshot_metadata(self, context, + snapshot, metadata, + delete=False): + """Updates or creates snapshot metadata. + + If delete is True, metadata items that are not specified in the + `metadata` argument will be deleted. + + """ + orig_meta = self.get_snapshot_metadata(context, snapshot) + if delete: + _metadata = metadata + else: + _metadata = orig_meta.copy() + _metadata.update(metadata) + + self._check_metadata_properties(context, _metadata) + + self.db.snapshot_metadata_update(context, + snapshot['id'], + _metadata, + True) + + # TODO(jdg): Implement an RPC call for drivers that may use this info + + return _metadata + + def get_snapshot_metadata_value(self, snapshot, key): + pass + @wrap_check_policy def get_volume_image_metadata(self, context, volume): db_data = self.db.volume_glance_metadata_get(context, volume['id'])