BP mistral-actions-design (add new actions package)

* Base class for actions (Action)
* Class for representing action namespaces (NameSpace)
* Module action_factory.py for registering action classes, accessing
  them and creating action instances
* Unit tests for action_factory.py
* Standard actions (echo, http, email)

TODO:
* Ad-hoc per-workflow namespaces and actions
* Mistral HTTP standard action
* Refactoring executor to use new action infrastructure
* Remove old action infrastructure
* Action plugin architecture

Partially implements: blueprint mistral-actions-design
Partially implements: blueprint mistral-change-rest-api-to-http

Change-Id: I8e79b1f8173809584f0156e666e1675f5bd7bb37
This commit is contained in:
Renat Akhmerov 2014-04-08 15:37:38 +07:00
parent ebeb68b6dc
commit 32720f2c72
7 changed files with 363 additions and 7 deletions

View File

View File

@ -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()

103
mistral/actions/base.py Normal file
View File

@ -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)

View File

@ -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]))

View File

@ -42,13 +42,6 @@ class DataAccessException(MistralException):
self.message = message self.message = message
class InvalidActionException(MistralException):
def __init__(self, message=None):
super(InvalidActionException, self).__init__(message)
if message:
self.message = message
class DBDuplicateEntry(MistralException): class DBDuplicateEntry(MistralException):
message = "Database object already exists" message = "Database object already exists"
code = "DB_DUPLICATE_ENTRY" code = "DB_DUPLICATE_ENTRY"
@ -68,6 +61,23 @@ class ActionException(MistralException):
self.message = message 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): class EngineException(MistralException):
code = "ENGINE_ERROR" code = "ENGINE_ERROR"

View File

View File

@ -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"))