Implement API call and RPC call for static actions

Static public methods can be called synchronously through the
API call without creating environment, object instances and
database records. It is proposed to make RPC call as the single
request-responce for now.
However async API and RPC calls may also be implemented later
exploiting the same pattern as for calling instance methods.
New call can be done through client method (see
Ib6a60f8e33c5d3593a55db9f758e94e27f0a4445)
Tempest and unit tests are added.

APIImpact
Implements: blueprint static-actions
Change-Id: I17ab2eba0fd6c42309667f42d0644d21940ab02d
This commit is contained in:
Valerii Kovalchuk 2016-06-27 16:46:20 +03:00 committed by Stan Lagun
parent 75bded129e
commit 7d186c191d
17 changed files with 623 additions and 13 deletions

View File

@ -83,3 +83,54 @@ Response:
}
}
}
==============
Static actions
==============
Static methods (:ref:`static_methods_and_properties`) can also be called
through the API if they are exposed by specifying ``Scope: Public``, and the
result of its execution will be returned.
Consider the following example of the static action that makes use both of
static class property and user's input as an argument:
::
Name: Bar
Properties:
greeting:
Usage: Static
Contract: $.string()
Default: 'Hello, '
Methods:
staticAction:
Scope: Public
Usage: Static
Arguments:
- myName:
Contract: $.string().notNull()
Body:
- Return: concat($.greeting, $myName)
Request:
``http://localhost:8082/v1/actions``
Request body:
.. code-block:: javascript
{
"className": "ns.Bar",
"methodName": "staticAction",
"parameters": {"myName": "John"}
}
Responce:
.. code-block:: javascript
"Hello, John"

View File

@ -54,8 +54,8 @@ particular version of the class).
Declaration of static methods and properties
--------------------------------------------
Methods and properties are declared to be static by specifying ``Usage: Static``
on them.
Methods and properties are declared to be static by specifying
``Usage: Static`` on them.
For example:
@ -74,9 +74,10 @@ For example:
Static properties are never initialized from object model but can be modified
from within MuranoPL code (i.e. they are not immutable).
Static methods cannot be executed as an `Action` from outside. Within static
method `Body` ``$this`` (and ``$`` if not set to something else in expression)
are set to type object rather than to instance, as it is for regular methods.
Static methods also can be executed as an action from outside using
``Scope: Public``. Within static method `Body` ``$this`` (and ``$`` if not
set to something else in expression) are set to type object rather than to
instance, as it is for regular methods.
Static methods written in Python

View File

@ -963,3 +963,77 @@ Json, describing action result is returned. Result type and value are provided.
"isException": false,
"result": ["item1", "item2"]
}
Static Actions API
==================
Static actions are MuranoPL methods that can be called on a MuranoPL class
without deploying actual applications and usually return a result.
Execute a static action
-----------------------
Invoke public static method of the specified MuranoPL class.
Input parameters may be provided if method requires them.
*Request*
**Content-Type**
application/json
+----------------+-----------------------------------------------------------+------------------------------------+
| Method | URI | Header |
+================+===========================================================+====================================+
| POST | /actions | |
+----------------+-----------------------------------------------------------+------------------------------------+
::
{
"className": "my.class.fqn",
"methodName": "myMethod",
"packageName": "optional.package.fqn",
"classVersion": "1.2.3",
"parameters": {
"arg1": "value1",
"arg2": "value2"
}
}
+-----------------+------------+-----------------------------------------------------------------------------+
| Attribute | Type | Description |
+=================+============+=============================================================================+
| className | string | Fully qualified name of MuranoPL class with static method |
+-----------------+------------+-----------------------------------------------------------------------------+
| methodName | string | Name of the method to invoke |
+-----------------+------------+-----------------------------------------------------------------------------+
| packageName | string | Fully qualified name of a package with the MuranoPL class (optional) |
+-----------------+------------+-----------------------------------------------------------------------------+
| classVersion | string | Class version specification, "=0" by default |
+-----------------+------------+-----------------------------------------------------------------------------+
| parameters | object | Key-value pairs of method parameter names and their values, "{}" by default |
+-----------------+------------+-----------------------------------------------------------------------------+
*Response*
JSON-serialized result of the static method execution.
HTTP codes:
+----------------+-----------------------------------------------------------+
| Code | Description |
+================+===========================================================+
| 200 | OK. Action was executed successfully |
+----------------+-----------------------------------------------------------+
| 400 | Bad request. The format of the body is invalid, method |
| | doesn't match provided arguments, mandatory arguments are |
| | not provided |
+----------------+-----------------------------------------------------------+
| 403 | User is not allowed to execute the action |
+----------------+-----------------------------------------------------------+
| 404 | Not found. Specified class, package or method doesn't |
| | exist or method is not exposed |
+----------------+-----------------------------------------------------------+
| 503 | Unhandled exception in the action |
+----------------+-----------------------------------------------------------+

