From 6990331639a96a7073f66a63c99f49d8f6c576b7 Mon Sep 17 00:00:00 2001 From: ricolin Date: Fri, 6 Jul 2018 13:27:33 +0800 Subject: [PATCH] Support remote stack with another OpenStack provider Allow OS::Heat::Stack to access remote stack from another OpenStack provider. Also enable functional tests for multi-cloud. Implement multi-cloud support as an extension to the existing multi-region support. Allow operate a remote stack (from another OpenStack cloud) as a resource in stack from local OpenStack cloud. I propose we add multi cloud support into ``OS::Heat::Stack`` and change the property schema for ``context``. Within context, we should adding following properties: * credential_secret_id: ID of Barbican Secret. Which stores authN information for remote cloud. Service will use auth information from Barbican Secret to access Orchestration service in another OpenStack. Must make sure you're able toget that secret from Barbican service when provide `credential_secret_id` property. Story: #2002126 Task: #26907 Depends-On: https://review.openstack.org/579750 Change-Id: I2f3de3e7c29cf7debb1474228c8a9a81725a72ed --- .../resources/openstack/heat/remote_stack.py | 88 ++++++++++++++++--- .../tests/openstack/heat/test_remote_stack.py | 72 +++++++++++++-- heat_integrationtests/prepare_test_env.sh | 7 ++ ...nStack-Cloud-Support-b1ae023811d88854.yaml | 8 ++ 4 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 releasenotes/notes/Multi-OpenStack-Cloud-Support-b1ae023811d88854.yaml diff --git a/heat/engine/resources/openstack/heat/remote_stack.py b/heat/engine/resources/openstack/heat/remote_stack.py index d51c9bf896..66c6b33396 100644 --- a/heat/engine/resources/openstack/heat/remote_stack.py +++ b/heat/engine/resources/openstack/heat/remote_stack.py @@ -14,6 +14,7 @@ from oslo_serialization import jsonutils import six +from heat.common import auth_plugin from heat.common import context from heat.common import exception from heat.common.i18n import _ @@ -22,6 +23,7 @@ from heat.engine import attributes from heat.engine import environment from heat.engine import properties from heat.engine import resource +from heat.engine import support from heat.engine import template @@ -48,21 +50,31 @@ class RemoteStack(resource.Resource): ) _CONTEXT_KEYS = ( - REGION_NAME + REGION_NAME, CREDENTIAL_SECRET_ID ) = ( - 'region_name' + 'region_name', 'credential_secret_id' ) properties_schema = { CONTEXT: properties.Schema( properties.Schema.MAP, _('Context for this stack.'), + update_allowed=True, schema={ REGION_NAME: properties.Schema( properties.Schema.STRING, _('Region name in which this stack will be created.'), - required=True, - ) + required=False, + ), + CREDENTIAL_SECRET_ID: properties.Schema( + properties.Schema.STRING, + _('A Barbican secret ID. The Barbican secret should ' + 'contain an OpenStack credential that can be used to ' + 'access a remote cloud.'), + required=False, + update_allowed=True, + support_status=support.SupportStatus(version='12.0.0'), + ), } ), TEMPLATE: properties.Schema( @@ -107,10 +119,45 @@ class RemoteStack(resource.Resource): ctx_props = self.properties.get(self.CONTEXT) if ctx_props: - self._region_name = ctx_props[self.REGION_NAME] + self._credential = ctx_props[self.CREDENTIAL_SECRET_ID] + self._region_name = ctx_props[self.REGION_NAME] if ctx_props[ + self.REGION_NAME] else self.context.region_name else: + self._credential = None self._region_name = self.context.region_name + if ctx_props and self._credential: + return self._prepare_cloud_context() + else: + return self._prepare_region_context() + + def _fetch_barbican_credential(self): + """Fetch credential information and return context dict.""" + + auth = super(RemoteStack, self).client_plugin( + 'barbican').get_secret_payload_by_ref( + secret_ref='secrets/%s' % (self._credential)) + return auth + + def _prepare_cloud_context(self): + """Prepare context for remote cloud.""" + + auth = self._fetch_barbican_credential() + dict_ctxt = self.context.to_dict() + dict_ctxt.update({ + 'request_id': dict_ctxt['request_id'], + 'global_request_id': dict_ctxt['global_request_id'], + 'show_deleted': dict_ctxt['show_deleted'] + }) + self._local_context = context.RequestContext.from_dict(dict_ctxt) + self._local_context._auth_plugin = ( + auth_plugin.get_keystone_plugin_loader( + auth, self._local_context.keystone_session)) + + return self._local_context + + def _prepare_region_context(self): + # Build RequestContext from existing one dict_ctxt = self.context.to_dict() dict_ctxt.update({'region_name': self._region_name, @@ -132,9 +179,13 @@ class RemoteStack(resource.Resource): try: self.heat() except Exception as ex: - exc_info = dict(region=self._region_name, exc=six.text_type(ex)) - msg = _('Cannot establish connection to Heat endpoint at region ' - '"%(region)s" due to "%(exc)s"') % exc_info + if self._credential: + location = "remote cloud" + else: + location = 'region "%s"' % self._region_name + exc_info = dict(location=location, exc=six.text_type(ex)) + msg = _('Cannot establish connection to Heat endpoint at ' + '%(location)s due to "%(exc)s"') % exc_info raise exception.StackValidationFailed(message=msg) try: @@ -148,9 +199,13 @@ class RemoteStack(resource.Resource): } self.heat().stacks.validate(**args) except Exception as ex: - exc_info = dict(region=self._region_name, exc=six.text_type(ex)) + if self._credential: + location = "remote cloud" + else: + location = 'region "%s"' % self._region_name + exc_info = dict(location=location, exc=six.text_type(ex)) msg = _('Failed validating stack template using Heat endpoint at ' - 'region "%(region)s" due to "%(exc)s"') % exc_info + '%(location)s due to "%(exc)s"') % exc_info raise exception.StackValidationFailed(message=msg) def handle_create(self): @@ -304,6 +359,19 @@ class RemoteStack(resource.Resource): def get_reference_id(self): return self.resource_id + def needs_replace_with_prop_diff(self, changed_properties_set, + after_props, before_props): + """Needs replace based on prop_diff.""" + + # If region_name changed, trigger UpdateReplace. + # `context` now set update_allowed=True, but `region_name` is not. + if self.CONTEXT in changed_properties_set and ( + after_props.get(self.CONTEXT).get( + 'region_name') != before_props.get(self.CONTEXT).get( + 'region_name')): + return True + return False + def resource_mapping(): return { diff --git a/heat/tests/openstack/heat/test_remote_stack.py b/heat/tests/openstack/heat/test_remote_stack.py index 728622cc67..e777f92a3d 100644 --- a/heat/tests/openstack/heat/test_remote_stack.py +++ b/heat/tests/openstack/heat/test_remote_stack.py @@ -12,16 +12,20 @@ # under the License. import collections +import json from heatclient import exc from heatclient.v1 import stacks +from keystoneauth1 import loading as ks_loading import mock from oslo_config import cfg import six from heat.common import exception from heat.common.i18n import _ +from heat.common import policy from heat.common import template_format +from heat.engine.clients.os import barbican as barbican_client from heat.engine.clients.os import heat_plugin from heat.engine import environment from heat.engine import node_data @@ -144,14 +148,18 @@ class RemoteStackTest(tests_common.HeatTestCase): self.addCleanup(unset_clients_property) - def initialize(self): - parent, rsrc = self.create_parent_stack(remote_region='RegionTwo') + def initialize(self, stack_template=None): + parent, rsrc = self.create_parent_stack(remote_region='RegionTwo', + stack_template=stack_template) self.parent = parent self.heat = rsrc._context().clients.client("heat") self.client_plugin = rsrc._context().clients.client_plugin('heat') - def create_parent_stack(self, remote_region=None, custom_template=None): - snippet = template_format.parse(parent_stack_template) + def create_parent_stack(self, remote_region=None, custom_template=None, + stack_template=None): + if not stack_template: + stack_template = parent_stack_template + snippet = template_format.parse(stack_template) self.files = { 'remote_template.yaml': custom_template or remote_template } @@ -196,13 +204,13 @@ class RemoteStackTest(tests_common.HeatTestCase): return parent, rsrc - def create_remote_stack(self): + def create_remote_stack(self, stack_template=None): # This method default creates a stack on RegionTwo (self.other_region) defaults = [get_stack(stack_status='CREATE_IN_PROGRESS'), get_stack(stack_status='CREATE_COMPLETE')] if self.parent is None: - self.initialize() + self.initialize(stack_template=stack_template) # prepare clients to return status self.heat.stacks.create.return_value = {'stack': get_stack().to_dict()} @@ -297,6 +305,58 @@ class RemoteStackTest(tests_common.HeatTestCase): self.heat.stacks.create.assert_called_with(**args) self.assertEqual(2, len(self.heat.stacks.get.call_args_list)) + def _create_with_remote_credential(self, credential_secret_id=None): + self.auth = ( + '{"auth_type": "v3applicationcredential", ' + '"auth": {"auth_url": "http://192.168.1.101/identity/v3", ' + '"application_credential_id": "9dfa187e5a354484bf9c49a2b674333a", ' + '"application_credential_secret": "sec"} }') + + t = template_format.parse(parent_stack_template) + properties = t['resources']['remote_stack']['properties'] + if credential_secret_id: + properties['context']['credential_secret_id'] = ( + credential_secret_id) + t = json.dumps(t) + self.patchobject(policy.Enforcer, 'check_is_admin') + self.m_gsbr = self.patchobject( + barbican_client.BarbicanClientPlugin, 'get_secret_payload_by_ref') + self.m_gsbr.return_value = self.auth + + rsrc = self.create_remote_stack(stack_template=t) + env = environment.get_child_environment(rsrc.stack.env, + {'name': 'foo'}) + args = { + 'stack_name': rsrc.physical_resource_name(), + 'template': template_format.parse(remote_template), + 'timeout_mins': 60, + 'disable_rollback': True, + 'parameters': {'name': 'foo'}, + 'files': self.files, + 'environment': env.user_env_as_dict(), + } + self.heat.stacks.create.assert_called_with(**args) + self.assertEqual(2, len(self.heat.stacks.get.call_args_list)) + rsrc.validate() + return rsrc + + def test_create_with_credential_secret_id(self): + self.m_plugin = mock.Mock() + self.m_loader = self.patchobject( + ks_loading, 'get_plugin_loader', return_value=self.m_plugin) + self._create_with_remote_credential('cred_2') + self.assertEqual( + [mock.call(secret_ref='secrets/cred_2')]*2, + self.m_gsbr.call_args_list) + expected_load_options = [ + mock.call( + application_credential_id='9dfa187e5a354484bf9c49a2b674333a', + application_credential_secret='sec', + auth_url='http://192.168.1.101/identity/v3')]*2 + + self.assertEqual(expected_load_options, + self.m_plugin.load_from_options.call_args_list) + def test_create_failed(self): returns = [get_stack(stack_status='CREATE_IN_PROGRESS'), get_stack(stack_status='CREATE_FAILED', diff --git a/heat_integrationtests/prepare_test_env.sh b/heat_integrationtests/prepare_test_env.sh index 2615833c3d..636972205d 100755 --- a/heat_integrationtests/prepare_test_env.sh +++ b/heat_integrationtests/prepare_test_env.sh @@ -80,6 +80,13 @@ function _config_tempest_plugin iniset $conf_file heat_plugin heat_config_notify_script $CONF_DEST/heat-agents/heat-config/bin/heat-config-notify iniset $conf_file heat_plugin boot_config_env $CONF_DEST/heat-templates/hot/software-config/boot-config/test_image_env.yaml + # support test multi-cloud + openstack application credential create heat_multicloud --secret secret --unrestricted + app_cred_id=$(openstack application credential show heat_multicloud|grep ' id '|awk '{print $4}') + export OS_CREDENTIAL_SECRET_ID=$(openstack secret store -n heat-multi-cloud-test-cred --payload '{"auth_type": "v3applicationcredential", "auth": {"auth_url": $OS_AUTH_URL, "application_credential_id": $app_cred_id, "application_credential_secret": "secret"}, "roles": ["admin"], "project_id": $app_cred_project_id}') + iniset $conf_file heat_features_enabled multi_cloud True + iniset $conf_file heat_plugin heat_plugin credential_secret_id $OS_CREDENTIAL_SECRET_ID + # Skip SoftwareConfigIntegrationTest because it requires a custom image # Skip VolumeBackupRestoreIntegrationTest skipped until failure rate can be reduced ref bug #1382300 # Skip AutoscalingLoadBalancerTest and AutoscalingLoadBalancerv2Test as deprecated neutron-lbaas service is not enabled diff --git a/releasenotes/notes/Multi-OpenStack-Cloud-Support-b1ae023811d88854.yaml b/releasenotes/notes/Multi-OpenStack-Cloud-Support-b1ae023811d88854.yaml new file mode 100644 index 0000000000..f26fef79cd --- /dev/null +++ b/releasenotes/notes/Multi-OpenStack-Cloud-Support-b1ae023811d88854.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Add multiple OpenStack orchestration support - User can now use + ``OS::Heat::Stack`` to create stack in another OpenStack cloud. + Must provide properties ``credential_secret_id`` in ``context``. + Remote stack resource will get authentication information from + cloud credential to refresh context before calling stack create.