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.