diff --git a/cinder/api/contrib/resource_common_manage.py b/cinder/api/contrib/resource_common_manage.py new file mode 100644 index 00000000000..ebf20332264 --- /dev/null +++ b/cinder/api/contrib/resource_common_manage.py @@ -0,0 +1,56 @@ +# Copyright (c) 2016 Stratoscale, Ltd. +# +# 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 cinder.api import common +from cinder import exception +from cinder.i18n import _ + + +def get_manageable_resources(req, is_detail, function_get_manageable, + view_builder): + context = req.environ['cinder.context'] + params = req.params.copy() + host = params.get('host') + if host is None: + raise exception.InvalidHost( + reason=_("Host must be specified in query parameters")) + + marker, limit, offset = common.get_pagination_params(params) + sort_keys, sort_dirs = common.get_sort_params(params, + default_key='reference') + + # These parameters are generally validated at the DB layer, but in this + # case sorting is not done by the DB + valid_sort_keys = ('reference', 'size') + invalid_keys = [key for key in sort_keys if key not in valid_sort_keys] + if invalid_keys: + msg = _("Invalid sort keys passed: %s") % ', '.join(invalid_keys) + raise exception.InvalidParameterValue(err=msg) + valid_sort_dirs = ('asc', 'desc') + invalid_dirs = [d for d in sort_dirs if d not in valid_sort_dirs] + if invalid_dirs: + msg = _("Invalid sort dirs passed: %s") % ', '.join(invalid_dirs) + raise exception.InvalidParameterValue(err=msg) + + resources = function_get_manageable(context, host, marker=marker, + limit=limit, offset=offset, + sort_keys=sort_keys, + sort_dirs=sort_dirs) + resource_count = len(resources) + + if is_detail: + resources = view_builder.detail_list(req, resources, resource_count) + else: + resources = view_builder.summary_list(req, resources, resource_count) + return resources diff --git a/cinder/api/contrib/snapshot_manage.py b/cinder/api/contrib/snapshot_manage.py index e797dd27e6e..304288f063c 100644 --- a/cinder/api/contrib/snapshot_manage.py +++ b/cinder/api/contrib/snapshot_manage.py @@ -16,8 +16,10 @@ from oslo_config import cfg from oslo_log import log as logging from webob import exc +from cinder.api.contrib import resource_common_manage from cinder.api import extensions from cinder.api.openstack import wsgi +from cinder.api.views import manageable_snapshots as list_manageable_view from cinder.api.views import snapshots as snapshot_views from cinder import exception from cinder.i18n import _ @@ -25,7 +27,10 @@ from cinder import volume as cinder_volume LOG = logging.getLogger(__name__) CONF = cfg.CONF -authorize = extensions.extension_authorizer('snapshot', 'snapshot_manage') +authorize_manage = extensions.extension_authorizer('snapshot', + 'snapshot_manage') +authorize_list_manageable = extensions.extension_authorizer('snapshot', + 'list_manageable') class SnapshotManageController(wsgi.Controller): @@ -36,6 +41,7 @@ class SnapshotManageController(wsgi.Controller): def __init__(self, *args, **kwargs): super(SnapshotManageController, self).__init__(*args, **kwargs) self.volume_api = cinder_volume.API() + self._list_manageable_view = list_manageable_view.ViewBuilder() @wsgi.response(202) def create(self, req, body): @@ -81,7 +87,7 @@ class SnapshotManageController(wsgi.Controller): """ context = req.environ['cinder.context'] - authorize(context) + authorize_manage(context) if not self.is_valid_body(body, 'snapshot'): msg = _("Missing required element snapshot in request body.") @@ -130,6 +136,24 @@ class SnapshotManageController(wsgi.Controller): return self._view_builder.detail(req, new_snapshot) + @wsgi.extends + def index(self, req): + """Returns a summary list of snapshots available to manage.""" + context = req.environ['cinder.context'] + authorize_list_manageable(context) + return resource_common_manage.get_manageable_resources( + req, False, self.volume_api.get_manageable_snapshots, + self._list_manageable_view) + + @wsgi.extends + def detail(self, req): + """Returns a detailed list of snapshots available to manage.""" + context = req.environ['cinder.context'] + authorize_list_manageable(context) + return resource_common_manage.get_manageable_resources( + req, True, self.volume_api.get_manageable_snapshots, + self._list_manageable_view) + class Snapshot_manage(extensions.ExtensionDescriptor): """Allows existing backend storage to be 'managed' by Cinder.""" @@ -141,4 +165,6 @@ class Snapshot_manage(extensions.ExtensionDescriptor): def get_resources(self): controller = SnapshotManageController() return [extensions.ResourceExtension(Snapshot_manage.alias, - controller)] + controller, + collection_actions= + {'detail': 'GET'})] diff --git a/cinder/api/contrib/volume_manage.py b/cinder/api/contrib/volume_manage.py index 1953a1b5de4..855437032ef 100644 --- a/cinder/api/contrib/volume_manage.py +++ b/cinder/api/contrib/volume_manage.py @@ -16,9 +16,11 @@ from oslo_log import log as logging from oslo_utils import uuidutils from webob import exc +from cinder.api.contrib import resource_common_manage from cinder.api import extensions from cinder.api.openstack import wsgi from cinder.api.v2.views import volumes as volume_views +from cinder.api.views import manageable_volumes as list_manageable_view from cinder import exception from cinder.i18n import _ from cinder import utils @@ -26,7 +28,9 @@ from cinder import volume as cinder_volume from cinder.volume import volume_types LOG = logging.getLogger(__name__) -authorize = extensions.extension_authorizer('volume', 'volume_manage') +authorize_manage = extensions.extension_authorizer('volume', 'volume_manage') +authorize_list_manageable = extensions.extension_authorizer('volume', + 'list_manageable') class VolumeManageController(wsgi.Controller): @@ -37,6 +41,7 @@ class VolumeManageController(wsgi.Controller): def __init__(self, *args, **kwargs): super(VolumeManageController, self).__init__(*args, **kwargs) self.volume_api = cinder_volume.API() + self._list_manageable_view = list_manageable_view.ViewBuilder() @wsgi.response(202) def create(self, req, body): @@ -93,7 +98,7 @@ class VolumeManageController(wsgi.Controller): """ context = req.environ['cinder.context'] - authorize(context) + authorize_manage(context) self.assert_valid_body(body, 'volume') @@ -145,6 +150,24 @@ class VolumeManageController(wsgi.Controller): return self._view_builder.detail(req, new_volume) + @wsgi.extends + def index(self, req): + """Returns a summary list of volumes available to manage.""" + context = req.environ['cinder.context'] + authorize_list_manageable(context) + return resource_common_manage.get_manageable_resources( + req, False, self.volume_api.get_manageable_volumes, + self._list_manageable_view) + + @wsgi.extends + def detail(self, req): + """Returns a detailed list of volumes available to manage.""" + context = req.environ['cinder.context'] + authorize_list_manageable(context) + return resource_common_manage.get_manageable_resources( + req, True, self.volume_api.get_manageable_volumes, + self._list_manageable_view) + class Volume_manage(extensions.ExtensionDescriptor): """Allows existing backend storage to be 'managed' by Cinder.""" @@ -156,5 +179,7 @@ class Volume_manage(extensions.ExtensionDescriptor): def get_resources(self): controller = VolumeManageController() res = extensions.ResourceExtension(Volume_manage.alias, - controller) + controller, + collection_actions= + {'detail': 'GET'}) return [res] diff --git a/cinder/api/views/manageable_snapshots.py b/cinder/api/views/manageable_snapshots.py new file mode 100644 index 00000000000..f3e8d9357c8 --- /dev/null +++ b/cinder/api/views/manageable_snapshots.py @@ -0,0 +1,60 @@ +# Copyright (c) 2016 Stratoscale, Ltd. +# +# 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 oslo_log import log as logging + +from cinder.api import common + + +LOG = logging.getLogger(__name__) + + +class ViewBuilder(common.ViewBuilder): + """Model manageable snapshot responses as a python dictionary.""" + + _collection_name = "os-snapshot-manage" + + def summary_list(self, request, snapshots, count): + """Show a list of manageable snapshots without many details.""" + return self._list_view(self.summary, request, snapshots, count) + + def detail_list(self, request, snapshots, count): + """Detailed view of a list of manageable snapshots.""" + return self._list_view(self.detail, request, snapshots, count) + + def summary(self, request, snapshot): + """Generic, non-detailed view of a manageable snapshot description.""" + return { + 'reference': snapshot['reference'], + 'size': snapshot['size'], + 'safe_to_manage': snapshot['safe_to_manage'], + 'source_reference': snapshot['source_reference'] + } + + def detail(self, request, snapshot): + """Detailed view of a manageable snapshot description.""" + return { + 'reference': snapshot['reference'], + 'size': snapshot['size'], + 'safe_to_manage': snapshot['safe_to_manage'], + 'reason_not_safe': snapshot['reason_not_safe'], + 'extra_info': snapshot['extra_info'], + 'cinder_id': snapshot['cinder_id'], + 'source_reference': snapshot['source_reference'] + } + + def _list_view(self, func, request, snapshots, count): + """Provide a view for a list of manageable snapshots.""" + snap_list = [func(request, snapshot) for snapshot in snapshots] + return {"manageable-snapshots": snap_list} diff --git a/cinder/api/views/manageable_volumes.py b/cinder/api/views/manageable_volumes.py new file mode 100644 index 00000000000..892fe86520d --- /dev/null +++ b/cinder/api/views/manageable_volumes.py @@ -0,0 +1,58 @@ +# Copyright (c) 2016 Stratoscale, Ltd. +# +# 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 oslo_log import log as logging + +from cinder.api import common + + +LOG = logging.getLogger(__name__) + + +class ViewBuilder(common.ViewBuilder): + """Model manageable volume responses as a python dictionary.""" + + _collection_name = "os-volume-manage" + + def summary_list(self, request, volumes, count): + """Show a list of manageable volumes without many details.""" + return self._list_view(self.summary, request, volumes, count) + + def detail_list(self, request, volumes, count): + """Detailed view of a list of manageable volumes.""" + return self._list_view(self.detail, request, volumes, count) + + def summary(self, request, volume): + """Generic, non-detailed view of a manageable volume description.""" + return { + 'reference': volume['reference'], + 'size': volume['size'], + 'safe_to_manage': volume['safe_to_manage'] + } + + def detail(self, request, volume): + """Detailed view of a manageable volume description.""" + return { + 'reference': volume['reference'], + 'size': volume['size'], + 'safe_to_manage': volume['safe_to_manage'], + 'reason_not_safe': volume['reason_not_safe'], + 'cinder_id': volume['cinder_id'], + 'extra_info': volume['extra_info'] + } + + def _list_view(self, func, request, volumes, count): + """Provide a view for a list of manageable volumes.""" + vol_list = [func(request, volume) for volume in volumes] + return {"manageable-volumes": vol_list} diff --git a/cinder/tests/unit/api/contrib/test_snapshot_manage.py b/cinder/tests/unit/api/contrib/test_snapshot_manage.py index 6b2867b3ed6..5c4592400ac 100644 --- a/cinder/tests/unit/api/contrib/test_snapshot_manage.py +++ b/cinder/tests/unit/api/contrib/test_snapshot_manage.py @@ -1,4 +1,5 @@ # Copyright (c) 2015 Huawei Technologies Co., Ltd. +# Copyright (c) 2016 Stratoscale, Ltd. # # 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 @@ -13,7 +14,12 @@ # under the License. import mock +from oslo_config import cfg from oslo_serialization import jsonutils +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode import webob from cinder import context @@ -23,6 +29,8 @@ from cinder.tests.unit.api import fakes from cinder.tests.unit import fake_constants as fake from cinder.tests.unit import fake_service +CONF = cfg.CONF + def app(): # no auth, just let environ['cinder.context'] pass through @@ -39,6 +47,28 @@ def volume_get(self, context, volume_id, viewable_admin_meta=False): raise exception.VolumeNotFound(volume_id=volume_id) +def api_get_manageable_snapshots(*args, **kwargs): + """Replacement for cinder.volume.api.API.get_manageable_snapshots.""" + snap_id = 'ffffffff-0000-ffff-0000-ffffffffffff' + snaps = [ + {'reference': {'source-name': 'snapshot-%s' % snap_id}, + 'size': 4, + 'extra_info': 'qos_setting:high', + 'safe_to_manage': False, + 'reason_not_safe': 'snapshot in use', + 'cinder_id': snap_id, + 'source_reference': {'source-name': + 'volume-00000000-ffff-0000-ffff-000000'}}, + {'reference': {'source-name': 'mysnap'}, + 'size': 5, + 'extra_info': 'qos_setting:low', + 'safe_to_manage': True, + 'reason_not_safe': None, + 'cinder_id': None, + 'source_reference': {'source-name': 'myvol'}}] + return snaps + + @mock.patch('cinder.volume.api.API.get', volume_get) class SnapshotManageTest(test.TestCase): """Test cases for cinder/api/contrib/snapshot_manage.py @@ -55,15 +85,22 @@ class SnapshotManageTest(test.TestCase): with the correct arguments. """ - def _get_resp(self, body): + def setUp(self): + super(SnapshotManageTest, self).setUp() + self._admin_ctxt = context.RequestContext(fake.USER_ID, + fake.PROJECT_ID, + is_admin=True) + self._non_admin_ctxt = context.RequestContext(fake.USER_ID, + fake.PROJECT_ID, + is_admin=False) + + def _get_resp_post(self, body): """Helper to execute an os-snapshot-manage API call.""" req = webob.Request.blank('/v2/%s/os-snapshot-manage' % fake.PROJECT_ID) req.method = 'POST' req.headers['Content-Type'] = 'application/json' - req.environ['cinder.context'] = context.RequestContext(fake.USER_ID, - fake.PROJECT_ID, - True) + req.environ['cinder.context'] = self._admin_ctxt req.body = jsonutils.dump_as_bytes(body) res = req.get_response(app()) return res @@ -80,13 +117,11 @@ class SnapshotManageTest(test.TestCase): called with the correct arguments, and that we return the correct HTTP code to the caller. """ - ctxt = context.RequestContext(fake.USER_ID, fake.PROJECT_ID, True) mock_db.return_value = fake_service.fake_service_obj( - ctxt, + self._admin_ctxt, binary='cinder-volume') body = {'snapshot': {'volume_id': fake.VOLUME_ID, 'ref': 'fake_ref'}} - - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(202, res.status_int, res) # Check the db.service_get_by_host_and_topic was called with correct @@ -112,24 +147,96 @@ class SnapshotManageTest(test.TestCase): def test_manage_snapshot_missing_volume_id(self): """Test correct failure when volume_id is not specified.""" body = {'snapshot': {'ref': 'fake_ref'}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(400, res.status_int) def test_manage_snapshot_missing_ref(self): """Test correct failure when the ref is not specified.""" body = {'snapshot': {'volume_id': fake.VOLUME_ID}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(400, res.status_int) def test_manage_snapshot_error_body(self): """Test correct failure when body is invaild.""" body = {'error_snapshot': {'volume_id': fake.VOLUME_ID}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(400, res.status_int) def test_manage_snapshot_error_volume_id(self): """Test correct failure when volume can't be found.""" body = {'snapshot': {'volume_id': 'error_volume_id', 'ref': 'fake_ref'}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(404, res.status_int) + + def _get_resp_get(self, host, detailed, paging, admin=True): + """Helper to execute a GET os-snapshot-manage API call.""" + params = {'host': host} + if paging: + params.update({'marker': '1234', 'limit': 10, + 'offset': 4, 'sort': 'reference:asc'}) + query_string = "?%s" % urlencode(params) + detail = "" + if detailed: + detail = "/detail" + url = "/v2/%s/os-snapshot-manage%s%s" % (fake.PROJECT_ID, detail, + query_string) + req = webob.Request.blank(url) + req.method = 'GET' + req.headers['Content-Type'] = 'application/json' + req.environ['cinder.context'] = (self._admin_ctxt if admin + else self._non_admin_ctxt) + res = req.get_response(app()) + return res + + @mock.patch('cinder.volume.api.API.get_manageable_snapshots', + wraps=api_get_manageable_snapshots) + def test_get_manageable_snapshots_non_admin(self, mock_api_manageable): + res = self._get_resp_get('fakehost', False, False, admin=False) + self.assertEqual(403, res.status_int) + self.assertEqual(False, mock_api_manageable.called) + res = self._get_resp_get('fakehost', True, False, admin=False) + self.assertEqual(403, res.status_int) + self.assertEqual(False, mock_api_manageable.called) + + @mock.patch('cinder.volume.api.API.get_manageable_snapshots', + wraps=api_get_manageable_snapshots) + def test_get_manageable_snapshots_ok(self, mock_api_manageable): + res = self._get_resp_get('fakehost', False, False) + snap_name = 'snapshot-ffffffff-0000-ffff-0000-ffffffffffff' + exp = {'manageable-snapshots': + [{'reference': {'source-name': snap_name}, 'size': 4, + 'safe_to_manage': False, + 'source_reference': + {'source-name': 'volume-00000000-ffff-0000-ffff-000000'}}, + {'reference': {'source-name': 'mysnap'}, 'size': 5, + 'safe_to_manage': True, + 'source_reference': {'source-name': 'myvol'}}]} + self.assertEqual(200, res.status_int) + self.assertEqual(jsonutils.loads(res.body), exp) + mock_api_manageable.assert_called_once_with( + self._admin_ctxt, 'fakehost', limit=CONF.osapi_max_limit, + marker=None, offset=0, sort_dirs=['desc'], + sort_keys=['reference']) + + @mock.patch('cinder.volume.api.API.get_manageable_snapshots', + wraps=api_get_manageable_snapshots) + def test_get_manageable_snapshots_detailed_ok(self, mock_api_manageable): + res = self._get_resp_get('fakehost', True, True) + snap_id = 'ffffffff-0000-ffff-0000-ffffffffffff' + exp = {'manageable-snapshots': + [{'reference': {'source-name': 'snapshot-%s' % snap_id}, + 'size': 4, 'safe_to_manage': False, 'cinder_id': snap_id, + 'reason_not_safe': 'snapshot in use', + 'extra_info': 'qos_setting:high', + 'source_reference': + {'source-name': 'volume-00000000-ffff-0000-ffff-000000'}}, + {'reference': {'source-name': 'mysnap'}, 'size': 5, + 'cinder_id': None, 'safe_to_manage': True, + 'reason_not_safe': None, 'extra_info': 'qos_setting:low', + 'source_reference': {'source-name': 'myvol'}}]} + self.assertEqual(200, res.status_int) + self.assertEqual(jsonutils.loads(res.body), exp) + mock_api_manageable.assert_called_once_with( + self._admin_ctxt, 'fakehost', limit=10, marker='1234', offset=4, + sort_dirs=['asc'], sort_keys=['reference']) diff --git a/cinder/tests/unit/api/contrib/test_volume_manage.py b/cinder/tests/unit/api/contrib/test_volume_manage.py index 337979b9d35..250178de1e5 100644 --- a/cinder/tests/unit/api/contrib/test_volume_manage.py +++ b/cinder/tests/unit/api/contrib/test_volume_manage.py @@ -1,4 +1,5 @@ # Copyright 2014 IBM Corp. +# Copyright (c) 2016 Stratoscale, Ltd. # # 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 @@ -13,7 +14,12 @@ # under the License. import mock +from oslo_config import cfg from oslo_serialization import jsonutils +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode import webob from cinder import context @@ -23,6 +29,8 @@ from cinder.tests.unit.api import fakes from cinder.tests.unit import fake_constants as fake from cinder.tests.unit import fake_volume +CONF = cfg.CONF + def app(): # no auth, just let environ['cinder.context'] pass through @@ -100,6 +108,25 @@ def api_manage(*args, **kwargs): return fake_volume.fake_volume_obj(ctx, **vol) +def api_get_manageable_volumes(*args, **kwargs): + """Replacement for cinder.volume.api.API.get_manageable_volumes.""" + vol_id = 'ffffffff-0000-ffff-0000-ffffffffffff' + vols = [ + {'reference': {'source-name': 'volume-%s' % vol_id}, + 'size': 4, + 'extra_info': 'qos_setting:high', + 'safe_to_manage': False, + 'cinder_id': vol_id, + 'reason_not_safe': 'volume in use'}, + {'reference': {'source-name': 'myvol'}, + 'size': 5, + 'extra_info': 'qos_setting:low', + 'safe_to_manage': True, + 'cinder_id': None, + 'reason_not_safe': None}] + return vols + + @mock.patch('cinder.db.service_get_by_host_and_topic', db_service_get_by_host_and_topic) @mock.patch('cinder.volume.volume_types.get_volume_type_by_name', @@ -122,15 +149,19 @@ class VolumeManageTest(test.TestCase): def setUp(self): super(VolumeManageTest, self).setUp() + self._admin_ctxt = context.RequestContext(fake.USER_ID, + fake.PROJECT_ID, + is_admin=True) + self._non_admin_ctxt = context.RequestContext(fake.USER_ID, + fake.PROJECT_ID, + is_admin=False) - def _get_resp(self, body): - """Helper to execute an os-volume-manage API call.""" + def _get_resp_post(self, body): + """Helper to execute a POST os-volume-manage API call.""" req = webob.Request.blank('/v2/%s/os-volume-manage' % fake.PROJECT_ID) req.method = 'POST' req.headers['Content-Type'] = 'application/json' - req.environ['cinder.context'] = context.RequestContext(fake.USER_ID, - fake.PROJECT_ID, - True) + req.environ['cinder.context'] = self._admin_ctxt req.body = jsonutils.dump_as_bytes(body) res = req.get_response(app()) return res @@ -148,7 +179,7 @@ class VolumeManageTest(test.TestCase): """ body = {'volume': {'host': 'host_ok', 'ref': 'fake_ref'}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(202, res.status_int, res) # Check that the manage API was called with the correct arguments. @@ -161,13 +192,13 @@ class VolumeManageTest(test.TestCase): def test_manage_volume_missing_host(self): """Test correct failure when host is not specified.""" body = {'volume': {'ref': 'fake_ref'}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(400, res.status_int) def test_manage_volume_missing_ref(self): """Test correct failure when the ref is not specified.""" body = {'volume': {'host': 'host_ok'}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(400, res.status_int) pass @@ -183,7 +214,7 @@ class VolumeManageTest(test.TestCase): body = {'volume': {'host': 'host_ok', 'ref': 'fake_ref', 'volume_type': fake.VOLUME_TYPE_ID}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(202, res.status_int, res) self.assertTrue(mock_validate.called) pass @@ -200,7 +231,7 @@ class VolumeManageTest(test.TestCase): body = {'volume': {'host': 'host_ok', 'ref': 'fake_ref', 'volume_type': 'good_fakevt'}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(202, res.status_int, res) self.assertTrue(mock_validate.called) pass @@ -210,7 +241,7 @@ class VolumeManageTest(test.TestCase): body = {'volume': {'host': 'host_ok', 'ref': 'fake_ref', 'volume_type': fake.WILL_NOT_BE_FOUND_ID}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(404, res.status_int, res) pass @@ -219,6 +250,73 @@ class VolumeManageTest(test.TestCase): body = {'volume': {'host': 'host_ok', 'ref': 'fake_ref', 'volume_type': 'bad_fakevt'}} - res = self._get_resp(body) + res = self._get_resp_post(body) self.assertEqual(404, res.status_int, res) pass + + def _get_resp_get(self, host, detailed, paging, admin=True): + """Helper to execute a GET os-volume-manage API call.""" + params = {'host': host} + if paging: + params.update({'marker': '1234', 'limit': 10, + 'offset': 4, 'sort': 'reference:asc'}) + query_string = "?%s" % urlencode(params) + detail = "" + if detailed: + detail = "/detail" + url = "/v2/%s/os-volume-manage%s%s" % (fake.PROJECT_ID, detail, + query_string) + req = webob.Request.blank(url) + req.method = 'GET' + req.headers['Content-Type'] = 'application/json' + req.environ['cinder.context'] = (self._admin_ctxt if admin + else self._non_admin_ctxt) + res = req.get_response(app()) + return res + + @mock.patch('cinder.volume.api.API.get_manageable_volumes', + wraps=api_get_manageable_volumes) + def test_get_manageable_volumes_non_admin(self, mock_api_manageable): + res = self._get_resp_get('fakehost', False, False, admin=False) + self.assertEqual(403, res.status_int) + self.assertEqual(False, mock_api_manageable.called) + res = self._get_resp_get('fakehost', True, False, admin=False) + self.assertEqual(403, res.status_int) + self.assertEqual(False, mock_api_manageable.called) + + @mock.patch('cinder.volume.api.API.get_manageable_volumes', + wraps=api_get_manageable_volumes) + def test_get_manageable_volumes_ok(self, mock_api_manageable): + res = self._get_resp_get('fakehost', False, True) + exp = {'manageable-volumes': + [{'reference': + {'source-name': + 'volume-ffffffff-0000-ffff-0000-ffffffffffff'}, + 'size': 4, 'safe_to_manage': False}, + {'reference': {'source-name': 'myvol'}, + 'size': 5, 'safe_to_manage': True}]} + self.assertEqual(200, res.status_int) + self.assertEqual(jsonutils.loads(res.body), exp) + mock_api_manageable.assert_called_once_with( + self._admin_ctxt, 'fakehost', limit=10, marker='1234', offset=4, + sort_dirs=['asc'], sort_keys=['reference']) + + @mock.patch('cinder.volume.api.API.get_manageable_volumes', + wraps=api_get_manageable_volumes) + def test_get_manageable_volumes_detailed_ok(self, mock_api_manageable): + res = self._get_resp_get('fakehost', True, False) + vol_id = 'ffffffff-0000-ffff-0000-ffffffffffff' + exp = {'manageable-volumes': + [{'reference': {'source-name': 'volume-%s' % vol_id}, + 'size': 4, 'reason_not_safe': 'volume in use', + 'cinder_id': vol_id, 'safe_to_manage': False, + 'extra_info': 'qos_setting:high'}, + {'reference': {'source-name': 'myvol'}, 'cinder_id': None, + 'size': 5, 'reason_not_safe': None, 'safe_to_manage': True, + 'extra_info': 'qos_setting:low'}]} + self.assertEqual(200, res.status_int) + self.assertEqual(jsonutils.loads(res.body), exp) + mock_api_manageable.assert_called_once_with( + self._admin_ctxt, 'fakehost', limit=CONF.osapi_max_limit, + marker=None, offset=0, sort_dirs=['desc'], + sort_keys=['reference']) diff --git a/cinder/tests/unit/policy.json b/cinder/tests/unit/policy.json index 8c257c36a90..c0616b79151 100644 --- a/cinder/tests/unit/policy.json +++ b/cinder/tests/unit/policy.json @@ -74,6 +74,7 @@ "volume_extension:services:update" : "rule:admin_api", "volume_extension:volume_manage": "rule:admin_api", "volume_extension:volume_unmanage": "rule:admin_api", + "volume_extension:list_manageable": "rule:admin_api", "volume_extension:capabilities": "rule:admin_api", "limits_extension:used_limits": "", @@ -81,6 +82,7 @@ "snapshot_extension:snapshot_actions:update_snapshot_status": "", "snapshot_extension:snapshot_manage": "rule:admin_api", "snapshot_extension:snapshot_unmanage": "rule:admin_api", + "snapshot_extension:list_manageable": "rule:admin_api", "volume:create_transfer": "", "volume:accept_transfer": "", diff --git a/cinder/volume/api.py b/cinder/volume/api.py index 3dc46b10919..3642cca85ee 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -1506,14 +1506,7 @@ class API(base.Base): LOG.info(_LI("Retype volume request issued successfully."), resource=volume) - def manage_existing(self, context, host, ref, name=None, description=None, - volume_type=None, metadata=None, - availability_zone=None, bootable=False): - if volume_type and 'extra_specs' not in volume_type: - extra_specs = volume_types.get_volume_type_extra_specs( - volume_type['id']) - volume_type['extra_specs'] = extra_specs - + def _get_service_by_host(self, context, host): elevated = context.elevated() try: svc_host = volume_utils.extract_host(host, 'backend') @@ -1530,6 +1523,18 @@ class API(base.Base): 'service.')) raise exception.ServiceUnavailable() + return service + + def manage_existing(self, context, host, ref, name=None, description=None, + volume_type=None, metadata=None, + availability_zone=None, bootable=False): + if volume_type and 'extra_specs' not in volume_type: + extra_specs = volume_types.get_volume_type_extra_specs( + volume_type['id']) + volume_type['extra_specs'] = extra_specs + + service = self._get_service_by_host(context, host) + if availability_zone is None: availability_zone = service.get('availability_zone') @@ -1564,6 +1569,14 @@ class API(base.Base): resource=vol_ref) return vol_ref + def get_manageable_volumes(self, context, host, marker=None, limit=None, + offset=None, sort_keys=None, sort_dirs=None): + self._get_service_by_host(context, host) + return self.volume_rpcapi.get_manageable_volumes(context, host, + marker, limit, + offset, sort_keys, + sort_dirs) + def manage_existing_snapshot(self, context, ref, volume, name=None, description=None, metadata=None): @@ -1591,6 +1604,14 @@ class API(base.Base): ref, host) return snapshot_object + def get_manageable_snapshots(self, context, host, marker=None, limit=None, + offset=None, sort_keys=None, sort_dirs=None): + self._get_service_by_host(context, host) + return self.volume_rpcapi.get_manageable_snapshots(context, host, + marker, limit, + offset, sort_keys, + sort_dirs) + # FIXME(jdg): Move these Cheesecake methods (freeze, thaw and failover) # to a services API because that's what they are def failover_host(self, diff --git a/cinder/volume/driver.py b/cinder/volume/driver.py index 579b51fffc3..c2d0aedef24 100644 --- a/cinder/volume/driver.py +++ b/cinder/volume/driver.py @@ -1819,6 +1819,37 @@ class ManageableVD(object): """ return + def get_manageable_volumes(self, cinder_volumes, marker, limit, offset, + sort_keys, sort_dirs): + """List volumes on the backend available for management by Cinder. + + Returns a list of dictionaries, each specifying a volume in the host, + with the following keys: + - reference (dictionary): The reference for a volume, which can be + passed to "manage_existing". + - size (int): The size of the volume according to the storage + backend, rounded up to the nearest GB. + - safe_to_manage (boolean): Whether or not this volume is safe to + manage according to the storage backend. For example, is the volume + in use or invalid for any reason. + - reason_not_safe (string): If safe_to_manage is False, the reason why. + - cinder_id (string): If already managed, provide the Cinder ID. + - extra_info (string): Any extra information to return to the user + + :param cinder_volumes: A list of volumes in this host that Cinder + currently manages, used to determine if + a volume is manageable or not. + :param marker: The last item of the previous page; we return the + next results after this value (after sorting) + :param limit: Maximum number of items to return + :param offset: Number of items to skip after marker + :param sort_keys: List of keys to sort results by (valid keys are + 'identifier' and 'size') + :param sort_dirs: List of directions to sort by, corresponding to + sort_keys (valid directions are 'asc' and 'desc') + """ + return [] + @abc.abstractmethod def unmanage(self, volume): """Removes the specified volume from Cinder management. @@ -1871,6 +1902,40 @@ class ManageableSnapshotsVD(object): """ return + def get_manageable_snapshots(self, cinder_snapshots, marker, limit, offset, + sort_keys, sort_dirs): + """List snapshots on the backend available for management by Cinder. + + Returns a list of dictionaries, each specifying a snapshot in the host, + with the following keys: + - reference (dictionary): The reference for a snapshot, which can be + passed to "manage_existing_snapshot". + - size (int): The size of the snapshot according to the storage + backend, rounded up to the nearest GB. + - safe_to_manage (boolean): Whether or not this snapshot is safe to + manage according to the storage backend. For example, is the snapshot + in use or invalid for any reason. + - reason_not_safe (string): If safe_to_manage is False, the reason why. + - cinder_id (string): If already managed, provide the Cinder ID. + - extra_info (string): Any extra information to return to the user + - source_reference (string): Similar to "reference", but for the + snapshot's source volume. + + :param cinder_snapshots: A list of snapshots in this host that Cinder + currently manages, used to determine if + a snapshot is manageable or not. + :param marker: The last item of the previous page; we return the + next results after this value (after sorting) + :param limit: Maximum number of items to return + :param offset: Number of items to skip after marker + :param sort_keys: List of keys to sort results by (valid keys are + 'identifier' and 'size') + :param sort_dirs: List of directions to sort by, corresponding to + sort_keys (valid directions are 'asc' and 'desc') + + """ + return [] + # NOTE: Can't use abstractmethod before all drivers implement it def unmanage_snapshot(self, snapshot): """Removes the specified snapshot from Cinder management. @@ -2025,6 +2090,11 @@ class VolumeDriver(ConsistencyGroupVD, TransferVD, ManageableVD, ExtendVD, msg = _("Manage existing volume not implemented.") raise NotImplementedError(msg) + def get_manageable_volumes(self, cinder_volumes, marker, limit, offset, + sort_keys, sort_dirs): + msg = _("Get manageable volumes not implemented.") + raise NotImplementedError(msg) + def unmanage(self, volume): pass @@ -2036,6 +2106,11 @@ class VolumeDriver(ConsistencyGroupVD, TransferVD, ManageableVD, ExtendVD, msg = _("Manage existing snapshot not implemented.") raise NotImplementedError(msg) + def get_manageable_snapshots(self, cinder_snapshots, marker, limit, offset, + sort_keys, sort_dirs): + msg = _("Get manageable snapshots not implemented.") + raise NotImplementedError(msg) + def unmanage_snapshot(self, snapshot): """Unmanage the specified snapshot from Cinder management.""" diff --git a/cinder/volume/manager.py b/cinder/volume/manager.py index 7916b646454..0daf7f21c0b 100644 --- a/cinder/volume/manager.py +++ b/cinder/volume/manager.py @@ -36,6 +36,7 @@ intact. """ + import requests import time @@ -217,7 +218,7 @@ def locked_snapshot_operation(f): class VolumeManager(manager.SchedulerDependentManager): """Manages attachable block storage devices.""" - RPC_API_VERSION = '2.0' + RPC_API_VERSION = '2.1' target = messaging.Target(version=RPC_API_VERSION) @@ -2321,6 +2322,25 @@ class VolumeManager(manager.SchedulerDependentManager): resource=vol_ref) return vol_ref['id'] + def get_manageable_volumes(self, ctxt, marker, limit, offset, sort_keys, + sort_dirs): + try: + utils.require_driver_initialized(self.driver) + except exception.DriverNotInitialized: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE("Listing manageable volumes failed, due " + "to uninitialized driver.")) + + cinder_volumes = objects.VolumeList.get_all_by_host(ctxt, self.host) + try: + driver_entries = self.driver.get_manageable_volumes( + cinder_volumes, marker, limit, offset, sort_keys, sort_dirs) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE("Listing manageable volumes failed, due " + "to driver error.")) + return driver_entries + def promote_replica(self, ctxt, volume_id): """Promote volume replica secondary to be the primary volume.""" volume = self.db.volume_get(ctxt, volume_id) @@ -3411,6 +3431,25 @@ class VolumeManager(manager.SchedulerDependentManager): flow_engine.run() return snapshot.id + def get_manageable_snapshots(self, ctxt, marker, limit, offset, + sort_keys, sort_dirs): + try: + utils.require_driver_initialized(self.driver) + except exception.DriverNotInitialized: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE("Listing manageable snapshots failed, due " + "to uninitialized driver.")) + + cinder_snapshots = self.db.snapshot_get_by_host(ctxt, self.host) + try: + driver_entries = self.driver.get_manageable_snapshots( + cinder_snapshots, marker, limit, offset, sort_keys, sort_dirs) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE("Listing manageable snapshots failed, due " + "to driver error.")) + return driver_entries + def get_capabilities(self, context, discover): """Get capabilities of backend storage.""" if discover: diff --git a/cinder/volume/rpcapi.py b/cinder/volume/rpcapi.py index f90027a31e6..1a6f9777080 100644 --- a/cinder/volume/rpcapi.py +++ b/cinder/volume/rpcapi.py @@ -99,9 +99,10 @@ class VolumeAPI(rpc.RPCAPI): the version_cap being set to 1.40. 2.0 - Remove 1.x compatibility + 2.1 - Add get_manageable_volumes() and get_manageable_snapshots(). """ - RPC_API_VERSION = '2.0' + RPC_API_VERSION = '2.1' TOPIC = CONF.volume_topic BINARY = 'cinder-volume' @@ -296,3 +297,17 @@ class VolumeAPI(rpc.RPCAPI): cctxt = self._get_cctxt(volume.host, '2.0') return cctxt.call(ctxt, 'secure_file_operations_enabled', volume=volume) + + def get_manageable_volumes(self, ctxt, host, marker, limit, offset, + sort_keys, sort_dirs): + cctxt = self._get_cctxt(host, '2.1') + return cctxt.call(ctxt, 'get_manageable_volumes', marker=marker, + limit=limit, offset=offset, sort_keys=sort_keys, + sort_dirs=sort_dirs) + + def get_manageable_snapshots(self, ctxt, host, marker, limit, offset, + sort_keys, sort_dirs): + cctxt = self._get_cctxt(host, '2.1') + return cctxt.call(ctxt, 'get_manageable_snapshots', marker=marker, + limit=limit, offset=offset, sort_keys=sort_keys, + sort_dirs=sort_dirs) diff --git a/etc/cinder/policy.json b/etc/cinder/policy.json index cccd2590e39..02440a6258b 100644 --- a/etc/cinder/policy.json +++ b/etc/cinder/policy.json @@ -67,6 +67,7 @@ "volume_extension:volume_manage": "rule:admin_api", "volume_extension:volume_unmanage": "rule:admin_api", + "volume_extension:list_manageable": "rule:admin_api", "volume_extension:capabilities": "rule:admin_api", @@ -94,6 +95,7 @@ "snapshot_extension:snapshot_actions:update_snapshot_status": "", "snapshot_extension:snapshot_manage": "rule:admin_api", "snapshot_extension:snapshot_unmanage": "rule:admin_api", + "snapshot_extension:list_manageable": "rule:admin_api", "consistencygroup:create" : "group:nobody", "consistencygroup:delete": "group:nobody", diff --git a/releasenotes/notes/list-manageable-86c77fc39c5b2cc9.yaml b/releasenotes/notes/list-manageable-86c77fc39c5b2cc9.yaml new file mode 100644 index 00000000000..e8f776d553c --- /dev/null +++ b/releasenotes/notes/list-manageable-86c77fc39c5b2cc9.yaml @@ -0,0 +1,5 @@ +--- +features: + - Added the ability to list manageable volumes and snapshots via GET + operation on the /v2//os-volume-manage and + /v2//os-snapshot-manage URLs, respectively.