From 21f60b155e4b65396ebf77e05a0ef300e7c3c1cf Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Sat, 18 Jan 2014 17:08:33 +1300 Subject: [PATCH] Resource type for software configuration Implementation of the SoftwareConfig resource. Some notes on the implementation: * This is a simple wrapper over the REST API, and is essentially just for defining data which gets stored. * SoftwareConfig will always be UpdateReplace, and the REST entity is immutable. * OS::Heat::SoftwareConfig will sometimes be used in a template directly and sometimes inside a resource provider template which defines CM-tool specific properties and aggregates the result into the OS::Heat::SoftwareConfig config property. Implements: blueprint hot-software-config Change-Id: I7350c31ec59d152751c6aa7d811a91e1df62e89d --- heat/common/exception.py | 4 + .../resources/software_config/__init__.py | 0 .../software_config/software_config.py | 178 ++++++++++++++++++ heat/tests/test_software_config.py | 109 +++++++++++ 4 files changed, 291 insertions(+) create mode 100644 heat/engine/resources/software_config/__init__.py create mode 100644 heat/engine/resources/software_config/software_config.py create mode 100644 heat/tests/test_software_config.py diff --git a/heat/common/exception.py b/heat/common/exception.py index 280653f02b..2be9ef172f 100644 --- a/heat/common/exception.py +++ b/heat/common/exception.py @@ -327,3 +327,7 @@ class StackResourceLimitExceeded(HeatException): class ActionInProgress(HeatException): msg_fmt = _("Stack %(stack_name)s already has an action (%(action)s) " "in progress.") + + +class SoftwareConfigMissing(HeatException): + msg_fmt = _("The config (%(software_config_id)s) could not be found.") diff --git a/heat/engine/resources/software_config/__init__.py b/heat/engine/resources/software_config/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/engine/resources/software_config/software_config.py b/heat/engine/resources/software_config/software_config.py new file mode 100644 index 0000000000..3ecf4b661e --- /dev/null +++ b/heat/engine/resources/software_config/software_config.py @@ -0,0 +1,178 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# +# 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.engine import constraints +from heat.engine import properties +from heat.engine import resource +from heat.openstack.common.gettextutils import _ +from heat.openstack.common import log as logging + +import heatclient.exc as heat_exp + +logger = logging.getLogger(__name__) + + +class SoftwareConfig(resource.Resource): + + PROPERTIES = ( + GROUP, CONFIG, OPTIONS, INPUTS, OUTPUTS + ) = ( + 'group', 'config', 'options', 'inputs', 'outputs' + ) + + IO_PROPERTIES = ( + NAME, DESCRIPTION, TYPE, DEFAULT, ERROR_OUTPUT + ) = ( + 'name', 'description', 'type', 'default', 'error_output' + ) + + input_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('Name of the input.'), + required=True + ), + DESCRIPTION: properties.Schema( + properties.Schema.STRING, + _('Description of the input.') + ), + TYPE: properties.Schema( + properties.Schema.STRING, + _('Type of the value of the input.'), + default='String', + constraints=[constraints.AllowedValues(( + 'String', 'Number', 'CommaDelimitedList', 'Json'))] + ), + DEFAULT: properties.Schema( + properties.Schema.STRING, + _('Default value for the input if none is specified.'), + ), + } + + output_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('Name of the output.'), + required=True + ), + DESCRIPTION: properties.Schema( + properties.Schema.STRING, + _('Description of the output.') + ), + TYPE: properties.Schema( + properties.Schema.STRING, + _('Type of the value of the output.'), + default='String', + constraints=[constraints.AllowedValues(( + 'String', 'Number', 'CommaDelimitedList', 'Json'))] + ), + ERROR_OUTPUT: properties.Schema( + properties.Schema.BOOLEAN, + _('Denotes that the deployment is in an error state if this ' + 'output has a value.'), + default=False + ) + } + + properties_schema = { + GROUP: properties.Schema( + properties.Schema.STRING, + _('Namespace to group this software config by when delivered to ' + 'a server. This may imply what configuration tool is going to ' + 'perform the configuration.'), + default='Heat::Ungrouped', + required=True + ), + CONFIG: properties.Schema( + properties.Schema.STRING, + _('Configuration script or manifest which specifies what actual ' + 'configuration is performed.'), + ), + OPTIONS: properties.Schema( + properties.Schema.MAP, + _('Map containing options specific to the configuration ' + 'management tool used by this.'), + ), + INPUTS: properties.Schema( + properties.Schema.LIST, + _('Schema representing the inputs that this software config is ' + 'expecting.'), + schema=properties.Schema(properties.Schema.MAP, + schema=input_schema) + ), + OUTPUTS: properties.Schema( + properties.Schema.LIST, + _('Schema representing the outputs that this software config ' + 'will produce.'), + schema=properties.Schema(properties.Schema.MAP, + schema=output_schema) + ), + } + + attributes_schema = { + "config": _("The config value of the software config.") + } + + def handle_create(self): + props = dict(self.properties) + props[self.NAME] = self.physical_resource_name() + + sc = self.heat().software_configs.create(**props) + self.resource_id_set(sc.id) + + def handle_delete(self): + + if self.resource_id is None: + return + + try: + self.heat().software_configs.delete(self.resource_id) + except heat_exp.HTTPNotFound: + logger.debug( + _('Software config %s is not found.') % self.resource_id) + + def _resolve_attribute(self, name): + ''' + "config" returns the config value of the software config. If the + software config does not exist, returns an empty string. + ''' + if name == self.CONFIG and self.resource_id: + try: + return self.get_software_config(self.heat(), self.resource_id) + except exception.SoftwareConfigMissing: + return '' + + @staticmethod + def get_software_config(heat_client, software_config_id): + ''' + Get the software config specified by :software_config_id: + + :param heat_client: the heat client to use + :param software_config_id: the ID of the config to look for + :returns: the config script string for :software_config_id: + :raises: exception.NotFound + ''' + try: + return heat_client.software_configs.get(software_config_id).config + except heat_exp.HTTPNotFound: + raise exception.SoftwareConfigMissing( + software_config_id=software_config_id) + + +def resource_mapping(): + return { + 'OS::Heat::SoftwareConfig': SoftwareConfig, + } diff --git a/heat/tests/test_software_config.py b/heat/tests/test_software_config.py new file mode 100644 index 0000000000..03a127c26b --- /dev/null +++ b/heat/tests/test_software_config.py @@ -0,0 +1,109 @@ +# +# 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 heat.common import exception +from heat.engine import parser +from heat.engine import template + +import heat.engine.resources.software_config.software_config as sc +from heatclient.exc import HTTPNotFound + +from heat.tests.common import HeatTestCase +from heat.tests import utils + + +class SoftwareConfigTest(HeatTestCase): + + def setUp(self): + super(SoftwareConfigTest, self).setUp() + utils.setup_dummy_db() + self.ctx = utils.dummy_context() + self.properties = { + 'group': 'Heat::Shell', + 'inputs': [], + 'outputs': [], + 'options': {}, + 'config': '#!/bin/bash' + } + self.stack = parser.Stack( + self.ctx, 'software_config_test_stack', + template.Template({ + 'Resources': { + 'config_mysql': { + 'Type': 'OS::Heat::SoftwareConfig', + 'Properties': self.properties + }}})) + self.config = self.stack['config_mysql'] + heat = mock.MagicMock() + self.heatclient = mock.MagicMock() + self.config.heat = heat + heat.return_value = self.heatclient + self.software_configs = self.heatclient.software_configs + + def test_resource_mapping(self): + mapping = sc.resource_mapping() + self.assertEqual(1, len(mapping)) + self.assertEqual(sc.SoftwareConfig, + mapping['OS::Heat::SoftwareConfig']) + self.assertIsInstance(self.config, sc.SoftwareConfig) + + def test_handle_create(self): + value = mock.MagicMock() + config_id = 'c8a19429-7fde-47ea-a42f-40045488226c' + value.id = config_id + self.software_configs.create.return_value = value + self.config.handle_create() + self.assertEqual(config_id, self.config.resource_id) + + def test_handle_delete(self): + self.resource_id = None + self.assertIsNone(self.config.handle_delete()) + config_id = 'c8a19429-7fde-47ea-a42f-40045488226c' + self.config.resource_id = config_id + self.software_configs.delete.return_value = None + self.assertIsNone(self.config.handle_delete()) + self.software_configs.delete.side_effect = HTTPNotFound() + self.assertIsNone(self.config.handle_delete()) + + def test_get_software_config(self): + config_id = 'c8a19429-7fde-47ea-a42f-40045488226c' + value = mock.MagicMock() + value.config = '#!/bin/bash' + self.software_configs.get.return_value = value + heatclient = self.heatclient + config = sc.SoftwareConfig.get_software_config(heatclient, config_id) + self.assertEqual('#!/bin/bash', config) + + self.software_configs.get.side_effect = HTTPNotFound() + err = self.assertRaises( + exception.SoftwareConfigMissing, + self.config.get_software_config, + heatclient, config_id) + self.assertEqual( + ('The config (c8a19429-7fde-47ea-a42f-40045488226c) ' + 'could not be found.'), str(err)) + + def test_resolve_attribute(self): + self.assertIsNone(self.config._resolve_attribute('others')) + self.config.resource_id = None + self.assertIsNone(self.config._resolve_attribute('config')) + self.config.resource_id = 'c8a19429-7fde-47ea-a42f-40045488226c' + value = mock.MagicMock() + value.config = '#!/bin/bash' + self.software_configs.get.return_value = value + self.assertEqual( + '#!/bin/bash', self.config._resolve_attribute('config')) + self.software_configs.get.side_effect = HTTPNotFound() + self.assertEqual('', self.config._resolve_attribute('config'))