View File

@ -22,6 +22,7 @@ from murano.api.v1 import instance_statistics
from murano.api.v1 import request_statistics
from murano.api.v1 import services
from murano.api.v1 import sessions
from murano.api.v1 import static_actions
from murano.api.v1 import template_applications
from murano.api.v1 import templates
from murano.common import wsgi
@ -206,6 +207,12 @@ class API(wsgi.Router):
action='get_result',
conditions={'method': ['GET']})
static_actions_resource = static_actions.create_resource()
mapper.connect('/actions',
controller=static_actions_resource,
action='execute',
conditions={'method': ['POST']})
catalog_resource = catalog.create_resource()
mapper.connect('/catalog/packages/categories',
controller=catalog_resource,

View File

@ -0,0 +1,75 @@
# Copyright (c) 2016 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 oslo_log import log as logging
from oslo_messaging.rpc import client
from webob import exc
from murano.common.i18n import _LE, _
from murano.common import policy
from murano.common import wsgi
from murano.services import static_actions
LOG = logging.getLogger(__name__)
class Controller(object):
def execute(self, request, body):
policy.check("execute_action", request.context, {})
class_name = body.get('className')
method_name = body.get('methodName')
if not class_name or not method_name:
msg = _('Class name and method name must be specified for '
'static action')
LOG.error(msg)
raise exc.HTTPBadRequest(msg)
args = body.get('parameters')
pkg_name = body.get('packageName')
class_version = body.get('classVersion', '=0')
LOG.debug('StaticAction:Execute <MethodName: {0}, '
'ClassName: {1}>'.format(method_name, class_name))
credentials = {
'token': request.context.auth_token,
'tenant_id': request.context.tenant
}
try:
return static_actions.StaticActionServices.execute(
method_name, class_name, pkg_name, class_version, args,
credentials)
except client.RemoteError as e:
LOG.error(_LE('Exception during call of the method {method_name}: '
'{exc}').format(method_name=method_name, exc=str(e)))
if e.exc_type in (
'NoClassFound', 'NoMethodFound', 'NoPackageFound',
'NoPackageForClassFound', 'MethodNotExposed',
'NoMatchingMethodException'):
raise exc.HTTPNotFound(e.value)
elif e.exc_type == 'ContractViolationException':
raise exc.HTTPBadRequest(e.value)
raise exc.HTTPServiceUnavailable(e.value)
except ValueError as e:
LOG.error(_LE('Exception during call of the method {method_name}: '
'{exc}').format(method_name=method_name, exc=str(e)))
raise exc.HTTPBadRequest(e.message)
def create_resource():
return wsgi.Resource(Controller())

View File

@ -57,7 +57,7 @@ class EngineService(service.Service):
self.server = None
def start(self):
endpoints = [TaskProcessingEndpoint()]
endpoints = [TaskProcessingEndpoint(), StaticActionEndpoint()]
transport = messaging.get_transport(CONF)
s_target = target.Target('murano', 'tasks', server=str(uuid.uuid4()))
@ -127,6 +127,25 @@ class TaskProcessingEndpoint(object):
task_desc=jsonutils.dumps(result)))
class StaticActionEndpoint(object):
@classmethod
def call_static_action(cls, context, task):
s_task = token_sanitizer.TokenSanitizer().sanitize(task)
LOG.info(_LI('Starting execution of static action: '
'{task_desc}').format(task_desc=jsonutils.dumps(s_task)))
result = None
reporter = status_reporter.StatusReporter(task['id'])
try:
task_executor = StaticActionExecutor(task, reporter)
result = task_executor.execute()
return result
finally:
LOG.info(_LI('Finished execution of static action: '
'{task_desc}').format(task_desc=jsonutils.dumps(result)))
class TaskExecutor(object):
@property
def action(self):
@ -288,3 +307,49 @@ class TaskExecutor(object):
auth_utils.delete_trust(self._session.trust_id)
self._session.system_attributes['TrustId'] = None
self._session.trust_id = None
class StaticActionExecutor(object):
@property
def action(self):
return self._action
@property
def session(self):
return self._session
def __init__(self, task, reporter=None):
if reporter is None:
reporter = status_reporter.StatusReporter(task['id'])
self._action = task['action']
self._session = execution_session.ExecutionSession()
self._session.token = task['token']
self._session.project_id = task['tenant_id']
self._reporter = reporter
self._model_policy_enforcer = enforcer.ModelPolicyEnforcer(
self._session)
def execute(self):
with package_loader.CombinedPackageLoader(self._session) as pkg_loader:
get_plugin_loader().register_in_loader(pkg_loader)
executor = dsl_executor.MuranoDslExecutor(pkg_loader,
ContextManager())
action_result = self._invoke(executor)
action_result = serializer.serialize(action_result, executor)
return action_result
def _invoke(self, mpl_executor):
class_name = self.action['class_name']
pkg_name = self.action['pkg_name']
class_version = self.action['class_version']
version_spec = helpers.parse_version_spec(class_version)
if pkg_name:
package = mpl_executor.package_loader.load_package(
pkg_name, version_spec)
else:
package = mpl_executor.package_loader.load_class_package(
class_name, version_spec)
cls = package.find_class(class_name, search_requirements=False)
method_name, kwargs = self.action['method'], self.action['args']
return cls.invoke(method_name, mpl_executor, None, (), kwargs)

