Browse Source

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
tags/12.0.0.0rc1
ricolin 1 year ago
parent
commit
6990331639
4 changed files with 159 additions and 16 deletions
  1. +78
    -10
      heat/engine/resources/openstack/heat/remote_stack.py
  2. +66
    -6
      heat/tests/openstack/heat/test_remote_stack.py
  3. +7
    -0
      heat_integrationtests/prepare_test_env.sh
  4. +8
    -0
      releasenotes/notes/Multi-OpenStack-Cloud-Support-b1ae023811d88854.yaml

+ 78
- 10
heat/engine/resources/openstack/heat/remote_stack.py View File

@@ -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 {

+ 66
- 6
heat/tests/openstack/heat/test_remote_stack.py View File

@@ -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',

+ 7
- 0
heat_integrationtests/prepare_test_env.sh View File

@@ -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

+ 8
- 0
releasenotes/notes/Multi-OpenStack-Cloud-Support-b1ae023811d88854.yaml View File

@@ -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.

Loading…
Cancel
Save