Add Keystone V2 plugin
This adds the previous Keystone V2 client to be used as a plugin by clouds that have not upgraded to V3 yet. This replacement also raises NotSupported exceptions in methods that are V3 only. Co-Authored-By: Richard Lee <rblee88@gmail.com> Closes-Bug: #1274201 Change-Id: I97d3fe7e5ff52250c699c9b470d114e53888ef15
This commit is contained in:
parent
9b3fc1bc4e
commit
dc8d98dc6e
29
contrib/heat_keystoneclient_v2/README.md
Normal file
29
contrib/heat_keystoneclient_v2/README.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Heat Keystone V2
|
||||
|
||||
This plugin is a Keystone V2 compatible client. It can be used to
|
||||
replace the default client for clouds running older versions of
|
||||
Keystone.
|
||||
|
||||
Some forward compatibility decisions had to be made:
|
||||
|
||||
* Stack domain users are created as users on the stack owner's tenant
|
||||
rather than the stack's domain
|
||||
* Trusts are not supported
|
||||
|
||||
|
||||
# Installation
|
||||
|
||||
1. In `heat.conf`, add the path to the `heat_keystoneclient_v2` root
|
||||
directory to `plugin_dirs`.
|
||||
e.g.: `plugin_dirs=path/to/heat/contrib/heat_keystoneclient_v2`
|
||||
|
||||
2. Set the `keystone_backend` option to
|
||||
`heat.engine.plugins.heat_keystoneclient_v2.client.KeystoneClientV2`
|
||||
|
||||
|
||||
# How it works
|
||||
|
||||
By setting the `keystone_backend` option, the KeystoneBackend class in
|
||||
`heat/common/heat_keystoneclient.py` will instantiate the plugin
|
||||
KeystoneClientV2 class and use that instead of the default client in
|
||||
`heat/common/heat_keystoneclient.py`.
|
231
contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/client.py
Normal file
231
contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/client.py
Normal file
@ -0,0 +1,231 @@
|
||||
# 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.
|
||||
|
||||
from heat.common import exception
|
||||
|
||||
from keystoneclient.v2_0 import client as kc
|
||||
from oslo.config import cfg
|
||||
|
||||
from heat.openstack.common import importutils
|
||||
from heat.openstack.common import log as logging
|
||||
from heat.openstack.common.gettextutils import _
|
||||
|
||||
logger = logging.getLogger('heat.common.keystoneclient')
|
||||
logger.info(_("Keystone V2 loaded"))
|
||||
|
||||
|
||||
class KeystoneClientV2(object):
|
||||
"""
|
||||
Wrap keystone client so we can encapsulate logic used in resources
|
||||
Note this is intended to be initialized from a resource on a per-session
|
||||
basis, so the session context is passed in on initialization
|
||||
Also note that a copy of this is created every resource as self.keystone()
|
||||
via the code in engine/client.py, so there should not be any need to
|
||||
directly instantiate instances of this class inside resources themselves
|
||||
"""
|
||||
def __init__(self, context):
|
||||
# If a trust_id is specified in the context, we immediately
|
||||
# authenticate so we can populate the context with a trust token
|
||||
# otherwise, we delay client authentication until needed to avoid
|
||||
# unnecessary calls to keystone.
|
||||
#
|
||||
# Note that when you obtain a token using a trust, it cannot be
|
||||
# used to reauthenticate and get another token, so we have to
|
||||
# get a new trust-token even if context.auth_token is set.
|
||||
#
|
||||
# - context.auth_url is expected to contain the v2.0 keystone endpoint
|
||||
self.context = context
|
||||
self._client_v2 = None
|
||||
|
||||
if self.context.trust_id:
|
||||
# Create a connection to the v2 API, with the trust_id, this
|
||||
# populates self.context.auth_token with a trust-scoped token
|
||||
self._client_v2 = self._v2_client_init()
|
||||
|
||||
@property
|
||||
def client_v2(self):
|
||||
if not self._client_v2:
|
||||
self._client_v2 = self._v2_client_init()
|
||||
return self._client_v2
|
||||
|
||||
def _v2_client_init(self):
|
||||
kwargs = {
|
||||
'auth_url': self.context.auth_url
|
||||
}
|
||||
auth_kwargs = {}
|
||||
# Note try trust_id first, as we can't reuse auth_token in that case
|
||||
if self.context.trust_id is not None:
|
||||
# We got a trust_id, so we use the admin credentials
|
||||
# to authenticate, then re-scope the token to the
|
||||
# trust impersonating the trustor user.
|
||||
# Note that this currently requires the trustor tenant_id
|
||||
# to be passed to the authenticate(), unlike the v3 call
|
||||
kwargs.update(self._service_admin_creds())
|
||||
auth_kwargs['trust_id'] = self.context.trust_id
|
||||
auth_kwargs['tenant_id'] = self.context.tenant_id
|
||||
elif self.context.auth_token is not None:
|
||||
kwargs['tenant_name'] = self.context.tenant
|
||||
kwargs['token'] = self.context.auth_token
|
||||
elif self.context.password is not None:
|
||||
kwargs['username'] = self.context.username
|
||||
kwargs['password'] = self.context.password
|
||||
kwargs['tenant_name'] = self.context.tenant
|
||||
kwargs['tenant_id'] = self.context.tenant_id
|
||||
else:
|
||||
logger.error(_("Keystone v2 API connection failed, no password "
|
||||
"or auth_token!"))
|
||||
raise exception.AuthorizationFailure()
|
||||
kwargs['cacert'] = self._get_client_option('ca_file')
|
||||
kwargs['insecure'] = self._get_client_option('insecure')
|
||||
kwargs['cert'] = self._get_client_option('cert_file')
|
||||
kwargs['key'] = self._get_client_option('key_file')
|
||||
client_v2 = kc.Client(**kwargs)
|
||||
|
||||
client_v2.authenticate(**auth_kwargs)
|
||||
# If we are authenticating with a trust auth_kwargs are set, so set
|
||||
# the context auth_token with the re-scoped trust token
|
||||
if auth_kwargs:
|
||||
# Sanity check
|
||||
if not client_v2.auth_ref.trust_scoped:
|
||||
logger.error(_("v2 trust token re-scoping failed!"))
|
||||
raise exception.AuthorizationFailure()
|
||||
# All OK so update the context with the token
|
||||
self.context.auth_token = client_v2.auth_ref.auth_token
|
||||
self.context.auth_url = kwargs.get('auth_url')
|
||||
# Ensure the v2 API we're using is not impacted by keystone
|
||||
# bug #1239303, otherwise we can't trust the user_id
|
||||
if self.context.trustor_user_id != client_v2.auth_ref.user_id:
|
||||
logger.error("Trust impersonation failed, bug #1239303 "
|
||||
"suspected, you may need a newer keystone")
|
||||
raise exception.AuthorizationFailure()
|
||||
|
||||
return client_v2
|
||||
|
||||
@staticmethod
|
||||
def _service_admin_creds():
|
||||
# Import auth_token to have keystone_authtoken settings setup.
|
||||
importutils.import_module('keystoneclient.middleware.auth_token')
|
||||
|
||||
creds = {
|
||||
'username': cfg.CONF.keystone_authtoken.admin_user,
|
||||
'password': cfg.CONF.keystone_authtoken.admin_password,
|
||||
'auth_url': cfg.CONF.keystone_authtoken.auth_uri,
|
||||
'tenant_name': cfg.CONF.keystone_authtoken.admin_tenant_name,
|
||||
}
|
||||
|
||||
return creds
|
||||
|
||||
def _get_client_option(self, option):
|
||||
try:
|
||||
cfg.CONF.import_opt(option, 'heat.common.config',
|
||||
group='clients_keystone')
|
||||
return getattr(cfg.CONF.clients_keystone, option)
|
||||
except (cfg.NoSuchGroupError, cfg.NoSuchOptError):
|
||||
cfg.CONF.import_opt(option, 'heat.common.config', group='clients')
|
||||
return getattr(cfg.CONF.clients, option)
|
||||
|
||||
def create_stack_user(self, username, password=''):
|
||||
"""
|
||||
Create a user defined as part of a stack, either via template
|
||||
or created internally by a resource. This user will be added to
|
||||
the heat_stack_user_role as defined in the config
|
||||
Returns the keystone ID of the resulting user
|
||||
"""
|
||||
if(len(username) > 64):
|
||||
logger.warning(_("Truncating the username %s to the last 64 "
|
||||
"characters.") % username)
|
||||
#get the last 64 characters of the username
|
||||
username = username[-64:]
|
||||
user = self.client_v2.users.create(username,
|
||||
password,
|
||||
'%s@openstack.org' %
|
||||
username,
|
||||
tenant_id=self.context.tenant_id,
|
||||
enabled=True)
|
||||
|
||||
# We add the new user to a special keystone role
|
||||
# This role is designed to allow easier differentiation of the
|
||||
# heat-generated "stack users" which will generally have credentials
|
||||
# deployed on an instance (hence are implicitly untrusted)
|
||||
roles = self.client_v2.roles.list()
|
||||
stack_user_role = [r.id for r in roles
|
||||
if r.name == cfg.CONF.heat_stack_user_role]
|
||||
if len(stack_user_role) == 1:
|
||||
role_id = stack_user_role[0]
|
||||
logger.debug(_("Adding user %(user)s to role %(role)s") % {
|
||||
'user': user.id, 'role': role_id})
|
||||
self.client_v2.roles.add_user_role(user.id, role_id,
|
||||
self.context.tenant_id)
|
||||
else:
|
||||
logger.error(_("Failed to add user %(user)s to role %(role)s, "
|
||||
"check role exists!") % {'user': username,
|
||||
'role': cfg.CONF.heat_stack_user_role})
|
||||
|
||||
return user.id
|
||||
|
||||
def delete_stack_user(self, user_id):
|
||||
self.client_v2.users.delete(user_id)
|
||||
|
||||
def delete_ec2_keypair(self, user_id, accesskey):
|
||||
self.client_v2.ec2.delete(user_id, accesskey)
|
||||
|
||||
def get_ec2_keypair(self, access, user_id=None):
|
||||
uid = user_id or self.client_v2.auth_ref.user_id
|
||||
return self.client_v2.ec2.get(uid, access)
|
||||
|
||||
def create_ec2_keypair(self, user_id=None):
|
||||
uid = user_id or self.client_v2.auth_ref.user_id
|
||||
return self.client_v2.ec2.create(uid, self.context.tenant_id)
|
||||
|
||||
def disable_stack_user(self, user_id):
|
||||
self.client_v2.users.update_enabled(user_id, False)
|
||||
|
||||
def enable_stack_user(self, user_id):
|
||||
self.client_v2.users.update_enabled(user_id, True)
|
||||
|
||||
def url_for(self, **kwargs):
|
||||
return self.client_v2.service_catalog.url_for(**kwargs)
|
||||
|
||||
@property
|
||||
def auth_token(self):
|
||||
return self.client_v2.auth_token
|
||||
|
||||
# ##################### #
|
||||
# V3 Compatible Methods #
|
||||
# ##################### #
|
||||
|
||||
def create_stack_domain_user(self, username, project_id, password=None):
|
||||
return self.create_stack_user(username, password)
|
||||
|
||||
def delete_stack_domain_user(self, user_id, project_id):
|
||||
return self.delete_stack_user(user_id)
|
||||
|
||||
def create_stack_domain_project(self, project_id):
|
||||
'''Use the tenant ID as domain project.'''
|
||||
return self.context.tenant_id
|
||||
|
||||
def delete_stack_domain_project(self, project_id):
|
||||
'''Pass through method since no project was created.'''
|
||||
pass
|
||||
|
||||
# ###################### #
|
||||
# V3 Unsupported Methods #
|
||||
# ###################### #
|
||||
|
||||
def create_trust_context(self):
|
||||
raise exception.NotSupported(feature='Keystone Trusts')
|
||||
|
||||
def delete_trust(self, trust_id):
|
||||
raise exception.NotSupported(feature='Keystone Trusts')
|
@ -0,0 +1,253 @@
|
||||
# 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 mock
|
||||
import mox
|
||||
from oslo.config import cfg
|
||||
|
||||
from .. import client as heat_keystoneclient # noqa
|
||||
from heat.common import exception
|
||||
from heat.openstack.common import importutils
|
||||
from heat.tests.common import HeatTestCase
|
||||
from heat.tests import utils
|
||||
|
||||
|
||||
class KeystoneClientTest(HeatTestCase):
|
||||
"""Test cases for heat.common.heat_keystoneclient."""
|
||||
|
||||
def setUp(self):
|
||||
super(KeystoneClientTest, self).setUp()
|
||||
self.ctx = utils.dummy_context()
|
||||
|
||||
# Import auth_token to have keystone_authtoken settings setup.
|
||||
importutils.import_module('keystoneclient.middleware.auth_token')
|
||||
|
||||
dummy_url = 'http://server.test:5000/v2.0'
|
||||
cfg.CONF.set_override('auth_uri', dummy_url,
|
||||
group='keystone_authtoken')
|
||||
cfg.CONF.set_override('admin_user', 'heat',
|
||||
group='keystone_authtoken')
|
||||
cfg.CONF.set_override('admin_password', 'verybadpass',
|
||||
group='keystone_authtoken')
|
||||
cfg.CONF.set_override('admin_tenant_name', 'service',
|
||||
group='keystone_authtoken')
|
||||
self.addCleanup(self.m.VerifyAll)
|
||||
|
||||
def _stubs_v2(self, method='token', auth_ok=True, trust_scoped=True,
|
||||
user_id='trustor_user_id'):
|
||||
self.mock_ks_client = self.m.CreateMock(heat_keystoneclient.kc.Client)
|
||||
self.m.StubOutWithMock(heat_keystoneclient.kc, "Client")
|
||||
if method == 'token':
|
||||
heat_keystoneclient.kc.Client(
|
||||
auth_url=mox.IgnoreArg(),
|
||||
tenant_name='test_tenant',
|
||||
token='abcd1234',
|
||||
cacert=None,
|
||||
cert=None,
|
||||
insecure=False,
|
||||
key=None).AndReturn(self.mock_ks_client)
|
||||
self.mock_ks_client.authenticate().AndReturn(auth_ok)
|
||||
elif method == 'password':
|
||||
heat_keystoneclient.kc.Client(
|
||||
auth_url=mox.IgnoreArg(),
|
||||
tenant_name='test_tenant',
|
||||
tenant_id='test_tenant_id',
|
||||
username='test_username',
|
||||
password='password',
|
||||
cacert=None,
|
||||
cert=None,
|
||||
insecure=False,
|
||||
key=None).AndReturn(self.mock_ks_client)
|
||||
self.mock_ks_client.authenticate().AndReturn(auth_ok)
|
||||
if method == 'trust':
|
||||
heat_keystoneclient.kc.Client(
|
||||
auth_url='http://server.test:5000/v2.0',
|
||||
password='verybadpass',
|
||||
tenant_name='service',
|
||||
username='heat',
|
||||
cacert=None,
|
||||
cert=None,
|
||||
insecure=False,
|
||||
key=None).AndReturn(self.mock_ks_client)
|
||||
self.mock_ks_client.authenticate(trust_id='atrust123',
|
||||
tenant_id='test_tenant_id'
|
||||
).AndReturn(auth_ok)
|
||||
self.mock_ks_client.auth_ref = self.m.CreateMockAnything()
|
||||
self.mock_ks_client.auth_ref.trust_scoped = trust_scoped
|
||||
self.mock_ks_client.auth_ref.auth_token = 'atrusttoken'
|
||||
self.mock_ks_client.auth_ref.user_id = user_id
|
||||
|
||||
def test_username_length(self):
|
||||
"""Test that user names >64 characters are properly truncated."""
|
||||
|
||||
self._stubs_v2()
|
||||
|
||||
# a >64 character user name and the expected version
|
||||
long_user_name = 'U' * 64 + 'S'
|
||||
good_user_name = long_user_name[-64:]
|
||||
# mock keystone client user functions
|
||||
self.mock_ks_client.users = self.m.CreateMockAnything()
|
||||
mock_user = self.m.CreateMockAnything()
|
||||
# when keystone is called, the name should have been truncated
|
||||
# to the last 64 characters of the long name
|
||||
(self.mock_ks_client.users.create(good_user_name, 'password',
|
||||
mox.IgnoreArg(), enabled=True,
|
||||
tenant_id=mox.IgnoreArg())
|
||||
.AndReturn(mock_user))
|
||||
# mock out the call to roles; will send an error log message but does
|
||||
# not raise an exception
|
||||
self.mock_ks_client.roles = self.m.CreateMockAnything()
|
||||
self.mock_ks_client.roles.list().AndReturn([])
|
||||
self.m.ReplayAll()
|
||||
# call create_stack_user with a long user name.
|
||||
# the cleanup VerifyAll should verify that though we passed
|
||||
# long_user_name, keystone was actually called with a truncated
|
||||
# user name
|
||||
self.ctx.trust_id = None
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
heat_ks_client.create_stack_user(long_user_name, password='password')
|
||||
|
||||
def test_init_v2_password(self):
|
||||
"""Test creating the client, user/password context."""
|
||||
|
||||
self._stubs_v2(method='password')
|
||||
self.m.ReplayAll()
|
||||
|
||||
self.ctx.auth_token = None
|
||||
self.ctx.trust_id = None
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
self.assertIsNotNone(heat_ks_client.client_v2)
|
||||
|
||||
def test_init_v2_bad_nocreds(self):
|
||||
"""Test creating the client without trusts, no credentials."""
|
||||
|
||||
self.ctx.auth_token = None
|
||||
self.ctx.username = None
|
||||
self.ctx.password = None
|
||||
self.ctx.trust_id = None
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
self.assertRaises(exception.AuthorizationFailure,
|
||||
heat_ks_client._v2_client_init)
|
||||
|
||||
def test_trust_init(self):
|
||||
"""Test consuming a trust when initializing."""
|
||||
|
||||
self._stubs_v2(method='trust')
|
||||
self.m.ReplayAll()
|
||||
|
||||
self.ctx.username = None
|
||||
self.ctx.password = None
|
||||
self.ctx.auth_token = None
|
||||
self.ctx.trust_id = 'atrust123'
|
||||
self.ctx.trustor_user_id = 'trustor_user_id'
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
client_v2 = heat_ks_client.client_v2
|
||||
self.assertIsNotNone(client_v2)
|
||||
|
||||
def test_trust_init_fail(self):
|
||||
"""Test consuming a trust when initializing, error scoping."""
|
||||
|
||||
self._stubs_v2(method='trust', trust_scoped=False)
|
||||
self.m.ReplayAll()
|
||||
|
||||
self.ctx.username = None
|
||||
self.ctx.password = None
|
||||
self.ctx.auth_token = None
|
||||
self.ctx.trust_id = 'atrust123'
|
||||
self.ctx.trustor_user_id = 'trustor_user_id'
|
||||
self.assertRaises(exception.AuthorizationFailure,
|
||||
heat_keystoneclient.KeystoneClientV2, self.ctx)
|
||||
|
||||
def test_trust_init_fail_impersonation(self):
|
||||
"""Test consuming a trust when initializing, impersonation error."""
|
||||
|
||||
self._stubs_v2(method='trust', user_id='wrong_user_id')
|
||||
self.m.ReplayAll()
|
||||
|
||||
self.ctx.username = 'heat'
|
||||
self.ctx.password = None
|
||||
self.ctx.auth_token = None
|
||||
self.ctx.trust_id = 'atrust123'
|
||||
self.ctx.trustor_user_id = 'trustor_user_id'
|
||||
self.assertRaises(exception.AuthorizationFailure,
|
||||
heat_keystoneclient.KeystoneClientV2, self.ctx)
|
||||
|
||||
def test_trust_init_pw(self):
|
||||
"""Test trust_id is takes precedence username/password specified."""
|
||||
|
||||
self._stubs_v2(method='trust')
|
||||
self.m.ReplayAll()
|
||||
|
||||
self.ctx.auth_token = None
|
||||
self.ctx.trust_id = 'atrust123'
|
||||
self.ctx.trustor_user_id = 'trustor_user_id'
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
self.assertIsNotNone(heat_ks_client._client_v2)
|
||||
|
||||
def test_trust_init_token(self):
|
||||
"""Test trust_id takes precedence when token specified."""
|
||||
|
||||
self._stubs_v2(method='trust')
|
||||
self.m.ReplayAll()
|
||||
|
||||
self.ctx.username = None
|
||||
self.ctx.password = None
|
||||
self.ctx.trust_id = 'atrust123'
|
||||
self.ctx.trustor_user_id = 'trustor_user_id'
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
self.assertIsNotNone(heat_ks_client._client_v2)
|
||||
|
||||
# ##################### #
|
||||
# V3 Compatible Methods #
|
||||
# ##################### #
|
||||
|
||||
def test_create_stack_domain_user_pass_through_to_create_stack_user(self):
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
mock_create_stack_user = mock.Mock()
|
||||
heat_ks_client.create_stack_user = mock_create_stack_user
|
||||
heat_ks_client.create_stack_domain_user('username', 'project_id',
|
||||
'password')
|
||||
mock_create_stack_user.assert_called_once_with('username', 'password')
|
||||
|
||||
def test_delete_stack_domain_user_pass_through_to_delete_stack_user(self):
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
mock_delete_stack_user = mock.Mock()
|
||||
heat_ks_client.delete_stack_user = mock_delete_stack_user
|
||||
heat_ks_client.delete_stack_domain_user('user_id', 'project_id')
|
||||
mock_delete_stack_user.assert_called_once_with('user_id')
|
||||
|
||||
def test_create_stack_domain_project(self):
|
||||
tenant_id = self.ctx.tenant_id
|
||||
ks = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
self.assertEqual(tenant_id, ks.create_stack_domain_project('fakeid'))
|
||||
|
||||
def test_delete_stack_domain_project(self):
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
self.assertIsNone(heat_ks_client.delete_stack_domain_project('fakeid'))
|
||||
|
||||
# ###################### #
|
||||
# V3 Unsupported Methods #
|
||||
# ###################### #
|
||||
|
||||
def test_create_trust_context(self):
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
self.assertRaises(exception.NotSupported,
|
||||
heat_ks_client.create_trust_context)
|
||||
|
||||
def test_delete_trust(self):
|
||||
heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx)
|
||||
self.assertRaises(exception.NotSupported,
|
||||
heat_ks_client.delete_trust,
|
||||
'fake_trust_id')
|
Loading…
Reference in New Issue
Block a user