Cloud Foundry Service Broker API initial commit

In this commit we added new murano service called murano-cfapi.
It's Cloud Foundry Service Broker API implementation for OpenStack.

* add new entrypoint for murano-cfapi
* add provision/deprovision operations for Cloud Foundry.

partial-implement: bp cloudfoundry-api-support

Change-Id: I5cdb612774f5e46ec7fc787f961e5c7e16ed8501
This commit is contained in:
Nikolay Starodubtsev 2015-06-29 22:38:17 +03:00
parent 788cd5aa7a
commit 5e3b37aac8
13 changed files with 739 additions and 2 deletions

View File

@ -1,3 +1,6 @@
[pipeline:cloudfoundry]
pipeline = cloudfoundryapi
[pipeline:murano]
pipeline = request_id versionnegotiation faultwrap authtoken context rootapp
@ -20,6 +23,9 @@ paste.app_factory = murano.api.versions:create_resource
[app:apiv1app]
paste.app_factory = murano.api.v1.router:API.factory
[app:cloudfoundryapi]
paste.app_factory = murano.api.v1.cloudfoundry.router:API.factory
[filter:versionnegotiation]
paste.filter_factory = murano.api.middleware.version_negotiation:VersionNegotiationFilter.factory

View File

View File

@ -0,0 +1,30 @@
# Copyright (c) 2015 Mirantis, Inc.
#
# 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 keystoneclient.v3 import client
from oslo_config import cfg
from oslo_log import log as logging
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
def authenticate(user, password, tenant=None):
project_name = tenant or CONF.cfapi.tenant
keystone = client.Client(username=user,
password=password,
project_name=project_name,
auth_url=CONF.cfapi.auth_url.replace(
'v2.0', 'v3'))
return keystone

View File

