From afb7b73f5dde907d90bf2400c1362ed2abf84b0a Mon Sep 17 00:00:00 2001 From: Ethan Lynn Date: Thu, 19 Nov 2015 13:32:54 +0800 Subject: [PATCH] Add OS::Senlin::Cluster resource Add OS::Senlin::Cluster to heat, handle_update will be added in another patch. blueprint senlin-resources Change-Id: Ia7cf6045d6aa0389c3c6782276f3fd19787cb08a --- heat/engine/clients/os/senlin.py | 2 + .../resources/openstack/senlin/__init__.py | 0 .../resources/openstack/senlin/cluster.py | 214 ++++++++++++++++++ heat/tests/openstack/senlin/__init__.py | 0 heat/tests/openstack/senlin/test_cluster.py | 199 ++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 heat/engine/resources/openstack/senlin/__init__.py create mode 100644 heat/engine/resources/openstack/senlin/cluster.py create mode 100644 heat/tests/openstack/senlin/__init__.py create mode 100644 heat/tests/openstack/senlin/test_cluster.py diff --git a/heat/engine/clients/os/senlin.py b/heat/engine/clients/os/senlin.py index 38afd2ecf7..078c352d39 100644 --- a/heat/engine/clients/os/senlin.py +++ b/heat/engine/clients/os/senlin.py @@ -31,6 +31,8 @@ class SenlinClientPlugin(client_plugin.ClientPlugin): 'auth_url': con.auth_url, 'project_id': con.tenant_id, 'token': self.auth_token, + 'user_id': con.user_id, + 'auth_plugin': 'token', } return client.Client(self.VERSION, **args) diff --git a/heat/engine/resources/openstack/senlin/__init__.py b/heat/engine/resources/openstack/senlin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/engine/resources/openstack/senlin/cluster.py b/heat/engine/resources/openstack/senlin/cluster.py new file mode 100644 index 0000000000..30babe3156 --- /dev/null +++ b/heat/engine/resources/openstack/senlin/cluster.py @@ -0,0 +1,214 @@ +# Copyright 2015 IBM Corp. +# +# 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 Cluster(resource.Resource): + """A resource that creates a Senlin Cluster. + + Cluster resource in senlin can create and manage objects of + the same nature, e.g. Nova servers, Heat stacks, Cinder volumes, etc. + The collection of these objects is referred to as a cluster. + """ + + support_status = support.SupportStatus(version='6.0.0') + + default_client_name = 'senlin' + + PROPERTIES = ( + NAME, PROFILE, DESIRED_CAPACITY, MIN_SIZE, MAX_SIZE, + METADATA, TIMEOUT + ) = ( + 'name', 'profile', 'desired_capacity', 'min_size', 'max_size', + 'metadata', 'timeout' + ) + + ATTRIBUTES = ( + ATTR_NAME, ATTR_METADATA, ATTR_NODES, ATTR_DESIRED_CAPACITY, + ATTR_MIN_SIZE, ATTR_MAX_SIZE, + ) = ( + "name", 'metadata', 'nodes', 'desired_capacity', + 'min_size', 'max_size' + ) + + _CLUSTER_STATUS = ( + CLUSTER_INIT, CLUSTER_ACTIVE, CLUSTER_ERROR, CLUSTER_WARNING, + CLUSTER_CREATING, CLUSTER_DELETING, CLUSTER_UPDATING + ) = ( + 'INIT', 'ACTIVE', 'ERROR', 'WARNING', + 'CREATING', 'DELETING', 'UPDATING' + ) + + properties_schema = { + PROFILE: properties.Schema( + properties.Schema.STRING, + _('The name or id of the Senlin profile.'), + required=True, + constraints=[ + constraints.CustomConstraint('senlin.profile') + ] + ), + NAME: properties.Schema( + properties.Schema.STRING, + _('Name of the cluster. By default, physical resource name ' + 'is used.'), + ), + DESIRED_CAPACITY: properties.Schema( + properties.Schema.INTEGER, + _('Desired initial number of resources in cluster.'), + default=0 + ), + MIN_SIZE: properties.Schema( + properties.Schema.INTEGER, + _('Minimum number of resources in the cluster.'), + default=0, + constraints=[ + constraints.Range(min=0) + ] + ), + MAX_SIZE: properties.Schema( + properties.Schema.INTEGER, + _('Maximum number of resources in the cluster. ' + '-1 means unlimited.'), + default=-1, + constraints=[ + constraints.Range(min=-1) + ] + ), + METADATA: properties.Schema( + properties.Schema.MAP, + _('Metadata key-values defined for cluster.'), + ), + TIMEOUT: properties.Schema( + properties.Schema.INTEGER, + _('The number of seconds to wait for the cluster actions.'), + constraints=[ + constraints.Range(min=0) + ] + ), + } + + attributes_schema = { + ATTR_NAME: attributes.Schema( + _("Cluster name."), + type=attributes.Schema.STRING + ), + ATTR_METADATA: attributes.Schema( + _("Cluster metadata."), + type=attributes.Schema.MAP + ), + ATTR_DESIRED_CAPACITY: attributes.Schema( + _("Desired capacity of the cluster."), + type=attributes.Schema.INTEGER + ), + ATTR_NODES: attributes.Schema( + _("Nodes list in the cluster."), + type=attributes.Schema.LIST + ), + ATTR_MIN_SIZE: attributes.Schema( + _("Min size of the cluster."), + type=attributes.Schema.INTEGER + ), + ATTR_MAX_SIZE: attributes.Schema( + _("Max size of the cluster."), + type=attributes.Schema.INTEGER + ), + } + + def handle_create(self): + params = { + 'name': (self.properties[self.NAME] or + self.physical_resource_name()), + 'profile_id': self.properties[self.PROFILE], + 'desired_capacity': self.properties[self.DESIRED_CAPACITY], + 'min_size': self.properties[self.MIN_SIZE], + 'max_size': self.properties[self.MAX_SIZE], + 'metadata': self.properties[self.METADATA], + 'timeout': self.properties[self.TIMEOUT] + } + cluster = self.client().create_cluster(**params) + self.resource_id_set(cluster.id) + return cluster.id + + def check_create_complete(self, resource_id): + cluster = self.client().get_cluster(resource_id) + if cluster.status in [self.CLUSTER_ACTIVE, self.CLUSTER_WARNING]: + return True + elif cluster.status in [self.CLUSTER_INIT, self.CLUSTER_CREATING]: + return False + else: + raise exception.ResourceInError( + status_reason=cluster.status_reason, + resource_status=cluster.status + ) + + def handle_delete(self): + if self.resource_id is not None: + with self.client_plugin().ignore_not_found: + self.client().delete_cluster(self.resource_id) + return self.resource_id + + def check_delete_complete(self, resource_id): + if not resource_id: + return True + + try: + self.client().get_cluster(self.resource_id) + except Exception as ex: + self.client_plugin().ignore_not_found(ex) + return True + return False + + def validate(self): + min_size = self.properties[self.MIN_SIZE] + max_size = self.properties[self.MAX_SIZE] + desired_capacity = self.properties[self.DESIRED_CAPACITY] + + if max_size != -1 and max_size < min_size: + msg = _("%(min_size)s can not be greater than %(max_size)s") % { + 'min_size': self.MIN_SIZE, + 'max_size': self.MAX_SIZE, + } + raise exception.StackValidationFailed(message=msg) + + if (desired_capacity < min_size or + (max_size != -1 and desired_capacity > max_size)): + msg = _("%(desired_capacity)s must be between %(min_size)s " + "and %(max_size)s") % { + 'desired_capacity': self.DESIRED_CAPACITY, + 'min_size': self.MIN_SIZE, + 'max_size': self.MAX_SIZE, + } + raise exception.StackValidationFailed(message=msg) + + def _resolve_attribute(self, name): + cluster = self.client().get_cluster(self.resource_id) + return getattr(cluster, name, None) + + def _show_resource(self): + cluster = self.client().get_cluster(self.resource_id) + return cluster.to_dict() + + +def resource_mapping(): + return { + 'OS::Senlin::Cluster': Cluster + } diff --git a/heat/tests/openstack/senlin/__init__.py b/heat/tests/openstack/senlin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/tests/openstack/senlin/test_cluster.py b/heat/tests/openstack/senlin/test_cluster.py new file mode 100644 index 0000000000..40ef3bfdcb --- /dev/null +++ b/heat/tests/openstack/senlin/test_cluster.py @@ -0,0 +1,199 @@ +# Copyright 2015 IBM Corp. +# +# 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 +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 cluster as sc +from heat.engine import scheduler +from heat.tests import common +from heat.tests import utils +from senlinclient.common import exc + + +cluster_stack_template = """ +heat_template_version: 2016-04-08 +description: Senlin Cluster Template +resources: + senlin-cluster: + type: OS::Senlin::Cluster + properties: + name: SenlinCluster + profile: fake_profile + min_size: 0 + max_size: -1 + desired_capacity: 1 + timeout: 3600 + metadata: + foo: bar +""" + + +class FakeCluster(object): + def __init__(self, id='some_id', status='ACTIVE'): + self.status = status + self.status_reason = 'Unknown' + self.id = id + self.name = "SenlinCluster" + self.metadata = {} + self.nodes = ['node1'] + self.desired_capacity = 1 + self.metadata = {'foo': 'bar'} + self.timeout = 3600 + self.max_size = -1 + self.min_size = 0 + + def to_dict(self): + return { + 'id': self.id, + 'status': self.status, + 'status_reason': self.status_reason, + 'name': self.name, + 'metadata': self.metadata, + 'timeout': self.timeout, + 'desired_capacity': self.desired_capacity, + 'max_size': self.max_size, + 'min_size': self.min_size, + 'nodes': self.nodes + } + + +class SenlinClusterTest(common.HeatTestCase): + def setUp(self): + super(SenlinClusterTest, self).setUp() + self.senlin_mock = mock.MagicMock() + self.patchobject(sc.Cluster, 'client', return_value=self.senlin_mock) + self.patchobject(senlin.ProfileConstraint, 'validate', + return_value=True) + self.fake_cl = FakeCluster() + self.t = template_format.parse(cluster_stack_template) + + def _init_cluster(self, template): + self.stack = utils.parse_stack(template) + cluster = self.stack['senlin-cluster'] + return cluster + + def _create_cluster(self, template): + cluster = self._init_cluster(template) + self.senlin_mock.create_cluster.return_value = self.fake_cl + self.senlin_mock.get_cluster.return_value = self.fake_cl + scheduler.TaskRunner(cluster.create)() + self.assertEqual((cluster.CREATE, cluster.COMPLETE), + cluster.state) + self.assertEqual(self.fake_cl.id, cluster.resource_id) + return cluster + + def test_resource_mapping(self): + mapping = sc.resource_mapping() + self.assertEqual(1, len(mapping)) + self.assertEqual(sc.Cluster, + mapping['OS::Senlin::Cluster']) + + def test_cluster_create_success(self): + self._create_cluster(self.t) + expect_kwargs = { + 'name': 'SenlinCluster', + 'profile_id': 'fake_profile', + 'desired_capacity': 1, + 'min_size': 0, + 'max_size': -1, + 'metadata': {'foo': 'bar'}, + 'timeout': 3600, + } + self.senlin_mock.create_cluster.assert_called_once_with( + **expect_kwargs) + self.senlin_mock.get_cluster.assert_called_once_with(self.fake_cl.id) + + def test_cluster_create_error(self): + cfg.CONF.set_override('action_retry_limit', 0) + cluster = self._init_cluster(self.t) + self.senlin_mock.create_cluster.return_value = self.fake_cl + self.senlin_mock.get_cluster.return_value = FakeCluster( + status='ERROR') + create_task = scheduler.TaskRunner(cluster.create) + ex = self.assertRaises(exception.ResourceFailure, create_task) + expected = ('ResourceInError: resources.senlin-cluster: ' + 'Went to status ERROR due to "Unknown"') + self.assertEqual(expected, six.text_type(ex)) + + def test_cluster_delete_success(self): + cluster = self._create_cluster(self.t) + self.senlin_mock.get_cluster.side_effect = [ + exc.HTTPNotFound(), + ] + scheduler.TaskRunner(cluster.delete)() + self.senlin_mock.delete_cluster.assert_called_once_with( + cluster.resource_id) + + def test_cluster_delete_error(self): + cluster = self._create_cluster(self.t) + self.senlin_mock.get_cluster.side_effect = exception.Error('oops') + delete_task = scheduler.TaskRunner(cluster.delete) + ex = self.assertRaises(exception.ResourceFailure, delete_task) + expected = 'Error: resources.senlin-cluster: oops' + self.assertEqual(expected, six.text_type(ex)) + + def test_cluster_resolve_attribute(self): + excepted_show = { + 'id': 'some_id', + 'status': 'ACTIVE', + 'status_reason': 'Unknown', + 'name': 'SenlinCluster', + 'metadata': {'foo': 'bar'}, + 'timeout': 3600, + 'desired_capacity': 1, + 'max_size': -1, + 'min_size': 0, + 'nodes': ['node1'] + } + cluster = self._create_cluster(self.t) + self.assertEqual(self.fake_cl.desired_capacity, + cluster._resolve_attribute('desired_capacity')) + self.assertEqual(['node1'], + cluster._resolve_attribute('nodes')) + self.assertEqual(excepted_show, + cluster._show_resource()) + + +class TestSenlinClusterValidation(common.HeatTestCase): + def setUp(self): + super(TestSenlinClusterValidation, self).setUp() + self.t = template_format.parse(cluster_stack_template) + + def test_invalid_min_max_size(self): + self.t['resources']['senlin-cluster']['properties']['min_size'] = 2 + self.t['resources']['senlin-cluster']['properties']['max_size'] = 1 + stack = utils.parse_stack(self.t) + ex = self.assertRaises(exception.StackValidationFailed, + stack['senlin-cluster'].validate) + self.assertEqual('min_size can not be greater than max_size', + six.text_type(ex)) + + def test_invalid_desired_capacity(self): + self.t['resources']['senlin-cluster']['properties']['min_size'] = 1 + self.t['resources']['senlin-cluster']['properties']['max_size'] = 2 + self.t['resources']['senlin-cluster']['properties'][ + 'desired_capacity'] = 3 + stack = utils.parse_stack(self.t) + ex = self.assertRaises(exception.StackValidationFailed, + stack['senlin-cluster'].validate) + self.assertEqual( + 'desired_capacity must be between min_size and max_size', + six.text_type(ex) + )