A multipart cloud-init resource based on SoftwareConfig
This simple resource allows a multipart mime message to be built out of an assembly of other SoftwareConfig resources. These assembled resources will generally be a mixture of OS::Heat::CloudConfig and OS::Heat::SoftwareConfig resources. The full multipart content is fetched and stored during resource create. This is safe since SoftwareConfig data is immutable once created. Because cloud-init is boot only, this resource won't be used with a SoftwareDeployment resource, also it will not have inputs or outputs. Instead, a server's user_data can contain this resource via get_resource when user_data_format is SOFTWARE_CONFIG. Implements: blueprint cloud-init-resource Change-Id: Id72f6ccb6802d654a1dec31f37fb0bcab0e36843
This commit is contained in:
parent
21f60b155e
commit
bb0db1f722
|
@ -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,
|
||||
}
|
|
@ -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())
|
Loading…
Reference in New Issue