@ -0,0 +1,249 @@
# Copyright (c) 2015 Mirantis, Inc.
#
# 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 base64
import json
import uuid
import muranoclient.client as client
from oslo_config import cfg
from oslo_log import log as logging
from webob import exc
from murano.api.v1.cloudfoundry import auth as keystone_auth
from murano.common.i18n import _LI, _LW
from murano.common import wsgi
from murano import context
from murano.db.catalog import api as db_api
from murano.db.services import cf_connections as db_cf
cfapi_opts = [
cfg.StrOpt('tenant', default='admin',
help=('Tenant for service broker')),
cfg.StrOpt('bind_host', default='localhost',
help=('host for service broker')),
cfg.StrOpt('bind_port', default='8083',
help=('host for service broker')),
cfg.StrOpt('auth_url', default='localhost:5000/v2.0')]
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
CONF.register_opts(cfapi_opts, group='cfapi')
class Controller(object):
"""WSGI controller for application catalog resource in Murano v1 API"""
def _package_to_service(self, package):
srv = {}
srv['id'] = package.id
srv['name'] = package.name
srv['description'] = package.description
srv['bindable'] = True
srv['tags'] = []
for tag in package.tags:
srv['tags'].append(tag.name)
plan = {'id': package.id + '-1',
'name': 'default',
'description': 'Default plan for the service {name}'.format(
name=package.name)}
srv['plans'] = [plan]
return srv
def _check_auth(self, req, tenant=None):
auth = req.headers.get('Authorization', None)
if auth is None:
raise exc.HTTPUnauthorized(explanation='Bad credentials')
auth_info = auth.split(' ')[1]
auth_decoded = base64.b64decode(auth_info)
user = auth_decoded.split(':')[0]
password = auth_decoded.split(':')[1]
if tenant:
keystone = keystone_auth.authenticate(user, password, tenant)
else:
keystone = keystone_auth.authenticate(user, password)
return (user, password, keystone)
def _make_service(self, name, package, plan_id):
id = uuid.uuid4().hex
return {"name": name,
"?": {plan_id: {"name": package.name},
"type": package.fully_qualified_name,
"id": id}}
def list(self, req):
user, passwd, keystone = self._check_auth(req)
# Once we get here we were authorized by keystone
token = keystone.auth_token
ctx = context.RequestContext(user=user, tenant='', auth_token=token)
packages = db_api.package_search({'type': 'application'}, ctx,
catalog=True)
services = []
for package in packages:
services.append(self._package_to_service(package))
resp = {'services': services}
return resp
def provision(self, req, body, instance_id):
"""Here is the example of request body given us from Cloud Foundry:
{
"service_id": "service-guid-here",
"plan_id": "plan-guid-here",
"organization_guid": "org-guid-here",
"space_guid": "space-guid-here",
"parameters": {"param1": "value1",
"param2": "value2"}
}
"""
data = json.loads(req.body)
space_guid = data['space_guid']
org_guid = data['organization_guid']
plan_id = data['plan_id']
service_id = data['service_id']
parameters = data['parameters']
self.current_session = None
# Here we'll take an entry for CF org and space from db. If we
# don't have any entries we will create it from scratch.
try:
tenant = db_cf.get_tenant_for_org(org_guid)
except AttributeError:
# FIXME(Kezar): need to find better way to get tenant
tenant = CONF.cfapi.tenant
db_cf.set_tenant_for_org(org_guid, tenant)
LOG.info(_LI("Cloud Foundry {org_id} mapped to tenant "
"{tenant_name}").format(org_id=org_guid,
tenant_name=tenant))
# Now as we have all parameters we can try to auth user in actual
# tenant
user, passwd, keystone = self._check_auth(req, tenant)
# Once we get here we were authorized by keystone
token = keystone.auth_token
m_cli = muranoclient(token)
try:
environment_id = db_cf.get_environment_for_space(space_guid)
except AttributeError:
body = {'name': 'my_{uuid}'.format(uuid=uuid.uuid4().hex)}
env = m_cli.environments.create(body)
environment_id = env.id
db_cf.set_environment_for_space(space_guid, environment_id)
LOG.info(_LI("Cloud Foundry {space_id} mapped to {environment_id}")
.format(space_id=space_guid,
environment_id=environment_id))
LOG.debug('Auth: %s' % keystone.auth_ref)
tenant_id = keystone.project_id
ctx = context.RequestContext(user=user, tenant=tenant_id)
package = db_api.package_get(service_id, ctx)
LOG.debug('Adding service {name}'.format(name=package.name))
service = self._make_service(space_guid, package, plan_id)
db_cf.set_instance_for_service(instance_id, service['?']['id'],
environment_id, tenant)
# NOTE(Kezar): Here we are going through JSON and add ids where
# it's necessary
params = [parameters]
while params:
a = params.pop()
for k, v in a.iteritems():
if isinstance(v, dict):
params.append(v)
if k == '?':
v['id'] = uuid.uuid4().hex
service.update(parameters)
# Now we need to obtain session to modify the env
session_id = create_session(m_cli, environment_id)
m_cli.services.post(environment_id,
path='/',
data=service,
session_id=session_id)
m_cli.sessions.deploy(environment_id, session_id)
self.current_session = session_id
return {}
def deprovision(self, req, instance_id):
service = db_cf.get_service_for_instance(instance_id)
if not service:
return {}
service_id = service.service_id
environment_id = service.environment_id
tenant = service.tenant
user, passwd, keystone = self._check_auth(req, tenant)
# Once we get here we were authorized by keystone
token = keystone.auth_token
m_cli = muranoclient(token)
try:
session_id = create_session(m_cli, environment_id)
except exc.HTTPForbidden:
# FIXME(Kezar): this is a temporary solution, should be replaced
# with 'incomplete' response for Cloud Foudry as soon as we will
# know which is right format for it.
LOG.warning(_LW("Can't create new session. Please remove service "
"manually in environment {0}")
.format(environment_id))
return {}
m_cli.services.delete(environment_id, '/' + service_id, session_id)
m_cli.sessions.deploy(environment_id, session_id)
return {}
def bind(self, req, instance_id, id):
pass
def unbind(self, req, instance_id, id):
pass
def get_last_operation(self):
"""Not implemented functionality
For some reason it's difficult to provide a valid JSON with the
response code which is needed for our broker to be true asynchronous.
In that case last_operation API call is not supported.
"""
raise NotImplementedError
def muranoclient(token_id):
endpoint = "http://{murano_host}:{murano_port}".format(
murano_host=CONF.bind_host, murano_port=CONF.bind_port)
insecure = False
LOG.debug('murano client created. Murano::Client <Url: {endpoint}'.format(
endpoint=endpoint))
return client.Client(1, endpoint=endpoint, token=token_id,
insecure=insecure)
def create_session(client, environment_id):
id = client.sessions.configure(environment_id).id
return id
def create_resource():
return wsgi.Resource(Controller())

View File

