diff --git a/heat/engine/resources/software_config/multi_part.py b/heat/engine/resources/software_config/multi_part.py new file mode 100644 index 0000000000..903dfec976 --- /dev/null +++ b/heat/engine/resources/software_config/multi_part.py @@ -0,0 +1,148 @@ +# 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. + +import email +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +import os + +from heat.common import exception +from heat.engine import constraints +from heat.engine import properties +from heat.engine.resources.software_config import software_config +from heat.openstack.common.gettextutils import _ + + +class MultipartMime(software_config.SoftwareConfig): + ''' + A resource which assembles a collection of software configurations + as a multi-part mime message. Any configuration which is itself a + multi-part message will be broken into parts and those parts appended + to this message. + ''' + + PROPERTIES = ( + PARTS, CONFIG, FILENAME, TYPE, SUBTYPE + ) = ( + 'parts', 'config', 'filename', 'type', 'subtype' + ) + + TYPES = ( + TEXT, MULTIPART + ) = ( + 'text', 'multipart' + ) + + properties_schema = { + PARTS: properties.Schema( + properties.Schema.LIST, + _('Parts belonging to this messsage.'), + default=[], + schema=properties.Schema( + properties.Schema.MAP, + schema={ + CONFIG: properties.Schema( + properties.Schema.STRING, + _('Content of part to attach, either inline or by ' + 'referencing the ID of another software config ' + 'resource'), + required=True + ), + FILENAME: properties.Schema( + properties.Schema.STRING, + _('Optional filename to associate with part.') + ), + TYPE: properties.Schema( + properties.Schema.STRING, + _('Whether the part content is text or multipart.'), + default=TEXT, + constraints=[constraints.AllowedValues(TYPES)] + ), + SUBTYPE: properties.Schema( + properties.Schema.STRING, + _('Optional subtype to specify with the type.') + ), + } + ) + ) + } + + message = None + + def handle_create(self): + props = {self.NAME: self.physical_resource_name()} + props[self.CONFIG] = self.get_message() + sc = self.heat().software_configs.create(**props) + self.resource_id_set(sc.id) + + def get_message(self): + if self.message: + return self.message + + subparts = [] + for item in self.properties.get(self.PARTS): + config = item.get(self.CONFIG) + part_type = item.get(self.TYPE, self.TEXT) + part = config + try: + part = self.get_software_config(self.heat(), config) + except exception.SoftwareConfigMissing: + pass + + if part_type == self.MULTIPART: + self._append_multiparts(subparts, part) + else: + filename = item.get(self.FILENAME, '') + subtype = item.get(self.SUBTYPE, '') + self._append_part(subparts, part, subtype, filename) + + mime_blob = MIMEMultipart(_subparts=subparts) + self.message = mime_blob.as_string() + return self.message + + @staticmethod + def _append_multiparts(subparts, multi_part): + multi_parts = email.message_from_string(multi_part) + if not multi_parts or not multi_parts.is_multipart(): + return + + for part in multi_parts.get_payload(): + MultipartMime._append_part( + subparts, + part.get_payload(), + part.get_content_subtype(), + part.get_filename()) + + @staticmethod + def _append_part(subparts, part, subtype, filename): + if not subtype and filename: + subtype = os.path.splitext(filename)[0] + + msg = MultipartMime._create_message(part, subtype, filename) + subparts.append(msg) + + @staticmethod + def _create_message(part, subtype, filename): + msg = MIMEText(part, _subtype=subtype) if subtype else MIMEText(part) + if filename: + msg.add_header('Content-Disposition', 'attachment', + filename=filename) + return msg + + +def resource_mapping(): + return { + 'OS::Heat::MultipartMime': MultipartMime, + } diff --git a/heat/tests/test_multi_part.py b/heat/tests/test_multi_part.py new file mode 100644 index 0000000000..840f627e4a --- /dev/null +++ b/heat/tests/test_multi_part.py @@ -0,0 +1,164 @@ +# +# 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 email +import mock + +from heat.engine import parser +from heat.engine import template + +import heat.engine.resources.software_config.multi_part as mp + +from heat.tests.common import HeatTestCase +from heat.tests import utils +import heatclient.exc as exc + + +class MultipartMimeTest(HeatTestCase): + + def setUp(self): + super(MultipartMimeTest, self).setUp() + utils.setup_dummy_db() + self.ctx = utils.dummy_context() + self.init_config() + + def init_config(self, parts=[]): + stack = parser.Stack( + self.ctx, 'software_config_test_stack', + template.Template({ + 'Resources': { + 'config_mysql': { + 'Type': 'OS::Heat::MultipartMime', + 'Properties': { + 'parts': parts + }}}})) + self.config = stack['config_mysql'] + heat = mock.MagicMock() + self.config.heat = heat + self.software_configs = heat.return_value.software_configs + + def test_resource_mapping(self): + mapping = mp.resource_mapping() + self.assertEqual(1, len(mapping)) + self.assertEqual(mp.MultipartMime, + mapping['OS::Heat::MultipartMime']) + self.assertIsInstance(self.config, mp.MultipartMime) + + def test_handle_create(self): + sc = mock.MagicMock() + config_id = 'c8a19429-7fde-47ea-a42f-40045488226c' + sc.id = config_id + self.software_configs.create.return_value = sc + self.config.handle_create() + self.assertEqual(config_id, self.config.resource_id) + args = self.software_configs.create.call_args[1] + self.assertEqual(self.config.message, args['config']) + + def test_get_message_not_none(self): + self.config.message = 'Not none' + result = self.config.get_message() + self.assertEqual('Not none', result) + + def test_get_message_empty_list(self): + parts = [] + self.init_config(parts=parts) + result = self.config.get_message() + message = email.message_from_string(result) + self.assertTrue(message.is_multipart()) + + def test_get_message_text(self): + parts = [{ + 'config': '1e0e5a60-2843-4cfd-9137-d90bdf18eef5', + 'type': 'text' + }] + self.init_config(parts=parts) + self.software_configs.get.return_value.config = '#!/bin/bash' + result = self.config.get_message() + message = email.message_from_string(result) + self.assertTrue(message.is_multipart()) + subs = message.get_payload() + self.assertEqual(1, len(subs)) + self.assertEqual('#!/bin/bash', subs[0].get_payload()) + + def test_get_message_fail_back(self): + parts = [{ + 'config': '#!/bin/bash', + 'type': 'text' + }] + self.init_config(parts=parts) + self.software_configs.get.side_effect = exc.HTTPNotFound() + result = self.config.get_message() + message = email.message_from_string(result) + self.assertTrue(message.is_multipart()) + subs = message.get_payload() + self.assertEqual(1, len(subs)) + self.assertEqual('#!/bin/bash', subs[0].get_payload()) + + def test_get_message_text_with_filename(self): + parts = [{ + 'config': '1e0e5a60-2843-4cfd-9137-d90bdf18eef5', + 'type': 'text', + 'filename': '/opt/stack/configure.d/55-heat-config' + }] + self.init_config(parts=parts) + self.software_configs.get.return_value.config = '#!/bin/bash' + result = self.config.get_message() + message = email.message_from_string(result) + self.assertTrue(message.is_multipart()) + subs = message.get_payload() + self.assertEqual(1, len(subs)) + self.assertEqual('#!/bin/bash', subs[0].get_payload()) + self.assertEqual(parts[0]['filename'], subs[0].get_filename()) + + def test_get_message_multi_part(self): + multipart = ('Content-Type: multipart/mixed; ' + 'boundary="===============2579792489038011818=="\n' + 'MIME-Version: 1.0\n' + '\n--===============2579792489038011818==' + '\nContent-Type: text; ' + 'charset="us-ascii"\n' + 'MIME-Version: 1.0\n' + 'Content-Transfer-Encoding: 7bit\n' + 'Content-Disposition: attachment;\n' + ' filename="/opt/stack/configure.d/55-heat-config"\n' + '#!/bin/bash\n' + '--===============2579792489038011818==--\n') + parts = [{ + 'config': '1e0e5a60-2843-4cfd-9137-d90bdf18eef5', + 'type': 'multipart' + }] + self.init_config(parts=parts) + self.software_configs.get.return_value.config = multipart + result = self.config.get_message() + message = email.message_from_string(result) + self.assertTrue(message.is_multipart()) + subs = message.get_payload() + self.assertEqual(1, len(subs)) + self.assertEqual('#!/bin/bash', subs[0].get_payload()) + self.assertEqual('/opt/stack/configure.d/55-heat-config', + subs[0].get_filename()) + + def test_get_message_multi_part_bad_format(self): + parts = [ + {'config': '1e0e5a60-2843-4cfd-9137-d90bdf18eef5', + 'type': 'multipart'}, + {'config': '9cab10ef-16ce-4be9-8b25-a67b7313eddb', + 'type': 'text'}] + self.init_config(parts=parts) + self.software_configs.get.return_value.config = '#!/bin/bash' + result = self.config.get_message() + message = email.message_from_string(result) + self.assertTrue(message.is_multipart()) + subs = message.get_payload() + self.assertEqual(1, len(subs)) + self.assertEqual('#!/bin/bash', subs[0].get_payload())