diff --git a/heat/engine/clients/os/manila.py b/heat/engine/clients/os/manila.py index 84d54a10bf..71c5d0f779 100644 --- a/heat/engine/clients/os/manila.py +++ b/heat/engine/clients/os/manila.py @@ -12,6 +12,7 @@ # under the License. from heat.engine.clients import client_plugin +from heat.engine import constraints from manilaclient import client as manila_client from manilaclient import exceptions @@ -44,3 +45,84 @@ class ManilaClientPlugin(client_plugin.ClientPlugin): def is_conflict(self, ex): return isinstance(ex, exceptions.Conflict) + + @staticmethod + def _find_resource_by_id_or_name(id_or_name, resource_list, + resource_type_name): + """The method is trying to find id or name in item_list + + The method searches item with id_or_name in list and returns it. + If there is more than one value or no values then it raises an + exception + + :param id_or_name: resource id or name + :param resource_list: list of resources + :param resource_type_name: name of resource type that will be used + for exceptions + :raises NotFound, NoUniqueMatch + :return: resource or generate an exception otherwise + """ + search_result_by_id = [res for res in resource_list + if res.id == id_or_name] + if search_result_by_id: + return search_result_by_id[0] + else: + # try to find resource by name + search_result_by_name = [res for res in resource_list + if res.name == id_or_name] + match_count = len(search_result_by_name) + if match_count > 1: + message = ("Ambiguous {0} name '{1}'. Found more than one " + "{0} for this name in Manila." + ).format(resource_type_name, id_or_name) + raise exceptions.NoUniqueMatch(message) + elif match_count == 1: + return search_result_by_name[0] + else: + message = ("{0} '{1}' was not found in Manila. Please " + "use the identity of existing {0} in Heat " + "template.").format(resource_type_name, id_or_name) + raise exceptions.NotFound(message=message) + + def get_share_type(self, share_type_identity): + return self._find_resource_by_id_or_name( + share_type_identity, + self.client().share_types.list(), + "share type" + ) + + def get_share_network(self, share_network_identity): + return self._find_resource_by_id_or_name( + share_network_identity, + self.client().share_networks.list(), + "share network" + ) + + def get_share_snapshot(self, snapshot_identity): + return self._find_resource_by_id_or_name( + snapshot_identity, + self.client().share_snapshots.list(), + "share snapshot" + ) + + +class ManilaShareBaseConstraint(constraints.BaseCustomConstraint): + # check that exceptions module has been loaded. Without this check + # doc tests on gates will fail + expected_exceptions = (exceptions.NotFound, exceptions.NoUniqueMatch) + + def validate_with_client(self, client, resource_id): + getattr(client.client_plugin("manila"), self.resource_getter_name)( + resource_id) + + +class ManilaShareNetworkConstraint(ManilaShareBaseConstraint): + resource_getter_name = 'get_share_network' + + +class ManilaShareTypeConstraint(ManilaShareBaseConstraint): + resource_getter_name = 'get_share_type' + + +class ManilaShareSnapshotConstraint(ManilaShareBaseConstraint): + resource_getter_name = 'get_share_snapshot' diff --git a/heat/engine/resources/openstack/manila/share.py b/heat/engine/resources/openstack/manila/share.py new file mode 100644 index 0000000000..eba1a9b961 --- /dev/null +++ b/heat/engine/resources/openstack/manila/share.py @@ -0,0 +1,305 @@ +# +# 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 +import six + +from heat.common.i18n import _ +from heat.common.i18n import _LI +from heat.engine import attributes +from heat.engine import constraints +from heat.engine import properties +from heat.engine import resource +from heat.engine import support + +LOG = logging.getLogger(__name__) + + +class ManilaShare(resource.Resource): + """A resource that creates shared mountable file system. + + The resource creates a manila share - shared mountable filesystem that + can be attached to any client(or clients) that has a network access and + permission to mount filesystem. Share is a unit of storage with specific + size that supports pre-defined share protocol and advanced security model + (access lists, share networks and security services). + """ + + support_status = support.SupportStatus(version='5.0.0') + + _ACCESS_RULE_PROPERTIES = ( + ACCESS_TO, ACCESS_TYPE, ACCESS_LEVEL + ) = ( + 'access_to', 'access_type', 'access_level') + + _SHARE_STATUSES = ( + STATUS_CREATING, STATUS_DELETING, STATUS_ERROR, STATUS_ERROR_DELETING, + STATUS_AVAILABLE + ) = ( + 'creating', 'deleting', 'error', 'error_deleting', + 'available' + ) + + PROPERTIES = ( + SHARE_PROTOCOL, SIZE, SHARE_SNAPSHOT, NAME, METADATA, + SHARE_NETWORK, DESCRIPTION, SHARE_TYPE, IS_PUBLIC, + ACCESS_RULES + ) = ( + 'share_protocol', 'size', 'snapshot', 'name', 'metadata', + 'share_network', 'description', 'share_type', 'is_public', + 'access_rules' + ) + + ATTRIBUTES = ( + AVAILABILITY_ZONE_ATTR, HOST_ATTR, EXPORT_LOCATIONS_ATTR, + SHARE_SERVER_ID_ATTR, CREATED_AT_ATTR, SHARE_STATUS_ATTR, + PROJECT_ID_ATTR + ) = ( + 'availability_zone', 'host', 'export_locations', + 'share_server_id', 'created_at', 'status', + 'project_id' + ) + + properties_schema = { + SHARE_PROTOCOL: properties.Schema( + properties.Schema.STRING, + _('Share protocol supported by shared filesystem.'), + required=True, + constraints=[constraints.AllowedValues( + ['NFS', 'CIFS', 'GlusterFS', 'HDFS'])] + ), + SIZE: properties.Schema( + properties.Schema.INTEGER, + _('Share storage size in GB.'), + required=True + ), + SHARE_SNAPSHOT: properties.Schema( + properties.Schema.STRING, + _('Name or ID of shared file system snapshot that will be restored' + ' and created as a new share.'), + constraints=[constraints.CustomConstraint('manila.share_snapshot')] + ), + NAME: properties.Schema( + properties.Schema.STRING, + _('Share name.'), + update_allowed=True + ), + METADATA: properties.Schema( + properties.Schema.MAP, + _('Metadata key-values defined for share.'), + update_allowed=True + ), + SHARE_NETWORK: properties.Schema( + properties.Schema.STRING, + _('Name or ID of shared network defined for shared filesystem.'), + constraints=[constraints.CustomConstraint('manila.share_network')] + ), + DESCRIPTION: properties.Schema( + properties.Schema.STRING, + _('Share description.'), + update_allowed=True + ), + SHARE_TYPE: properties.Schema( + properties.Schema.STRING, + _('Name or ID of shared filesystem type. Types defines some share ' + 'filesystem profiles that will be used for share creation.'), + constraints=[constraints.CustomConstraint("manila.share_type")] + ), + IS_PUBLIC: properties.Schema( + properties.Schema.BOOLEAN, + _('Defines if shared filesystem is public or private.'), + default=False, + update_allowed=True + ), + ACCESS_RULES: properties.Schema( + properties.Schema.LIST, + _('A list of access rules that define access from IP to Share.'), + schema=properties.Schema( + properties.Schema.MAP, + schema={ + ACCESS_TO: properties.Schema( + properties.Schema.STRING, + _('IP or other address information about guest that ' + 'allowed to access to Share.'), + required=True + ), + ACCESS_TYPE: properties.Schema( + properties.Schema.STRING, + _('Type of access that should be provided to guest.'), + constraints=[constraints.AllowedValues( + ['ip', 'domain'])], + required=True + ), + ACCESS_LEVEL: properties.Schema( + properties.Schema.STRING, + _('Level of access that need to be provided for ' + 'guest.'), + constraints=[constraints.AllowedValues(['ro', 'rw'])] + ) + } + ), + update_allowed=True, + default=[] + ) + } + + attributes_schema = { + AVAILABILITY_ZONE_ATTR: attributes.Schema( + _('The availability zone of shared filesystem.'), + type=attributes.Schema.STRING + ), + HOST_ATTR: attributes.Schema( + _('Share host.'), + type=attributes.Schema.STRING + ), + EXPORT_LOCATIONS_ATTR: attributes.Schema( + _('Export locations of share.'), + type=attributes.Schema.LIST + ), + SHARE_SERVER_ID_ATTR: attributes.Schema( + _('ID of server (VM, etc...) on host that is used for ' + 'exporting network file-system.'), + type=attributes.Schema.STRING + ), + CREATED_AT_ATTR: attributes.Schema( + _('Datetime when a share was created.'), + type=attributes.Schema.STRING + ), + SHARE_STATUS_ATTR: attributes.Schema( + _('Current share status.'), + type=attributes.Schema.STRING + ), + PROJECT_ID_ATTR: attributes.Schema( + _('Share project ID.'), + type=attributes.Schema.STRING + ) + } + + default_client_name = 'manila' + + def _request_share(self): + return self.client().shares.get(self.resource_id) + + def _resolve_attribute(self, name): + share = self._request_share() + return six.text_type(getattr(share, name)) + + def handle_create(self): + # Request IDs of entities from manila + # if name of the entity defined in template + share_net_identity = self.properties[self.SHARE_NETWORK] + if share_net_identity: + share_net_identity = self.client_plugin().get_share_network( + share_net_identity).id + snapshot_identity = self.properties[self.SHARE_SNAPSHOT] + if snapshot_identity: + snapshot_identity = self.client_plugin().get_share_snapshot( + snapshot_identity).id + share_type_identity = self.properties[self.SHARE_TYPE] + if share_type_identity: + share_type_identity = self.client_plugin().get_share_type( + share_type_identity).id + + share = self.client().shares.create( + share_proto=self.properties[self.SHARE_PROTOCOL], + size=self.properties[self.SIZE], + snapshot_id=snapshot_identity, + name=self.properties[self.NAME], + description=self.properties[self.DESCRIPTION], + metadata=self.properties[self.METADATA], + share_network=share_net_identity, + share_type=share_type_identity, + is_public=self.properties[self.IS_PUBLIC]) + + self.resource_id_set(share.id) + + def check_create_complete(self, *args): + share_status = self._request_share().status + if share_status == self.STATUS_CREATING: + return False + elif share_status == self.STATUS_AVAILABLE: + LOG.info(_LI('Applying access rules to created Share.')) + # apply access rules to created share. please note that it is not + # possible to define rules for share with share_status = creating + access_rules = self.properties.get(self.ACCESS_RULES) + try: + if access_rules: + for rule in access_rules: + self.client().shares.allow( + share=self.resource_id, + access_type=rule.get(self.ACCESS_TYPE), + access=rule.get(self.ACCESS_TO), + access_level=rule.get(self.ACCESS_LEVEL)) + return True + except Exception as ex: + reason = _( + 'Error during applying access rules to share "{0}". ' + 'The root cause of the problem is the following: {1}.' + ).format(self.resource_id, ex.message) + raise resource.ResourceInError(status_reason=reason) + elif share_status == self.STATUS_ERROR: + reason = _('Error during creation of share "{0}"').format( + self.resource_id) + raise resource.ResourceInError(status_reason=reason, + resource_status=share_status) + else: + reason = _('Unknown share_status during creation of share "{0}"' + ).format(self.resource_id) + raise resource.ResourceUnknownStatus(status_reason=reason, + resource_status=share_status) + + def handle_delete(self): + if not self.resource_id: + return + + try: + self.client().shares.delete(self.resource_id) + except Exception as ex: + self.client_plugin().ignore_not_found(ex) + + def check_delete_complete(self, *args): + if not self.resource_id: + return True + + try: + share = self._request_share() + except Exception as ex: + self.client_plugin().ignore_not_found(ex) + return True + else: + # when share creation is not finished proceed listening + if share.status == self.STATUS_DELETING: + return False + elif share.status in (self.STATUS_ERROR, + self.STATUS_ERROR_DELETING): + raise resource.ResourceInError( + status_reason=_( + 'Error during deleting share "{0}".' + ).format(self.resource_id), + resource_status=share.status) + else: + reason = _('Unknown status during deleting share ' + '"{0}"').format(self.resource_id) + raise resource.ResourceUnknownStatus( + status_reason=reason, resource_status=share.status) + + def handle_check(self): + share = self._request_share() + expected_statuses = [self.STATUS_AVAILABLE] + checks = [{'attr': 'status', 'expected': expected_statuses, + 'current': share.status}] + self._verify_check_conditions(checks) + + +def resource_mapping(): + return {'OS::Manila::Share': ManilaShare} diff --git a/heat/tests/test_manila_client.py b/heat/tests/test_manila_client.py index 0d3de13180..ad6cec6226 100644 --- a/heat/tests/test_manila_client.py +++ b/heat/tests/test_manila_client.py @@ -10,12 +10,43 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +import collections + +from manilaclient import exceptions +import mock from heat.tests import common from heat.tests import utils class ManilaClientPluginTests(common.HeatTestCase): + scenarios = [ + ('share_type', + dict(manager_name="share_types", + method_name="get_share_type")), + ('share_network', + dict(manager_name="share_networks", + method_name="get_share_network")), + ('share_snapshot', + dict(manager_name="share_snapshots", + method_name="get_share_snapshot")), + ] + + def setUp(self): + super(ManilaClientPluginTests, self).setUp() + # mock client and plugin + self.manila_client = mock.MagicMock() + con = utils.dummy_context() + c = con.clients + self.manila_plugin = c.client_plugin('manila') + self.manila_plugin._client = self.manila_client + # prepare list of items to test search + Item = collections.namedtuple('Item', ['id', 'name']) + self.item_list = [ + Item(name="unique_name", id="unique_id"), + Item(name="unique_id", id="i_am_checking_that_id_prior"), + Item(name="duplicated_name", id="duplicate_test_one"), + Item(name="duplicated_name", id="duplicate_test_second")] def test_create(self): context = utils.dummy_context() @@ -23,3 +54,14 @@ class ManilaClientPluginTests(common.HeatTestCase): client = plugin.client() self.assertIsNotNone(client.security_services) self.assertEqual('http://server.test:5000/v3', client.client.base_url) + + def test_manila_get_method(self): + # set item list as client output + manager = getattr(self.manila_client, self.manager_name) + manager.list.return_value = self.item_list + # test that get_ is searching correctly + get_method = getattr(self.manila_plugin, self.method_name) + self.assertEqual(get_method("unique_id").name, "unique_name") + self.assertRaises(exceptions.NotFound, get_method, "non_exist") + self.assertRaises(exceptions.NoUniqueMatch, get_method, + "duplicated_name") diff --git a/heat/tests/test_manila_share.py b/heat/tests/test_manila_share.py new file mode 100644 index 0000000000..29455f235c --- /dev/null +++ b/heat/tests/test_manila_share.py @@ -0,0 +1,156 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import six + +from heat.common import exception +from heat.common import template_format +from heat.engine.resources.openstack.manila import share as mshare +from heat.engine import scheduler +from heat.tests import common +from heat.tests import utils + + +manila_template = """ +heat_template_version: 2015-04-30 +resources: + test_share: + type: OS::Manila::Share + properties: + share_protocol: NFS + size: 1 + access_rules: + - access_to: 127.0.0.1 + access_type: ip + access_level: ro + name: basic_test_share + description: basic test share + is_public: True + metadata: {"key": "value"} +""" + + +class ManilaShareTest(common.HeatTestCase): + + def setUp(self): + super(ManilaShareTest, self).setUp() + utils.setup_dummy_db() + self.ctx = utils.dummy_context() + + self.fake_share = mock.MagicMock(id="test_share_id") + self.available_share = mock.MagicMock( + id="test_share_id", + status=mshare.ManilaShare.STATUS_AVAILABLE) + self.failed_share = mock.MagicMock( + id="test_share_id", + status=mshare.ManilaShare.STATUS_ERROR) + self.deleting_share = mock.MagicMock( + id="test_share_id", + status=mshare.ManilaShare.STATUS_DELETING) + + def _init_share(self, stack_name): + tmp = template_format.parse(manila_template) + self.stack = utils.parse_stack(tmp, stack_name=stack_name) + res_def = self.stack.t.resource_definitions(self.stack)["test_share"] + share = mshare.ManilaShare("test_share", res_def, self.stack) + + # replace clients and plugins with mocks + mock_client = mock.MagicMock() + client = mock.MagicMock(return_value=mock_client) + share.client = client + mock_plugin = mock.MagicMock() + client_plugin = mock.MagicMock(return_value=mock_plugin) + share.client_plugin = client_plugin + + return share + + def _create_share(self, stack_name): + share = self._init_share(stack_name) + share.client().shares.create.return_value = self.fake_share + share.client().shares.get.return_value = self.available_share + scheduler.TaskRunner(share.create)() + return share + + def test_share_create(self): + share = self._create_share("stack_share_create") + + expected_state = (share.CREATE, share.COMPLETE) + self.assertEqual(expected_state, share.state, + "Share is not in expected state") + self.assertEqual(self.fake_share.id, share.resource_id, + "Expected share ID was not propagated to share") + + share.client().shares.allow.assert_called_once_with( + access="127.0.0.1", access_level="ro", + share=share.resource_id, access_type="ip") + args, kwargs = share.client().shares.create.call_args + message_end = " parameter was not passed to manila client" + self.assertEqual(u"NFS", kwargs["share_proto"], + "Share protocol" + message_end) + self.assertEqual(1, kwargs["size"], "Share size" + message_end) + self.assertEqual("basic_test_share", kwargs["name"], + "Share name" + message_end) + self.assertEqual("basic test share", kwargs["description"], + "Share description" + message_end) + self.assertEqual({u"key": u"value"}, kwargs["metadata"], + "Metadata" + message_end) + self.assertTrue(kwargs["is_public"]) + share.client().shares.get.assert_called_once_with(self.fake_share.id) + + def test_share_create_fail(self): + share = self._init_share("stack_share_create_fail") + share.client().shares.create.return_value = self.fake_share + share.client().shares.get.return_value = self.failed_share + exc = self.assertRaises(exception.ResourceFailure, + scheduler.TaskRunner(share.create)) + self.assertIn("Error during creation", six.text_type(exc)) + + def test_share_create_unknown_status(self): + share = self._init_share("stack_share_create_unknown") + share.client().shares.create.return_value = self.fake_share + share.client().shares.get.return_value = self.deleting_share + exc = self.assertRaises(exception.ResourceFailure, + scheduler.TaskRunner(share.create)) + self.assertIn("Unknown status", six.text_type(exc)) + + def test_share_delete(self): + share = self._create_share("stack_share_delete") + share.client().shares.get.side_effect = exception.NotFound() + share.client_plugin().ignore_not_found.return_value = None + scheduler.TaskRunner(share.delete)() + share.client().shares.delete.assert_called_once_with( + self.fake_share.id) + + def test_share_delete_fail(self): + share = self._create_share("stack_share_delete_fail") + share.client().shares.delete.return_value = None + share.client().shares.get.return_value = self.failed_share + exc = self.assertRaises(exception.ResourceFailure, + scheduler.TaskRunner(share.delete)) + self.assertIn("Error during deleting share", six.text_type(exc)) + + def test_share_check(self): + share = self._create_share("stack_share_check") + scheduler.TaskRunner(share.check)() + expected_state = (share.CHECK, share.COMPLETE) + self.assertEqual(expected_state, share.state, + "Share is not in expected state") + + def test_share_check_fail(self): + share = self._create_share("stack_share_check_fail") + share.client().shares.get.return_value = self.failed_share + exc = self.assertRaises(exception.ResourceFailure, + scheduler.TaskRunner(share.check)) + self.assertIn("Error: 'status': expected '['available']'", + six.text_type(exc)) diff --git a/setup.cfg b/setup.cfg index cae6d4cef8..52320907b6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,6 +86,9 @@ heat.constraints = keystone.project = heat.engine.clients.os.keystone:KeystoneProjectConstraint keystone.group = heat.engine.clients.os.keystone:KeystoneGroupConstraint keystone.service = heat.engine.clients.os.keystone:KeystoneServiceConstraint + manila.share_snapshot = heat.engine.clients.os.manila:ManilaShareSnapshotConstraint + manila.share_network = heat.engine.clients.os.manila:ManilaShareNetworkConstraint + manila.share_type = heat.engine.clients.os.manila:ManilaShareTypeConstraint heat.stack_lifecycle_plugins =