Code improvements after the dynamic actions patch

* Style improvements to make sure the code is compliant with the
  coding guidelines
* Fixing small but important things like the mismatch between the
  sinatures of the methods find_all() in the DynamicActionProvider
  class and the base ActionProvider interface.
* Improved the tests.
* Simplified the implementation of DynamicActionProvider.

Change-Id: Idbfb15b4c3bb415e7fa9c7ece27eabfe674b6059
This commit is contained in:
Renat Akhmerov 2020-11-19 17:00:31 +07:00
parent 9be4f8e119
commit f78f33507e
11 changed files with 203 additions and 234 deletions

View File

@ -13,24 +13,22 @@
# limitations under the License.
import collections
from oslo_config import cfg
import types
from mistral import exceptions as exc
from mistral_lib import actions as ml_actions
from mistral_lib import serialization
from mistral_lib.utils import inspect_utils
from mistral.db.v2 import api as db_api
from mistral.services import code_sources as code_sources_service
CONF = cfg.CONF
class DynamicAction(ml_actions.Action):
def __init__(self, action, code_source_id, namespace=''):
super(DynamicAction, self).__init__()
self.action = action
self.namespace = namespace
self.code_source_id = code_source_id
@ -47,7 +45,7 @@ class DynamicAction(ml_actions.Action):
class DynamicActionDescriptor(ml_actions.PythonActionDescriptor):
def __init__(self, name, cls_name, action_cls, version, code_source_id,
def __init__(self, name, cls_name, action_cls, code_source_id, version,
action_cls_attrs=None, namespace='', project_id=None,
scope=None):
super(DynamicActionDescriptor, self).__init__(
@ -60,8 +58,8 @@ class DynamicActionDescriptor(ml_actions.PythonActionDescriptor):
)
self.cls_name = cls_name
self.version = version
self.code_source_id = code_source_id
self.version = version
def __repr__(self):
return 'Dynamic action [name=%s, cls=%s , code_source_id=%s,' \
@ -78,7 +76,8 @@ class DynamicActionDescriptor(ml_actions.PythonActionDescriptor):
return DynamicAction(
self._action_cls(**params),
self.code_source_id,
self.namespace)
self.namespace
)
dynamic_cls = type(
self._action_cls.__name__,
@ -108,7 +107,7 @@ class DynamicActionSerializer(serialization.DictBasedSerializer):
def deserialize_from_dict(self, entity_dict):
cls_name = entity_dict['cls_name']
mod = _get_module(
mod = _get_python_module(
entity_dict['code_source_id'],
entity_dict['namespace']
)
@ -132,18 +131,18 @@ class DynamicActionSerializer(serialization.DictBasedSerializer):
)
def _get_module(code_source_id, namespace=''):
code_source = code_sources_service.get_code_source(
def _get_python_module(code_source_id, namespace=''):
code_source = db_api.get_code_source(
code_source_id,
namespace
namespace=namespace
)
mod = _load_module(code_source.name, code_source.src)
mod = _load_python_module(code_source.name, code_source.src)
return mod, code_source.version
def _load_module(fullname, content):
def _load_python_module(fullname, content):
mod = types.ModuleType(fullname)
exec(content, mod.__dict__)
@ -160,157 +159,73 @@ class DynamicActionProvider(ml_actions.ActionProvider):
def __init__(self, name='dynamic'):
super().__init__(name)
self._action_descs = collections.OrderedDict()
self._code_sources = collections.OrderedDict()
# {code_source_id => (python module, version)}
self._code_sources = dict()
def _get_code_source_version(self, code_src_id, namespace=''):
code_src = code_sources_service.get_code_source(
code_src_id,
namespace,
fields=['version']
)
def ensure_latest_module_version(self, action_def):
# We need to compare the version of the corresponding module
# that's already loaded into memory with the version stored in
# DB and reimport the module if the DB version is higher.
return code_src[0]
code_src_id = action_def.code_source_id
def _load_code_source(self, id):
mod_pair = _get_module(id)
self._code_sources[id] = mod_pair
# TODO(rakhmerov): To avoid this DB call we need to store code source
# versions also in the dynamic action definition model.
db_ver = db_api.get_code_source(code_src_id, fields=['version'])[0]
return mod_pair
if db_ver > self._code_sources.get(code_src_id, (None, -1))[1]:
# Reload module.
code_src = db_api.get_code_source(code_src_id)
def _get_code_source(self, id):
mod_pair = self._code_sources.get(id)
code_src_db_version = self._get_code_source_version(id)
module = _load_python_module(code_src.name, code_src.src)
if not mod_pair or mod_pair[1] != code_src_db_version:
mod_pair = self._load_code_source(id)
return mod_pair
def _get_action_from_db(self, name, namespace, fields=()):
action = None
try:
action = db_api.get_dynamic_action(
identifier=name,
namespace=namespace,
fields=fields
)
except exc.DBEntityNotFoundError:
pass
return action
def _action_exists_in_db(self, name, namespace):
action = self._get_action_from_db(
name,
namespace,
fields=['name']
)
return action is not None
def _reload_action(self, action_desc, mod_pair):
action_desc._action_cls = getattr(
mod_pair[0],
action_desc.cls_name
)
action_desc.version = mod_pair[1]
def _load_new_action(self, action_name, namespace, action_def):
# only query the db if action_def was None
action_def = action_def or self._get_action_from_db(
action_name,
namespace=namespace
)
if not action_def:
return
mod_pair = self._get_code_source(action_def.code_source_id)
cls = getattr(mod_pair[0], action_def.class_name)
action_desc = DynamicActionDescriptor(
name=action_def.name,
action_cls=cls,
cls_name=action_def.class_name,
version=1,
code_source_id=action_def.code_source_id
)
self._action_descs[(action_name, namespace)] = action_desc
return action_desc
def _load_existing_action(self, action_desc, action_name, namespace):
if not self._action_exists_in_db(action_name, namespace=namespace):
# deleting action from cache
del self._action_descs[(action_name, namespace)]
return
mod_pair = self._get_code_source(action_desc.code_source_id)
if action_desc.version != mod_pair[1]:
self._reload_action(action_desc, mod_pair)
return action_desc
def _load_action(self, action_name, namespace=None, action_def=None):
action_desc = self._action_descs.get((action_name, namespace))
if action_desc:
action_desc = self._load_existing_action(
action_desc,
action_name,
namespace
)
self._code_sources[code_src_id] = (module, code_src.version)
else:
action_desc = self._load_new_action(
action_name,
namespace,
action_def
)
module = self._code_sources[code_src_id][0]
return action_desc
return module
def _get_action_class(self, action_def):
module = self.ensure_latest_module_version(action_def)
return getattr(module, action_def.class_name)
def _build_action_descriptor(self, action_def):
action_cls = self._get_action_class(action_def)
return DynamicActionDescriptor(
name=action_def.name,
cls_name=action_def.class_name,
action_cls=action_cls,
code_source_id=action_def.code_source_id,
version=1,
project_id=action_def.project_id,
scope=action_def.scope
)
def find(self, action_name, namespace=None):
action_def = db_api.load_dynamic_action_definition(
action_name,
namespace
)
return self._load_action(action_name, namespace)
if action_def is None:
return None
def _clean_deleted_actions_from_cache(self):
to_delete = [
key for key in self._action_descs.keys()
if not self._action_exists_in_db(*key)
]
for key in to_delete:
del self._action_descs[key]
return self._build_action_descriptor(action_def)
def find_all(self, namespace='', limit=None, sort_fields=None,
sort_dirs=None, **filters):
filters = {
'namespace': {'eq': namespace}
}
self._clean_deleted_actions_from_cache()
sort_dirs=None, filters=None):
if filters is None:
filters = dict()
actions = db_api.get_dynamic_actions(
filters['namespace'] = {'eq': namespace}
action_defs = db_api.get_dynamic_action_definitions(
limit=limit,
sort_keys=sort_fields,
sort_dirs=sort_dirs,
**filters
)
for action in actions:
self._load_action(
action.name,
namespace=namespace,
action_def=action
)
return dict(filter(
lambda elem: elem[0][1] == namespace,
self._action_descs.items())
)
return [self._build_action_descriptor(a_d) for a_d in action_defs]

View File

@ -42,19 +42,19 @@ class DynamicActionsController(rest.RestController, hooks.HookController):
@rest_utils.wrap_pecan_controller_exception
@pecan.expose(content_type="text/plain")
def post(self, namespace=''):
"""Creates new Actions.
"""Creates new dynamic actions.
:param namespace: Optional. The namespace to create the actions in.
The text is allowed to have multiple Actions, In such case,
they all will be created.
The text is allowed to have multiple actions. In such case, they all
will be created.
"""
acl.enforce('dynamic_actions:create', context.ctx())
actions = safe_yaml.load(pecan.request.text)
LOG.debug(
'Creating Actions with names: %s in namespace:[%s]',
'Creating dynamic actions with names: %s in namespace:[%s]',
actions,
namespace
)
@ -66,8 +66,7 @@ class DynamicActionsController(rest.RestController, hooks.HookController):
for action in actions_db
]
return resources.DynamicActions(
dynamic_actions=actions_list).to_json()
return resources.DynamicActions(dynamic_actions=actions_list).to_json()
@wsme_pecan.wsexpose(resources.DynamicActions, types.uuid, int,
types.uniquelist, types.list, types.uniquelist,
@ -138,8 +137,8 @@ class DynamicActionsController(rest.RestController, hooks.HookController):
return rest_utils.get_all(
resources.DynamicActions,
resources.DynamicAction,
db_api.get_dynamic_actions,
db_api.get_dynamic_action,
db_api.get_dynamic_action_definitions,
db_api.get_dynamic_action_definition,
marker=marker,
limit=limit,
sort_keys=sort_keys,
@ -223,5 +222,4 @@ class DynamicActionsController(rest.RestController, hooks.HookController):
for action in actions_db
]
return resources.DynamicActions(
dynamic_actions=actions_list).to_json()
return resources.DynamicActions(dynamic_actions=actions_list).to_json()

View File

@ -53,7 +53,7 @@ def upgrade():
)
op.create_table(
'dynamic_actions',
'dynamic_action_definitions',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
@ -74,7 +74,7 @@ def upgrade():
sa.UniqueConstraint('name', 'namespace', 'project_id'),
sa.Index('dynamic_actions_project_id', 'project_id'),
sa.Index('dynamic_actions_scope', 'scope'),
sa.Index('dynamic_action_definitions_project_id', 'project_id'),
sa.Index('dynamic_action_definitions_scope', 'scope'),
)

View File

@ -170,25 +170,31 @@ def delete_workflow_definitions(**kwargs):
IMPL.delete_workflow_definitions(**kwargs)
def create_dynamic_action(values):
return IMPL.create_dynamic_action(values)
# Dynamic actions.
def get_dynamic_action_definition(identifier, namespace='', fields=()):
return IMPL.get_dynamic_action_definition(identifier, fields, namespace)
def delete_dynamic_action(identifier, namespace=''):
return IMPL.delete_dynamic_action(identifier, namespace)
def load_dynamic_action_definition(identifier, namespace='', fields=()):
return IMPL.load_dynamic_action_definition(identifier, fields, namespace)
def update_dynamic_action(identifier, values, namespace=''):
return IMPL.update_dynamic_action(identifier, values, namespace)
def create_dynamic_action_definition(values):
return IMPL.create_dynamic_action_definition(values)
def get_dynamic_action(identifier, namespace='', fields=()):
return IMPL.get_dynamic_action(identifier, fields, namespace)
def delete_dynamic_action_definition(identifier, namespace=''):
return IMPL.delete_dynamic_action_definition(identifier, namespace)
def get_dynamic_actions(limit=None, marker=None, sort_keys=None,
sort_dirs=None, fields=None, **kwargs):
return IMPL.get_dynamic_actions(
def update_dynamic_action_definition(identifier, values, namespace=''):
return IMPL.update_dynamic_action_definition(identifier, values, namespace)
def get_dynamic_action_definitions(limit=None, marker=None, sort_keys=None,
sort_dirs=None, fields=None, **kwargs):
return IMPL.get_dynamic_action_definitions(
limit=limit,
marker=marker,
sort_keys=sort_keys,
@ -198,10 +204,24 @@ def get_dynamic_actions(limit=None, marker=None, sort_keys=None,
)
# Code sources.
def get_code_source(identifier, namespace='', fields=()):
return IMPL.get_code_source(identifier, fields, namespace=namespace)
def create_code_source(values):
return IMPL.create_code_source(values)
def update_code_source(identifier, values, namespace=''):
return IMPL.update_code_source(identifier, values, namespace=namespace)
def delete_code_source(name, namespace=''):
return IMPL.delete_code_source(name, namespace=namespace)
def get_code_sources(limit=None, marker=None, sort_keys=None,
sort_dirs=None, fields=None, **kwargs):
return IMPL.get_code_sources(
@ -214,17 +234,7 @@ def get_code_sources(limit=None, marker=None, sort_keys=None,
)
def delete_code_source(name, namespace=''):
return IMPL.delete_code_source(name, namespace=namespace)
def get_code_source(identifier, namespace='', fields=()):
return IMPL.get_code_source(identifier, fields, namespace=namespace)
def create_code_source(values):
return IMPL.create_code_source(values)
# Action definitions.
def get_action_definition_by_id(id, fields=()):
return IMPL.get_action_definition_by_id(id, fields=fields)

View File

@ -640,7 +640,7 @@ def delete_workflow_definitions(session=None, **kwargs):
return _delete_all(models.WorkflowDefinition, **kwargs)
# Action definitions.
# Code sources.
@b.session_aware()
def create_code_source(values, session=None):
@ -709,9 +709,11 @@ def delete_code_source(identifier, namespace='', session=None):
session.delete(code_src)
# Dynamic actions.
@b.session_aware()
def create_dynamic_action(values, session=None):
action_def = models.DynamicAction()
def create_dynamic_action_definition(values, session=None):
action_def = models.DynamicActionDefinition()
action_def.update(values.copy())
@ -719,7 +721,8 @@ def create_dynamic_action(values, session=None):
action_def.save(session=session)
except db_exc.DBDuplicateEntry:
raise exc.DBDuplicateEntryError(
"Duplicate entry for Action[name=%s, namespace=%s, project_id=%s]"
"Duplicate entry for dynamic action definition "
"[name=%s, namespace=%s, project_id=%s]"
% (action_def.name, action_def.namespace, action_def.project_id)
)
@ -727,8 +730,9 @@ def create_dynamic_action(values, session=None):
@b.session_aware()
def update_dynamic_action(identifier, values, namespace='', session=None):
action_def = get_dynamic_action(identifier, namespace=namespace)
def update_dynamic_action_definition(identifier, values, namespace='',
session=None):
action_def = get_dynamic_action_definition(identifier, namespace=namespace)
action_def.update(values.copy())
@ -736,9 +740,10 @@ def update_dynamic_action(identifier, values, namespace='', session=None):
@b.session_aware()
def get_dynamic_action(identifier, fields=(), namespace='', session=None):
def get_dynamic_action_definition(identifier, fields=(), namespace='',
session=None):
action = _get_db_object_by_name_and_namespace_or_id(
models.DynamicAction,
models.DynamicActionDefinition,
identifier,
namespace=namespace,
columns=fields
@ -754,21 +759,34 @@ def get_dynamic_action(identifier, fields=(), namespace='', session=None):
@b.session_aware()
def get_dynamic_actions(fields=None, session=None, **kwargs):
def load_dynamic_action_definition(identifier, fields=(), namespace='',
session=None):
return _get_db_object_by_name_and_namespace_or_id(
models.DynamicActionDefinition,
identifier,
namespace=namespace,
columns=fields
)
@b.session_aware()
def get_dynamic_action_definitions(fields=None, session=None, **kwargs):
return _get_collection(
model=models.DynamicAction,
model=models.DynamicActionDefinition,
fields=fields,
**kwargs
)
@b.session_aware()
def delete_dynamic_action(identifier, namespace='', session=None):
action_def = get_dynamic_action(identifier, namespace)
print(action_def)
def delete_dynamic_action_definition(identifier, namespace='', session=None):
action_def = get_dynamic_action_definition(identifier, namespace)
session.delete(action_def)
# Action definitions.
@b.session_aware()
def get_action_definition_by_id(id, fields=(), session=None):
action_def = _get_db_object_by_id(
@ -793,6 +811,7 @@ def get_action_definition(identifier, fields=(), session=None, namespace=''):
namespace=namespace,
columns=fields
)
# If the action was not found in the given namespace,
# look in the default namespace
if not a_def:

View File

@ -200,7 +200,17 @@ class CodeSource(mb.MistralSecureModelBase):
"""Contains info about registered CodeSources."""
__tablename__ = 'code_sources'
__table_args__ = (
sa.UniqueConstraint(
'name',
'namespace',
'project_id'
),
sa.Index('%s_project_id' % __tablename__, 'project_id'),
sa.Index('%s_scope' % __tablename__, 'scope'),
)
# Main properties.
id = mb.id_column()
name = sa.Column(sa.String(255))
src = sa.Column(sa.Text())
@ -208,45 +218,36 @@ class CodeSource(mb.MistralSecureModelBase):
namespace = sa.Column(sa.String(255), nullable=True)
tags = sa.Column(st.JsonListType())
class DynamicActionDefinition(mb.MistralSecureModelBase):
"""Contains info about registered Dynamic Actions."""
__tablename__ = 'dynamic_action_definitions'
__table_args__ = (
sa.UniqueConstraint(
'name',
'namespace',
'project_id'),
'project_id'
),
sa.Index('%s_project_id' % __tablename__, 'project_id'),
sa.Index('%s_scope' % __tablename__, 'scope'),
)
class DynamicAction(mb.MistralSecureModelBase):
"""Contains info about registered Dynamic Actions."""
__tablename__ = 'dynamic_actions'
# Main properties.
id = mb.id_column()
name = sa.Column(sa.String(255))
namespace = sa.Column(sa.String(255), nullable=True)
class_name = sa.Column(sa.String(255))
__table_args__ = (
sa.UniqueConstraint(
'name',
'namespace',
'project_id'),
sa.Index('%s_project_id' % __tablename__, 'project_id'),
sa.Index('%s_scope' % __tablename__, 'scope'),
)
DynamicAction.code_source_id = sa.Column(
DynamicActionDefinition.code_source_id = sa.Column(
sa.String(36),
sa.ForeignKey(CodeSource.id, ondelete='CASCADE'),
nullable=False
)
# Execution objects.
# Execution objects.
class Execution(mb.MistralSecureModelBase):
__abstract__ = True

View File

@ -72,9 +72,7 @@ def update_code_source(identifier, src_code, namespace=''):
return db_api.update_code_source(
identifier=identifier,
namespace=namespace,
values={
'src': src_code,
}
values={'src': src_code}
)

View File

@ -23,10 +23,11 @@ LOG = logging.getLogger(__name__)
def create_dynamic_actions(action_list, namespace=''):
created_actions = []
with db_api.transaction():
for action in action_list:
created_actions.append(
db_api.create_dynamic_action({
db_api.create_dynamic_action_definition({
'name': action['name'],
'class_name': action['class_name'],
'namespace': namespace,
@ -39,7 +40,7 @@ def create_dynamic_actions(action_list, namespace=''):
def delete_dynamic_action(identifier, namespace=''):
with db_api.transaction():
return db_api.delete_dynamic_action(
return db_api.delete_dynamic_action_definition(
identifier,
namespace
)
@ -48,7 +49,7 @@ def delete_dynamic_action(identifier, namespace=''):
def get_dynamic_actions(limit=None, marker=None, sort_keys=None,
sort_dirs=None, fields=None, **kwargs):
with db_api.transaction():
return db_api.get_dynamic_actions(
return db_api.get_dynamic_action_definitions(
limit=limit,
marker=marker,
sort_keys=sort_keys,
@ -60,7 +61,7 @@ def get_dynamic_actions(limit=None, marker=None, sort_keys=None,
def get_dynamic_action(identifier, namespace=''):
with db_api.transaction():
return db_api.get_dynamic_action(
return db_api.get_dynamic_action_definition(
identifier,
namespace=namespace
)
@ -68,7 +69,7 @@ def get_dynamic_action(identifier, namespace=''):
def update_dynamic_action(identifier, values, namespace=''):
with db_api.transaction():
return db_api.update_dynamic_action(
return db_api.update_dynamic_action_definition(
identifier,
values,
namespace

View File

@ -36,7 +36,6 @@ NAMESPACE = "ns"
class DynamicActionProviderTest(base.DbTestCase):
def _create_code_source(self, namespace=''):
return code_sources_service.create_code_source(
name='code_source',
@ -67,7 +66,7 @@ class DynamicActionProviderTest(base.DbTestCase):
namespace=namespace
)
def test_Dynamic_actions(self):
def test_dynamic_actions(self):
provider = dynamic_action.DynamicActionProvider()
action_descs = provider.find_all()
@ -75,6 +74,7 @@ class DynamicActionProviderTest(base.DbTestCase):
self.assertEqual(0, len(action_descs))
code_source = self._create_code_source()
self._create_dynamic_actions(code_source_id=code_source['id'])
action_descs = provider.find_all()
@ -91,6 +91,7 @@ class DynamicActionProviderTest(base.DbTestCase):
self.assertEqual(0, len(action_descs))
code_source = self._create_code_source()
self._create_dynamic_actions(code_source_id=code_source['id'])
action_descs = provider.find_all()
@ -103,7 +104,7 @@ class DynamicActionProviderTest(base.DbTestCase):
self.assertEqual(0, len(action_descs))
def test_Dynamic_actions_with_namespace(self):
def test_dynamic_actions_with_namespace(self):
provider = dynamic_action.DynamicActionProvider()
action_descs = provider.find_all()
@ -111,6 +112,7 @@ class DynamicActionProviderTest(base.DbTestCase):
self.assertEqual(0, len(action_descs))
code_source = self._create_code_source()
self._create_dynamic_actions(
code_source_id=code_source['id'],
namespace=NAMESPACE

View File

@ -24,7 +24,6 @@ NAMESPACE = "NS"
class TestCodeSourcesController(base.APITest):
def _create_code_source(self, module_name, file_content,
namespace=NAMESPACE, expect_errors=False):
return self.app.post(
@ -46,7 +45,8 @@ class TestCodeSourcesController(base.APITest):
resp = self._create_code_source(
mod_name,
FILE_CONTENT)
FILE_CONTENT
)
resp_json = resp.json

View File

@ -12,21 +12,23 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from mistral.services import actions
from mistral.tests.unit.api import base
FILE_CONTENT = """from mistral_lib import actions
TEST_MODULE_TEXT = """
from mistral_lib import actions
class DummyAction(actions.Action):
def run(self, context):
return None
return "Hello from the dummy action 1!"
def test(self, context):
return None
class DummyAction2(actions.Action):
def run(self, context):
return None
return "Hello from the dummy action 2!"
def test(self, context):
return None"""
@ -46,19 +48,20 @@ dummy_action:
class TestDynamicActionsController(base.APITest):
def setUp(self):
super(TestDynamicActionsController, self).setUp()
resp = self._create_code_source().json
self.code_source_id = resp.get('code_sources')[0].get('id')
self.addCleanup(self._delete_code_source)
def _create_code_source(self):
return self.app.post(
'/v2/code_sources',
upload_files=[
('modulename', 'filename', FILE_CONTENT.encode())
('test_dummy_module', 'filename', TEST_MODULE_TEXT.encode())
],
)
@ -70,15 +73,14 @@ class TestDynamicActionsController(base.APITest):
)
def _delete_code_source(self):
return self.app.delete(
'/v2/code_sources/modulename',
)
return self.app.delete('/v2/code_sources/test_dummy_module')
def test_create_dynamic_action(self):
resp = self._create_dynamic_action(
CREATE_REQUEST.format(self.code_source_id)
)
# Check the structure of the response.
resp_json = resp.json
self.assertEqual(200, resp.status_int)
@ -95,6 +97,22 @@ class TestDynamicActionsController(base.APITest):
self.code_source_id,
dynamic_action.get('code_source_id')
)
# Make sure the action can be found via the system action provider
# and it's fully functioning.
provider = actions.get_system_action_provider()
action_desc = provider.find('dummy_action')
self.assertIsNotNone(action_desc)
action = action_desc.instantiate({}, None)
self.assertIsNotNone(action)
self.assertEqual("Hello from the dummy action 1!", action.run(None))
# Delete the action
self.app.delete('/v2/dynamic_actions/dummy_action')
def test_update_dynamic_action(self):
@ -158,6 +176,7 @@ class TestDynamicActionsController(base.APITest):
CREATE_REQUEST.format(self.code_source_id)
)
# Check the structure of the response
self.assertEqual(200, resp.status_int)
resp = self.app.get('/v2/dynamic_actions/dummy_action')
@ -172,3 +191,9 @@ class TestDynamicActionsController(base.APITest):
)
self.assertEqual(404, resp.status_int)
# Make sure the system action provider doesn't find an action
# descriptor for the action.
provider = actions.get_system_action_provider()
self.assertIsNone(provider.find('dummy_action'))