View File

@ -40,6 +40,9 @@ class EngineClient(object):
def handle_task(self, task):
return self._client.cast({}, 'handle_task', task=task)
def call_static_action(self, task):
return self._client.call({}, 'call_static_action', task=task)
def api():
global TRANSPORT

View File

@ -77,6 +77,10 @@ class NoObjectFoundError(Exception):
'Object "%s" is not found in object store' % object_id)
class MethodNotExposed(Exception):
pass
class AmbiguousMethodName(Exception):
def __init__(self, name):
super(AmbiguousMethodName, self).__init__(

View File

@ -30,6 +30,7 @@ from murano.dsl import attribute_store
from murano.dsl import constants
from murano.dsl import dsl
from murano.dsl import dsl_types
from murano.dsl import exceptions as dsl_exceptions
from murano.dsl import helpers
from murano.dsl import object_store
from murano.dsl.principal_objects import stack_trace
@ -97,7 +98,8 @@ class MuranoDslExecutor(object):
*args, **kwargs)
if context[constants.CTX_ACTIONS_ONLY] and not method.is_action:
raise Exception('{0} is not an action'.format(method.name))
raise dsl_exceptions.MethodNotExposed(
'{0} is not an action'.format(method.name))
if method.is_static:
obj_context = self.create_object_context(

View File

@ -0,0 +1,36 @@
# 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 uuid
from murano.common import rpc
class StaticActionServices(object):
@staticmethod
def execute(method_name, class_name, pkg_name, class_version, args,
credentials):
action = {
'method': method_name,
'args': args or {},
'class_name': class_name,
'pkg_name': pkg_name,
'class_version': class_version
}
task = {
'action': action,
'token': credentials['token'],
'tenant_id': credentials['tenant_id'],
'id': str(uuid.uuid4())
}
return rpc.engine().call_static_action(task)

View File

@ -0,0 +1,65 @@
# Copyright (c) 2016 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 mock
from oslo_serialization import jsonutils
from murano.api.v1 import static_actions
from murano.common import policy
import murano.tests.unit.api.base as tb
@mock.patch.object(policy, 'check')
class TestStaticActionsApi(tb.ControllerTest, tb.MuranoApiTestCase):
def setUp(self):
super(TestStaticActionsApi, self).setUp()
self.controller = static_actions.Controller()
def test_execute_static_action(self, mock_policy_check):
"""Test that action execution results in the correct rpc call."""
self._set_policy_rules(
{'execute_action': '@'}
)
action = {
'method': 'TestAction',
'args': {'name': 'John'},
'class_name': 'TestClass',
'pkg_name': 'TestPackage',
'class_version': '=0'
}
rpc_task = {
'action': action,
'token': None,
'tenant_id': 'test_tenant',
'id': mock.ANY
}
request_data = {
"className": 'TestClass',
"methodName": 'TestAction',
"packageName": 'TestPackage',
"classVersion": '=0',
"parameters": {'name': 'John'}
}
req = self._post('/actions', jsonutils.dump_as_bytes(request_data))
try:
self.controller.execute(req, request_data)
except TypeError:
pass
self.mock_engine_rpc.call_static_action.assert_called_once_with(
rpc_task)

View File

@ -2,7 +2,8 @@ Namespaces:
=: io.murano.apps
std: io.murano
# Name: MockApp # use name from the manifest
# Write name into next line
Name: test_repository_class_xxxxxxxx
Extends: std:Application
@ -11,13 +12,18 @@ Properties:
userName:
Contract: $.string()
greeting:
Usage: Static
Contract: $.string()
Default: 'Hello, '
Methods:
testAction:
Usage: Action
Scope: Public
Body:
- $this.find(std:Environment).reporter.report($this, 'Completed')
getCredentials:
Usage: Action
Scope: Public
Body:
- Return:
credentials:
@ -25,3 +31,20 @@ Methods:
deploy:
Body:
- $this.find(std:Environment).reporter.report($this, 'Follow the white rabbit')
staticAction:
Scope: Public
Usage: Static
Arguments:
- myName:
Contract: $.string().notNull()
Body:
- Return: concat($.greeting, $myName)
staticNotAction:
Usage: Static
Arguments:
- myName:
Contract: $.string().notNull()
Body:
- Return: concat($.greeting, $myName)

View File

@ -365,3 +365,22 @@ class ApplicationCatalogClient(rest_client.RestClient):
resp, body = self.post(uri, json.dumps(body))
self.expected_success(200, resp.status)
return self._parse_resp(body)
# ----------------------------Static action methods----------------------------
def call_static_action(self, class_name=None, method_name=None, args=None,
package_name=None, class_version="=0"):
uri = 'v1/actions'
post_body = {
'parameters': args or {},
'packageName': package_name,
'classVersion': class_version
}
if class_name:
post_body['className'] = class_name
if method_name:
post_body['methodName'] = method_name
resp, body = self.post(uri, json.dumps(post_body))
self.expected_success(200, resp.status)
# _parse_resp() cannot be used because body is expected to be string
return body

View File

@ -0,0 +1,65 @@
# Copyright (c) 2016 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
import testtools
from tempest import config
from murano_tempest_tests.tests.api.application_catalog import base
from murano_tempest_tests import utils
CONF = config.CONF
class TestStaticActions(base.BaseApplicationCatalogTest):
@classmethod
def resource_setup(cls):
if CONF.application_catalog.glare_backend:
msg = ("Murano using GLARE backend. "
"Static actions tests will be skipped.")
raise cls.skipException(msg)
super(TestStaticActions, cls).resource_setup()
application_name = utils.generate_name('test_repository_class')
cls.abs_archive_path, dir_with_archive, archive_name = \
utils.prepare_package(application_name, add_class_name=True)
cls.package = cls.application_catalog_client.upload_package(
application_name, archive_name, dir_with_archive,
{"categories": [], "tags": [], 'is_public': False})
@classmethod
def resource_cleanup(cls):
super(TestStaticActions, cls).resource_cleanup()
os.remove(cls.abs_archive_path)
cls.application_catalog_client.delete_package(cls.package['id'])
@testtools.testcase.attr('smoke')
def test_call_static_action_basic(self):
action_result = self.application_catalog_client.call_static_action(
class_name=self.package['class_definitions'][0],
method_name='staticAction',
args={'myName': 'John'})
self.assertEqual('"Hello, John"', action_result)
@testtools.testcase.attr('smoke')
def test_call_static_action_full(self):
action_result = self.application_catalog_client.call_static_action(
class_name=self.package['class_definitions'][0],
method_name='staticAction',
package_name=self.package['fully_qualified_name'],
class_version="<1", args={'myName': 'John'})
self.assertEqual('"Hello, John"', action_result)

View File

@ -0,0 +1,103 @@
# Copyright (c) 2016 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
import testtools
from tempest import config
from tempest.lib import exceptions
from murano_tempest_tests.tests.api.application_catalog import base
from murano_tempest_tests import utils
CONF = config.CONF
class TestStaticActionsNegative(base.BaseApplicationCatalogTest):
@classmethod
def resource_setup(cls):
if CONF.application_catalog.glare_backend:
msg = ("Murano using GLARE backend. "
"Static actions tests will be skipped.")
raise cls.skipException(msg)
super(TestStaticActionsNegative, cls).resource_setup()
application_name = utils.generate_name('test_repository_class')
cls.abs_archive_path, dir_with_archive, archive_name = \
utils.prepare_package(application_name, add_class_name=True)
cls.package = cls.application_catalog_client.upload_package(
application_name, archive_name, dir_with_archive,
{"categories": [], "tags": [], 'is_public': False})
@classmethod
def resource_cleanup(cls):
super(TestStaticActionsNegative, cls).resource_cleanup()
os.remove(cls.abs_archive_path)
cls.application_catalog_client.delete_package(cls.package['id'])
@testtools.testcase.attr('negative')
def test_call_static_action_no_args(self):
self.assertRaises(exceptions.BadRequest,
self.application_catalog_client.call_static_action)
@testtools.testcase.attr('negative')
def test_call_static_action_wrong_class(self):
self.assertRaises(exceptions.NotFound,
self.application_catalog_client.call_static_action,
'wrong.class', 'staticAction',
args={'myName': 'John'})
@testtools.testcase.attr('negative')
def test_call_static_action_wrong_method(self):
self.assertRaises(exceptions.NotFound,
self.application_catalog_client.call_static_action,
class_name=self.package['class_definitions'][0],
method_name='wrongMethod',
args={'myName': 'John'})
@testtools.testcase.attr('negative')
def test_call_static_action_session_method(self):
self.assertRaises(exceptions.NotFound,
self.application_catalog_client.call_static_action,
class_name=self.package['class_definitions'][0],
method_name='staticNotAction',
args={'myName': 'John'})
@testtools.testcase.attr('negative')
def test_call_static_action_wrong_args(self):
self.assertRaises(exceptions.BadRequest,
self.application_catalog_client.call_static_action,
class_name=self.package['class_definitions'][0],
method_name='staticAction',
args={'myEmail': 'John'})
@testtools.testcase.attr('negative')
def test_call_static_action_wrong_package(self):
self.assertRaises(exceptions.NotFound,
self.application_catalog_client.call_static_action,
class_name=self.package['class_definitions'][0],
method_name='staticAction',
package_name='wrong.package',
args={'myName': 'John'})
@testtools.testcase.attr('negative')
def test_call_static_action_wrong_version_format(self):
self.assertRaises(exceptions.BadRequest,
self.application_catalog_client.call_static_action,
class_name=self.package['class_definitions'][0],
method_name='staticAction',
class_version='aaa',
args={'myName': 'John'})

View File

@ -25,7 +25,7 @@ MANIFEST = {'Format': 'MuranoPL/1.0',
def compose_package(app_name, manifest, package_dir,
require=None, archive_dir=None):
require=None, archive_dir=None, add_class_name=False):
"""Composes a murano package
Composes package `app_name` with `manifest` file as a template for the
@ -44,6 +44,14 @@ def compose_package(app_name, manifest, package_dir,
mfest_copy['Require'] = require
f.write(yaml.dump(mfest_copy, default_flow_style=False))
if add_class_name:
class_file = os.path.join(package_dir, 'Classes', 'mock_muranopl.yaml')
with open(class_file, 'r+') as f:
line = ''
while line != '# Write name into next line\n':
line = f.readline()
f.write('Name: {0}'.format(app_name))
name = app_name + '.zip'
if not archive_dir:
@ -61,11 +69,12 @@ def compose_package(app_name, manifest, package_dir,
return archive_path, name
def prepare_package(name, require=None):
def prepare_package(name, require=None, add_class_name=False):
"""Prepare package.
:param name: Package name to compose
:param require: Parameter 'require' for manifest
:param add_class_name: Option to write class name to class file
:return: Path to archive, directory with archive, filename of archive
"""
app_dir = acquire_package_directory()
@ -73,7 +82,8 @@ def prepare_package(name, require=None):
arc_path, filename = compose_package(
name, os.path.join(app_dir, 'manifest.yaml'),
app_dir, require=require, archive_dir=target_arc_path)
app_dir, require=require, archive_dir=target_arc_path,
add_class_name=add_class_name)
return arc_path, target_arc_path, filename

View File

@ -0,0 +1,7 @@
---
features:
- Static public methods can be called synchronously through the API call
"http://murano-url:port/v1/actions" specifying class name, method name,
method arguments and optionally package name and class version in the
request body. This call does not create environment, object instances and
database records.