Merge "Adds metadata in search option for snapshot"
This commit is contained in:
commit
a72d529fbe
@ -71,6 +71,7 @@ REST_API_VERSION_HISTORY = """
|
|||||||
* 3.20 - Add API reset status actions 'reset_status' to generic
|
* 3.20 - Add API reset status actions 'reset_status' to generic
|
||||||
volume group.
|
volume group.
|
||||||
* 3.21 - Show provider_id in detailed view of a volume for admin.
|
* 3.21 - Show provider_id in detailed view of a volume for admin.
|
||||||
|
* 3.22 - Add filtering based on metadata for snapshot listing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The minimum and maximum versions of the API supported
|
# The minimum and maximum versions of the API supported
|
||||||
@ -78,7 +79,7 @@ REST_API_VERSION_HISTORY = """
|
|||||||
# minimum version of the API supported.
|
# minimum version of the API supported.
|
||||||
# Explicitly using /v1 or /v2 enpoints will still work
|
# Explicitly using /v1 or /v2 enpoints will still work
|
||||||
_MIN_API_VERSION = "3.0"
|
_MIN_API_VERSION = "3.0"
|
||||||
_MAX_API_VERSION = "3.21"
|
_MAX_API_VERSION = "3.22"
|
||||||
_LEGACY_API_VERSION1 = "1.0"
|
_LEGACY_API_VERSION1 = "1.0"
|
||||||
_LEGACY_API_VERSION2 = "2.0"
|
_LEGACY_API_VERSION2 = "2.0"
|
||||||
|
|
||||||
|
@ -222,3 +222,8 @@ user documentation.
|
|||||||
3.21
|
3.21
|
||||||
----
|
----
|
||||||
Show provider_id in detailed view of a volume for admin.
|
Show provider_id in detailed view of a volume for admin.
|
||||||
|
|
||||||
|
3.22
|
||||||
|
----
|
||||||
|
Added support to filter snapshot list based on metadata of snapshot.
|
||||||
|
|
||||||
|
@ -15,9 +15,17 @@
|
|||||||
|
|
||||||
"""The volumes snapshots V3 api."""
|
"""The volumes snapshots V3 api."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
|
||||||
|
from oslo_log import log as logging
|
||||||
|
|
||||||
|
from cinder.api import common
|
||||||
from cinder.api.openstack import wsgi
|
from cinder.api.openstack import wsgi
|
||||||
from cinder.api.v2 import snapshots as snapshots_v2
|
from cinder.api.v2 import snapshots as snapshots_v2
|
||||||
from cinder.api.v3.views import snapshots as snapshot_views
|
from cinder.api.v3.views import snapshots as snapshot_views
|
||||||
|
from cinder import utils
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SnapshotsController(snapshots_v2.SnapshotsController):
|
class SnapshotsController(snapshots_v2.SnapshotsController):
|
||||||
@ -25,6 +33,74 @@ class SnapshotsController(snapshots_v2.SnapshotsController):
|
|||||||
|
|
||||||
_view_builder_class = snapshot_views.ViewBuilder
|
_view_builder_class = snapshot_views.ViewBuilder
|
||||||
|
|
||||||
|
def _get_snapshot_filter_options(self):
|
||||||
|
"""returns tuple of valid filter options"""
|
||||||
|
|
||||||
|
return 'status', 'volume_id', 'name', 'metadata'
|
||||||
|
|
||||||
|
def _format_snapshot_filter_options(self, search_opts):
|
||||||
|
"""Convert valid filter options to correct expected format"""
|
||||||
|
|
||||||
|
# Get the dict object out of queried metadata
|
||||||
|
# convert metadata query value from string to dict
|
||||||
|
if 'metadata' in search_opts.keys():
|
||||||
|
try:
|
||||||
|
search_opts['metadata'] = ast.literal_eval(
|
||||||
|
search_opts['metadata'])
|
||||||
|
except (ValueError, SyntaxError):
|
||||||
|
LOG.debug('Could not evaluate value %s, assuming string',
|
||||||
|
search_opts['metadata'])
|
||||||
|
|
||||||
|
def _process_filters(self, req, context, search_opts):
|
||||||
|
"""Formats allowed filters"""
|
||||||
|
|
||||||
|
req_version = req.api_version_request
|
||||||
|
# if the max version is less than or same as 3.21
|
||||||
|
# metadata based filtering is not supported
|
||||||
|
if req_version.matches(None, "3.21"):
|
||||||
|
search_opts.pop('metadata', None)
|
||||||
|
|
||||||
|
# Filter out invalid options
|
||||||
|
allowed_search_options = self._get_snapshot_filter_options()
|
||||||
|
|
||||||
|
utils.remove_invalid_filter_options(context, search_opts,
|
||||||
|
allowed_search_options)
|
||||||
|
|
||||||
|
# process snapshot filters to appropriate formats if required
|
||||||
|
self._format_snapshot_filter_options(search_opts)
|
||||||
|
|
||||||
|
def _items(self, req, is_detail=True):
|
||||||
|
"""Returns a list of snapshots, transformed through view builder."""
|
||||||
|
context = req.environ['cinder.context']
|
||||||
|
|
||||||
|
# Pop out non search_opts and create local variables
|
||||||
|
search_opts = req.GET.copy()
|
||||||
|
sort_keys, sort_dirs = common.get_sort_params(search_opts)
|
||||||
|
marker, limit, offset = common.get_pagination_params(search_opts)
|
||||||
|
|
||||||
|
# process filters
|
||||||
|
self._process_filters(req, context, search_opts)
|
||||||
|
|
||||||
|
# NOTE(thingee): v3 API allows name instead of display_name
|
||||||
|
if 'name' in search_opts:
|
||||||
|
search_opts['display_name'] = search_opts.pop('name')
|
||||||
|
|
||||||
|
snapshots = self.volume_api.get_all_snapshots(context,
|
||||||
|
search_opts=search_opts,
|
||||||
|
marker=marker,
|
||||||
|
limit=limit,
|
||||||
|
sort_keys=sort_keys,
|
||||||
|
sort_dirs=sort_dirs,
|
||||||
|
offset=offset)
|
||||||
|
|
||||||
|
req.cache_db_snapshots(snapshots.objects)
|
||||||
|
|
||||||
|
if is_detail:
|
||||||
|
snapshots = self._view_builder.detail_list(req, snapshots.objects)
|
||||||
|
else:
|
||||||
|
snapshots = self._view_builder.summary_list(req, snapshots.objects)
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
|
||||||
def create_resource(ext_mgr):
|
def create_resource(ext_mgr):
|
||||||
return wsgi.Resource(SnapshotsController(ext_mgr))
|
return wsgi.Resource(SnapshotsController(ext_mgr))
|
||||||
|
@ -2774,11 +2774,38 @@ def _snaps_get_query(context, session=None, project_only=False):
|
|||||||
|
|
||||||
def _process_snaps_filters(query, filters):
|
def _process_snaps_filters(query, filters):
|
||||||
if filters:
|
if filters:
|
||||||
# Ensure that filters' keys exist on the model
|
|
||||||
if not is_valid_model_filters(models.Snapshot, filters,
|
|
||||||
exclude_list=('host', 'cluster_name')):
|
|
||||||
return None
|
|
||||||
filters = filters.copy()
|
filters = filters.copy()
|
||||||
|
|
||||||
|
exclude_list = ('host', 'cluster_name')
|
||||||
|
|
||||||
|
# Ensure that filters' keys exist on the model or is metadata
|
||||||
|
for key in filters.keys():
|
||||||
|
# Ensure if filtering based on metadata filter is queried
|
||||||
|
# then the filters value is a dictionary
|
||||||
|
if key == 'metadata':
|
||||||
|
if not isinstance(filters[key], dict):
|
||||||
|
LOG.debug("Metadata filter value is not valid dictionary")
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key in exclude_list:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# for keys in filter other than metadata and exclude_list
|
||||||
|
# ensure that the keys are in Snapshot modelt
|
||||||
|
try:
|
||||||
|
column_attr = getattr(models.Snapshot, key)
|
||||||
|
prop = getattr(column_attr, 'property')
|
||||||
|
if isinstance(prop, RelationshipProperty):
|
||||||
|
LOG.debug(
|
||||||
|
"'%s' key is not valid, it maps to a relationship.",
|
||||||
|
key)
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
LOG.debug("'%s' filter key is not valid.", key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# filter handling for host and cluster name
|
||||||
host = filters.pop('host', None)
|
host = filters.pop('host', None)
|
||||||
cluster = filters.pop('cluster_name', None)
|
cluster = filters.pop('cluster_name', None)
|
||||||
if host or cluster:
|
if host or cluster:
|
||||||
@ -2788,7 +2815,21 @@ def _process_snaps_filters(query, filters):
|
|||||||
query = query.filter(_filter_host(vol_field.host, host))
|
query = query.filter(_filter_host(vol_field.host, host))
|
||||||
if cluster:
|
if cluster:
|
||||||
query = query.filter(_filter_host(vol_field.cluster_name, cluster))
|
query = query.filter(_filter_host(vol_field.cluster_name, cluster))
|
||||||
query = query.filter_by(**filters)
|
|
||||||
|
filters_dict = {}
|
||||||
|
LOG.debug("Building query based on filter")
|
||||||
|
for key, value in filters.items():
|
||||||
|
if key == 'metadata':
|
||||||
|
col_attr = getattr(models.Snapshot, 'snapshot_metadata')
|
||||||
|
for k, v in value.items():
|
||||||
|
query = query.filter(col_attr.any(key=k, value=v))
|
||||||
|
else:
|
||||||
|
filters_dict[key] = value
|
||||||
|
|
||||||
|
# Apply exact matches
|
||||||
|
if filters_dict:
|
||||||
|
query = query.filter_by(**filters_dict)
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,15 +27,44 @@ from cinder.tests.unit.api import fakes
|
|||||||
from cinder.tests.unit import fake_constants as fake
|
from cinder.tests.unit import fake_constants as fake
|
||||||
from cinder.tests.unit import fake_snapshot
|
from cinder.tests.unit import fake_snapshot
|
||||||
from cinder.tests.unit import fake_volume
|
from cinder.tests.unit import fake_volume
|
||||||
|
from cinder import volume
|
||||||
|
|
||||||
UUID = '00000000-0000-0000-0000-000000000001'
|
UUID = '00000000-0000-0000-0000-000000000001'
|
||||||
INVALID_UUID = '00000000-0000-0000-0000-000000000002'
|
INVALID_UUID = '00000000-0000-0000-0000-000000000002'
|
||||||
|
|
||||||
|
|
||||||
|
def stub_get(context, *args, **kwargs):
|
||||||
|
vol = {'id': fake.VOLUME_ID,
|
||||||
|
'size': 100,
|
||||||
|
'name': 'fake',
|
||||||
|
'host': 'fake-host',
|
||||||
|
'status': 'available',
|
||||||
|
'encryption_key_id': None,
|
||||||
|
'volume_type_id': None,
|
||||||
|
'migration_status': None,
|
||||||
|
'availability_zone': 'fake-zone',
|
||||||
|
'attach_status': 'detached',
|
||||||
|
'metadata': {}}
|
||||||
|
return fake_volume.fake_volume_obj(context, **vol)
|
||||||
|
|
||||||
|
|
||||||
|
def create_snapshot_query_with_metadata(metadata_query_string,
|
||||||
|
api_microversion):
|
||||||
|
"""Helper to create metadata querystring with microversion"""
|
||||||
|
req = fakes.HTTPRequest.blank('/v3/snapshots?metadata=' +
|
||||||
|
metadata_query_string)
|
||||||
|
req.headers["OpenStack-API-Version"] = "volume " + api_microversion
|
||||||
|
req.api_version_request = api_version.APIVersionRequest(
|
||||||
|
api_microversion)
|
||||||
|
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
@ddt.ddt
|
@ddt.ddt
|
||||||
class SnapshotApiTest(test.TestCase):
|
class SnapshotApiTest(test.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(SnapshotApiTest, self).setUp()
|
super(SnapshotApiTest, self).setUp()
|
||||||
|
self.stubs.Set(volume.api.API, 'get', stub_get)
|
||||||
self.controller = snapshots.SnapshotsController()
|
self.controller = snapshots.SnapshotsController()
|
||||||
self.ctx = context.RequestContext(fake.USER_ID, fake.PROJECT_ID, True)
|
self.ctx = context.RequestContext(fake.USER_ID, fake.PROJECT_ID, True)
|
||||||
|
|
||||||
@ -77,3 +106,89 @@ class SnapshotApiTest(test.TestCase):
|
|||||||
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % snapshot_id)
|
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % snapshot_id)
|
||||||
self.assertRaises(exception.SnapshotNotFound,
|
self.assertRaises(exception.SnapshotNotFound,
|
||||||
self.controller.show, req, snapshot_id)
|
self.controller.show, req, snapshot_id)
|
||||||
|
|
||||||
|
def _create_snapshot_with_metadata(self, metadata):
|
||||||
|
"""Creates test snapshopt with provided metadata"""
|
||||||
|
req = fakes.HTTPRequest.blank('/v3/snapshots')
|
||||||
|
snap = {"volume_size": 200,
|
||||||
|
"volume_id": fake.VOLUME_ID,
|
||||||
|
"display_name": "Volume Test Name",
|
||||||
|
"display_description": "Volume Test Desc",
|
||||||
|
"availability_zone": "zone1:host1",
|
||||||
|
"host": "fake-host",
|
||||||
|
"metadata": metadata}
|
||||||
|
body = {"snapshot": snap}
|
||||||
|
self.controller.create(req, body)
|
||||||
|
|
||||||
|
def test_snapshot_list_with_one_metadata_in_filter(self):
|
||||||
|
# Create snapshot with metadata key1: value1
|
||||||
|
metadata = {"key1": "val1"}
|
||||||
|
self._create_snapshot_with_metadata(metadata)
|
||||||
|
|
||||||
|
# Create request with metadata filter key1: value1
|
||||||
|
req = create_snapshot_query_with_metadata('{"key1":"val1"}', '3.22')
|
||||||
|
|
||||||
|
# query controller with above request
|
||||||
|
res_dict = self.controller.detail(req)
|
||||||
|
|
||||||
|
# verify 1 snapshot is returned
|
||||||
|
self.assertEqual(1, len(res_dict['snapshots']))
|
||||||
|
|
||||||
|
# verify if the medadata of the returned snapshot is key1: value1
|
||||||
|
self.assertDictEqual({"key1": "val1"}, res_dict['snapshots'][0][
|
||||||
|
'metadata'])
|
||||||
|
|
||||||
|
# Create request with metadata filter key2: value2
|
||||||
|
req = create_snapshot_query_with_metadata('{"key2":"val2"}', '3.22')
|
||||||
|
|
||||||
|
# query controller with above request
|
||||||
|
res_dict = self.controller.detail(req)
|
||||||
|
|
||||||
|
# verify no snapshot is returned
|
||||||
|
self.assertEqual(0, len(res_dict['snapshots']))
|
||||||
|
|
||||||
|
def test_snapshot_list_with_multiple_metadata_in_filter(self):
|
||||||
|
# Create snapshot with metadata key1: value1, key11: value11
|
||||||
|
metadata = {"key1": "val1", "key11": "val11"}
|
||||||
|
self._create_snapshot_with_metadata(metadata)
|
||||||
|
|
||||||
|
# Create request with metadata filter key1: value1, key11: value11
|
||||||
|
req = create_snapshot_query_with_metadata(
|
||||||
|
'{"key1":"val1", "key11":"val11"}', '3.22')
|
||||||
|
|
||||||
|
# query controller with above request
|
||||||
|
res_dict = self.controller.detail(req)
|
||||||
|
|
||||||
|
# verify 1 snapshot is returned
|
||||||
|
self.assertEqual(1, len(res_dict['snapshots']))
|
||||||
|
|
||||||
|
# verify if the medadata of the returned snapshot is key1: value1
|
||||||
|
self.assertDictEqual({"key1": "val1", "key11": "val11"}, res_dict[
|
||||||
|
'snapshots'][0]['metadata'])
|
||||||
|
|
||||||
|
# Create request with metadata filter key1: value1
|
||||||
|
req = create_snapshot_query_with_metadata('{"key1":"val1"}', '3.22')
|
||||||
|
|
||||||
|
# query controller with above request
|
||||||
|
res_dict = self.controller.detail(req)
|
||||||
|
|
||||||
|
# verify 1 snapshot is returned
|
||||||
|
self.assertEqual(1, len(res_dict['snapshots']))
|
||||||
|
|
||||||
|
# verify if the medadata of the returned snapshot is key1: value1
|
||||||
|
self.assertDictEqual({"key1": "val1", "key11": "val11"}, res_dict[
|
||||||
|
'snapshots'][0]['metadata'])
|
||||||
|
|
||||||
|
def test_snapshot_list_with_metadata_unsupported_microversion(self):
|
||||||
|
# Create snapshot with metadata key1: value1
|
||||||
|
metadata = {"key1": "val1"}
|
||||||
|
self._create_snapshot_with_metadata(metadata)
|
||||||
|
|
||||||
|
# Create request with metadata filter key2: value2
|
||||||
|
req = create_snapshot_query_with_metadata('{"key2":"val2"}', '3.21')
|
||||||
|
|
||||||
|
# query controller with above request
|
||||||
|
res_dict = self.controller.detail(req)
|
||||||
|
|
||||||
|
# verify some snapshot is returned
|
||||||
|
self.assertNotEqual(0, len(res_dict['snapshots']))
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- Added support to querying snapshots filtered by metadata key/value
|
||||||
|
using 'metadata' optional URL parameter.
|
||||||
|
For example, "/v3/snapshots?metadata=={'key1':'value1'}".
|
Loading…
x
Reference in New Issue
Block a user