diff --git a/etc/manila/policy.json b/etc/manila/policy.json index 83b8ca92ca..173de2851b 100644 --- a/etc/manila/policy.json +++ b/etc/manila/policy.json @@ -20,6 +20,7 @@ "share:access_get_all": "rule:default", "share:allow_access": "rule:default", "share:deny_access": "rule:default", + "share:extend": "rule:default", "share:get_share_metadata": "rule:default", "share:delete_share_metadata": "rule:default", "share:update_share_metadata": "rule:default", diff --git a/manila/api/contrib/share_actions.py b/manila/api/contrib/share_actions.py index a625612087..dfb25131b8 100644 --- a/manila/api/contrib/share_actions.py +++ b/manila/api/contrib/share_actions.py @@ -135,6 +135,30 @@ class ShareActionsController(wsgi.Controller): access_list = self.share_api.access_get_all(context, share) return {'access_list': access_list} + @wsgi.action('os-extend') + def _extend(self, req, id, body): + """Extend size of share.""" + context = req.environ['manila.context'] + try: + share = self.share_api.get(context, id) + except exception.NotFound as e: + raise webob.exc.HTTPNotFound(explanation=six.text_type(e)) + + try: + size = int(body['os-extend']['new_size']) + except (KeyError, ValueError, TypeError): + msg = _("New share size must be specified as an integer.") + raise webob.exc.HTTPBadRequest(explanation=msg) + + try: + self.share_api.extend(context, share, size) + except (exception.InvalidInput, exception.InvalidShare) as e: + raise webob.exc.HTTPBadRequest(explanation=six.text_type(e)) + except exception.ShareSizeExceedsAvailableQuota as e: + raise webob.exc.HTTPForbidden(explanation=six.text_type(e)) + + return webob.Response(status_int=202) + # def create_resource(): # return wsgi.Resource(ShareActionsController()) diff --git a/manila/common/constants.py b/manila/common/constants.py index e77b2f17ed..1b8a83e6ad 100644 --- a/manila/common/constants.py +++ b/manila/common/constants.py @@ -29,11 +29,14 @@ STATUS_MANAGE_ERROR = 'MANAGE_ERROR' STATUS_UNMANAGING = 'UNMANAGE_STARTING' STATUS_UNMANAGE_ERROR = 'UNMANAGE_ERROR' STATUS_UNMANAGED = 'UNMANAGED' +STATUS_EXTENDING = 'EXTENDING' +STATUS_EXTENDING_ERROR = 'EXTENDING_ERROR' TRANSITIONAL_STATUSES = ( STATUS_CREATING, STATUS_DELETING, STATUS_ACTIVATING, STATUS_DEACTIVATING, STATUS_MANAGING, STATUS_UNMANAGING, + STATUS_EXTENDING, ) SUPPORTED_SHARE_PROTOCOLS = ( diff --git a/manila/exception.py b/manila/exception.py index 4836e92be0..6ec78c4b76 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -482,6 +482,11 @@ class ManageExistingShareTypeMismatch(ManilaException): "%(reason)s") +class ShareExtendingError(ManilaException): + message = _("Share %(share_id)s could not be extended due to error " + "in the driver: %(reason)s") + + class InstanceNotFound(NotFound): message = _("Instance %(instance_id)s could not be found.") diff --git a/manila/share/api.py b/manila/share/api.py index 99a69a71ab..8375113675 100644 --- a/manila/share/api.py +++ b/manila/share/api.py @@ -32,6 +32,7 @@ from manila.db import base from manila import exception from manila.i18n import _ from manila.i18n import _LE +from manila.i18n import _LI from manila.i18n import _LW from manila import policy from manila import quota @@ -674,3 +675,55 @@ class API(base.Base): def get_share_network(self, context, share_net_id): return self.db.share_network_get(context, share_net_id) + + def extend(self, context, share, new_size): + policy.check_policy(context, 'share', 'extend') + + status = six.text_type(share['status']).upper() + + if status != constants.STATUS_AVAILABLE: + msg_params = { + 'valid_status': constants.STATUS_AVAILABLE, + 'share_id': share['id'], + 'status': status, + } + msg = _("Share %(share_id)s status must be '%(valid_status)s' " + "to extend, but current status is: " + "%(status)s.") % msg_params + raise exception.InvalidShare(reason=msg) + + size_increase = int(new_size) - share['size'] + if size_increase <= 0: + msg = (_("New size for extend must be greater " + "than current size. (current: %(size)s, " + "extended: %(new_size)s).") % {'new_size': new_size, + 'size': share['size']}) + raise exception.InvalidInput(reason=msg) + + try: + reservations = QUOTAS.reserve(context, + project_id=share['project_id'], + gigabytes=size_increase) + except exception.OverQuota as exc: + usages = exc.kwargs['usages'] + quotas = exc.kwargs['quotas'] + + def _consumed(name): + return usages[name]['reserved'] + usages[name]['in_use'] + + msg = _LE("Quota exceeded for %(s_pid)s, tried to extend share " + "by %(s_size)sG, (%(d_consumed)dG of %(d_quota)dG " + "already consumed).") + LOG.error(msg, {'s_pid': context.project_id, + 's_size': size_increase, + 'd_consumed': _consumed('gigabytes'), + 'd_quota': quotas['gigabytes']}) + raise exception.ShareSizeExceedsAvailableQuota( + requested=size_increase, + consumed=_consumed('gigabytes'), + quota=quotas['gigabytes']) + + self.update(context, share, {'status': constants.STATUS_EXTENDING}) + self.share_rpcapi.extend_share(context, share, new_size, reservations) + LOG.info(_LI("Extend share request issued successfully."), + resource=share) diff --git a/manila/share/driver.py b/manila/share/driver.py index 917a704143..e3345d66a1 100644 --- a/manila/share/driver.py +++ b/manila/share/driver.py @@ -375,6 +375,15 @@ class ShareDriver(object): UnmanageInvalidShare exception, specifying a reason for the failure. """ + def extend_share(self, share, new_size, share_server=None): + """Extends size of existing share. + + :param share: Share model + :param new_size: New size of share (new_size > share['size']) + :param share_server: Optional -- Share server model + """ + raise NotImplementedError() + def teardown_server(self, *args, **kwargs): if self.driver_handles_share_servers: return self._teardown_server(*args, **kwargs) diff --git a/manila/share/manager.py b/manila/share/manager.py index c5d85471b9..499bfd5746 100644 --- a/manila/share/manager.py +++ b/manila/share/manager.py @@ -92,7 +92,7 @@ QUOTAS = quota.QUOTAS class ShareManager(manager.SchedulerDependentManager): """Manages NAS storages.""" - RPC_API_VERSION = '1.1' + RPC_API_VERSION = '1.2' def __init__(self, share_driver=None, service_name=None, *args, **kwargs): """Load the driver from args, or from flags.""" @@ -833,3 +833,37 @@ class ShareManager(manager.SchedulerDependentManager): raise exception.InvalidParameterValue( "Option unused_share_server_cleanup_interval should be " "between 10 minutes and 1 hour.") + + def extend_share(self, context, share_id, new_size, reservations): + context = context.elevated() + share = self.db.share_get(context, share_id) + share_server = self._get_share_server(context, share) + project_id = share['project_id'] + + try: + self.driver.extend_share( + share, new_size, share_server=share_server) + except Exception as e: + LOG.exception(_LE("Extend share failed."), resource=share) + + try: + self.db.share_update( + context, share['id'], + {'status': constants.STATUS_EXTENDING_ERROR} + ) + raise exception.ShareExtendingError( + reason=six.text_type(e), share_id=share_id) + finally: + QUOTAS.rollback(context, reservations, project_id=project_id) + + QUOTAS.commit(context, reservations, project_id=project_id) + + share_update = { + 'size': int(new_size), + # NOTE(u_glide): translation to lower case should be removed in + # a row with usage of upper case of share statuses in all places + 'status': constants.STATUS_AVAILABLE.lower() + } + share = self.db.share_update(context, share['id'], share_update) + + LOG.info(_LI("Extend share completed successfully."), resource=share) diff --git a/manila/share/rpcapi.py b/manila/share/rpcapi.py index 83afea3f94..30867466f8 100644 --- a/manila/share/rpcapi.py +++ b/manila/share/rpcapi.py @@ -33,6 +33,7 @@ class ShareAPI(object): 1.0 - Initial version. 1.1 - Add manage_share() and unmanage_share() methods + 1.2 - Add extend_share() method ''' BASE_RPC_API_VERSION = '1.0' @@ -41,7 +42,7 @@ class ShareAPI(object): super(ShareAPI, self).__init__() target = messaging.Target(topic=CONF.share_topic, version=self.BASE_RPC_API_VERSION) - self.client = rpc.get_client(target, version_cap='1.1') + self.client = rpc.get_client(target, version_cap='1.2') def create_share(self, ctxt, share, host, request_spec, filter_properties, @@ -109,3 +110,9 @@ class ShareAPI(object): def publish_service_capabilities(self, ctxt): cctxt = self.client.prepare(fanout=True, version='1.0') cctxt.cast(ctxt, 'publish_service_capabilities') + + def extend_share(self, ctxt, share, new_size, reservations): + host = utils.extract_host(share['host']) + cctxt = self.client.prepare(server=host, version='1.2') + cctxt.cast(ctxt, 'extend_share', share_id=share['id'], + new_size=new_size, reservations=reservations) diff --git a/manila/tests/api/contrib/test_share_actions.py b/manila/tests/api/contrib/test_share_actions.py index 62c26cd106..158b57f899 100644 --- a/manila/tests/api/contrib/test_share_actions.py +++ b/manila/tests/api/contrib/test_share_actions.py @@ -18,6 +18,7 @@ from oslo_config import cfg import webob from manila.api.contrib import share_actions +from manila import exception from manila.share import api as share_api from manila import test from manila.tests.api.contrib import stubs @@ -140,3 +141,46 @@ class ShareActionsTest(test.TestCase): res_dict = self.controller._access_list(req, id, body) expected = _fake_access_get_all() self.assertEqual(res_dict['access_list'], expected) + + def test_extend(self): + id = 'fake_share_id' + share = stubs.stub_share_get(None, None, id) + self.mock_object(share_api.API, 'get', mock.Mock(return_value=share)) + self.mock_object(share_api.API, "extend") + + size = '123' + body = {"os-extend": {'new_size': size}} + req = fakes.HTTPRequest.blank('/v1/shares/%s/action' % id) + + actual_response = self.controller._extend(req, id, body) + + share_api.API.get.assert_called_once_with(mock.ANY, id) + share_api.API.extend.assert_called_once_with( + mock.ANY, share, int(size)) + self.assertEqual(202, actual_response.status_int) + + @ddt.data({"os-extend": ""}, + {"os-extend": {"new_size": "foo"}}, + {"os-extend": {"new_size": {'foo': 'bar'}}}) + def test_extend_invalid_body(self, body): + id = 'fake_share_id' + req = fakes.HTTPRequest.blank('/v1/shares/%s/action' % id) + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller._extend, req, id, body) + + @ddt.data({'source': exception.InvalidInput, + 'target': webob.exc.HTTPBadRequest}, + {'source': exception.InvalidShare, + 'target': webob.exc.HTTPBadRequest}, + {'source': exception.ShareSizeExceedsAvailableQuota, + 'target': webob.exc.HTTPForbidden}) + @ddt.unpack + def test_extend_exception(self, source, target): + id = 'fake_share_id' + req = fakes.HTTPRequest.blank('/v1/shares/%s/action' % id) + body = {"os-extend": {'new_size': '123'}} + self.mock_object(share_api.API, "extend", + mock.Mock(side_effect=source('fake'))) + + self.assertRaises(target, self.controller._extend, req, id, body) diff --git a/manila/tests/policy.json b/manila/tests/policy.json index ced09630f2..abe3550d50 100644 --- a/manila/tests/policy.json +++ b/manila/tests/policy.json @@ -15,6 +15,7 @@ "share:delete_snapshot": "", "share:get_snapshot": "", "share:get_all_snapshots": "", + "share:extend": "", "share_network:create": "", "share_network:index": "", diff --git a/manila/tests/share/test_api.py b/manila/tests/share/test_api.py index 0295749978..f568e33053 100644 --- a/manila/tests/share/test_api.py +++ b/manila/tests/share/test_api.py @@ -1360,6 +1360,46 @@ class ShareAPITestCase(test.TestCase): self.assertEqual(should_be, db_driver.share_metadata_get(self.context, share_id)) + def test_extend_invalid_status(self): + invalid_status = 'fake' + share = fake_share('fake', status=invalid_status) + new_size = 123 + + self.assertRaises(exception.InvalidShare, + self.api.extend, self.context, share, new_size) + + def test_extend_invalid_size(self): + share = fake_share('fake', status=constants.STATUS_AVAILABLE, size=200) + new_size = 123 + + self.assertRaises(exception.InvalidInput, + self.api.extend, self.context, share, new_size) + + def test_extend_quota_error(self): + share = fake_share('fake', status=constants.STATUS_AVAILABLE, size=100) + new_size = 123 + usages = {'gigabytes': {'reserved': 'fake', 'in_use': 'fake'}} + quotas = {'gigabytes': 'fake'} + exc = exception.OverQuota(usages=usages, quotas=quotas) + self.mock_object(quota.QUOTAS, 'reserve', mock.Mock(side_effect=exc)) + + self.assertRaises(exception.ShareSizeExceedsAvailableQuota, + self.api.extend, self.context, share, new_size) + + def test_extend_valid(self): + share = fake_share('fake', status=constants.STATUS_AVAILABLE, size=100) + new_size = 123 + self.mock_object(self.api, 'update') + self.mock_object(self.api.share_rpcapi, 'extend_share') + + self.api.extend(self.context, share, new_size) + + self.api.update.assert_called_once_with( + self.context, share, {'status': constants.STATUS_EXTENDING}) + self.api.share_rpcapi.extend_share.assert_called_once_with( + self.context, share, new_size, mock.ANY + ) + class OtherTenantsShareActionsTestCase(test.TestCase): def setUp(self): diff --git a/manila/tests/share/test_manager.py b/manila/tests/share/test_manager.py index ec37fd29c4..68977eec29 100644 --- a/manila/tests/share/test_manager.py +++ b/manila/tests/share/test_manager.py @@ -1514,3 +1514,52 @@ class ShareManagerTestCase(test.TestCase): self.context, 'server1') timeutils.utcnow.assert_called_once_with() + + def test_extend_share_invalid(self): + share = self._create_share() + share_id = share['id'] + + self.mock_object(self.share_manager, 'driver') + self.mock_object(self.share_manager.db, 'share_update') + self.mock_object(quota.QUOTAS, 'rollback') + self.mock_object(self.share_manager.driver, 'extend_share', + mock.Mock(side_effect=Exception('fake'))) + + self.assertRaises( + exception.ShareExtendingError, + self.share_manager.extend_share, self.context, share_id, 123, {}) + + def test_extend_share(self): + share = self._create_share() + share_id = share['id'] + new_size = 123 + shr_update = { + 'size': int(new_size), + 'status': constants.STATUS_AVAILABLE.lower() + } + reservations = {} + fake_share_server = 'fake' + + manager = self.share_manager + self.mock_object(manager, 'driver') + self.mock_object(manager.db, 'share_get', + mock.Mock(return_value=share)) + self.mock_object(manager.db, 'share_update', + mock.Mock(return_value=share)) + self.mock_object(quota.QUOTAS, 'commit') + self.mock_object(manager.driver, 'extend_share') + self.mock_object(manager, '_get_share_server', + mock.Mock(return_value=fake_share_server)) + + self.share_manager.extend_share(self.context, share_id, + new_size, reservations) + + self.assertTrue(manager._get_share_server.called) + manager.driver.extend_share.assert_called_once_with( + share, new_size, share_server=fake_share_server + ) + quota.QUOTAS.commit.assert_called_once_with( + mock.ANY, reservations, project_id=share['project_id']) + manager.db.share_update.assert_called_once_with( + mock.ANY, share_id, shr_update + ) \ No newline at end of file diff --git a/manila/tests/share/test_rpcapi.py b/manila/tests/share/test_rpcapi.py index be11c6694f..5b0d2da892 100644 --- a/manila/tests/share/test_rpcapi.py +++ b/manila/tests/share/test_rpcapi.py @@ -161,3 +161,11 @@ class ShareRpcAPITestCase(test.TestCase): self._test_share_api('delete_share_server', rpc_method='cast', share_server=self.fake_share_server) + + def test_extend_share(self): + self._test_share_api('extend_share', + rpc_method='cast', + version='1.2', + share=self.fake_share, + new_size=123, + reservations={'fake': 'fake'})