From 458198bbb4db4a827eece15919943aaf7c9b4515 Mon Sep 17 00:00:00 2001 From: ricolin Date: Wed, 15 Mar 2017 12:47:57 +0800 Subject: [PATCH] Add magnum cluster resource Add 'OS::Magnum::Cluster' resource. This resouce is prefered than use Bay in template. Will deprecate Bay once this resource landed. Partial-Bug: #1625757 Change-Id: Ie0eb8918b8489b1cd41daeed1f30dd55c2b79899 --- .../resources/openstack/magnum/cluster.py | 171 +++++++++++++ heat/tests/openstack/magnum/test_cluster.py | 226 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 heat/engine/resources/openstack/magnum/cluster.py create mode 100644 heat/tests/openstack/magnum/test_cluster.py diff --git a/heat/engine/resources/openstack/magnum/cluster.py b/heat/engine/resources/openstack/magnum/cluster.py new file mode 100644 index 0000000000..69a498b2d8 --- /dev/null +++ b/heat/engine/resources/openstack/magnum/cluster.py @@ -0,0 +1,171 @@ +# +# 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 six + +from heat.common import exception +from heat.common.i18n import _ +from heat.engine import constraints +from heat.engine import properties +from heat.engine import resource +from heat.engine import support +from heat.engine import translation + + +class Cluster(resource.Resource): + """A resource that creates a magnum cluster. + + This resource creates a magnum cluster, which is a + collection of node objects where work is scheduled. + """ + + support_status = support.SupportStatus(version='9.0.0') + + default_client_name = 'magnum' + + entity = 'clusters' + + PROPERTIES = ( + NAME, CLUSTER_TEMPLATE, KEYPAIR, NODE_COUNT, MASTER_COUNT, + DISCOVERY_URL, CREATE_TIMEOUT + ) = ( + 'name', 'cluster_template', 'keypair', 'node_count', 'master_count', + 'discovery_url', 'create_timeout' + ) + + properties_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('The cluster name.'), + ), + CLUSTER_TEMPLATE: properties.Schema( + properties.Schema.STRING, + _('The name or ID of the cluster template.'), + constraints=[ + constraints.CustomConstraint('magnum.cluster_template') + ], + required=True + ), + KEYPAIR: properties.Schema( + properties.Schema.STRING, + _('The name of the keypair. If not presented, use keypair in ' + 'cluster template.'), + constraints=[ + constraints.CustomConstraint('nova.keypair') + ] + ), + NODE_COUNT: properties.Schema( + properties.Schema.INTEGER, + _('The node count for this cluster.'), + constraints=[constraints.Range(min=1)], + update_allowed=True, + default=1 + ), + MASTER_COUNT: properties.Schema( + properties.Schema.INTEGER, + _('The number of master nodes for this cluster.'), + constraints=[constraints.Range(min=1)], + update_allowed=True, + default=1 + ), + DISCOVERY_URL: properties.Schema( + properties.Schema.STRING, + _('Specifies a custom discovery url for node discovery.'), + update_allowed=True, + ), + CREATE_TIMEOUT: properties.Schema( + properties.Schema.INTEGER, + _('Timeout for creating the cluster in minutes. ' + 'Set to 0 for no timeout.'), + constraints=[constraints.Range(min=0)], + update_allowed=True, + default=60 + ) + } + + def translation_rules(self, props): + return [ + translation.TranslationRule( + props, + translation.TranslationRule.RESOLVE, + [self.CLUSTER_TEMPLATE], + client_plugin=self.client_plugin('magnum'), + finder='get_cluster_template') + ] + + def handle_create(self): + args = dict(self.properties.items()) + + args['cluster_template_id'] = self.properties[self.CLUSTER_TEMPLATE] + del args[self.CLUSTER_TEMPLATE] + cluster = self.client().clusters.create(**args) + self.resource_id_set(cluster.uuid) + return cluster.uuid + + def check_create_complete(self, id): + cluster = self.client().clusters.get(id) + if cluster.status == 'CREATE_IN_PROGRESS': + return False + elif cluster.status == 'CREATE_COMPLETE': + return True + elif cluster.status == 'CREATE_FAILED': + msg = (_("Failed to create Cluster '%(name)s' - %(reason)s") + % {'name': self.name, 'reason': cluster.status_reason}) + raise exception.ResourceInError(status_reason=msg, + resource_status=cluster.status) + else: + msg = (_("Unknown status creating Cluster '%(name)s' - %(reason)s") + % {'name': self.name, 'reason': cluster.status_reason}) + raise exception.ResourceUnknownStatus( + status_reason=msg, resource_status=cluster.status) + + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + if prop_diff: + patch = [{'op': 'replace', 'path': '/' + k, 'value': v} + for k, v in six.iteritems(prop_diff)] + self.client().clusters.update(self.resource_id, patch) + return self.resource_id + + def check_update_complete(self, id): + cluster = self.client().clusters.get(id) + if cluster.status == 'UPDATE_IN_PROGRESS': + return False + elif cluster.status == 'UPDATE_COMPLETE': + return True + elif cluster.status == 'UPDATE_FAILED': + msg = (_("Failed to update Cluster '%(name)s' - %(reason)s") + % {'name': self.name, 'reason': cluster.status_reason}) + raise exception.ResourceInError( + status_reason=msg, resource_status=cluster.status) + + else: + msg = (_("Unknown status updating Cluster '%(name)s' - %(reason)s") + % {'name': self.name, 'reason': cluster.status_reason}) + raise exception.ResourceUnknownStatus( + status_reason=msg, resource_status=cluster.status) + + def check_delete_complete(self, id): + if not id: + return True + try: + self.client().clusters.get(id) + except Exception as exc: + self.client_plugin().ignore_not_found(exc) + return True + return False + + +def resource_mapping(): + return { + 'OS::Magnum::Cluster': Cluster + } diff --git a/heat/tests/openstack/magnum/test_cluster.py b/heat/tests/openstack/magnum/test_cluster.py new file mode 100644 index 0000000000..95a8f78a04 --- /dev/null +++ b/heat/tests/openstack/magnum/test_cluster.py @@ -0,0 +1,226 @@ +# +# 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 magnum as mc +from heat.engine.clients.os import nova +from heat.engine import resource +from heat.engine.resources.openstack.magnum import cluster +from heat.engine import scheduler +from heat.engine import template +from heat.tests import common +from heat.tests import utils + + +magnum_template = ''' + heat_template_version: ocata + resources: + test_cluster: + type: OS::Magnum::Cluster + properties: + name: test_cluster + keypair: key + cluster_template: 123456 + node_count: 5 + master_count: 1 + discovery_url: https://discovery.etcd.io + create_timeout: 15 + test_cluster_min: + type: OS::Magnum::Cluster + properties: + cluster_template: 123456 +''' + +RESOURCE_TYPE = 'OS::Magnum::Cluster' + + +class TestMagnumCluster(common.HeatTestCase): + def setUp(self): + super(TestMagnumCluster, self).setUp() + resource._register_class(RESOURCE_TYPE, cluster.Cluster) + t = template_format.parse(magnum_template) + self.stack = utils.parse_stack(t) + resource_defns = self.stack.t.resource_definitions(self.stack) + self.min_rsrc_defn = resource_defns['test_cluster_min'] + resource_defns = self.stack.t.resource_definitions(self.stack) + self.rsrc_defn = resource_defns['test_cluster'] + self.client = mock.Mock() + self.patchobject(cluster.Cluster, 'client', return_value=self.client) + self.m_fct = self.patchobject(mc.MagnumClientPlugin, + 'get_cluster_template') + self.m_fnk = self.patchobject(nova.NovaClientPlugin, + 'get_keypair', return_value='key') + + def _create_resource(self, name, snippet, stack, stat='CREATE_COMPLETE'): + self.resource_id = '12345' + self.m_fct.return_value = '123456' + value = mock.MagicMock(uuid=self.resource_id) + self.client.clusters.create.return_value = value + get_rv = mock.MagicMock(status=stat) + self.client.clusters.get.return_value = get_rv + b = cluster.Cluster(name, snippet, stack) + return b + + def test_cluster_create(self): + b = self._create_resource('cluster', self.rsrc_defn, self.stack) + # validate the properties + self.assertEqual( + 'test_cluster', + b.properties.get(cluster.Cluster.NAME)) + self.assertEqual( + '123456', + b.properties.get(cluster.Cluster.CLUSTER_TEMPLATE)) + self.assertEqual( + 'key', + b.properties.get(cluster.Cluster.KEYPAIR)) + self.assertEqual( + 5, + b.properties.get(cluster.Cluster.NODE_COUNT)) + self.assertEqual( + 1, + b.properties.get(cluster.Cluster.MASTER_COUNT)) + self.assertEqual( + 'https://discovery.etcd.io', + b.properties.get(cluster.Cluster.DISCOVERY_URL)) + self.assertEqual( + 15, + b.properties.get(cluster.Cluster.CREATE_TIMEOUT)) + scheduler.TaskRunner(b.create)() + self.assertEqual(self.resource_id, b.resource_id) + self.assertEqual((b.CREATE, b.COMPLETE), b.state) + self.client.clusters.create.assert_called_once_with( + name=u'test_cluster', + keypair=u'key', + cluster_template_id='123456', + node_count=5, + master_count=1, + discovery_url=u'https://discovery.etcd.io', + create_timeout=15) + + def test_cluster_create_with_default_value(self): + b = self._create_resource('cluster', self.min_rsrc_defn, + self.stack) + # validate the properties + self.assertEqual( + None, + b.properties.get(cluster.Cluster.NAME)) + self.assertEqual( + '123456', + b.properties.get(cluster.Cluster.CLUSTER_TEMPLATE)) + self.assertEqual( + None, + b.properties.get(cluster.Cluster.KEYPAIR)) + self.assertEqual( + 1, + b.properties.get(cluster.Cluster.NODE_COUNT)) + self.assertEqual( + 1, + b.properties.get(cluster.Cluster.MASTER_COUNT)) + self.assertEqual( + None, + b.properties.get(cluster.Cluster.DISCOVERY_URL)) + self.assertEqual( + 60, + b.properties.get(cluster.Cluster.CREATE_TIMEOUT)) + scheduler.TaskRunner(b.create)() + self.assertEqual(self.resource_id, b.resource_id) + self.assertEqual((b.CREATE, b.COMPLETE), b.state) + self.client.clusters.create.assert_called_once_with( + name=None, + keypair=None, + cluster_template_id='123456', + node_count=1, + master_count=1, + discovery_url=None, + create_timeout=60) + + def test_cluster_create_failed(self): + cfg.CONF.set_override('action_retry_limit', 0, enforce_type=True) + b = self._create_resource('cluster', self.rsrc_defn, self.stack, + stat='CREATE_FAILED') + exc = self.assertRaises( + exception.ResourceFailure, + scheduler.TaskRunner(b.create)) + self.assertIn("Failed to create Cluster", six.text_type(exc)) + + def test_cluster_create_unknown_status(self): + b = self._create_resource('cluster', self.rsrc_defn, self.stack, + stat='CREATE_FOO') + exc = self.assertRaises( + exception.ResourceFailure, + scheduler.TaskRunner(b.create)) + self.assertIn("Unknown status creating Cluster", six.text_type(exc)) + + def _cluster_update(self, update_status='UPDATE_COMPLETE', exc_msg=None): + b = self._create_resource('cluster', self.rsrc_defn, self.stack) + scheduler.TaskRunner(b.create)() + status = mock.MagicMock(status=update_status) + self.client.clusters.get.return_value = status + t = template_format.parse(magnum_template) + new_t = copy.deepcopy(t) + new_t['resources']['test_cluster']['properties']['node_count'] = 10 + rsrc_defns = template.Template(new_t).resource_definitions(self.stack) + new_bm = rsrc_defns['test_cluster'] + if update_status == 'UPDATE_COMPLETE': + scheduler.TaskRunner(b.update, new_bm)() + self.assertEqual((b.UPDATE, b.COMPLETE), b.state) + else: + exc = self.assertRaises( + exception.ResourceFailure, + scheduler.TaskRunner(b.update, new_bm)) + self.assertIn(exc_msg, six.text_type(exc)) + + def test_cluster_update(self): + self._cluster_update() + + def test_cluster_update_failed(self): + self._cluster_update('UPDATE_FAILED', 'Failed to update Cluster') + + def test_cluster_update_unknown_status(self): + self._cluster_update('UPDATE_BAR', 'Unknown status updating Cluster') + + def test_cluster_delete(self): + b = self._create_resource('cluster', self.rsrc_defn, self.stack) + scheduler.TaskRunner(b.create)() + b.client_plugin = mock.MagicMock() + self.client.clusters.get.side_effect = Exception('Not Found') + self.client.get.reset_mock() + scheduler.TaskRunner(b.delete)() + self.assertEqual((b.DELETE, b.COMPLETE), b.state) + self.assertEqual(2, self.client.clusters.get.call_count) + + def test_cluster_get_live_state(self): + b = self._create_resource('cluster', self.rsrc_defn, self.stack) + scheduler.TaskRunner(b.create)() + value = mock.MagicMock() + value.to_dict.return_value = { + 'cluster_template': 123456, + 'node_count': 5, + 'master_count': 1, + 'discovery_url': 'https://discovery.etcd.io', + 'create_timeout': 15} + self.client.clusters.get.return_value = value + reality = b.get_live_state(b.properties) + self.assertEqual( + {'create_timeout': 15, + 'discovery_url': 'https://discovery.etcd.io', + 'master_count': 1, + 'node_count': 5 + }, + reality)