diff --git a/heat/engine/resources/openstack/heat/deployed_server.py b/heat/engine/resources/openstack/heat/deployed_server.py new file mode 100644 index 0000000000..c44c03538e --- /dev/null +++ b/heat/engine/resources/openstack/heat/deployed_server.py @@ -0,0 +1,118 @@ +# +# 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 oslo_config import cfg +from oslo_log import log as logging + +from heat.common.i18n import _ +from heat.engine import attributes +from heat.engine import constraints +from heat.engine import properties +from heat.engine.resources import server_base + +cfg.CONF.import_opt('default_software_config_transport', 'heat.common.config') +cfg.CONF.import_opt('default_user_data_format', 'heat.common.config') +cfg.CONF.import_opt('max_server_name_length', 'heat.common.config') + +LOG = logging.getLogger(__name__) + + +class DeployedServer(server_base.BaseServer): + """A resource for managing servers that are already deployed. + + A DeployedServer resource manages resources for servers that have been + deployed externally from OpenStack. These servers can be associated with + SoftwareDeployments for further orchestration via Heat. + """ + + PROPERTIES = ( + NAME, METADATA, SOFTWARE_CONFIG_TRANSPORT + ) = ( + 'name', 'metadata', 'software_config_transport' + ) + + _SOFTWARE_CONFIG_TRANSPORTS = ( + POLL_SERVER_CFN, POLL_SERVER_HEAT, POLL_TEMP_URL, ZAQAR_MESSAGE + ) = ( + 'POLL_SERVER_CFN', 'POLL_SERVER_HEAT', 'POLL_TEMP_URL', 'ZAQAR_MESSAGE' + ) + + properties_schema = { + NAME: properties.Schema( + properties.Schema.STRING, + _('Server name.'), + update_allowed=True + ), + METADATA: properties.Schema( + properties.Schema.MAP, + _('Arbitrary key/value metadata to store for this server. Both ' + 'keys and values must be 255 characters or less. Non-string ' + 'values will be serialized to JSON (and the serialized ' + 'string must be 255 characters or less).'), + update_allowed=True + ), + SOFTWARE_CONFIG_TRANSPORT: properties.Schema( + properties.Schema.STRING, + _('How the server should receive the metadata required for ' + 'software configuration. POLL_SERVER_CFN will allow calls to ' + 'the cfn API action DescribeStackResource authenticated with ' + 'the provided keypair. POLL_SERVER_HEAT will allow calls to ' + 'the Heat API resource-show using the provided keystone ' + 'credentials. POLL_TEMP_URL will create and populate a ' + 'Swift TempURL with metadata for polling. ZAQAR_MESSAGE will ' + 'create a dedicated zaqar queue and post the metadata ' + 'for polling.'), + default=cfg.CONF.default_software_config_transport, + update_allowed=True, + constraints=[ + constraints.AllowedValues(_SOFTWARE_CONFIG_TRANSPORTS), + ] + ), + } + + ATTRIBUTES = ( + NAME_ATTR + ) = ( + 'name' + ) + + attributes_schema = { + NAME_ATTR: attributes.Schema( + _('Name of the server.'), + type=attributes.Schema.STRING + ) + } + + def __init__(self, name, json_snippet, stack): + super(DeployedServer, self).__init__(name, json_snippet, stack) + self._register_access_key() + + def handle_create(self): + metadata = self.metadata_get(True) or {} + self.resource_id_set(self.uuid) + + self._create_transport_credentials(self.properties) + self._populate_deployments_metadata(metadata, self.properties) + + return self.resource_id + + def _delete(self): + self._delete_queue() + self._delete_user() + self._delete_temp_url() + + +def resource_mapping(): + return { + 'OS::Heat::DeployedServer': DeployedServer, + } diff --git a/heat/engine/resources/openstack/heat/software_deployment.py b/heat/engine/resources/openstack/heat/software_deployment.py index 018d0071f5..0fa33904c9 100644 --- a/heat/engine/resources/openstack/heat/software_deployment.py +++ b/heat/engine/resources/openstack/heat/software_deployment.py @@ -543,8 +543,9 @@ class SoftwareDeployment(signal_responder.SignalResponder): if server: res = self.stack.resource_by_refid(server) if res: - if not (res.properties.get('user_data_format') == - 'SOFTWARE_CONFIG'): + user_data_format = res.properties.get('user_data_format') + if user_data_format and \ + not (user_data_format == 'SOFTWARE_CONFIG'): raise exception.StackValidationFailed(message=_( "Resource %s's property user_data_format should be " "set to SOFTWARE_CONFIG since there are software " diff --git a/heat/tests/openstack/heat/test_deployed_server.py b/heat/tests/openstack/heat/test_deployed_server.py new file mode 100644 index 0000000000..8c132f9985 --- /dev/null +++ b/heat/tests/openstack/heat/test_deployed_server.py @@ -0,0 +1,351 @@ +# +# 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_serialization import jsonutils +from oslo_utils import uuidutils +from six.moves.urllib import parse as urlparse + +from heat.common import template_format +from heat.engine.clients.os import heat_plugin +from heat.engine.clients.os import swift +from heat.engine.clients.os import zaqar +from heat.engine import environment +from heat.engine.resources.openstack.heat import deployed_server +from heat.engine import scheduler +from heat.engine import stack as parser +from heat.engine import template +from heat.tests import common +from heat.tests import utils + +ds_tmpl = """ +heat_template_version: 2015-10-15 +resources: + server: + type: OS::Heat::DeployedServer + properties: + software_config_transport: POLL_TEMP_URL +""" + +server_sc_tmpl = """ +heat_template_version: 2015-10-15 +resources: + server: + type: OS::Heat::DeployedServer + properties: + software_config_transport: POLL_SERVER_CFN +""" + +server_heat_tmpl = """ +heat_template_version: 2015-10-15 +resources: + server: + type: OS::Heat::DeployedServer + properties: + software_config_transport: POLL_SERVER_HEAT +""" + +server_zaqar_tmpl = """ +heat_template_version: 2015-10-15 +resources: + server: + type: OS::Heat::DeployedServer + properties: + software_config_transport: ZAQAR_MESSAGE +""" + + +class DeployedServersTest(common.HeatTestCase): + def setUp(self): + super(DeployedServersTest, self).setUp() + + def _create_test_server(self, name, override_name=False): + server = self._setup_test_server(name, override_name) + scheduler.TaskRunner(server.create)() + return server + + def _setup_test_stack(self, stack_name, test_templ=ds_tmpl): + t = template_format.parse(test_templ) + tmpl = template.Template(t, env=environment.Environment()) + stack = parser.Stack(utils.dummy_context(), stack_name, tmpl, + stack_id=uuidutils.generate_uuid(), + stack_user_project_id='8888') + return (tmpl, stack) + + def _server_create_software_config_poll_temp_url(self, + server_name='server'): + stack_name = '%s_s' % server_name + (tmpl, stack) = self._setup_test_stack(stack_name) + + props = tmpl.t['resources']['server']['properties'] + props['software_config_transport'] = 'POLL_TEMP_URL' + self.server_props = props + + resource_defns = tmpl.resource_definitions(stack) + server = deployed_server.DeployedServer( + server_name, resource_defns[server_name], stack) + + sc = mock.Mock() + sc.head_account.return_value = { + 'x-account-meta-temp-url-key': 'secrit' + } + sc.url = 'http://192.0.2.2' + + self.patchobject(swift.SwiftClientPlugin, '_create', + return_value=sc) + scheduler.TaskRunner(server.create)() + # self._create_test_server(server_name) + metadata_put_url = server.data().get('metadata_put_url') + md = server.metadata_get() + metadata_url = md['os-collect-config']['request']['metadata_url'] + self.assertNotEqual(metadata_url, metadata_put_url) + + container_name = server.physical_resource_name() + object_name = server.data().get('metadata_object_name') + self.assertTrue(uuidutils.is_uuid_like(object_name)) + test_path = '/v1/AUTH_test_tenant_id/%s/%s' % ( + server.physical_resource_name(), object_name) + self.assertEqual(test_path, urlparse.urlparse(metadata_put_url).path) + self.assertEqual(test_path, urlparse.urlparse(metadata_url).path) + sc.put_object.assert_called_once_with( + container_name, object_name, jsonutils.dumps(md)) + + sc.head_container.return_value = {'x-container-object-count': '0'} + server._delete_temp_url() + sc.delete_object.assert_called_once_with(container_name, object_name) + sc.head_container.assert_called_once_with(container_name) + sc.delete_container.assert_called_once_with(container_name) + return metadata_url, server + + def test_server_create_software_config_poll_temp_url(self): + metadata_url, server = ( + self._server_create_software_config_poll_temp_url()) + + self.assertEqual({ + 'os-collect-config': { + 'request': { + 'metadata_url': metadata_url + }, + 'collectors': ['ec2', 'request', 'local'] + }, + 'deployments': [] + }, server.metadata_get()) + + def _server_create_software_config(self, + server_name='server_sc', + md=None, + ret_tmpl=False): + stack_name = '%s_s' % server_name + (tmpl, stack) = self._setup_test_stack(stack_name, server_sc_tmpl) + self.stack = stack + self.server_props = tmpl.t['resources']['server']['properties'] + if md is not None: + tmpl.t['resources']['server']['metadata'] = md + + stack.stack_user_project_id = '8888' + resource_defns = tmpl.resource_definitions(stack) + server = deployed_server.DeployedServer( + 'server', resource_defns['server'], stack) + self.patchobject(server, 'heat') + scheduler.TaskRunner(server.create)() + + self.assertEqual('4567', server.access_key) + self.assertEqual('8901', server.secret_key) + self.assertEqual('1234', server._get_user_id()) + self.assertEqual('POLL_SERVER_CFN', + server.properties.get('software_config_transport')) + + self.assertTrue(stack.access_allowed('4567', 'server')) + self.assertFalse(stack.access_allowed('45678', 'server')) + self.assertFalse(stack.access_allowed('4567', 'wserver')) + if ret_tmpl: + return server, tmpl + else: + return server + + @mock.patch.object(heat_plugin.HeatClientPlugin, 'url_for') + def test_server_create_software_config(self, fake_url): + fake_url.return_value = 'the-cfn-url' + server = self._server_create_software_config() + + self.assertEqual({ + 'os-collect-config': { + 'cfn': { + 'access_key_id': '4567', + 'metadata_url': 'the-cfn-url/v1/', + 'path': 'server.Metadata', + 'secret_access_key': '8901', + 'stack_name': 'server_sc_s' + }, + 'collectors': ['ec2', 'cfn', 'local'] + }, + 'deployments': [] + }, server.metadata_get()) + + @mock.patch.object(heat_plugin.HeatClientPlugin, 'url_for') + def test_server_create_software_config_metadata(self, fake_url): + md = {'os-collect-config': {'polling_interval': 10}} + fake_url.return_value = 'the-cfn-url' + server = self._server_create_software_config(md=md) + + self.assertEqual({ + 'os-collect-config': { + 'cfn': { + 'access_key_id': '4567', + 'metadata_url': 'the-cfn-url/v1/', + 'path': 'server.Metadata', + 'secret_access_key': '8901', + 'stack_name': 'server_sc_s' + }, + 'collectors': ['ec2', 'cfn', 'local'], + 'polling_interval': 10 + }, + 'deployments': [] + }, server.metadata_get()) + + def _server_create_software_config_poll_heat(self, + server_name='server_heat', + md=None): + stack_name = '%s_s' % server_name + (tmpl, stack) = self._setup_test_stack(stack_name, server_heat_tmpl) + self.stack = stack + props = tmpl.t['resources']['server']['properties'] + props['software_config_transport'] = 'POLL_SERVER_HEAT' + if md is not None: + tmpl.t['resources']['server']['metadata'] = md + self.server_props = props + + resource_defns = tmpl.resource_definitions(stack) + server = deployed_server.DeployedServer( + 'server', resource_defns['server'], stack) + + scheduler.TaskRunner(server.create)() + self.assertEqual('1234', server._get_user_id()) + + self.assertTrue(stack.access_allowed('1234', 'server')) + self.assertFalse(stack.access_allowed('45678', 'server')) + self.assertFalse(stack.access_allowed('4567', 'wserver')) + return stack, server + + def test_server_create_software_config_poll_heat(self): + stack, server = self._server_create_software_config_poll_heat() + + self.assertEqual({ + 'os-collect-config': { + 'heat': { + 'auth_url': 'http://server.test:5000/v2.0', + 'password': server.password, + 'project_id': '8888', + 'resource_name': 'server', + 'stack_id': 'server_heat_s/%s' % stack.id, + 'user_id': '1234' + }, + 'collectors': ['ec2', 'heat', 'local'] + }, + 'deployments': [] + }, server.metadata_get()) + + def test_server_create_software_config_poll_heat_metadata(self): + md = {'os-collect-config': {'polling_interval': 10}} + stack, server = self._server_create_software_config_poll_heat(md=md) + + self.assertEqual({ + 'os-collect-config': { + 'heat': { + 'auth_url': 'http://server.test:5000/v2.0', + 'password': server.password, + 'project_id': '8888', + 'resource_name': 'server', + 'stack_id': 'server_heat_s/%s' % stack.id, + 'user_id': '1234' + }, + 'collectors': ['ec2', 'heat', 'local'], + 'polling_interval': 10 + }, + 'deployments': [] + }, server.metadata_get()) + + def _server_create_software_config_zaqar(self, + server_name='server_zaqar', + md=None): + stack_name = '%s_s' % server_name + (tmpl, stack) = self._setup_test_stack(stack_name, server_zaqar_tmpl) + self.stack = stack + props = tmpl.t['resources']['server']['properties'] + props['software_config_transport'] = 'ZAQAR_MESSAGE' + if md is not None: + tmpl.t['resources']['server']['metadata'] = md + self.server_props = props + + resource_defns = tmpl.resource_definitions(stack) + server = deployed_server.DeployedServer( + 'server', resource_defns['server'], stack) + + zcc = self.patchobject(zaqar.ZaqarClientPlugin, 'create_for_tenant') + zc = mock.Mock() + zcc.return_value = zc + queue = mock.Mock() + zc.queue.return_value = queue + scheduler.TaskRunner(server.create)() + + metadata_queue_id = server.data().get('metadata_queue_id') + md = server.metadata_get() + queue_id = md['os-collect-config']['zaqar']['queue_id'] + self.assertEqual(queue_id, metadata_queue_id) + + zc.queue.assert_called_once_with(queue_id) + queue.post.assert_called_once_with( + {'body': server.metadata_get(), 'ttl': 3600}) + + zc.queue.reset_mock() + + server._delete_queue() + + zc.queue.assert_called_once_with(queue_id) + zc.queue(queue_id).delete.assert_called_once_with() + return queue_id, server + + def test_server_create_software_config_zaqar(self): + queue_id, server = self._server_create_software_config_zaqar() + self.assertEqual({ + 'os-collect-config': { + 'zaqar': { + 'user_id': '1234', + 'password': server.password, + 'auth_url': 'http://server.test:5000/v2.0', + 'project_id': '8888', + 'queue_id': queue_id + }, + 'collectors': ['ec2', 'zaqar', 'local'] + }, + 'deployments': [] + }, server.metadata_get()) + + def test_server_create_software_config_zaqar_metadata(self): + md = {'os-collect-config': {'polling_interval': 10}} + queue_id, server = self._server_create_software_config_zaqar(md=md) + self.assertEqual({ + 'os-collect-config': { + 'zaqar': { + 'user_id': '1234', + 'password': server.password, + 'auth_url': 'http://server.test:5000/v2.0', + 'project_id': '8888', + 'queue_id': queue_id + }, + 'collectors': ['ec2', 'zaqar', 'local'], + 'polling_interval': 10 + }, + 'deployments': [] + }, server.metadata_get())