diff --git a/manila/api/openstack/api_version_request.py b/manila/api/openstack/api_version_request.py index adcf081f4f..67d2080c94 100644 --- a/manila/api/openstack/api_version_request.py +++ b/manila/api/openstack/api_version_request.py @@ -187,6 +187,7 @@ REST_API_VERSION_HISTORY = """ network. * 2.71 - Added 'updated_at' field in share instance show API output. * 2.72 - Added new option ``share-network`` to share replica creare API. + * 2.73 - Added Share Snapshot Metadata to Metadata API """ @@ -194,7 +195,7 @@ REST_API_VERSION_HISTORY = """ # The default api version request is defined to be the # minimum version of the API supported. _MIN_API_VERSION = "2.0" -_MAX_API_VERSION = "2.72" +_MAX_API_VERSION = "2.73" DEFAULT_API_VERSION = _MIN_API_VERSION diff --git a/manila/api/openstack/rest_api_version_history.rst b/manila/api/openstack/rest_api_version_history.rst index b617f2b456..6ff47bb438 100644 --- a/manila/api/openstack/rest_api_version_history.rst +++ b/manila/api/openstack/rest_api_version_history.rst @@ -399,4 +399,10 @@ ____ 2.72 ---- + Added 'share_network' option to share replica create API. + +2.73 +---- + Added Metadata API methods (GET, PUT, POST, DELETE) + to Share Snapshots diff --git a/manila/api/v1/share_snapshots.py b/manila/api/v1/share_snapshots.py index 6dc03b0761..5f6a906068 100644 --- a/manila/api/v1/share_snapshots.py +++ b/manila/api/v1/share_snapshots.py @@ -15,6 +15,7 @@ """The share snapshots api.""" +import ast from http import client as http_client from oslo_log import log @@ -22,6 +23,7 @@ import webob from webob import exc from manila.api import common +from manila.api.openstack import api_version_request as api_version from manila.api.openstack import wsgi from manila.api.views import share_snapshots as snapshot_views from manila import db @@ -114,6 +116,18 @@ class ShareSnapshotMixin(object): search_opts['display_description'] = search_opts.pop( 'description') + # Deserialize dicts + if req.api_version_request >= api_version.APIVersionRequest("2.73"): + if 'metadata' in search_opts: + try: + search_opts['metadata'] = ast.literal_eval( + search_opts['metadata']) + except ValueError: + msg = _('Invalid value for metadata filter.') + raise webob.exc.HTTPBadRequest(explanation=msg) + else: + search_opts.pop('metadata', None) + # like filter for key, db_key in (('name~', 'display_name~'), ('description~', 'display_description~')): @@ -141,7 +155,7 @@ class ShareSnapshotMixin(object): def _get_snapshots_search_options(self): """Return share snapshot search options allowed by non-admin.""" return ('display_name', 'status', 'share_id', 'size', 'display_name~', - 'display_description~', 'display_description') + 'display_description~', 'display_description', 'metadata') def update(self, req, id, body): """Update a snapshot.""" @@ -212,11 +226,20 @@ class ShareSnapshotMixin(object): snapshot['display_description'] = snapshot.get('description') del snapshot['description'] + kwargs = {} + if req.api_version_request >= api_version.APIVersionRequest("2.73"): + if snapshot.get('metadata'): + metadata = snapshot.get('metadata') + kwargs.update({ + 'metadata': metadata, + }) + new_snapshot = self.share_api.create_snapshot( context, share, snapshot.get('display_name'), - snapshot.get('display_description')) + snapshot.get('display_description'), + **kwargs) return self._view_builder.detail( req, dict(new_snapshot.items())) diff --git a/manila/api/v2/metadata.py b/manila/api/v2/metadata.py index e2d5f97236..78e0056a64 100644 --- a/manila/api/v2/metadata.py +++ b/manila/api/v2/metadata.py @@ -26,30 +26,37 @@ class MetadataController(object): # From db, ensure it exists resource_get = { "share": "share_get", + "share_snapshot": "share_snapshot_get", } resource_metadata_get = { "share": "share_metadata_get", + "share_snapshot": "share_snapshot_metadata_get", } resource_metadata_get_item = { "share": "share_metadata_get_item", + "share_snapshot": "share_snapshot_metadata_get_item", } resource_metadata_update = { "share": "share_metadata_update", + "share_snapshot": "share_snapshot_metadata_update", } resource_metadata_update_item = { "share": "share_metadata_update_item", + "share_snapshot": "share_snapshot_metadata_update_item", } resource_metadata_delete = { "share": "share_metadata_delete", + "share_snapshot": "share_snapshot_metadata_delete", } resource_policy_get = { 'share': 'get', + 'share_snapshot': 'get_snapshot', } def __init__(self): @@ -60,7 +67,8 @@ class MetadataController(object): for_modification=False, parent_id=None): if self.resource_name in ['share']: # we would allow retrieving some "public" resources - # across project namespaces + # across project namespaces excpet share snaphots, + # project_only=True is hard coded kwargs = {} else: kwargs = {'project_only': True} diff --git a/manila/api/v2/router.py b/manila/api/v2/router.py index 9162e17550..e6388fba83 100644 --- a/manila/api/v2/router.py +++ b/manila/api/v2/router.py @@ -255,6 +255,44 @@ class APIRouter(manila.api.openstack.APIRouter): controller=self.resources["snapshots"], collection={"detail": "GET"}, member={"action": "POST"}) + for path_prefix in ['/{project_id}', '']: + # project_id is optional + mapper.connect("snapshots_metadata", + "%s/snapshots/{resource_id}/metadata" + % path_prefix, + controller=self.resources["snapshots"], + action="create_metadata", + conditions={"method": ["POST"]}) + mapper.connect("snapshots_metadata", + "%s/snapshots/{resource_id}/metadata" + % path_prefix, + controller=self.resources["snapshots"], + action="update_all_metadata", + conditions={"method": ["PUT"]}) + mapper.connect("snapshots_metadata", + "%s/snapshots/{resource_id}/metadata/{key}" + % path_prefix, + controller=self.resources["snapshots"], + action="update_metadata_item", + conditions={"method": ["POST"]}) + mapper.connect("snapshots_metadata", + "%s/snapshots/{resource_id}/metadata" + % path_prefix, + controller=self.resources["snapshots"], + action="index_metadata", + conditions={"method": ["GET"]}) + mapper.connect("snapshots_metadata", + "%s/snapshots/{resource_id}/metadata/{key}" + % path_prefix, + controller=self.resources["snapshots"], + action="show_metadata", + conditions={"method": ["GET"]}) + mapper.connect("snapshots_metadata", + "%s/snapshots/{resource_id}/metadata/{key}" + % path_prefix, + controller=self.resources["snapshots"], + action="delete_metadata", + conditions={"method": ["DELETE"]}) for path_prefix in ['/{project_id}', '']: # project_id is optional diff --git a/manila/api/v2/share_snapshots.py b/manila/api/v2/share_snapshots.py index 0f91b0bbc2..257bcd3a22 100644 --- a/manila/api/v2/share_snapshots.py +++ b/manila/api/v2/share_snapshots.py @@ -26,6 +26,7 @@ from manila.api import common from manila.api.openstack import api_version_request as api_version from manila.api.openstack import wsgi from manila.api.v1 import share_snapshots +from manila.api.v2 import metadata from manila.api.views import share_snapshots as snapshot_views from manila.common import constants from manila.db import api as db_api @@ -37,7 +38,8 @@ LOG = log.getLogger(__name__) class ShareSnapshotsController(share_snapshots.ShareSnapshotMixin, - wsgi.Controller, wsgi.AdminActionsMixin): + wsgi.Controller, metadata.MetadataController, + wsgi.AdminActionsMixin): """The Share Snapshots API V2 controller for the OpenStack API.""" resource_name = 'share_snapshot' @@ -123,6 +125,12 @@ class ShareSnapshotsController(share_snapshots.ShareSnapshotMixin, 'display_name': name, 'display_description': description, } + if req.api_version_request >= api_version.APIVersionRequest("2.73"): + if snapshot_data.get('metadata'): + metadata = snapshot_data.get('metadata') + snapshot.update({ + 'metadata': metadata, + }) try: share_ref = self.share_api.get(context, share_id) @@ -339,6 +347,37 @@ class ShareSnapshotsController(share_snapshots.ShareSnapshotMixin, req.GET.pop('description', None) return self._get_snapshots(req, is_detail=True) + @wsgi.Controller.api_version("2.73") + @wsgi.Controller.authorize("get_metadata") + def index_metadata(self, req, resource_id): + """Returns the list of metadata for a given share snapshot.""" + return self._index_metadata(req, resource_id) + + @wsgi.Controller.api_version("2.73") + @wsgi.Controller.authorize("update_metadata") + def create_metadata(self, req, resource_id, body): + return self._create_metadata(req, resource_id, body) + + @wsgi.Controller.api_version("2.73") + @wsgi.Controller.authorize("update_metadata") + def update_all_metadata(self, req, resource_id, body): + return self._update_all_metadata(req, resource_id, body) + + @wsgi.Controller.api_version("2.73") + @wsgi.Controller.authorize("update_metadata") + def update_metadata_item(self, req, resource_id, body, key): + return self._update_metadata_item(req, resource_id, body, key) + + @wsgi.Controller.api_version("2.73") + @wsgi.Controller.authorize("get_metadata") + def show_metadata(self, req, resource_id, key): + return self._show_metadata(req, resource_id, key) + + @wsgi.Controller.api_version("2.73") + @wsgi.Controller.authorize("delete_metadata") + def delete_metadata(self, req, resource_id, key): + return self._delete_metadata(req, resource_id, key) + def create_resource(): return wsgi.Resource(ShareSnapshotsController()) diff --git a/manila/api/views/share_snapshots.py b/manila/api/views/share_snapshots.py index d6500a0374..41cd91aa35 100644 --- a/manila/api/views/share_snapshots.py +++ b/manila/api/views/share_snapshots.py @@ -23,6 +23,7 @@ class ViewBuilder(common.ViewBuilder): _detail_version_modifiers = [ "add_provider_location_field", "add_project_and_user_ids", + "add_metadata" ] def summary_list(self, request, snapshots): @@ -74,6 +75,15 @@ class ViewBuilder(common.ViewBuilder): snapshot_dict['user_id'] = snapshot.get('user_id') snapshot_dict['project_id'] = snapshot.get('project_id') + @common.ViewBuilder.versioned_method("2.73") + def add_metadata(self, context, snapshot_dict, snapshot): + metadata = snapshot.get('share_snapshot_metadata') + if metadata: + metadata = {item['key']: item['value'] for item in metadata} + else: + metadata = {} + snapshot_dict['metadata'] = metadata + def _list_view(self, func, request, snapshots): """Provide a view for a list of share snapshots.""" snapshots_list = [func(request, snapshot)['snapshot'] diff --git a/manila/db/api.py b/manila/db/api.py index 58a96b2a36..680f3e2aa0 100644 --- a/manila/db/api.py +++ b/manila/db/api.py @@ -626,9 +626,10 @@ def share_snapshot_create(context, values): return IMPL.share_snapshot_create(context, values) -def share_snapshot_get(context, snapshot_id): +def share_snapshot_get(context, snapshot_id, project_only=True): """Get a snapshot or raise if it does not exist.""" - return IMPL.share_snapshot_get(context, snapshot_id) + return IMPL.share_snapshot_get(context, snapshot_id, + project_only=project_only) def share_snapshot_get_all(context, filters=None, limit=None, offset=None, @@ -761,7 +762,42 @@ def share_snapshot_instance_export_location_delete(context, el_id): return IMPL.share_snapshot_instance_export_location_delete(context, el_id) +#################### + +def share_snapshot_metadata_get(context, share_snapshot_id, **kwargs): + """Get all metadata for a share snapshot.""" + return IMPL.share_snapshot_metadata_get(context, + share_snapshot_id, + **kwargs) + + +def share_snapshot_metadata_get_item(context, share_snapshot_id, key): + """Get metadata item for a share snapshot.""" + return IMPL.share_snapshot_metadata_get_item(context, + share_snapshot_id, key) + + +def share_snapshot_metadata_delete(context, share_snapshot_id, key): + """Delete the given metadata item.""" + IMPL.share_snapshot_metadata_delete(context, share_snapshot_id, key) + + +def share_snapshot_metadata_update(context, share_snapshot_id, + metadata, delete): + """Update metadata if it exists, otherwise create it.""" + return IMPL.share_snapshot_metadata_update(context, share_snapshot_id, + metadata, delete) + + +def share_snapshot_metadata_update_item(context, share_snapshot_id, + metadata): + """Update metadata item if it exists, otherwise create it.""" + return IMPL.share_snapshot_metadata_update_item(context, + share_snapshot_id, + metadata) ################### + + def security_service_create(context, values): """Create security service DB record.""" return IMPL.security_service_create(context, values) diff --git a/manila/db/migrations/alembic/versions/bb5938d74b73_add_snapshot_metadata_table.py b/manila/db/migrations/alembic/versions/bb5938d74b73_add_snapshot_metadata_table.py new file mode 100644 index 0000000000..7d2e15e88c --- /dev/null +++ b/manila/db/migrations/alembic/versions/bb5938d74b73_add_snapshot_metadata_table.py @@ -0,0 +1,66 @@ +# 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. + +"""add_snapshot_metadata_table + +Revision ID: bb5938d74b73 +Revises: a87e0fb17dee +Create Date: 2022-01-14 14:36:59.408638 + +""" + +# revision identifiers, used by Alembic. +revision = 'bb5938d74b73' +down_revision = 'a87e0fb17dee' + +from alembic import op +from oslo_log import log +import sqlalchemy as sql + +LOG = log.getLogger(__name__) + +share_snapshot_metadata_table_name = 'share_snapshot_metadata' + + +def upgrade(): + context = op.get_context() + mysql_dl = context.bind.dialect.name == 'mysql' + datetime_type = (sql.dialects.mysql.DATETIME(fsp=6) + if mysql_dl else sql.DateTime) + try: + op.create_table( + share_snapshot_metadata_table_name, + sql.Column('deleted', sql.String(36), default='False'), + sql.Column('created_at', datetime_type), + sql.Column('updated_at', datetime_type), + sql.Column('deleted_at', datetime_type), + sql.Column('share_snapshot_id', sql.String(36), + sql.ForeignKey('share_snapshots.id'), nullable=False), + sql.Column('key', sql.String(255), nullable=False), + sql.Column('value', sql.String(1023), nullable=False), + sql.Column('id', sql.Integer, primary_key=True, nullable=False), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + except Exception: + LOG.error("Table |%s| not created!", + share_snapshot_metadata_table_name) + raise + + +def downgrade(): + try: + op.drop_table(share_snapshot_metadata_table_name) + except Exception: + LOG.error("Table |%s| not dropped!", + share_snapshot_metadata_table_name) + raise diff --git a/manila/db/sqlalchemy/api.py b/manila/db/sqlalchemy/api.py index a59bcf2ff3..d37a2c8ca9 100644 --- a/manila/db/sqlalchemy/api.py +++ b/manila/db/sqlalchemy/api.py @@ -39,6 +39,7 @@ from oslo_db.sqlalchemy import utils as db_utils from oslo_log import log from oslo_utils import excutils from oslo_utils import importutils +from oslo_utils import strutils from oslo_utils import timeutils from oslo_utils import uuidutils import sqlalchemy @@ -208,6 +209,20 @@ def require_share_exists(f): return wrapper +def require_share_snapshot_exists(f): + """Decorator to require the specified share snapshot to exist. + + Requires the wrapped function to use context and share_snapshot_id as + their first two arguments. + """ + @wraps(f) + def wrapper(context, share_snapshot_id, *args, **kwargs): + share_snapshot_get(context, share_snapshot_id) + return f(context, share_snapshot_id, *args, **kwargs) + wrapper.__name__ = f.__name__ + return wrapper + + def require_share_instance_exists(f): """Decorator to require the specified share instance to exist. @@ -1999,10 +2014,10 @@ def share_replica_delete(context, share_replica_id, session=None, @require_context -def _share_get_query(context, session=None): +def _share_get_query(context, session=None, **kwargs): if session is None: session = get_session() - return (model_query(context, models.Share, session=session). + return (model_query(context, models.Share, session=session, **kwargs). options(joinedload('share_metadata'))) @@ -2174,8 +2189,9 @@ def share_update(context, share_id, update_values): @require_context -def share_get(context, share_id, session=None): - result = _share_get_query(context, session).filter_by(id=share_id).first() +def share_get(context, share_id, session=None, **kwargs): + result = _share_get_query(context, session, **kwargs).filter_by( + id=share_id).first() if result is None: raise exception.NotFound() @@ -2802,6 +2818,8 @@ def share_instance_access_update(context, access_id, instance_id, updates): def share_snapshot_instance_create(context, snapshot_id, values, session=None): session = session or get_session() values = copy.deepcopy(values) + values['share_snapshot_metadata'] = _metadata_refs( + values.get('metadata'), models.ShareSnapshotMetadata) _change_size_to_instance_size(values) @@ -2858,6 +2876,8 @@ def share_snapshot_instance_delete(context, snapshot_instance_id, snapshot = share_snapshot_get( context, snapshot_instance_ref['snapshot_id'], session=session) if len(snapshot.instances) == 0: + session.query(models.ShareSnapshotMetadata).filter_by( + share_snapshot_id=snapshot['id']).soft_delete() snapshot.soft_delete(session=session) @@ -2958,6 +2978,8 @@ def share_snapshot_create(context, create_values, create_snapshot_instance=True): values = copy.deepcopy(create_values) values = ensure_model_dict_has_id(values) + values['share_snapshot_metadata'] = _metadata_refs( + values.pop('metadata', {}), models.ShareSnapshotMetadata) snapshot_ref = models.ShareSnapshot() snapshot_instance_values, snapshot_values = ( @@ -3007,12 +3029,13 @@ def snapshot_data_get_for_project(context, project_id, user_id, @require_context -def share_snapshot_get(context, snapshot_id, session=None): +def share_snapshot_get(context, snapshot_id, project_only=True, session=None): result = (model_query(context, models.ShareSnapshot, session=session, - project_only=True). + project_only=project_only). filter_by(id=snapshot_id). options(joinedload('share')). options(joinedload('instances')). + options(joinedload('share_snapshot_metadata')). first()) if not result: @@ -3048,8 +3071,10 @@ def _share_snapshot_get_all_with_filters(context, project_id=None, query = query.filter_by(project_id=project_id) if share_id: query = query.filter_by(share_id=share_id) - query = query.options(joinedload('share')) - query = query.options(joinedload('instances')) + query = (query.options(joinedload('share')) + .options(joinedload('instances')) + .options(joinedload('share_snapshot_metadata')) + ) # Snapshots with no instances are filtered out. query = query.filter( @@ -3077,6 +3102,13 @@ def _share_snapshot_get_all_with_filters(context, project_id=None, query = query.filter(models.ShareSnapshotInstance.status == ( filters['status'])) filters.pop('status') + if 'metadata' in filters: + for k, v in filters['metadata'].items(): + # pylint: disable=no-member + query = query.filter( + or_(models.ShareSnapshot.share_snapshot_metadata.any( + key=k, value=v))) + filters.pop('metadata') legal_filter_keys = ('display_name', 'display_name~', 'display_description', 'display_description~', @@ -3166,6 +3198,125 @@ def share_snapshot_instances_status_update( return result + +################################### +# Share Snapshot Metadata functions +################################### + +@require_context +@require_share_snapshot_exists +def share_snapshot_metadata_get(context, share_snapshot_id): + session = get_session() + return _share_snapshot_metadata_get(context, + share_snapshot_id, session=session) + + +@require_context +@require_share_snapshot_exists +def share_snapshot_metadata_delete(context, share_snapshot_id, key): + session = get_session() + meta_ref = _share_snapshot_metadata_get_item( + context, share_snapshot_id, key, session=session) + meta_ref.soft_delete(session=session) + + +@require_context +@require_share_snapshot_exists +def share_snapshot_metadata_update(context, share_snapshot_id, + metadata, delete): + session = get_session() + return _share_snapshot_metadata_update(context, share_snapshot_id, + metadata, delete, + session=session) + + +def share_snapshot_metadata_update_item(context, share_snapshot_id, + item): + session = get_session() + return _share_snapshot_metadata_update(context, share_snapshot_id, + item, delete=False, + session=session) + + +def share_snapshot_metadata_get_item(context, share_snapshot_id, + key): + + session = get_session() + row = _share_snapshot_metadata_get_item(context, share_snapshot_id, + key, session=session) + result = {} + result[row['key']] = row['value'] + + return result + + +def _share_snapshot_metadata_get_query(context, share_snapshot_id, + session=None): + session = session or get_session() + return (model_query(context, models.ShareSnapshotMetadata, + session=session, + read_deleted="no"). + filter_by(share_snapshot_id=share_snapshot_id). + options(joinedload('share_snapshot'))) + + +def _share_snapshot_metadata_get(context, share_snapshot_id, session=None): + session = session or get_session() + rows = _share_snapshot_metadata_get_query(context, share_snapshot_id, + session=session).all() + + result = {} + for row in rows: + result[row['key']] = row['value'] + return result + + +def _share_snapshot_metadata_get_item(context, share_snapshot_id, + key, session=None): + session = session or get_session() + result = (_share_snapshot_metadata_get_query( + context, share_snapshot_id, session=session).filter_by( + key=key).first()) + if not result: + raise exception.MetadataItemNotFound + return result + + +@oslo_db_api.wrap_db_retry(max_retries=5, retry_on_deadlock=True) +def _share_snapshot_metadata_update(context, share_snapshot_id, + metadata, delete, session=None): + session = session or get_session() + delete = strutils.bool_from_string(delete) + with session.begin(): + if delete: + original_metadata = _share_snapshot_metadata_get( + context, share_snapshot_id, session=session) + for meta_key, meta_value in original_metadata.items(): + if meta_key not in metadata: + meta_ref = _share_snapshot_metadata_get_item( + context, share_snapshot_id, meta_key, + session=session) + meta_ref.soft_delete(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} + meta_ref = _share_snapshot_metadata_get_query( + context, share_snapshot_id, + session=session).filter_by( + key=meta_key).first() + if not meta_ref: + meta_ref = models.ShareSnapshotMetadata() + item.update({"key": meta_key, + "share_snapshot_id": share_snapshot_id}) + meta_ref.update(item) + meta_ref.save(session=session) + + return metadata + ################################# @@ -3582,6 +3733,7 @@ def _share_metadata_update(context, share_id, metadata, delete, session=None): with session.begin(): # Set existing metadata to deleted if delete argument is True + delete = strutils.bool_from_string(delete) if delete: original_metadata = _share_metadata_get(context, share_id, session=session) diff --git a/manila/db/sqlalchemy/models.py b/manila/db/sqlalchemy/models.py index 8f3778cd18..365fca4365 100644 --- a/manila/db/sqlalchemy/models.py +++ b/manila/db/sqlalchemy/models.py @@ -736,6 +736,24 @@ class ShareSnapshot(BASE, ManilaBase): 'ShareSnapshot.deleted == "False")') +class ShareSnapshotMetadata(BASE, ManilaBase): + """Represents a metadata key/value pair for a snapshot.""" + __tablename__ = 'share_snapshot_metadata' + id = Column(Integer, primary_key=True) + key = Column(String(255), nullable=False) + value = Column(String(1023), nullable=False) + deleted = Column(String(36), default='False') + share_snapshot_id = Column(String(36), ForeignKey( + 'share_snapshots.id'), nullable=False) + + share_snapshot = orm.relationship( + ShareSnapshot, backref="share_snapshot_metadata", + foreign_keys=share_snapshot_id, + primaryjoin='and_(' + 'ShareSnapshotMetadata.share_snapshot_id == ShareSnapshot.id,' + 'ShareSnapshotMetadata.deleted == "False")') + + class ShareSnapshotInstance(BASE, ManilaBase): """Represents a snapshot of a share.""" __tablename__ = 'share_snapshot_instances' diff --git a/manila/policies/share_snapshot.py b/manila/policies/share_snapshot.py index 5b2106b4a3..731781b33e 100644 --- a/manila/policies/share_snapshot.py +++ b/manila/policies/share_snapshot.py @@ -76,6 +76,24 @@ deprecated_snapshot_deny_access = policy.DeprecatedRule( deprecated_reason=DEPRECATED_REASON, deprecated_since=versionutils.deprecated.WALLABY ) +deprecated_update_snapshot_metadata = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'update_metadata', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='ZED' +) +deprecated_delete_snapshot_metadata = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'delete_metadata', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='ZED' +) +deprecated_get_snapshot_metadata = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'get_metadata', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='ZED' +) share_snapshot_policies = [ @@ -208,6 +226,57 @@ share_snapshot_policies = [ ], deprecated_rule=deprecated_snapshot_deny_access ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'update_metadata', + check_str=base.SYSTEM_ADMIN_OR_PROJECT_MEMBER, + scope_types=['system', 'project'], + description="Update snapshot metadata.", + operations=[ + { + 'method': 'PUT', + 'path': '/snapshots/{snapshot_id}/metadata', + }, + { + 'method': 'POST', + 'path': '/snapshots/{snapshot_id}/metadata/{key}', + }, + { + 'method': 'POST', + 'path': '/snapshots/{snapshot_id}/metadata', + }, + ], + deprecated_rule=deprecated_update_snapshot_metadata + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'delete_metadata', + check_str=base.SYSTEM_ADMIN_OR_PROJECT_MEMBER, + scope_types=['system', 'project'], + description="Delete snapshot metadata.", + operations=[ + { + 'method': 'DELETE', + 'path': '/snapshots/{snapshot_id}/metadata/{key}', + } + ], + deprecated_rule=deprecated_delete_snapshot_metadata + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'get_metadata', + check_str=base.SYSTEM_OR_PROJECT_READER, + scope_types=['system', 'project'], + description="Get snapshot metadata.", + operations=[ + { + 'method': 'GET', + 'path': '/snapshots/{snapshot_id}/metadata', + }, + { + 'method': 'GET', + 'path': '/snapshots/{snapshot_id}/metadata/{key}', + } + ], + deprecated_rule=deprecated_get_snapshot_metadata + ), ] diff --git a/manila/share/api.py b/manila/share/api.py index ac9a7d451d..883a3a0d34 100644 --- a/manila/share/api.py +++ b/manila/share/api.py @@ -1434,8 +1434,10 @@ class API(base.Base): context, share_server, force=force) def create_snapshot(self, context, share, name, description, - force=False): + force=False, metadata=None): policy.check_policy(context, 'share', 'create_snapshot', share) + if metadata: + api_common._check_metadata_properties(metadata) if ((not force) and (share['status'] != constants.STATUS_AVAILABLE)): msg = _("Source share status must be " @@ -1486,6 +1488,8 @@ class API(base.Base): 'display_name': name, 'display_description': description, 'share_proto': share['share_proto']} + if metadata: + options.update({"metadata": metadata}) try: snapshot = None @@ -2067,7 +2071,7 @@ class API(base.Base): string_args = {'sort_key': sort_key, 'sort_dir': sort_dir} string_args.update(search_opts) for k, v in string_args.items(): - if not (isinstance(v, str) and v): + if not (isinstance(v, str) and v) and k != 'metadata': msg = _("Wrong '%(k)s' filter provided: " "'%(v)s'.") % {'k': k, 'v': string_args[k]} raise exception.InvalidInput(reason=msg) diff --git a/manila/tests/api/contrib/stubs.py b/manila/tests/api/contrib/stubs.py index 18ed9b1e2d..1c9d3d5120 100644 --- a/manila/tests/api/contrib/stubs.py +++ b/manila/tests/api/contrib/stubs.py @@ -118,6 +118,7 @@ def stub_snapshot(id, **kwargs): 'display_name': 'displaysnapname', 'display_description': 'displaysnapdesc', 'created_at': datetime.datetime(1, 1, 1, 1, 1, 1), + 'metadata': {} } snapshot.update(kwargs) return snapshot diff --git a/manila/tests/api/v2/test_share_snapshots.py b/manila/tests/api/v2/test_share_snapshots.py index 5bce73e4ea..c3694a2b1a 100644 --- a/manila/tests/api/v2/test_share_snapshots.py +++ b/manila/tests/api/v2/test_share_snapshots.py @@ -15,6 +15,7 @@ from unittest import mock +import ast import ddt from oslo_serialization import jsonutils import webob @@ -144,7 +145,7 @@ class ShareSnapshotAPITest(test.TestCase): req, 200) - @ddt.data('2.0', '2.16', '2.17') + @ddt.data('2.0', '2.16', '2.17', '2.73') def test_snapshot_show(self, version): req = fakes.HTTPRequest.blank('/v2/fake/snapshots/200', version=version) @@ -247,6 +248,46 @@ class ShareSnapshotAPITest(test.TestCase): self._snapshot_list_summary_with_search_opts( version=version, use_admin_context=use_admin_context) + def test_snapshot_list_metadata_filter(self, version='2.73', + use_admin_context=True): + search_opts = { + 'sort_key': 'fake_sort_key', + 'sort_dir': 'fake_sort_dir', + 'offset': '1', + 'limit': '1', + 'metadata': "{'foo': 'bar'}" + } + # fake_key should be filtered for non-admin + url = '/v2/fake/snapshots?fake_key=fake_value' + for k, v in search_opts.items(): + url = url + '&' + k + '=' + v + req = fakes.HTTPRequest.blank( + url, use_admin_context=use_admin_context, version=version) + + snapshots = [ + {'id': 'id1', 'metadata': {'foo': 'bar'}} + ] + self.mock_object(share_api.API, 'get_all_snapshots', + mock.Mock(return_value=snapshots)) + + result = self.controller.index(req) + + search_opts_expected = { + 'metadata': ast.literal_eval(search_opts['metadata']) + } + if use_admin_context: + search_opts_expected.update({'fake_key': 'fake_value'}) + share_api.API.get_all_snapshots.assert_called_once_with( + req.environ['manila.context'], + limit=int(search_opts['limit']), + offset=int(search_opts['offset']), + sort_key=search_opts['sort_key'], + sort_dir=search_opts['sort_dir'], + search_opts=search_opts_expected, + ) + self.assertEqual(1, len(result['snapshots'])) + self.assertEqual(snapshots[0]['id'], result['snapshots'][0]['id']) + def _snapshot_list_detail_with_search_opts(self, use_admin_context): search_opts = fake_share.search_opts() # fake_key should be filtered for non-admin diff --git a/manila/tests/db/migrations/alembic/migrations_data_checks.py b/manila/tests/db/migrations/alembic/migrations_data_checks.py index 6da7949603..e547f2c97a 100644 --- a/manila/tests/db/migrations/alembic/migrations_data_checks.py +++ b/manila/tests/db/migrations/alembic/migrations_data_checks.py @@ -3175,3 +3175,81 @@ class ShareServerMultipleSubnets(BaseMigrationChecks): ).first() self.test_case.assertFalse( hasattr(na_record, 'share_network_subnet_id')) + + +@map_to_migration('bb5938d74b73') +class AddSnapshotMetadata(BaseMigrationChecks): + snapshot_id = uuidutils.generate_uuid() + new_table_name = 'share_snapshot_metadata' + + def setup_upgrade_data(self, engine): + # Setup Share + share_data = { + 'id': uuidutils.generate_uuid(), + 'share_proto': "NFS", + 'size': 1, + 'snapshot_id': None, + 'user_id': 'fake', + 'project_id': 'fake' + } + share_table = utils.load_table('shares', engine) + engine.execute(share_table.insert(share_data)) + + share_instance_data = { + 'id': uuidutils.generate_uuid(), + 'deleted': 'False', + 'host': 'fake', + 'share_id': share_data['id'], + 'status': 'available', + 'access_rules_status': 'active', + 'cast_rules_to_readonly': False, + } + share_instance_table = utils.load_table('share_instances', engine) + engine.execute(share_instance_table.insert(share_instance_data)) + + # Setup Share Snapshot + share_snapshot_data = { + 'id': self.snapshot_id, + 'share_id': share_data['id'] + } + snapshot_table = utils.load_table('share_snapshots', engine) + engine.execute(snapshot_table.insert(share_snapshot_data)) + + # Setup snapshot instances + snapshot_instance_data = { + 'id': uuidutils.generate_uuid(), + 'snapshot_id': share_snapshot_data['id'], + 'share_instance_id': share_instance_data['id'] + } + snap_i_table = utils.load_table('share_snapshot_instances', engine) + engine.execute(snap_i_table.insert(snapshot_instance_data)) + + def check_upgrade(self, engine, data): + data = { + 'id': 1, + 'key': 't' * 255, + 'value': 'v' * 1023, + 'share_snapshot_id': self.snapshot_id, + 'deleted': 'False', + } + + new_table = utils.load_table(self.new_table_name, engine) + engine.execute(new_table.insert(data)) + + item = engine.execute( + new_table.select().where(new_table.c.id == data['id'])).first() + self.test_case.assertTrue(hasattr(item, 'id')) + self.test_case.assertEqual(data['id'], item['id']) + self.test_case.assertTrue(hasattr(item, 'key')) + self.test_case.assertEqual(data['key'], item['key']) + self.test_case.assertTrue(hasattr(item, 'value')) + self.test_case.assertEqual(data['value'], item['value']) + self.test_case.assertTrue(hasattr(item, 'share_snapshot_id')) + self.test_case.assertEqual(self.snapshot_id, + item['share_snapshot_id']) + self.test_case.assertTrue(hasattr(item, 'deleted')) + self.test_case.assertEqual('False', item['deleted']) + + def check_downgrade(self, engine): + self.test_case.assertRaises(sa_exc.NoSuchTableError, utils.load_table, + self.new_table_name, engine) diff --git a/manila/tests/db/sqlalchemy/test_api.py b/manila/tests/db/sqlalchemy/test_api.py index 8779037393..3941f96d7c 100644 --- a/manila/tests/db/sqlalchemy/test_api.py +++ b/manila/tests/db/sqlalchemy/test_api.py @@ -1666,7 +1666,7 @@ class ShareSnapshotDatabaseAPITestCase(test.TestCase): instances=self.snapshot_instances[0:3]) self.snapshot_2 = db_utils.create_snapshot( id='fake_snapshot_id_2', share_id=self.share_2['id'], - instances=self.snapshot_instances[3:4]) + instances=self.snapshot_instances[3:4], metadata={'foo': 'bar'}) self.snapshot_instance_export_locations = [ db_utils.create_snapshot_instance_export_locations( @@ -1711,14 +1711,21 @@ class ShareSnapshotDatabaseAPITestCase(test.TestCase): def test_share_snapshot_get_all_with_filters_some(self): expected_status = constants.STATUS_AVAILABLE filters = { - 'status': expected_status + 'status': expected_status, + 'metadata': {'foo': 'bar'} } snapshots = db_api.share_snapshot_get_all( self.ctxt, filters=filters) for snapshot in snapshots: + s = snapshot.get('share_snapshot_metadata') + for k, v in filters['metadata'].items(): + filter_meta_key = k + filter_meta_val = v self.assertEqual('fake_snapshot_id_2', snapshot['id']) self.assertEqual(snapshot['status'], filters['status']) + self.assertEqual(s[0]['key'], filter_meta_key) + self.assertEqual(s[0]['value'], filter_meta_val) self.assertEqual(1, len(snapshots)) @@ -2044,6 +2051,68 @@ class ShareSnapshotDatabaseAPITestCase(test.TestCase): db_api.share_snapshot_instance_export_locations_update, self.ctxt, snapshot.instance['id'], new_export_locations, False) + def test_share_snapshot_metadata_get(self): + metadata = {'a': 'b', 'c': 'd'} + + self.share_1 = db_utils.create_share(size=1) + self.snapshot_1 = db_utils.create_snapshot( + share_id=self.share_1['id']) + db_api.share_snapshot_metadata_update( + self.ctxt, share_snapshot_id=self.snapshot_1['id'], + metadata=metadata, delete=False) + self.assertEqual( + metadata, db_api.share_snapshot_metadata_get( + self.ctxt, share_snapshot_id=self.snapshot_1['id'])) + + def test_share_snapshot_metadata_get_item(self): + metadata = {'a': 'b', 'c': 'd'} + key = 'a' + shouldbe = {'a': 'b'} + self.share_1 = db_utils.create_share(size=1) + self.snapshot_1 = db_utils.create_snapshot( + share_id=self.share_1['id']) + db_api.share_snapshot_metadata_update( + self.ctxt, share_snapshot_id=self.snapshot_1['id'], + metadata=metadata, delete=False) + self.assertEqual( + shouldbe, db_api.share_snapshot_metadata_get_item( + self.ctxt, share_snapshot_id=self.snapshot_1['id'], + key=key)) + + def test_share_snapshot_metadata_update(self): + metadata1 = {'a': '1', 'c': '2'} + metadata2 = {'a': '3', 'd': '5'} + should_be = {'a': '3', 'c': '2', 'd': '5'} + self.share_1 = db_utils.create_share(size=1) + self.snapshot_1 = db_utils.create_snapshot( + share_id=self.share_1['id']) + db_api.share_snapshot_metadata_update( + self.ctxt, share_snapshot_id=self.snapshot_1['id'], + metadata=metadata1, delete=False) + db_api.share_snapshot_metadata_update( + self.ctxt, share_snapshot_id=self.snapshot_1['id'], + metadata=metadata2, delete=False) + self.assertEqual( + should_be, db_api.share_snapshot_metadata_get( + self.ctxt, share_snapshot_id=self.snapshot_1['id'])) + + def test_share_snapshot_metadata_delete(self): + key = 'a' + metadata = {'a': '1', 'c': '2'} + should_be = {'c': '2'} + self.share_1 = db_utils.create_share(size=1) + self.snapshot_1 = db_utils.create_snapshot( + share_id=self.share_1['id']) + db_api.share_snapshot_metadata_update( + self.ctxt, share_snapshot_id=self.snapshot_1['id'], + metadata=metadata, delete=False) + db_api.share_snapshot_metadata_delete( + self.ctxt, share_snapshot_id=self.snapshot_1['id'], + key=key) + self.assertEqual( + should_be, db_api.share_snapshot_metadata_get( + self.ctxt, share_snapshot_id=self.snapshot_1['id'])) + class ShareExportLocationsDatabaseAPITestCase(test.TestCase): diff --git a/manila/tests/fake_share.py b/manila/tests/fake_share.py index 9255f548a2..a0605a30f1 100644 --- a/manila/tests/fake_share.py +++ b/manila/tests/fake_share.py @@ -207,7 +207,11 @@ def expected_snapshot(version=None, id='fake_snapshot_id', **kwargs): 'user_id': 'fakesnapuser', 'project_id': 'fakesnapproject', }) - + if version and (api_version.APIVersionRequest(version) + >= api_version.APIVersionRequest('2.73')): + snapshot.update({ + 'metadata': {} + }) snapshot.update(kwargs) return {'snapshot': snapshot} diff --git a/releasenotes/notes/add_snapshot_metadata-bd986e338220c90e.yaml b/releasenotes/notes/add_snapshot_metadata-bd986e338220c90e.yaml new file mode 100644 index 0000000000..b223c8ea1f --- /dev/null +++ b/releasenotes/notes/add_snapshot_metadata-bd986e338220c90e.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Adds snapshot metadata capabilities inlcuding, create, update all, + update single, show, and delete metadata. Snapshots may be + filtered using metadata keys. Snapshot metadata is + available to admin and nonadmin users.