@ -0,0 +1,45 @@
# Copyright (c) 2015 Mirantis, Inc.
#
# 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 routes
from murano.api.v1.cloudfoundry import cfapi
from murano.common import wsgi
class API(wsgi.Router):
@classmethod
def factory(cls, global_conf, **local_conf):
return cls(routes.Mapper())
def __init__(self, mapper):
services_resource = cfapi.create_resource()
mapper.connect('/v2/catalog',
controller=services_resource,
action='list',
conditions={'method': ['GET']})
mapper.connect(('/v2/service_instances/{instance_id}'),
controller=services_resource,
action='provision',
conditions={'method': ['PUT']})
mapper.connect(('/v2/service_instances/{instance_id}'),
controller=services_resource,
action='deprovision',
conditions={'method': ['DELETE']})
mapper.connect(('/v2/service_instances/{instance_id}/last_operation'),
controller=services_resource,
action='get_last_operation',
conditions={'method': ['GET']})
super(API, self).__init__(mapper)

75
murano/cmd/cfapi.py Normal file
View File

@ -0,0 +1,75 @@
#!/usr/bin/env python
#
# Copyright (c) 2015 Mirantis, Inc.
#
# 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 os
import sys
import eventlet
if os.name == 'nt':
# eventlet monkey patching causes subprocess.Popen to fail on Windows
# when using pipes due to missing non blocking I/O support
eventlet.monkey_patch(os=False)
else:
eventlet.monkey_patch()
# If ../murano/__init__.py exists, add ../ to Python search path, so that
# it will override what happens to be installed in /usr/(local/)lib/python...
root = os.path.join(os.path.abspath(__file__), os.pardir, os.pardir, os.pardir)
if os.path.exists(os.path.join(root, 'murano', '__init__.py')):
sys.path.insert(0, root)
from oslo_config import cfg
from oslo_log import log as logging
from oslo_service import service
from murano.api.v1 import request_statistics
from murano.common import app_loader
from murano.common import config
from murano.common import policy
from murano.common import server
from murano.common import wsgi
CONF = cfg.CONF
def main():
try:
config.parse_args()
logging.setup(CONF, 'murano-cfapi')
request_statistics.init_stats()
policy.init()
launcher = service.ServiceLauncher(CONF)
cfapp = app_loader.load_paste_app('cloudfoundry')
cfport, cfhost = (config.CONF.cfapi.bind_port,
config.CONF.cfapi.bind_host)
launcher.launch_service(wsgi.Service(cfapp, cfport, cfhost))
launcher.launch_service(server.get_rpc_service())
launcher.launch_service(server.get_notification_service())
launcher.wait()
except RuntimeError as e:
sys.stderr.write("ERROR: %s\n" % e)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,68 @@
# 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.
"""
Revision ID: 009
Revises: None
Create Date: 2015-08-17 16:34:33.698760
"""
# revision identifiers, used by Alembic.
revision = '009'
down_revision = '008'
from alembic import op
import sqlalchemy as sa
MYSQL_ENGINE = 'InnoDB'
MYSQL_CHARSET = 'utf8'
def upgrade():
op.create_table(
'cf_orgs',
sa.Column('id', sa.String(length=255), nullable=False),
sa.Column('tenant', sa.String(length=255), nullable=False),
sa.UniqueConstraint('tenant'),
sa.PrimaryKeyConstraint('id'),
mysql_engine=MYSQL_ENGINE,
mysql_charset=MYSQL_CHARSET)
op.create_table(
'cf_spaces',
sa.Column('id', sa.String(length=255), nullable=False),
sa.Column('environment_id', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['environment_id'], ['environment.id'], ),
sa.PrimaryKeyConstraint('id'),
mysql_engine=MYSQL_ENGINE,
mysql_charset=MYSQL_CHARSET)
op.create_table(
'cf_serv_inst',
sa.Column('id', sa.String(length=255), primary_key=True),
sa.Column('service_id', sa.String(255), nullable=False),
sa.Column('environment_id', sa.String(255), nullable=False),
sa.Column('tenant', sa.String(255), nullable=False),
sa.ForeignKeyConstraint(['environment_id'], ['environment.id'],),
mysql_engine=MYSQL_ENGINE,
mysql_charset=MYSQL_CHARSET)
# end Alembic commands #
def downgrade():
op.drop_table('cf_orgs')
op.drop_table('cf_spaces')
op.drop_table('cf_serv_inst')
# end Alembic commands #

View File

