diff --git a/mistral/api/controllers/v2/action.py b/mistral/api/controllers/v2/action.py index 660189707..32e00eaca 100644 --- a/mistral/api/controllers/v2/action.py +++ b/mistral/api/controllers/v2/action.py @@ -1,5 +1,6 @@ # Copyright 2014 - Mirantis, Inc. # Copyright 2015 Huawei Technologies Co., Ltd. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -46,11 +47,12 @@ class ActionsController(rest.RestController, hooks.HookController): spec_parser.get_action_list_spec_from_yaml) @rest_utils.wrap_wsme_controller_exception - @wsme_pecan.wsexpose(resources.Action, wtypes.text) - def get(self, identifier): + @wsme_pecan.wsexpose(resources.Action, wtypes.text, wtypes.text) + def get(self, identifier, namespace=''): """Return the named action. :param identifier: ID or name of the Action to get. + :param namespace: The namespace of the action. """ acl.enforce('actions:get', context.ctx()) @@ -60,17 +62,19 @@ class ActionsController(rest.RestController, hooks.HookController): # Use retries to prevent possible failures. db_model = rest_utils.rest_retry_on_db_error( db_api.get_action_definition - )(identifier) + )(identifier, namespace=namespace) return resources.Action.from_db_model(db_model) @rest_utils.wrap_pecan_controller_exception @pecan.expose(content_type="text/plain") - def put(self, identifier=None): + def put(self, identifier=None, namespace=''): """Update one or more actions. :param identifier: Optional. If provided, it's UUID or name of an action. Only one action can be updated with identifier param. + :param namespace: Optional. If provided, it's the namespace that + the action is under. NOTE: This text is allowed to have definitions of multiple actions. In this case they all will be updated. @@ -81,6 +85,7 @@ class ActionsController(rest.RestController, hooks.HookController): LOG.debug("Update action(s) [definition=%s]", definition) + namespace = namespace or '' scope = pecan.request.GET.get('scope', 'private') resources.Action.validate_scope(scope) if scope == 'public': @@ -92,7 +97,8 @@ class ActionsController(rest.RestController, hooks.HookController): return actions.update_actions( definition, scope=scope, - identifier=identifier + identifier=identifier, + namespace=namespace ) db_acts = _update_actions() @@ -105,13 +111,19 @@ class ActionsController(rest.RestController, hooks.HookController): @rest_utils.wrap_pecan_controller_exception @pecan.expose(content_type="text/plain") - def post(self): + def post(self, namespace=''): """Create a new action. + :param namespace: Optional. The namespace to create the ad-hoc action + in. actions with the same name can be added to a given + project if they are in two different namespaces. + (default namespace is '') + NOTE: This text is allowed to have definitions of multiple actions. In this case they all will be created. """ acl.enforce('actions:create', context.ctx()) + namespace = namespace or '' definition = pecan.request.text scope = pecan.request.GET.get('scope', 'private') @@ -126,7 +138,9 @@ class ActionsController(rest.RestController, hooks.HookController): @rest_utils.rest_retry_on_db_error def _create_action_definitions(): with db_api.transaction(): - return actions.create_actions(definition, scope=scope) + return actions.create_actions(definition, + scope=scope, + namespace=namespace) db_acts = _create_action_definitions() @@ -137,26 +151,27 @@ class ActionsController(rest.RestController, hooks.HookController): return resources.Actions(actions=action_list).to_json() @rest_utils.wrap_wsme_controller_exception - @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) - def delete(self, identifier): + @wsme_pecan.wsexpose(None, wtypes.text, wtypes.text, status_code=204) + def delete(self, identifier, namespace=''): """Delete the named action. :param identifier: Name or UUID of the action to delete. + :param namespace: The namespace of which the action is in. """ acl.enforce('actions:delete', context.ctx()) - LOG.debug("Delete action [identifier=%s]", identifier) @rest_utils.rest_retry_on_db_error def _delete_action_definition(): with db_api.transaction(): - db_model = db_api.get_action_definition(identifier) + db_model = db_api.get_action_definition(identifier, + namespace=namespace) if db_model.is_system: msg = "Attempt to delete a system action: %s" % identifier raise exc.DataAccessException(msg) - - db_api.delete_action_definition(identifier) + db_api.delete_action_definition(identifier, + namespace=namespace) _delete_action_definition() @@ -164,12 +179,13 @@ class ActionsController(rest.RestController, hooks.HookController): @wsme_pecan.wsexpose(resources.Actions, types.uuid, int, types.uniquelist, types.list, types.uniquelist, wtypes.text, wtypes.text, resources.SCOPE_TYPES, wtypes.text, - wtypes.text, wtypes.text, wtypes.text, wtypes.text, - wtypes.text) + wtypes.text, wtypes.text, wtypes.text, + wtypes.text, wtypes.text, wtypes.text) def get_all(self, marker=None, limit=None, sort_keys='name', - sort_dirs='asc', fields='', created_at=None, name=None, - scope=None, tags=None, updated_at=None, - description=None, definition=None, is_system=None, input=None): + sort_dirs='asc', fields='', created_at=None, + name=None, scope=None, tags=None, + updated_at=None, description=None, definition=None, + is_system=None, input=None, namespace=''): """Return all actions. :param marker: Optional. Pagination marker for large data sets. @@ -199,6 +215,7 @@ class ActionsController(rest.RestController, hooks.HookController): time and date. :param updated_at: Optional. Keep only resources with specific latest update time and date. + :param namespace: Optional. The namespace of the action. """ acl.enforce('actions:list', context.ctx()) @@ -211,7 +228,8 @@ class ActionsController(rest.RestController, hooks.HookController): description=description, definition=definition, is_system=is_system, - input=input + input=input, + namespace=namespace ) LOG.debug("Fetch actions. marker=%s, limit=%s, sort_keys=%s, " diff --git a/mistral/api/controllers/v2/action_execution.py b/mistral/api/controllers/v2/action_execution.py index 1708be855..33cce07be 100644 --- a/mistral/api/controllers/v2/action_execution.py +++ b/mistral/api/controllers/v2/action_execution.py @@ -1,5 +1,6 @@ # Copyright 2015 - Mirantis, Inc. # Copyright 2016 - Brocade Communications Systems, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -144,16 +145,15 @@ class ActionExecutionsController(rest.RestController): :param action_ex: Action to execute """ acl.enforce('action_executions:create', context.ctx()) - LOG.debug( "Create action_execution [action_execution=%s]", action_ex ) - name = action_ex.name description = action_ex.description or None action_input = action_ex.input or {} params = action_ex.params or {} + namespace = action_ex.workflow_namespace or '' if not name: raise exc.InputException( @@ -164,6 +164,7 @@ class ActionExecutionsController(rest.RestController): name, action_input, description=description, + namespace=namespace, **params ) diff --git a/mistral/api/controllers/v2/resources.py b/mistral/api/controllers/v2/resources.py index df6b0ac33..5a24bba36 100644 --- a/mistral/api/controllers/v2/resources.py +++ b/mistral/api/controllers/v2/resources.py @@ -1,6 +1,7 @@ # Copyright 2013 - Mirantis, Inc. # Copyright 2018 - Extreme Networks, Inc. # Copyright 2019 - NetCracker Technology Corp. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -206,6 +207,7 @@ class Action(resource.Resource, ScopedResource): created_at = wtypes.text updated_at = wtypes.text + namespace = wtypes.text @classmethod def sample(cls): @@ -217,7 +219,8 @@ class Action(resource.Resource, ScopedResource): scope='private', project_id='a7eb669e9819420ea4bd1453e672c0a7', created_at='1970-01-01T00:00:00.000000', - updated_at='1970-01-01T00:00:00.000000' + updated_at='1970-01-01T00:00:00.000000', + namespace='' ) diff --git a/mistral/db/sqlalchemy/migration/alembic_migrations/versions/037_add_namespace_column_to_action_definitions.py b/mistral/db/sqlalchemy/migration/alembic_migrations/versions/037_add_namespace_column_to_action_definitions.py new file mode 100644 index 000000000..e2e5e2657 --- /dev/null +++ b/mistral/db/sqlalchemy/migration/alembic_migrations/versions/037_add_namespace_column_to_action_definitions.py @@ -0,0 +1,67 @@ +# Copyright 2020 Nokia Software. +# +# 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. + +"""add namespace column to action definitions + +Revision ID: 037 +Revises: 036 +Create Date: 2020-1-6 10:22:20 + +""" + +# revision identifiers, used by Alembic. + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine import reflection +from sqlalchemy.sql import table, column + +revision = '037' +down_revision = '036' + + +def upgrade(): + op.add_column( + 'action_definitions_v2', + sa.Column('namespace', sa.String(length=255), nullable=True) + ) + + inspect = reflection.Inspector.from_engine(op.get_bind()) + + unique_constraints = [ + unique_constraint['name'] for unique_constraint in + inspect.get_unique_constraints('action_definitions_v2') + ] + if 'name' in unique_constraints: + op.drop_index('name', table_name='action_definitions_v2') + + if 'action_definitions_v2_name_project_id_key' in unique_constraints: + op.drop_constraint('action_definitions_v2_name_project_id_key', + table_name='action_definitions_v2') + + op.create_unique_constraint( + None, + 'action_definitions_v2', + ['name', 'namespace', 'project_id'] + ) + + action_def = table('action_definitions_v2', column('namespace')) + session = sa.orm.Session(bind=op.get_bind()) + with session.begin(subtransactions=True): + session.execute( + action_def.update().values(namespace='').where( + action_def.c.namespace is None)) # noqa + + session.commit() diff --git a/mistral/db/v2/api.py b/mistral/db/v2/api.py index b77789289..9d8f7d2d5 100644 --- a/mistral/db/v2/api.py +++ b/mistral/db/v2/api.py @@ -1,5 +1,6 @@ # Copyright 2015 - Mirantis, Inc. # Copyright 2015 - StackStorm, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -185,22 +186,30 @@ def get_action_definition_by_id(id, fields=()): return IMPL.get_action_definition_by_id(id, fields=fields) -def get_action_definition(name, fields=()): - return IMPL.get_action_definition(name, fields=fields) +def get_action_definition(name, fields=(), namespace=''): + return IMPL.get_action_definition(name, fields=fields, namespace=namespace) -def load_action_definition(name, fields=()): +def load_action_definition(name, fields=(), namespace=''): """Unlike get_action_definition this method is allowed to return None.""" + key = '{}:{}'.format(name, namespace) if namespace else name with _ACTION_DEF_CACHE_LOCK: - action_def = _ACTION_DEF_CACHE.get(name) + action_def = _ACTION_DEF_CACHE.get(key) if action_def: return action_def - action_def = IMPL.load_action_definition(name, fields=fields) + action_def = IMPL.load_action_definition(name, fields=fields, + namespace=namespace,) + + # If action definition was not found in the workflow namespace, + # check in the default namespace + if not action_def: + action_def = IMPL.load_action_definition(name, fields=fields, + namespace='') with _ACTION_DEF_CACHE_LOCK: - _ACTION_DEF_CACHE[name] = ( + _ACTION_DEF_CACHE[key] = ( action_def.get_clone() if action_def else None ) @@ -230,8 +239,8 @@ def create_or_update_action_definition(name, values): return IMPL.create_or_update_action_definition(name, values) -def delete_action_definition(name): - return IMPL.delete_action_definition(name) +def delete_action_definition(name, namespace=''): + return IMPL.delete_action_definition(name, namespace=namespace) def delete_action_definitions(**kwargs): @@ -539,7 +548,6 @@ def load_environment(name): def get_environments(limit=None, marker=None, sort_keys=None, sort_dirs=None, **kwargs): - return IMPL.get_environments( limit=limit, marker=marker, diff --git a/mistral/db/v2/sqlalchemy/api.py b/mistral/db/v2/sqlalchemy/api.py index 03aafcf05..09f048791 100644 --- a/mistral/db/v2/sqlalchemy/api.py +++ b/mistral/db/v2/sqlalchemy/api.py @@ -1,6 +1,7 @@ # Copyright 2015 - Mirantis, Inc. # Copyright 2015 - StackStorm, Inc. # Copyright 2016 - Brocade Communications Systems, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +21,6 @@ import re import sys import threading - from oslo_config import cfg from oslo_db import exception as db_exc from oslo_db import sqlalchemy as oslo_sqlalchemy @@ -41,11 +41,9 @@ from mistral.services import security from mistral.workflow import states from mistral_lib import utils - CONF = cfg.CONF LOG = logging.getLogger(__name__) - _SCHEMA_LOCK = threading.RLock() _initialized = False @@ -338,7 +336,7 @@ def _get_db_object_by_name_and_namespace_or_id(model, identifier, def _get_db_object_by_name_and_namespace(model, name, - namespace, insecure=False, + namespace='', insecure=False, columns=()): query = ( b.model_query(model, columns=columns) @@ -654,26 +652,38 @@ def get_action_definition_by_id(id, fields=(), session=None): @b.session_aware() -def get_action_definition(identifier, fields=(), session=None): +def get_action_definition(identifier, fields=(), session=None, namespace=''): a_def = _get_db_object_by_name_and_namespace_or_id( models.ActionDefinition, identifier, + namespace=namespace, columns=fields ) + # If the action was not found in the given namespace, + # look in the default namespace + if not a_def: + a_def = _get_db_object_by_name_and_namespace_or_id( + models.ActionDefinition, + identifier, + namespace='', + columns=fields + ) if not a_def: raise exc.DBEntityNotFoundError( - "Action definition not found [action_name=%s]" % identifier + "Action definition not found [action_name=%s,namespace=%s]" + % (identifier, namespace) ) return a_def @b.session_aware() -def load_action_definition(name, fields=(), session=None): - return _get_db_object_by_name( +def load_action_definition(name, fields=(), session=None, namespace=''): + return _get_db_object_by_name_and_namespace( models.ActionDefinition, name, + namespace=namespace, columns=fields ) @@ -693,8 +703,8 @@ def create_action_definition(values, session=None): a_def.save(session=session) except db_exc.DBDuplicateEntry: raise exc.DBDuplicateEntryError( - "Duplicate entry for Action ['name', 'project_id']:" - " {}, {}".format(a_def.name, a_def.project_id) + "Duplicate entry for Action ['name', 'namespace', 'project_id']:" + " {}, {}, {}".format(a_def.name, a_def.namespace, a_def.project_id) ) return a_def @@ -702,7 +712,8 @@ def create_action_definition(values, session=None): @b.session_aware() def update_action_definition(identifier, values, session=None): - a_def = get_action_definition(identifier) + namespace = values.get('namespace', '') + a_def = get_action_definition(identifier, namespace=namespace) a_def.update(values.copy()) @@ -711,15 +722,19 @@ def update_action_definition(identifier, values, session=None): @b.session_aware() def create_or_update_action_definition(name, values, session=None): - if not _get_db_object_by_name(models.ActionDefinition, name): + namespace = values.get('namespace', '') + if not _get_db_object_by_name_and_namespace( + models.ActionDefinition, + name, + namespace=namespace): return create_action_definition(values) else: return update_action_definition(name, values) @b.session_aware() -def delete_action_definition(identifier, session=None): - a_def = get_action_definition(identifier) +def delete_action_definition(identifier, namespace='', session=None): + a_def = get_action_definition(identifier, namespace=namespace) session.delete(a_def) @@ -793,8 +808,8 @@ def update_action_execution_heartbeat(id, session=None): raise exc.DBEntityNotFoundError now = utils.utc_now_sec() - session.query(models.ActionExecution).\ - filter(models.ActionExecution.id == id).\ + session.query(models.ActionExecution). \ + filter(models.ActionExecution.id == id). \ update({'last_heartbeat': now}) @@ -1758,8 +1773,8 @@ def update_resource_member(resource_id, res_type, member_id, values, @b.session_aware() def delete_resource_member(resource_id, res_type, member_id, session=None): - query = _secure_query(models.ResourceMember).\ - filter_by(resource_type=res_type).\ + query = _secure_query(models.ResourceMember). \ + filter_by(resource_type=res_type). \ filter(_get_criterion(resource_id, member_id)) # TODO(kong): Check association with cron triggers when deleting a workflow diff --git a/mistral/db/v2/sqlalchemy/models.py b/mistral/db/v2/sqlalchemy/models.py index ce410afdc..834020738 100644 --- a/mistral/db/v2/sqlalchemy/models.py +++ b/mistral/db/v2/sqlalchemy/models.py @@ -1,5 +1,6 @@ # Copyright 2015 - Mirantis, Inc. # Copyright 2015 - StackStorm, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -31,7 +32,6 @@ from mistral import exceptions as exc from mistral.services import security from mistral_lib import utils - # Definition objects. CONF = cfg.CONF @@ -174,9 +174,12 @@ class ActionDefinition(Definition): """Contains info about registered Actions.""" __tablename__ = 'action_definitions_v2' - + namespace = sa.Column(sa.String(255), nullable=True) __table_args__ = ( - sa.UniqueConstraint('name', 'project_id'), + sa.UniqueConstraint( + 'name', + 'namespace', + 'project_id'), sa.Index('%s_is_system' % __tablename__, 'is_system'), sa.Index('%s_action_class' % __tablename__, 'action_class'), sa.Index('%s_project_id' % __tablename__, 'project_id'), @@ -346,7 +349,6 @@ for cls in utils.iter_subclasses(Execution): retval=True ) - # Many-to-one for 'ActionExecution' and 'TaskExecution'. ActionExecution.task_execution_id = sa.Column( @@ -405,7 +407,6 @@ WorkflowExecution.root_execution = relationship( lazy='select' ) - # Many-to-one for 'TaskExecution' and 'WorkflowExecution'. TaskExecution.workflow_execution_id = sa.Column( diff --git a/mistral/engine/action_handler.py b/mistral/engine/action_handler.py index 4fa526b92..fd364d84d 100644 --- a/mistral/engine/action_handler.py +++ b/mistral/engine/action_handler.py @@ -1,5 +1,6 @@ # Copyright 2015 - Mirantis, Inc. # Copyright 2016 - Brocade Communications Systems, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -22,7 +23,6 @@ from mistral.engine import actions from mistral.engine import task_handler from mistral import exceptions as exc - LOG = logging.getLogger(__name__) @@ -31,7 +31,6 @@ def on_action_complete(action_ex, result): task_ex = action_ex.task_execution action = _build_action(action_ex) - try: action.complete(result) except exc.MistralException as e: @@ -87,8 +86,10 @@ def _build_action(action_ex): adhoc_action_name = action_ex.runtime_context.get('adhoc_action_name') if adhoc_action_name: - action_def = actions.resolve_action_definition(adhoc_action_name) - + action_def = actions.resolve_action_definition( + adhoc_action_name, + namespace=action_ex.workflow_namespace + ) return actions.AdHocAction(action_def, action_ex=action_ex) action_def = actions.resolve_action_definition(action_ex.name) @@ -96,9 +97,9 @@ def _build_action(action_ex): return actions.PythonAction(action_def, action_ex=action_ex) -def build_action_by_name(action_name): - action_def = actions.resolve_action_definition(action_name) - +def build_action_by_name(action_name, namespace=''): + action_def = actions.resolve_action_definition(action_name, + namespace=namespace) action_cls = (actions.PythonAction if not action_def.spec else actions.AdHocAction) diff --git a/mistral/engine/actions.py b/mistral/engine/actions.py index 39f01d880..aeb963762 100644 --- a/mistral/engine/actions.py +++ b/mistral/engine/actions.py @@ -37,7 +37,6 @@ from mistral.workflow import states from mistral_lib import actions as ml_actions from mistral_lib import utils - LOG = logging.getLogger(__name__) CONF = cfg.CONF @@ -152,7 +151,8 @@ class Action(object): return True def _create_action_execution(self, input_dict, runtime_ctx, is_sync, - desc='', action_ex_id=None): + desc='', action_ex_id=None, namespace=''): + action_ex_id = action_ex_id or utils.generate_unicode_uuid() values = { @@ -162,6 +162,7 @@ class Action(object): 'state': states.RUNNING, 'input': input_dict, 'runtime_context': runtime_ctx, + 'workflow_namespace': namespace, 'description': desc, 'is_sync': is_sync } @@ -245,7 +246,6 @@ class PythonAction(Action): # to be updated with the action execution ID after the action execution # DB object is created. action_ex_id = utils.generate_unicode_uuid() - self._create_action_execution( self._prepare_input(input_dict), self._prepare_runtime_context(index, safe_rerun), @@ -253,7 +253,6 @@ class PythonAction(Action): desc=desc, action_ex_id=action_ex_id ) - execution_context = self._prepare_execution_context() # Register an asynchronous command to send the action to @@ -320,7 +319,8 @@ class PythonAction(Action): try: prepared_input_dict = self._prepare_input(input_dict) - a = a_m.get_action_class(self.action_def.name)( + a = a_m.get_action_class(self.action_def.name, + self.action_def.namespace)( **prepared_input_dict ) @@ -356,9 +356,9 @@ class PythonAction(Action): if self.action_ex: exc_ctx['action_execution_id'] = self.action_ex.id - exc_ctx['callback_url'] = ( - '/v2/action_executions/%s' % self.action_ex.id - ) + exc_ctx['callback_url'] = ('/v2/action_executions/%s' + % self.action_ex.id + ) return exc_ctx @@ -394,7 +394,8 @@ class AdHocAction(PythonAction): self.action_spec = spec_parser.get_action_spec(action_def.spec) base_action_def = db_api.load_action_definition( - self.action_spec.get_base() + self.action_spec.get_base(), + namespace=action_def.namespace ) if not base_action_def: @@ -539,10 +540,12 @@ class AdHocAction(PythonAction): base_name = base.spec['base'] try: - base = db_api.get_action_definition(base_name) + base = db_api.get_action_definition(base_name, + namespace=base.namespace) except exc.DBEntityNotFoundError: raise exc.InvalidActionException( - "Failed to find action [action_name=%s]" % base_name + "Failed to find action [action_name=%s namespace=%s] " + % (base_name, base.namespace) ) # if the action is repeated @@ -554,6 +557,13 @@ class AdHocAction(PythonAction): return base + def _create_action_execution(self, input_dict, runtime_ctx, is_sync, + desc='', action_ex_id=None): + super()._create_action_execution(input_dict, + runtime_ctx, is_sync, + desc, action_ex_id, + self.adhoc_action_def.namespace) + class WorkflowAction(Action): """Workflow action.""" @@ -650,12 +660,13 @@ class WorkflowAction(Action): def resolve_action_definition(action_spec_name, wf_name=None, - wf_spec_name=None): + wf_spec_name=None, namespace=''): """Resolve action definition accounting for ad-hoc action namespacing. :param action_spec_name: Action name according to a spec. :param wf_name: Workflow name. :param wf_spec_name: Workflow name according to a spec. + :param namespace: The namespace of the action. :return: Action definition (python or ad-hoc). """ @@ -671,14 +682,17 @@ def resolve_action_definition(action_spec_name, wf_name=None, action_full_name = "%s.%s" % (wb_name, action_spec_name) - action_db = db_api.load_action_definition(action_full_name) + action_db = db_api.load_action_definition(action_full_name, + namespace=namespace) if not action_db: - action_db = db_api.load_action_definition(action_spec_name) + action_db = db_api.load_action_definition(action_spec_name, + namespace=namespace) if not action_db: raise exc.InvalidActionException( - "Failed to find action [action_name=%s]" % action_spec_name + "Failed to find action [action_name=%s] in [namespace=%s]" % + (action_spec_name, namespace) ) return action_db diff --git a/mistral/engine/base.py b/mistral/engine/base.py index 9130d20dc..f0686be79 100644 --- a/mistral/engine/base.py +++ b/mistral/engine/base.py @@ -1,5 +1,6 @@ # Copyright 2014 - Mirantis, Inc. # Copyright 2017 - Brocade Communications Systems, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -48,12 +49,13 @@ class Engine(object): @abc.abstractmethod def start_action(self, action_name, action_input, - description=None, **params): + description=None, namespace='', **params): """Starts the specific action. :param action_name: Action name. :param action_input: Action input data as a dictionary. :param description: Execution description. + :param namespace: The namespace of the action. :param params: Additional options for action running. :return: Action execution object. """ diff --git a/mistral/engine/default_engine.py b/mistral/engine/default_engine.py index 951f95051..8b1da720d 100644 --- a/mistral/engine/default_engine.py +++ b/mistral/engine/default_engine.py @@ -2,6 +2,7 @@ # Copyright 2015 - StackStorm, Inc. # Copyright 2016 - Brocade Communications Systems, Inc. # Copyright 2018 - Extreme Networks, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -83,9 +84,10 @@ class DefaultEngine(base.Engine): @db_utils.retry_on_db_error @post_tx_queue.run def start_action(self, action_name, action_input, - description=None, **params): + description=None, namespace='', **params): with db_api.transaction(): - action = action_handler.build_action_by_name(action_name) + action = action_handler.build_action_by_name(action_name, + namespace=namespace) action.validate_input(action_input) @@ -102,7 +104,6 @@ class DefaultEngine(base.Engine): if not sync and (save or not is_action_sync): action.schedule(action_input, target, timeout=timeout) - return action.action_ex.get_clone() output = action.run( @@ -111,7 +112,6 @@ class DefaultEngine(base.Engine): save=False, timeout=timeout ) - state = states.SUCCESS if output.is_success() else states.ERROR if not save: @@ -122,9 +122,9 @@ class DefaultEngine(base.Engine): description=description, input=action_input, output=output.to_dict(), - state=state + state=state, + workflow_namespace=namespace ) - action_ex_id = u.generate_unicode_uuid() values = { @@ -134,7 +134,8 @@ class DefaultEngine(base.Engine): 'input': action_input, 'output': output.to_dict(), 'state': state, - 'is_sync': is_action_sync + 'is_sync': is_action_sync, + 'workflow_namespace': namespace } return db_api.create_action_execution(values) @@ -147,7 +148,6 @@ class DefaultEngine(base.Engine): with db_api.transaction(): if wf_action: action_ex = db_api.get_workflow_execution(action_ex_id) - # If result is None it means that it's a normal subworkflow # output and we just need to fetch it from the model. # This is just an optimization to not send data over RPC @@ -170,7 +170,6 @@ class DefaultEngine(base.Engine): action_ex = db_api.get_workflow_execution(action_ex_id) else: action_ex = db_api.get_action_execution(action_ex_id) - action_handler.on_action_update(action_ex, state) return action_ex.get_clone() diff --git a/mistral/engine/engine_server.py b/mistral/engine/engine_server.py index 5e7472e91..2be3390a7 100644 --- a/mistral/engine/engine_server.py +++ b/mistral/engine/engine_server.py @@ -157,22 +157,24 @@ class EngineServer(service_base.MistralService): ) def start_action(self, rpc_ctx, action_name, - action_input, description, params): + action_input, description, namespace, params): """Receives calls over RPC to start actions on engine. :param rpc_ctx: RPC request context. :param action_name: name of the Action. :param action_input: input dictionary for Action. :param description: description of new Action execution. + :param namespace: The namespace of the action. :param params: extra parameters to run Action. :return: Action execution. """ LOG.info( "Received RPC request 'start_action'[name=%s, input=%s, " - "description=%s, params=%s]", + "description=%s, namespace=%s params=%s]", action_name, utils.cut(action_input), description, + namespace, params ) @@ -180,6 +182,7 @@ class EngineServer(service_base.MistralService): action_name, action_input, description, + namespace=namespace, **params ) @@ -198,7 +201,6 @@ class EngineServer(service_base.MistralService): action_ex_id, result.cut_repr() if result else '' ) - return self.engine.on_action_complete(action_ex_id, result, wf_action) def on_action_update(self, rpc_ctx, action_ex_id, state, wf_action): diff --git a/mistral/engine/tasks.py b/mistral/engine/tasks.py index 518c7abec..2f89955c1 100644 --- a/mistral/engine/tasks.py +++ b/mistral/engine/tasks.py @@ -691,7 +691,8 @@ class RegularTask(Task): action_def = actions.resolve_action_definition( action_name, self.wf_ex.name, - self.wf_spec.get_name() + self.wf_spec.get_name(), + namespace=self.wf_ex.workflow_namespace ) if action_def.spec: diff --git a/mistral/executors/default_executor.py b/mistral/executors/default_executor.py index 2aa06f04b..7813c1d6c 100644 --- a/mistral/executors/default_executor.py +++ b/mistral/executors/default_executor.py @@ -88,7 +88,6 @@ class DefaultExecutor(base.Executor): return None return error_result - if redelivered and not safe_rerun: msg = ( "Request to run action %s was redelivered, but action %s " diff --git a/mistral/rpc/clients.py b/mistral/rpc/clients.py index c9674e2f1..345bf8d0c 100644 --- a/mistral/rpc/clients.py +++ b/mistral/rpc/clients.py @@ -2,6 +2,7 @@ # Copyright 2015 - StackStorm, Inc. # Copyright 2017 - Brocade Communications Systems, Inc. # Copyright 2018 - Extreme Networks, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -150,12 +151,13 @@ class EngineClient(eng.Engine): @base.wrap_messaging_exception def start_action(self, action_name, action_input, - description=None, **params): + description=None, namespace='', **params): """Starts action sending a request to engine over RPC. :param action_name: Action name. :param action_input: Action input data as a dictionary. :param description: Execution description. + :param namespace: The namespace of the action. :param params: Additional options for action running. :return: Action execution. """ @@ -165,6 +167,7 @@ class EngineClient(eng.Engine): action_name=action_name, action_input=action_input or {}, description=description, + namespace=namespace, params=params ) @@ -372,7 +375,6 @@ class ExecutorClient(exe.Executor): action will be interrupted :return: Action result. """ - rpc_kwargs = { 'action_ex_id': action_ex_id, 'action_cls_str': action_cls_str, diff --git a/mistral/services/action_manager.py b/mistral/services/action_manager.py index 35c8d62e9..b8b99bd6f 100644 --- a/mistral/services/action_manager.py +++ b/mistral/services/action_manager.py @@ -1,5 +1,6 @@ # Copyright 2014 - Mirantis, Inc. # Copyright 2014 - StackStorm, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -51,7 +52,7 @@ def get_registered_actions(**kwargs): def register_action_class(name, action_class_str, attributes, - description=None, input_str=None): + description=None, input_str=None, namespace=''): values = { 'name': name, 'action_class': action_class_str, @@ -59,7 +60,8 @@ def register_action_class(name, action_class_str, attributes, 'description': description, 'input': input_str, 'is_system': True, - 'scope': 'public' + 'scope': 'public', + 'namespace': namespace } try: @@ -81,7 +83,7 @@ def sync_db(): register_standard_actions() -def _register_dynamic_action_classes(): +def _register_dynamic_action_classes(namespace=''): for generator in generator_factory.all_generators(): actions = generator.create_actions() @@ -98,11 +100,12 @@ def _register_dynamic_action_classes(): action_class_str, attrs, action['description'], - action['arg_list'] + action['arg_list'], + namespace=namespace ) -def register_action_classes(): +def register_action_classes(namespace=''): mgr = extension.ExtensionManager( namespace='mistral.actions', invoke_on_load=False @@ -120,23 +123,24 @@ def register_action_classes(): action_class_str, attrs, description=description, - input_str=input_str + input_str=input_str, + namespace=namespace ) - _register_dynamic_action_classes() + _register_dynamic_action_classes(namespace=namespace) -def get_action_db(action_name): - return db_api.load_action_definition(action_name) +def get_action_db(action_name, namespace=''): + return db_api.load_action_definition(action_name, namespace=namespace) -def get_action_class(action_full_name): +def get_action_class(action_full_name, namespace=''): """Finds action class by full action name (i.e. 'namespace.action_name'). :param action_full_name: Full action name (that includes namespace). :return: Action class or None if not found. """ - action_db = get_action_db(action_full_name) + action_db = get_action_db(action_full_name, namespace) if action_db: return action_factory.construct_action_class( diff --git a/mistral/services/actions.py b/mistral/services/actions.py index 118f1bb31..7d4492abe 100644 --- a/mistral/services/actions.py +++ b/mistral/services/actions.py @@ -1,4 +1,5 @@ # Copyright 2015 - Mirantis, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,18 +20,22 @@ from mistral import exceptions as exc from mistral.lang import parser as spec_parser -def create_actions(definition, scope='private'): +def create_actions(definition, scope='private', namespace=''): action_list_spec = spec_parser.get_action_list_spec_from_yaml(definition) db_actions = [] for action_spec in action_list_spec.get_actions(): - db_actions.append(create_action(action_spec, definition, scope)) + db_actions.append(create_action( + action_spec, + definition, + scope, + namespace)) return db_actions -def update_actions(definition, scope='private', identifier=None): +def update_actions(definition, scope='private', identifier=None, namespace=''): action_list_spec = spec_parser.get_action_list_spec_from_yaml(definition) actions = action_list_spec.get_actions() @@ -48,32 +53,35 @@ def update_actions(definition, scope='private', identifier=None): action_spec, definition, scope, - identifier=identifier + identifier=identifier, + namespace=namespace + )) return db_actions -def create_or_update_actions(definition, scope='private'): +def create_or_update_actions(definition, scope='private', namespace=''): action_list_spec = spec_parser.get_action_list_spec_from_yaml(definition) db_actions = [] for action_spec in action_list_spec.get_actions(): db_actions.append( - create_or_update_action(action_spec, definition, scope) + create_or_update_action(action_spec, definition, scope, namespace) ) return db_actions -def create_action(action_spec, definition, scope): +def create_action(action_spec, definition, scope, namespace): return db_api.create_action_definition( - _get_action_values(action_spec, definition, scope) + _get_action_values(action_spec, definition, scope, namespace) ) -def update_action(action_spec, definition, scope, identifier=None): +def update_action(action_spec, definition, scope, identifier=None, + namespace=''): action = db_api.load_action_definition(action_spec.get_name()) if action and action.is_system: @@ -82,7 +90,7 @@ def update_action(action_spec, definition, scope, identifier=None): action.name ) - values = _get_action_values(action_spec, definition, scope) + values = _get_action_values(action_spec, definition, scope, namespace) return db_api.update_action_definition( identifier if identifier else values['name'], @@ -90,7 +98,7 @@ def update_action(action_spec, definition, scope, identifier=None): ) -def create_or_update_action(action_spec, definition, scope): +def create_or_update_action(action_spec, definition, scope, namespace): action = db_api.load_action_definition(action_spec.get_name()) if action and action.is_system: @@ -99,7 +107,7 @@ def create_or_update_action(action_spec, definition, scope): action.name ) - values = _get_action_values(action_spec, definition, scope) + values = _get_action_values(action_spec, definition, scope, namespace) return db_api.create_or_update_action_definition(values['name'], values) @@ -117,7 +125,7 @@ def get_input_list(action_input): return input_list -def _get_action_values(action_spec, definition, scope): +def _get_action_values(action_spec, definition, scope, namespace=''): action_input = action_spec.to_dict().get('input', []) input_list = get_input_list(action_input) @@ -129,7 +137,8 @@ def _get_action_values(action_spec, definition, scope): 'spec': action_spec.to_dict(), 'is_system': False, 'input': ", ".join(input_list) if input_list else None, - 'scope': scope + 'scope': scope, + 'namespace': namespace } return values diff --git a/mistral/services/workbooks.py b/mistral/services/workbooks.py index 34b7ba904..df93750eb 100644 --- a/mistral/services/workbooks.py +++ b/mistral/services/workbooks.py @@ -1,4 +1,5 @@ # Copyright 2015 - Mirantis, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -57,9 +58,9 @@ def update_workbook_v2(definition, namespace='', scope='private', return wb_db -def _on_workbook_update(wb_db, wb_spec, namespace): - # TODO(hardikj) Handle actions for namespace - db_actions = _create_or_update_actions(wb_db, wb_spec.get_actions()) +def _on_workbook_update(wb_db, wb_spec, namespace=''): + db_actions = _create_or_update_actions(wb_db, wb_spec.get_actions(), + namespace=namespace) db_wfs = _create_or_update_workflows( wb_db, wb_spec.get_workflows(), @@ -69,7 +70,7 @@ def _on_workbook_update(wb_db, wb_spec, namespace): return db_actions, db_wfs -def _create_or_update_actions(wb_db, actions_spec): +def _create_or_update_actions(wb_db, actions_spec, namespace): db_actions = [] if actions_spec: @@ -88,7 +89,8 @@ def _create_or_update_actions(wb_db, actions_spec): 'is_system': False, 'input': ', '.join(input_list) if input_list else None, 'scope': wb_db.scope, - 'project_id': wb_db.project_id + 'project_id': wb_db.project_id, + 'namespace': namespace } db_actions.append( diff --git a/mistral/tests/unit/api/v2/test_action_executions.py b/mistral/tests/unit/api/v2/test_action_executions.py index 2dad135b8..8a729e5f5 100644 --- a/mistral/tests/unit/api/v2/test_action_executions.py +++ b/mistral/tests/unit/api/v2/test_action_executions.py @@ -304,7 +304,8 @@ class TestActionExecutionsController(base.APITest): json.loads(action_exec['input']), description=None, save_result=True, - run_sync=True + run_sync=True, + namespace='' ) @mock.patch.object(rpc_clients.EngineClient, 'start_action') @@ -331,7 +332,8 @@ class TestActionExecutionsController(base.APITest): action_exec['name'], json.loads(action_exec['input']), description=None, - timeout=2 + timeout=2, + namespace='' ) @mock.patch.object(rpc_clients.EngineClient, 'start_action') @@ -358,7 +360,8 @@ class TestActionExecutionsController(base.APITest): action_exec['name'], json.loads(action_exec['input']), description=None, - save_result=True + save_result=True, + namespace='' ) @mock.patch.object(rpc_clients.EngineClient, 'start_action') @@ -374,7 +377,9 @@ class TestActionExecutionsController(base.APITest): self.assertEqual(201, resp.status_int) self.assertEqual('{"result": "123"}', resp.json['output']) - f.assert_called_once_with('nova.servers_list', {}, description=None) + f.assert_called_once_with('nova.servers_list', {}, + description=None, + namespace='') def test_post_bad_result(self): resp = self.app.post_json( diff --git a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py index 50f60c33b..c9df95047 100644 --- a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +++ b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py @@ -1,5 +1,6 @@ # Copyright 2015 - Mirantis, Inc. # Copyright 2015 - StackStorm, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -1085,7 +1086,8 @@ ACTION_DEFINITIONS = [ 'action_class': 'mypackage.my_module.Action1', 'attributes': None, 'project_id': '', - 'created_at': datetime.datetime(2016, 12, 1, 15, 0, 0) + 'created_at': datetime.datetime(2016, 12, 1, 15, 0, 0), + 'namespace': '' }, { 'name': 'action2', @@ -1094,7 +1096,8 @@ ACTION_DEFINITIONS = [ 'action_class': 'mypackage.my_module.Action2', 'attributes': None, 'project_id': '', - 'created_at': datetime.datetime(2016, 12, 1, 15, 1, 0) + 'created_at': datetime.datetime(2016, 12, 1, 15, 1, 0), + 'namespace': '' }, { 'name': 'action3', @@ -1104,7 +1107,8 @@ ACTION_DEFINITIONS = [ 'action_class': 'mypackage.my_module.Action3', 'attributes': None, 'project_id': '', - 'created_at': datetime.datetime(2016, 12, 1, 15, 2, 0) + 'created_at': datetime.datetime(2016, 12, 1, 15, 2, 0), + 'namespace': '' }, ] @@ -1154,8 +1158,8 @@ class ActionDefinitionTest(SQLAlchemyTest): self.assertRaisesWithMessage( exc.DBDuplicateEntryError, - "Duplicate entry for Action ['name', 'project_id']: action1" - ", ", + "Duplicate entry for Action ['name', 'namespace', 'project_id']:" + " action1, , ", db_api.create_action_definition, ACTION_DEFINITIONS[0] ) diff --git a/mistral/tests/unit/engine/test_action_caching.py b/mistral/tests/unit/engine/test_action_caching.py index 86c639f6b..8a1374b04 100644 --- a/mistral/tests/unit/engine/test_action_caching.py +++ b/mistral/tests/unit/engine/test_action_caching.py @@ -28,9 +28,7 @@ cfg.CONF.set_default('auth_enable', False, group='pecan') class LookupUtilsTest(base.EngineTestCase): - - def test_action_definition_cache_ttl(self): - action = """--- + ACTION = """--- version: '2.0' action1: @@ -39,7 +37,7 @@ class LookupUtilsTest(base.EngineTestCase): result: $ """ - wf_text = """--- + WF_TEXT = """--- version: '2.0' wf: @@ -61,16 +59,23 @@ class LookupUtilsTest(base.EngineTestCase): pause-before: true """ - wf_service.create_workflows(wf_text) + def test_action_definition_cache_ttl(self): + namespace = 'test_namespace' + wf_service.create_workflows(self.WF_TEXT, namespace=namespace) # Create an action. - db_actions = action_service.create_actions(action) + db_actions = action_service.create_actions(self.ACTION, + namespace=namespace) self.assertEqual(1, len(db_actions)) - self._assert_single_item(db_actions, name='action1') + self._assert_single_item(db_actions, + name='action1', + namespace=namespace) # Explicitly mark the action to be deleted after the test execution. - self.addCleanup(db_api.delete_action_definitions, name='action1') + self.addCleanup(db_api.delete_action_definitions, + name='action1', + namespace=namespace) # Reinitialise the cache with reduced action_definition_cache_time # to make sure the test environment is under control. @@ -84,13 +89,15 @@ class LookupUtilsTest(base.EngineTestCase): self.addCleanup(cache_patch.stop) # Start workflow. - wf_ex = self.engine.start_workflow('wf') + wf_ex = self.engine.start_workflow('wf', wf_namespace=namespace) self.await_workflow_paused(wf_ex.id) # Check that 'action1' 'echo' and 'noop' are cached. - self.assertEqual(3, len(db_api._ACTION_DEF_CACHE)) - self.assertIn('action1', db_api._ACTION_DEF_CACHE) + self.assertEqual(5, len(db_api._ACTION_DEF_CACHE)) + self.assertIn('action1:test_namespace', db_api._ACTION_DEF_CACHE) + self.assertIn('std.noop:test_namespace', db_api._ACTION_DEF_CACHE) + self.assertIn('std.echo:test_namespace', db_api._ACTION_DEF_CACHE) self.assertIn('std.noop', db_api._ACTION_DEF_CACHE) self.assertIn('std.echo', db_api._ACTION_DEF_CACHE) @@ -110,6 +117,7 @@ class LookupUtilsTest(base.EngineTestCase): self.await_workflow_success(wf_ex.id) # Check all actions are cached again. - self.assertEqual(2, len(db_api._ACTION_DEF_CACHE)) - self.assertIn('action1', db_api._ACTION_DEF_CACHE) + self.assertEqual(3, len(db_api._ACTION_DEF_CACHE)) + self.assertIn('action1:test_namespace', db_api._ACTION_DEF_CACHE) self.assertIn('std.echo', db_api._ACTION_DEF_CACHE) + self.assertIn('std.echo:test_namespace', db_api._ACTION_DEF_CACHE) diff --git a/mistral/tests/unit/engine/test_adhoc_actions.py b/mistral/tests/unit/engine/test_adhoc_actions.py index c962bc812..b497151a1 100644 --- a/mistral/tests/unit/engine/test_adhoc_actions.py +++ b/mistral/tests/unit/engine/test_adhoc_actions.py @@ -1,4 +1,5 @@ # Copyright 2014 - Mirantis, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +16,7 @@ from oslo_config import cfg from mistral.db.v2 import api as db_api +from mistral import exceptions as exc from mistral.services import workbooks as wb_service from mistral.tests.unit.engine import base from mistral.workflow import states @@ -24,7 +26,6 @@ from mistral.workflow import states # the change in value is not permanent. cfg.CONF.set_default('auth_enable', False, group='pecan') - WORKBOOK = """ --- version: '2.0' @@ -305,6 +306,78 @@ class AdhocActionsTest(base.EngineTestCase): self.await_workflow_running(wf_ex.id) + def test_adhoc_action_difinition_with_namespace(self): + namespace = 'ad-hoc_test' + namespace2 = 'ad-hoc_test2' + wb_text = """--- + + version: '2.0' + + name: my_wb_namespace + + actions: + test_env: + base: std.echo + base-input: + output: '{{ env().foo }}' + + workflows: + wf_namespace: + type: direct + input: + - str1 + output: + workflow_result: '{{ _.printenv_result }}' + + tasks: + printenv: + action: test_env + publish: + printenv_result: '{{ task().result }}' + """ + + wb_service.create_workbook_v2(wb_text, namespace=namespace) + wb_service.create_workbook_v2(wb_text, namespace=namespace2) + + with db_api.transaction(): + action_def = db_api.get_action_definitions( + name='my_wb_namespace.test_env', ) + + self.assertEqual(2, len(action_def)) + + action_def = db_api.get_action_definitions( + name='my_wb_namespace.test_env', + namespace=namespace) + + self.assertEqual(1, len(action_def)) + + self.assertRaises(exc.DBEntityNotFoundError, + db_api.get_action_definition, + name='my_wb_namespace.test_env') + + def test_adhoc_action_execution_with_namespace(self): + namespace = 'ad-hoc_test' + + wb_service.create_workbook_v2(WORKBOOK, namespace=namespace) + wf_ex = self.engine.start_workflow( + 'my_wb.wf4', + wf_input={'str1': 'a'}, + env={'foo': 'bar'}, + wf_namespace=namespace + ) + + self.await_workflow_success(wf_ex.id) + + with db_api.transaction(): + action_execs = db_api.get_action_executions( + name='std.echo', + workflow_namespace=namespace) + self.assertEqual(1, len(action_execs)) + context = action_execs[0].runtime_context + self.assertEqual('my_wb.test_env', + context.get('adhoc_action_name')) + self.assertEqual(namespace, action_execs[0].workflow_namespace) + def test_adhoc_action_runtime_context_name(self): wf_ex = self.engine.start_workflow( 'my_wb.wf4', diff --git a/mistral/tests/unit/engine/test_run_action.py b/mistral/tests/unit/engine/test_run_action.py index 2ee7d3209..a16d19e6d 100644 --- a/mistral/tests/unit/engine/test_run_action.py +++ b/mistral/tests/unit/engine/test_run_action.py @@ -1,4 +1,5 @@ # Copyright 2015 - Mirantis, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -100,6 +101,81 @@ class RunActionEngineTest(base.EngineTestCase): self.assertEqual('Hello!', action_ex.output['result']) self.assertEqual(states.SUCCESS, action_ex.state) + def test_run_action_with_namespace(self): + namespace = 'test_namespace' + action = """--- + version: '2.0' + + concat_namespace: + base: std.echo + base-input: + output: <% $.left %><% $.right %> + input: + - left + - right + + concat_namespace2: + base: concat_namespace + base-input: + left: <% $.left %><% $.center %> + right: <% $.right %> + input: + - left + - center + - right + """ + actions.create_actions(action, namespace=namespace) + + self.assertRaises(exc.InvalidActionException, + self.engine.start_action, + 'concat_namespace', + {'left': 'Hello, ', 'right': 'John Doe!'}, + save_result=True, + namespace='') + + action_ex = self.engine.start_action( + 'concat_namespace', + {'left': 'Hello, ', 'right': 'John Doe!'}, + save_result=True, + namespace=namespace + ) + + self.assertEqual(namespace, action_ex.workflow_namespace) + + self.await_action_success(action_ex.id) + + with db_api.transaction(): + action_ex = db_api.get_action_execution(action_ex.id) + self.assertEqual(states.SUCCESS, action_ex.state) + self.assertEqual({'result': u'Hello, John Doe!'}, action_ex.output) + + action_ex = self.engine.start_action( + 'concat_namespace2', + {'left': 'Hello, ', 'center': 'John', 'right': ' Doe!'}, + save_result=True, + namespace=namespace + ) + self.assertEqual(namespace, action_ex.workflow_namespace) + self.await_action_success(action_ex.id) + + with db_api.transaction(): + action_ex = db_api.get_action_execution(action_ex.id) + self.assertEqual(states.SUCCESS, action_ex.state) + self.assertEqual('Hello, John Doe!', action_ex.output['result']) + + def test_run_action_with_invalid_namespace(self): + # This test checks the case in which, the action with that name is + # not found with the given name, if an action was found with the + # same name in default namespace, that action will run. + + action_ex = self.engine.start_action( + 'concat', + {'left': 'Hello, ', 'right': 'John Doe!'}, + save_result=True, + namespace='namespace' + ) + self.assertIsNotNone(action_ex) + @mock.patch.object( std_actions.EchoAction, 'run', @@ -325,7 +401,7 @@ class RunActionEngineTest(base.EngineTestCase): self.engine.start_action('fake_action', {'input': 'Hello'}) self.assertEqual(1, def_mock.call_count) - def_mock.assert_called_with('fake_action') + def_mock.assert_called_with('fake_action', namespace='') self.assertEqual(0, validate_mock.call_count) diff --git a/mistral/tests/unit/services/test_action_service.py b/mistral/tests/unit/services/test_action_service.py index c7c8e289b..3b0a90000 100644 --- a/mistral/tests/unit/services/test_action_service.py +++ b/mistral/tests/unit/services/test_action_service.py @@ -1,4 +1,5 @@ # Copyright 2014 - Mirantis, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,17 +16,16 @@ from oslo_config import cfg from mistral.db.v2 import api as db_api +from mistral.exceptions import DBEntityNotFoundError from mistral.lang import parser as spec_parser from mistral.services import actions as action_service from mistral.tests.unit import base from mistral_lib import utils - # Use the set_default method to set value otherwise in certain test cases # the change in value is not permanent. cfg.CONF.set_default('auth_enable', False, group='pecan') - ACTION_LIST = """ --- version: '2.0' @@ -54,6 +54,8 @@ action1: result: $ """ +NAMESPACE = 'test_namespace' + class ActionServiceTest(base.DbTestCase): def setUp(self): @@ -78,14 +80,35 @@ class ActionServiceTest(base.DbTestCase): # Action 2. action2_db = self._assert_single_item(db_actions, name='action2') + self.assertEqual('', action2_db.namespace) action2_spec = spec_parser.get_action_spec(action2_db.spec) self.assertEqual('action2', action2_spec.get_name()) self.assertEqual('std.echo', action1_spec.get_base()) self.assertDictEqual({'output': 'Hey'}, action2_spec.get_base_input()) + def test_create_actions_in_namespace(self): + db_actions = action_service.create_actions(ACTION_LIST, + namespace=NAMESPACE) + + self.assertEqual(2, len(db_actions)) + + action1_db = self._assert_single_item(db_actions, name='action1') + self.assertEqual(NAMESPACE, action1_db.namespace) + + action2_db = self._assert_single_item(db_actions, name='action2') + self.assertEqual(NAMESPACE, action2_db.namespace) + + self.assertRaises( + DBEntityNotFoundError, + db_api.get_action_definition, + name='action1', + namespace='' + ) + def test_update_actions(self): - db_actions = action_service.create_actions(ACTION_LIST) + db_actions = action_service.create_actions(ACTION_LIST, + namespace=NAMESPACE) self.assertEqual(2, len(db_actions)) @@ -97,7 +120,8 @@ class ActionServiceTest(base.DbTestCase): self.assertDictEqual({'output': 'Hi'}, action1_spec.get_base_input()) self.assertDictEqual({}, action1_spec.get_input()) - db_actions = action_service.update_actions(UPDATED_ACTION_LIST) + db_actions = action_service.update_actions(UPDATED_ACTION_LIST, + namespace=NAMESPACE) # Action 1. action1_db = self._assert_single_item(db_actions, name='action1') @@ -112,3 +136,29 @@ class ActionServiceTest(base.DbTestCase): action1_spec.get_input().get('param1'), utils.NotDefined ) + + self.assertRaises( + DBEntityNotFoundError, + action_service.update_actions, + UPDATED_ACTION_LIST, + namespace='' + ) + + def test_delete_action(self): + + # Create action. + action_service.create_actions(ACTION_LIST, namespace=NAMESPACE) + + action = db_api.get_action_definition('action1', namespace=NAMESPACE) + self.assertEqual(NAMESPACE, action.get('namespace')) + self.assertEqual('action1', action.get('name')) + + # Delete action. + db_api.delete_action_definition('action1', namespace=NAMESPACE) + + self.assertRaises( + DBEntityNotFoundError, + db_api.get_action_definition, + name='action1', + namespace=NAMESPACE + ) diff --git a/mistral/tests/unit/services/test_workbook_service.py b/mistral/tests/unit/services/test_workbook_service.py index 25abce86c..24fa56c76 100644 --- a/mistral/tests/unit/services/test_workbook_service.py +++ b/mistral/tests/unit/services/test_workbook_service.py @@ -1,4 +1,5 @@ # Copyright 2014 - Mirantis, Inc. +# Copyright 2020 Nokia Software. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -181,7 +182,10 @@ class WorkbookServiceTest(base.DbTestCase): self.assertIsNotNone(wb_db.spec) self.assertListEqual(['test'], wb_db.tags) - db_actions = db_api.get_action_definitions(name='my_wb.concat') + db_actions = db_api.get_action_definitions( + name='my_wb.concat', + namespace=namespace + ) self.assertEqual(1, len(db_actions)) @@ -279,7 +283,8 @@ class WorkbookServiceTest(base.DbTestCase): wb_service.create_workbook_v2(WORKBOOK, namespace=namespace) db_wfs = db_api.get_workflow_definitions() - db_actions = db_api.get_action_definitions(name='my_wb.concat') + db_actions = db_api.get_action_definitions(name='my_wb.concat', + namespace=namespace) self.assertEqual(2, len(db_wfs)) self.assertEqual(1, len(db_actions)) @@ -287,8 +292,8 @@ class WorkbookServiceTest(base.DbTestCase): db_api.delete_workbook('my_wb', namespace=namespace) db_wfs = db_api.get_workflow_definitions() - db_actions = db_api.get_action_definitions(name='my_wb.concat') - + db_actions = db_api.get_action_definitions(name='my_wb.concat', + namespace=namespace) # Deleting workbook shouldn't delete workflows and actions self.assertEqual(2, len(db_wfs)) self.assertEqual(1, len(db_actions)) diff --git a/releasenotes/notes/namespace_for_adhoc_actions.yaml b/releasenotes/notes/namespace_for_adhoc_actions.yaml new file mode 100644 index 000000000..d190b0df3 --- /dev/null +++ b/releasenotes/notes/namespace_for_adhoc_actions.yaml @@ -0,0 +1,14 @@ +--- +features: + - | + Add support for creating ad-hoc actions in a namespace. Creating actions + with same name is now possible inside the same project now. This feature + is backward compatible. + + All existing actions are assumed to be in the default namespace, + represented by an empty string. Also, if an action is created without a + namespace specified, it is assumed to be in the default namespace. + + If an ad-hoc action is created inside a workbook, then the namespace of the workbook + would be also it's namespace. +