diff --git a/heat/engine/resources/openstack/senlin/node.py b/heat/engine/resources/openstack/senlin/node.py new file mode 100644 index 0000000000..b7c9bfd503 --- /dev/null +++ b/heat/engine/resources/openstack/senlin/node.py @@ -0,0 +1,188 @@ +# +# 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 heat.common import exception +from heat.common.i18n import _ +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 + + +class Node(resource.Resource): + """A resource that creates a Senlin Node. + + Node is an object that belongs to at most one Cluster, it can be created + based on a profile. + """ + + support_status = support.SupportStatus(version='6.0.0') + + default_client_name = 'senlin' + + PROPERTIES = ( + NAME, METADATA, PROFILE, + ) = ( + 'name', 'metadata', 'profile', + ) + + _NODE_STATUS = ( + INIT, ACTIVE, CREATING, + ) = ( + 'INIT', 'ACTIVE', 'CREATING', + ) + + _ACTION_STATUS = ( + ACTION_SUCCEEDED, ACTION_FAILED, + ) = ( + 'SUCCEEDED', 'FAILED', + ) + + ATTRIBUTES = ( + ATTR_DETAILS, ATTR_CLUSTER, + ) = ( + 'details', 'cluster_id' + ) + + properties_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('Name of the senlin node. By default, physical resource name ' + 'is used.'), + update_allowed=True, + ), + METADATA: properties.Schema( + properties.Schema.MAP, + _('Metadata key-values defined for node.'), + update_allowed=True, + ), + PROFILE: properties.Schema( + properties.Schema.STRING, + _('Name or ID of senlin profile to create this node.'), + required=True, + update_allowed=True, + constraints=[ + constraints.CustomConstraint('senlin.profile') + ] + ), + } + + attributes_schema = { + ATTR_DETAILS: attributes.Schema( + _("The details of physical object."), + type=attributes.Schema.MAP + ), + ATTR_CLUSTER: attributes.Schema( + _("The cluster ID this node belongs to."), + type=attributes.Schema.STRING + ), + } + + def handle_create(self): + params = { + 'name': (self.properties[self.NAME] or + self.physical_resource_name()), + 'metadata': self.properties[self.METADATA], + 'profile_id': self.properties[self.PROFILE], + } + + node = self.client().create_node(**params) + self.resource_id_set(node.id) + return node.id + + def check_create_complete(self, resource_id): + node = self.client().get_node(resource_id) + if node.status == self.ACTIVE: + return True + elif node.status in [self.INIT, self.CREATING]: + return False + else: + raise exception.ResourceInError( + status_reason=node.status_reason, + resource_status=node.status, + ) + + def handle_delete(self): + if self.resource_id is not None: + with self.client_plugin().ignore_not_found: + self.client().delete_node(self.resource_id) + return self.resource_id + + def check_delete_complete(self, res_id): + if not res_id: + return True + + try: + self.client().get_node(self.resource_id) + except Exception as ex: + self.client_plugin().ignore_not_found(ex) + return True + return False + + def _show_resource(self): + node = self.client().get_node(self.resource_id) + return node.to_dict() + + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + updaters = dict() + UPDATE_PROPS = [self.NAME, self.METADATA, self.PROFILE] + if any(p in prop_diff for p in UPDATE_PROPS): + params = dict((k, v) for k, v in prop_diff.items() + if k in UPDATE_PROPS) + if self.PROFILE in params: + params['profile_id'] = params.pop(self.PROFILE) + updaters['profile_update'] = { + 'params': params, + 'finished': False, + } + + return updaters + + def check_update_complete(self, updaters): + def check_action(updater, set_key): + action = self.client().get_action(updater['action']) + if action.status == self.ACTION_SUCCEEDED: + updater[set_key] = True + elif action.status == self.ACTION_FAILED: + raise exception.ResourceInError( + status_reason=action.status_reason, + resource_status=action.status, + ) + + if not updaters: + return True + profile_update = updaters.get('profile_update') + if profile_update and not profile_update['finished']: + if 'action' not in profile_update: + resp = self.client().update_node( + self.resource_id, **profile_update['params']) + profile_update['action'] = resp.location.split('/')[-1] + return False + else: + check_action(profile_update, 'finished') + if not profile_update['finished']: + return False + + return True + + def _resolve_attribute(self, name): + node = self.client().get_node(self.resource_id, + args={'show_details': True}) + return getattr(node, name, None) + + +def resource_mapping(): + return { + 'OS::Senlin::Node': Node + } diff --git a/heat/tests/openstack/senlin/test_node.py b/heat/tests/openstack/senlin/test_node.py new file mode 100644 index 0000000000..c95a4f556a --- /dev/null +++ b/heat/tests/openstack/senlin/test_node.py @@ -0,0 +1,187 @@ +# +# 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 copy +import mock +from oslo_config import cfg +import six + +from heat.common import exception +from heat.common import template_format +from heat.engine.clients.os import senlin +from heat.engine.resources.openstack.senlin import node as sn +from heat.engine import scheduler +from heat.engine import template +from heat.tests import common +from heat.tests import utils +from senlinclient.common import exc + + +node_stack_template = """ +heat_template_version: 2016-04-08 +description: Senlin Node Template +resources: + senlin-node: + type: OS::Senlin::Node + properties: + name: SenlinNode + profile: fake_profile + metadata: + foo: bar +""" + + +class FakeNode(object): + def __init__(self, id='some_id', status='ACTIVE'): + self.status = status + self.status_reason = 'Unknown' + self.id = id + self.name = "SenlinNode" + self.metadata = {'foo': 'bar'} + self.profile_id = "fake_profile" + self.cluster_id = "fake_cluster" + self.details = {'id': 'physical_object_id'} + + def to_dict(self): + return { + 'id': self.id, + 'status': self.status, + 'status_reason': self.status_reason, + 'name': self.name, + 'metadata': self.metadata, + 'profile_id': self.profile_id, + 'cluster_id': self.cluster_id, + } + + +class SenlinNodeTest(common.HeatTestCase): + def setUp(self): + super(SenlinNodeTest, self).setUp() + self.senlin_mock = mock.MagicMock() + self.patchobject(sn.Node, 'client', return_value=self.senlin_mock) + self.patchobject(senlin.ProfileConstraint, 'validate', + return_value=True) + self.patchobject(senlin.ClusterConstraint, 'validate', + return_value=True) + self.fake_node = FakeNode() + self.t = template_format.parse(node_stack_template) + self.stack = utils.parse_stack(self.t) + self.node = self.stack['senlin-node'] + + def _create_node(self): + self.senlin_mock.create_node.return_value = self.fake_node + self.senlin_mock.get_node.return_value = self.fake_node + self.senlin_mock.get_action.return_value = mock.Mock( + status='SUCCEEDED') + scheduler.TaskRunner(self.node.create)() + self.assertEqual((self.node.CREATE, self.node.COMPLETE), + self.node.state) + self.assertEqual(self.fake_node.id, self.node.resource_id) + return self.node + + def test_node_create_success(self): + self._create_node() + expect_kwargs = { + 'name': 'SenlinNode', + 'profile_id': 'fake_profile', + 'metadata': {'foo': 'bar'}, + } + self.senlin_mock.create_node.assert_called_once_with( + **expect_kwargs) + self.senlin_mock.get_node.assert_called_once_with(self.fake_node.id) + + def test_node_create_error(self): + cfg.CONF.set_override('action_retry_limit', 0) + self.senlin_mock.create_node.return_value = self.fake_node + self.senlin_mock.get_node.return_value = FakeNode( + status='ERROR') + create_task = scheduler.TaskRunner(self.node.create) + ex = self.assertRaises(exception.ResourceFailure, create_task) + expected = ('ResourceInError: resources.senlin-node: ' + 'Went to status ERROR due to "Unknown"') + self.assertEqual(expected, six.text_type(ex)) + + def test_node_delete_success(self): + node = self._create_node() + self.senlin_mock.get_node.side_effect = [ + exc.sdkexc.ResourceNotFound('SenlinNode'), + ] + scheduler.TaskRunner(node.delete)() + self.senlin_mock.delete_node.assert_called_once_with( + node.resource_id) + + def test_cluster_delete_error(self): + node = self._create_node() + self.senlin_mock.get_node.side_effect = exception.Error('oops') + delete_task = scheduler.TaskRunner(node.delete) + ex = self.assertRaises(exception.ResourceFailure, delete_task) + expected = 'Error: resources.senlin-node: oops' + self.assertEqual(expected, six.text_type(ex)) + + def test_node_update_profile(self): + node = self._create_node() + new_t = copy.deepcopy(self.t) + props = new_t['resources']['senlin-node']['properties'] + props['profile'] = 'new_profile' + props['name'] = 'new_name' + rsrc_defns = template.Template(new_t).resource_definitions(self.stack) + new_node = rsrc_defns['senlin-node'] + self.senlin_mock.update_node.return_value = mock.Mock( + location='/actions/fake-action') + scheduler.TaskRunner(node.update, new_node)() + self.assertEqual((node.UPDATE, node.COMPLETE), node.state) + node_update_kwargs = { + 'profile_id': 'new_profile', + 'name': 'new_name' + } + self.senlin_mock.update_node.assert_called_once_with( + node.resource_id, **node_update_kwargs) + self.senlin_mock.get_action.assert_called_once_with( + 'fake-action') + + def test_node_update_failed(self): + node = self._create_node() + new_t = copy.deepcopy(self.t) + props = new_t['resources']['senlin-node']['properties'] + props['name'] = 'new_name' + rsrc_defns = template.Template(new_t).resource_definitions(self.stack) + new_node = rsrc_defns['senlin-node'] + self.senlin_mock.update_node.return_value = mock.Mock( + location='/actions/fake-action') + self.senlin_mock.get_action.return_value = mock.Mock( + status='FAILED', status_reason='oops') + update_task = scheduler.TaskRunner(node.update, new_node) + ex = self.assertRaises(exception.ResourceFailure, update_task) + expected = ('ResourceInError: resources.senlin-node: Went to ' + 'status FAILED due to "oops"') + self.assertEqual(expected, six.text_type(ex)) + self.assertEqual((node.UPDATE, node.FAILED), node.state) + self.senlin_mock.get_action.assert_called_once_with( + 'fake-action') + + def test_cluster_resolve_attribute(self): + excepted_show = { + 'id': 'some_id', + 'status': 'ACTIVE', + 'status_reason': 'Unknown', + 'name': 'SenlinNode', + 'metadata': {'foo': 'bar'}, + 'profile_id': 'fake_profile', + 'cluster_id': 'fake_cluster' + } + node = self._create_node() + self.assertEqual(excepted_show, + node._show_resource()) + self.assertEqual(self.fake_node.details, + node._resolve_attribute('details'))