diff --git a/mistral/actions/__init__.py b/mistral/actions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mistral/actions/action_factory.py b/mistral/actions/action_factory.py new file mode 100644 index 000000000..ff5f4f4d8 --- /dev/null +++ b/mistral/actions/action_factory.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 - 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 mistral.actions import base +from mistral.actions import std_actions + +_STD_NAMESPACE = "std" + +_NAMESPACES = {} + + +def _find_or_create_namespace(name): + ns = _NAMESPACES.get(name) + + if not ns: + ns = base.Namespace(name) + _NAMESPACES[name] = ns + + return ns + + +def register_action_class(namespace_name, action_name, action_cls): + _find_or_create_namespace(namespace_name).add(action_name, action_cls) + + +def get_registered_namespaces(): + return _NAMESPACES.copy() + + +def _register_standard_action_classes(): + register_action_class(_STD_NAMESPACE, "echo", std_actions.EchoAction) + register_action_class(_STD_NAMESPACE, "http", std_actions.HTTPAction) + register_action_class(_STD_NAMESPACE, "email", std_actions.SendEmailAction) + + +def create_action(db_task): + # TODO: Implement + return None + + +# Registering standard actions on module load. +_register_standard_action_classes() diff --git a/mistral/actions/base.py b/mistral/actions/base.py new file mode 100644 index 000000000..7a4e19eab --- /dev/null +++ b/mistral/actions/base.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 - 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 abc + +from mistral.openstack.common import log as logging +from mistral import exceptions as exc + +LOG = logging.getLogger(__name__) + + +class Action(object): + """Action. + + Action is a means in Mistral to perform some useful work associated with + a workflow during its execution. Every workflow task is configured with + an action and when the task runs it eventually delegates to the action. + When it happens task parameters get evaluated (calculating expressions, + if any) and are treated as action parameters. So in a regular general + purpose languages terminology action is a method declaration and task is + a method call. + """ + + def __init__(self, namespace, name): + self.namespace = namespace + self.name = name + + @abc.abstractmethod + def run(self): + """Run action logic. + + :return: result of the action. Note that for asynchronous actions + it should always be None, however, if even it's not None it will be + ignored by a caller. + + In case if action failed this method must throw a ActionException + to indicate that. + """ + pass + + @abc.abstractmethod + def test(self): + """Returns action test result. + + This method runs in test mode as a test version of method run() to + generate and return a representative test result. It's basically a + contract for action 'dry-run' behavior specifically useful for + testing and workflow designing purposes. + + :return: Representative action result. + """ + pass + + def is_sync(self): + """Returns True if the action is synchronous, otherwise False. + + :return: True if the action is synchronous and method run() returns + final action result. Otherwise returns False which means that + a result of method run() should be ignored and a real action + result is supposed to be delivered in an asynchronous manner + using public API. By default, if a concrete implementation + doesn't override this method then the action is synchronous. + """ + return True + + +class Namespace(object): + """Action namespace.""" + + def __init__(self, name): + self.name = name + self.action_classes = {} # action name -> action class + + def add(self, action_name, action_cls): + if action_name in self.action_classes: + raise exc.ActionRegistrationException( + "Action is already associated with namespace " + "[action_cls=%s, namespace=%s" % + (action_cls, self)) + + self.action_classes[action_name] = action_cls + + def contains_action_name(self, name): + return name in self.action_classes + + def get_action_class(self, name): + return self.action_classes.get(name) + + def __len__(self): + return len(self.action_classes) diff --git a/mistral/actions/std_actions.py b/mistral/actions/std_actions.py new file mode 100644 index 000000000..474f13a10 --- /dev/null +++ b/mistral/actions/std_actions.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 - 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 json +import smtplib +from email.mime.text import MIMEText + +import requests + +from mistral.openstack.common import log as logging +from mistral.actions import base +from mistral import exceptions as exc + + +LOG = logging.getLogger(__name__) + + +class EchoAction(base.Action): + """Echo action. + + This action just returns a configured value as a result without doing + anything else. The value of such action implementation is that it + can be used in development (for testing), demonstration and designing + of workflows themselves where echo action can play the role of temporary + stub. + """ + + def __init__(self, namespace, name, output): + super(EchoAction, self).__init__(namespace, name) + self.output = output + + def run(self): + LOG.info('Running echo action [output=%s]' % self.output) + + return self.output + + def test(self): + return 'Echo' + + +class HTTPAction(base.Action): + def __init__(self, namespace, name, url, params={}, + method="GET", headers={}, body={}): + super(HTTPAction, self).__init__(namespace, name) + + self.url = url + self.params = params + self.method = method + self.headers = headers + self.body = json.dumps(body) if isinstance(body, dict) else body + + def run(self): + LOG.info("Running HTTP action " + "[url=%s, params=%s, method=%s, headers=%s, body=%s]" % + (self.method, + self.url, + self.params, + self.headers, + self.body)) + + try: + resp = requests.request(self.method, + self.url, + params=self.params, + headers=self.headers, + data=self.body) + except Exception as e: + raise exc.ActionException("Failed to send HTTP request: %s" % e) + + LOG.info("HTTP action response:\n%s\n%s" % + (resp.status_code, resp.content)) + + # TODO: Not sure we need to have this check here in base HTTP action. + if resp.status_code not in range(200, 307): + raise exc.ActionException("Received error HTTP code: %s" % + resp.status_code) + + return resp + + +class SendEmailAction(base.Action): + def __init__(self, namespace, name, params, settings): + super(SendEmailAction, self).__init__(namespace, name) + #TODO(dzimine): validate parameters + + # Task invocation parameters. + self.to = ', '.join(params['to']) + self.subject = params['subject'] + self.body = params['body'] + + # Action provider settings. + self.smtp_server = settings['smtp_server'] + self.sender = settings['from'] + self.password = settings['password'] \ + if 'password' in settings else None + + def run(self): + LOG.info("Sending email message " + "[from=%s, to=%s, subject=%s, using smtp=%s, body=%s...]" % + (self.sender, self.to, self.subject, + self.smtp_server, self.body[:128])) + + #TODO(dzimine): handle utf-8, http://stackoverflow.com/a/14506784 + message = MIMEText(self.body) + message['Subject'] = self.subject + message['From'] = self.sender + message['To'] = self.to + + try: + s = smtplib.SMTP(self.smtp_server) + + if self.password is not None: + # Sequence to request TLS connection and log in (RFC-2487). + s.ehlo() + s.starttls() + s.ehlo() + s.login(self.sender, self.password) + + s.sendmail(from_addr=self.sender, + to_addrs=self.to, + msg=message.as_string()) + except (smtplib.SMTPException, IOError) as e: + raise exc.ActionException("Failed to send an email message: %s" + % e) + + def test(self): + # Just logging the operation since this action is not supposed + # to return a result. + LOG.info("Sending email message " + "[from=%s, to=%s, subject=%s, using smtp=%s, body=%s...]" % + (self.sender, self.to, self.subject, + self.smtp_server, self.body[:128])) diff --git a/mistral/exceptions.py b/mistral/exceptions.py index 7461931b0..56b3fa66b 100644 --- a/mistral/exceptions.py +++ b/mistral/exceptions.py @@ -42,13 +42,6 @@ class DataAccessException(MistralException): self.message = message -class InvalidActionException(MistralException): - def __init__(self, message=None): - super(InvalidActionException, self).__init__(message) - if message: - self.message = message - - class DBDuplicateEntry(MistralException): message = "Database object already exists" code = "DB_DUPLICATE_ENTRY" @@ -68,6 +61,23 @@ class ActionException(MistralException): self.message = message +class InvalidActionException(MistralException): + def __init__(self, message=None): + super(InvalidActionException, self).__init__(message) + if message: + self.message = message + + +class ActionRegistrationException(MistralException): + message = "Failed to register action" + code = "ACTION_REGISTRATION_ERROR" + + def __init__(self, message=None): + super(ActionRegistrationException, self).__init__(message) + if message: + self.message = message + + class EngineException(MistralException): code = "ENGINE_ERROR" diff --git a/mistral/tests/unit/actions/__init__.py b/mistral/tests/unit/actions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mistral/tests/unit/actions/test_action_factory.py b/mistral/tests/unit/actions/test_action_factory.py new file mode 100644 index 000000000..36796e597 --- /dev/null +++ b/mistral/tests/unit/actions/test_action_factory.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2014 - 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 mistral.openstack.common import log as logging +from mistral.actions import action_factory +from mistral.actions import std_actions as std +from mistral.tests import base + +LOG = logging.getLogger(__name__) + + +class ActionFactoryTest(base.BaseTest): + def test_register_standard_actions(self): + namespaces = action_factory.get_registered_namespaces() + + self.assertEqual(1, len(namespaces)) + self.assertIn("std", namespaces) + + std_ns = namespaces["std"] + + self.assertEqual(3, len(std_ns)) + + self.assertTrue(std_ns.contains_action_name("echo")) + self.assertTrue(std_ns.contains_action_name("http")) + self.assertTrue(std_ns.contains_action_name("email")) + + self.assertEqual(std.EchoAction, std_ns.get_action_class("echo")) + self.assertEqual(std.HTTPAction, std_ns.get_action_class("http")) + self.assertEqual(std.SendEmailAction, + std_ns.get_action_class("email"))