@ -74,6 +74,13 @@ class Environment(Base, TimestampMixin):
tasks = sa_orm.relationship('Task', backref='environment',
cascade='save-update, merge, delete')
cf_spaces = sa_orm.relationship("CFSpace", backref='environment',
cascade='save-update, merge, delete')
cf_serv_inst = sa_orm.relationship("CFServiceInstance",
backref='environment',
cascade='save-update, merge, delete')
def to_dict(self):
dictionary = super(Environment, self).to_dict()
del dictionary['description']
@ -320,10 +327,45 @@ class Lock(Base):
ts = sa.Column(sa.DateTime, nullable=False)
class CFOrganization(Base):
__tablename__ = "cf_orgs"
id = sa.Column(sa.String(255), primary_key=True)
tenant = sa.Column(sa.String(255), nullable=False)
class CFSpace(Base):
__tablename__ = "cf_spaces"
id = sa.Column(sa.String(255), primary_key=True)
environment_id = sa.Column(sa.String(255), sa.ForeignKey('environment.id'),
nullable=False)
def to_dict(self):
dictionary = super(CFSpace, self).to_dict()
if 'environment' in dictionary:
del dictionary['environment']
return dictionary
class CFServiceInstance(Base):
__tablename__ = 'cf_serv_inst'
id = sa.Column(sa.String(255), primary_key=True)
service_id = sa.Column(sa.String(255), nullable=False)
environment_id = sa.Column(sa.String(255), sa.ForeignKey('environment.id'),
nullable=False)
tenant = sa.Column(sa.String(255), nullable=False)
def to_dict(self):
dictionary = super(CFSpace, self).to_dict()
if 'environment' in dictionary:
del dictionary['environment']
return dictionary
def register_models(engine):
"""Creates database tables for all models with the given engine."""
models = (Environment, Status, Session, Task,
ApiStats, Package, Category, Class, Instance, Lock)
ApiStats, Package, Category, Class, Instance, Lock, CFSpace,
CFOrganization)
for model in models:
model.metadata.create_all(engine)
@ -331,6 +373,7 @@ def register_models(engine):
def unregister_models(engine):
"""Drops database tables for all models with the given engine."""
models = (Environment, Status, Session, Task,
ApiStats, Package, Category, Class, Lock)
ApiStats, Package, Category, Class, Lock, CFOrganization,
CFSpace)
for model in models:
model.metadata.drop_all(engine)

View File

@ -0,0 +1,91 @@
# 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_db import exception
import sqlalchemy
from murano.db import models
from murano.db import session as db_session
def set_tenant_for_org(cf_org_id, tenant):
"""Store tenant-org link to db"""
unit = db_session.get_session()
try:
with unit.begin():
org = models.CFOrganization()
org.id = cf_org_id
org.tenant = tenant
unit.add(org)
except exception.DBDuplicateEntry:
unit.execute(sqlalchemy.update(models.CFOrganization).where(
models.CFOrganization.id == cf_org_id).values(
tenant=tenant))
def set_environment_for_space(cf_space_id, environment_id):
"""Store env-space link to db"""
unit = db_session.get_session()
try:
with unit.begin():
space = models.CFSpace()
space.id = cf_space_id
space.environment_id = environment_id
unit.add(space)
except exception.DBDuplicateEntry:
unit.execute(sqlalchemy.update(models.CFSpace).where(
models.CFSpace.id == cf_space_id).values(
environment_id=environment_id))
def set_instance_for_service(instance_id, service_id, environment_id, tenant):
"""Store env-space link to db"""
unit = db_session.get_session()
try:
with unit.begin():
connection = models.CFServiceInstance()
connection.id = instance_id
connection.service_id = service_id
connection.environment_id = environment_id
connection.tenant = tenant
unit.add(connection)
except exception.DBDuplicateEntry:
unit.execute(sqlalchemy.update(models.CFServiceInstance).where(
models.CFServiceInstance.id == instance_id).values(
environment_id=environment_id))
def get_environment_for_space(cf_space_id):
"""Take env id related to space from db"""
unit = db_session.get_session()
connection = unit.query(models.CFSpace).get(cf_space_id)
return connection.environment_id
def get_tenant_for_org(cf_org_id):
"""Take tenant id related to org from db"""
unit = db_session.get_session()
connection = unit.query(models.CFOrganization).get(cf_org_id)
return connection.tenant
def get_service_for_instance(instance_id):
unit = db_session.get_session()
connection = unit.query(models.CFServiceInstance).get(instance_id)
return connection
def delete_environment_from_space(environment_id):
unit = db_session.get_session()
unit.query(models.CFSpace).filter(
models.CFSpace.environment_id == environment_id).delete(
synchronize=False)

View File

@ -0,0 +1,129 @@
# Copyright (c) 2015 Mirantis, Inc.
#
# 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 base64
import json
import mock
from murano.api.v1.cloudfoundry import cfapi as api
from murano.tests.unit import base
class TestController(base.MuranoTestCase):
def setUp(self):
super(TestController, self).setUp()
self.controller = api.Controller()
self.request = mock.MagicMock()
self.request.headers = {'Authorization': 'Basic {encoded}'.format(
encoded=base64.b64encode('test:test'))}
@mock.patch('murano.common.policy.check_is_admin')
@mock.patch('murano.db.catalog.api.package_search')
@mock.patch('murano.api.v1.cloudfoundry.auth.authenticate')
def test_list(self, mock_auth, mock_db_search, mock_policy):
pkg0 = mock.MagicMock()
pkg0.id = 'xxx'
pkg0.name = 'foo'
pkg0.description = 'stub pkg'
mock_db_search.return_value = [pkg0]
answer = {'services': [{'bindable': True,
'description': pkg0.description,
'id': pkg0.id,
'name': pkg0.name,
'plans': [{'description': ('Default plan for '
'the service '
'{name}').format(
name=pkg0.name),
'id': 'xxx-1',
'name': 'default'}],
'tags': []}]}
resp = self.controller.list(self.request)
self.assertEqual(answer, resp)
@mock.patch('murano.common.policy.check_is_admin')
@mock.patch('murano.db.catalog.api.package_get')
@mock.patch('murano.api.v1.cloudfoundry.cfapi.muranoclient')
@mock.patch('murano.db.services.cf_connections.set_instance_for_service')
@mock.patch('murano.db.services.cf_connections.get_environment_for_space')
@mock.patch('murano.db.services.cf_connections.get_tenant_for_org')
@mock.patch('murano.api.v1.cloudfoundry.auth.authenticate')
def test_provision_from_scratch(self, mock_auth, mock_get_tenant,
mock_get_environment, mock_is, mock_client,
mock_package, mock_policy):
body = {"space_guid": "s1-p1",
"organization_guid": "o1-r1",
"plan_id": "p1-l1",
"service_id": "s1-e1",
"parameters": {'some_parameter': 'value',
'?': {}}}
self.request.body = json.dumps(body)
mock_get_environment.return_value = '555-555'
mock_client.return_value = mock.MagicMock()
mock_package.return_value = mock.MagicMock()
resp = self.controller.provision(self.request, {}, '111-111')
self.assertEqual({}, resp)
@mock.patch('murano.common.policy.check_is_admin')
@mock.patch('murano.db.catalog.api.package_get')
@mock.patch('murano.api.v1.cloudfoundry.cfapi.muranoclient')
@mock.patch('murano.db.services.cf_connections.set_instance_for_service')
@mock.patch('murano.db.services.cf_connections.set_environment_for_space')
@mock.patch('murano.db.services.cf_connections.set_tenant_for_org')
@mock.patch('murano.db.services.cf_connections.get_environment_for_space')
@mock.patch('murano.db.services.cf_connections.get_tenant_for_org')
@mock.patch('murano.api.v1.cloudfoundry.auth.authenticate')
def test_provision_existent(self, mock_auth, mock_get_tenant,
mock_get_environment, mock_set_tenant,
mock_set_environment, mock_is, mock_client,
mock_package, mock_policy):
body = {"space_guid": "s1-p1",
"organization_guid": "o1-r1",
"plan_id": "p1-l1",
"service_id": "s1-e1",
"parameters": {'some_parameter': 'value',
'?': {}}}
self.request.body = json.dumps(body)
mock_package.return_value = mock.MagicMock()
mock_get_environment.side_effect = AttributeError
mock_get_tenant.side_effect = AttributeError
resp = self.controller.provision(self.request, {}, '111-111')
self.assertEqual({}, resp)
@mock.patch('murano.api.v1.cloudfoundry.cfapi.muranoclient')
@mock.patch('murano.api.v1.cloudfoundry.auth.authenticate')
@mock.patch('murano.db.services.cf_connections.get_service_for_instance')
def test_deprovision(self, mock_get_si, mock_auth, mock_client):
service = mock.MagicMock()
service.service_id = '111-111'
service.tenant_id = '222-222'
service.env_id = '333-333'
mock_get_si.return_value = service
resp = self.controller.deprovision(self.request, '555-555')
self.assertEqual({}, resp)

View File

@ -47,6 +47,7 @@ console_scripts =
murano-manage = murano.cmd.manage:main
murano-db-manage = murano.cmd.db_manage:main
murano-test-runner = murano.cmd.test_runner:main
murano-cfapi = murano.cmd.cfapi:main
oslo.config.opts =
murano = murano.opts:list_opts