From e47ef4e8495da8ea9e70f29f693620a808531106 Mon Sep 17 00:00:00 2001 From: Stan Lagun Date: Wed, 11 Jun 2014 22:47:32 +0400 Subject: [PATCH] Adds REST API endpoint for action execution Deployment is replaced with a more generic concept called 'action'. Action can be performed on any object of Object Model. Actions are marked with 'Usage: Action' in MuranoPL code. They can have arguments. List of available actions can be obtained from Object Model itself after initial deployment. This commit adds ability to REST API invoke actions by providing its unique id (from OM) and parameters. Also refactors API code to use tasks. Change-Id: If21809340bb799af58a8d1a2d148e52565028970 Partially-Implements: blueprint application-actions --- murano/api/v1/actions.py | 73 ++++++++++ murano/api/v1/deployments.py | 24 ++-- murano/api/v1/router.py | 7 + murano/api/v1/sessions.py | 3 +- murano/common/server.py | 12 +- .../versions/003_add_action_entry.py | 92 ++++++++++++ murano/db/migration/helpers.py | 56 ++++++++ murano/db/models.py | 27 ++-- murano/db/services/actions.py | 39 +++++ murano/db/services/environments.py | 19 ++- murano/db/services/sessions.py | 53 ++----- murano/services/__init__.py | 0 murano/services/actions.py | 107 ++++++++++++++ murano/services/state.py | 20 +++ murano/tests/test_actions.py | 135 ++++++++++++++++++ murano/tests/unit/api/v1/test_actions.py | 87 +++++++++++ murano/tests/unit/api/v1/test_environments.py | 10 -- .../unit/db/migration/test_migrations.py | 5 + 18 files changed, 679 insertions(+), 90 deletions(-) create mode 100644 murano/api/v1/actions.py create mode 100644 murano/db/migration/alembic_migrations/versions/003_add_action_entry.py create mode 100644 murano/db/migration/helpers.py create mode 100644 murano/db/services/actions.py create mode 100644 murano/services/__init__.py create mode 100644 murano/services/actions.py create mode 100644 murano/services/state.py create mode 100644 murano/tests/test_actions.py create mode 100644 murano/tests/unit/api/v1/test_actions.py diff --git a/murano/api/v1/actions.py b/murano/api/v1/actions.py new file mode 100644 index 00000000..1785cfd0 --- /dev/null +++ b/murano/api/v1/actions.py @@ -0,0 +1,73 @@ +# Copyright (c) 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 webob import exc + +from murano.common import policy +from murano.db import models +from murano.db.services import environments as envs +from murano.db.services import sessions +from murano.db import session as db_session + +from murano.openstack.common.gettextutils import _ # noqa +from murano.openstack.common import log as logging +from murano.openstack.common import wsgi +from murano.services import actions + + +LOG = logging.getLogger(__name__) + + +class Controller(object): + def execute(self, request, environment_id, action_id, body): + policy.check("execute_action", request.context, {}) + + LOG.debug('Action:Execute '.format(action_id)) + + unit = db_session.get_session() + environment = unit.query(models.Environment).get(environment_id) + + if environment is None: + LOG.info(_('Environment ' + 'is not found').format(environment_id)) + raise exc.HTTPNotFound + + if environment.tenant_id != request.context.tenant: + LOG.info(_('User is not authorized to access ' + 'this tenant resources.')) + raise exc.HTTPUnauthorized + + # no new session can be opened if environment has deploying status + env_status = envs.EnvironmentServices.get_status(environment_id) + if env_status in (envs.EnvironmentStatus.DEPLOYING, + envs.EnvironmentStatus.DELETING): + LOG.info(_('Could not open session for environment ,' + 'environment has deploying ' + 'status.').format(environment_id)) + raise exc.HTTPForbidden() + + user_id = request.context.user + session = sessions.SessionServices.create(environment_id, user_id) + + if not sessions.SessionServices.validate(session): + LOG.error(_('Session ' + 'is invalid').format(session.id)) + raise exc.HTTPForbidden() + + actions.ActionServices.execute(action_id, session, unit, + request.context.auth_token, body or {}) + + +def create_resource(): + return wsgi.Resource(Controller()) diff --git a/murano/api/v1/deployments.py b/murano/api/v1/deployments.py index 8f214772..ce9cb885 100644 --- a/murano/api/v1/deployments.py +++ b/murano/api/v1/deployments.py @@ -15,6 +15,7 @@ from sqlalchemy import desc from webob import exc from murano.api.v1 import request_statistics +from murano.common.helpers import token_sanitizer from murano.common import policy from murano.common import utils from murano.db import models @@ -37,10 +38,13 @@ class Controller(object): unit = db_session.get_session() verify_and_get_env(unit, environment_id, request) - query = unit.query(models.Deployment) \ + query = unit.query(models.Task) \ .filter_by(environment_id=environment_id) \ - .order_by(desc(models.Deployment.created)) + .order_by(desc(models.Task.created)) result = query.all() + # show only tasks with 'deploy' action + result = [task for task in result + if (task.action or {}).get('method', 'deploy') == 'deploy'] deployments = [set_dep_state(deployment, unit).to_dict() for deployment in result] return {'deployments': deployments} @@ -53,7 +57,7 @@ class Controller(object): unit = db_session.get_session() query = unit.query(models.Status) \ - .filter_by(deployment_id=deployment_id) \ + .filter_by(task_id=deployment_id) \ .order_by(models.Status.created) deployment = verify_and_get_deployment(unit, environment_id, deployment_id) @@ -88,12 +92,12 @@ def verify_and_get_env(db_session, environment_id, request): def _patch_description(description): - description['services'] = description.get('applications', []) - del description['applications'] + description['services'] = description.pop('applications', []) + return token_sanitizer.TokenSanitizer().sanitize(description) def verify_and_get_deployment(db_session, environment_id, deployment_id): - deployment = db_session.query(models.Deployment).get(deployment_id) + deployment = db_session.query(models.Task).get(deployment_id) if not deployment: LOG.info(_('Deployment with id {0} not found').format(deployment_id)) raise exc.HTTPNotFound @@ -103,7 +107,7 @@ def verify_and_get_deployment(db_session, environment_id, deployment_id): environment_id)) raise exc.HTTPBadRequest - _patch_description(deployment.description) + deployment.description = _patch_description(deployment.description) return deployment @@ -114,11 +118,11 @@ def create_resource(): def set_dep_state(deployment, unit): num_errors = unit.query(models.Status).filter_by( level='error', - deployment_id=deployment.id).count() + task_id=deployment.id).count() num_warnings = unit.query(models.Status).filter_by( level='warning', - deployment_id=deployment.id).count() + task_id=deployment.id).count() if deployment.finished: if num_errors: @@ -135,5 +139,5 @@ def set_dep_state(deployment, unit): else: deployment.state = 'running' - _patch_description(deployment.description) + deployment.description = _patch_description(deployment.description) return deployment diff --git a/murano/api/v1/router.py b/murano/api/v1/router.py index 6d287169..9982de64 100644 --- a/murano/api/v1/router.py +++ b/murano/api/v1/router.py @@ -13,6 +13,7 @@ # under the License. import routes +from murano.api.v1 import actions from murano.api.v1 import catalog from murano.api.v1 import deployments from murano.api.v1 import environments @@ -140,6 +141,12 @@ class API(wsgi.Router): action='get_aggregated', conditions={'method': ['GET']}) + actions_resource = actions.create_resource() + mapper.connect('/environments/{environment_id}/actions/{action_id}', + controller=actions_resource, + action='execute', + conditions={'method': ['POST']}) + catalog_resource = catalog.create_resource() mapper.connect('/catalog/packages/categories', controller=catalog_resource, diff --git a/murano/api/v1/sessions.py b/murano/api/v1/sessions.py index 2fa89b6a..665eb8ed 100644 --- a/murano/api/v1/sessions.py +++ b/murano/api/v1/sessions.py @@ -24,7 +24,6 @@ from murano.openstack.common.gettextutils import _ # noqa from murano.openstack.common import log as logging from murano.openstack.common import wsgi - LOG = logging.getLogger(__name__) API_NAME = 'Sessions' @@ -145,7 +144,7 @@ class Controller(object): LOG.error(msg) raise exc.HTTPForbidden(explanation=msg) - sessions.SessionServices.deploy(session, + envs.EnvironmentServices.deploy(session, unit, request.context.auth_token) diff --git a/murano/common/server.py b/murano/common/server.py index bbb3f276..f094407b 100644 --- a/murano/common/server.py +++ b/murano/common/server.py @@ -75,9 +75,9 @@ class ResultEndpoint(object): deployment.finished = timeutils.utcnow() num_errors = unit.query(models.Status)\ - .filter_by(level='error', deployment_id=deployment.id).count() + .filter_by(level='error', task_id=deployment.id).count() num_warnings = unit.query(models.Status)\ - .filter_by(level='warning', deployment_id=deployment.id).count() + .filter_by(level='warning', task_id=deployment.id).count() final_status_text = action_name + ' finished' if num_errors: @@ -87,7 +87,7 @@ class ResultEndpoint(object): final_status_text += " with warnings" status = models.Status() - status.deployment_id = deployment.id + status.task_id = deployment.id status.text = final_status_text status.level = 'info' deployment.statuses.append(status) @@ -170,9 +170,9 @@ def report_notification(report): def get_last_deployment(unit, env_id): - query = unit.query(models.Deployment)\ - .filter_by(environment_id=env_id)\ - .order_by(desc(models.Deployment.started)) + query = unit.query(models.Task) \ + .filter_by(environment_id=env_id) \ + .order_by(desc(models.Task.started)) return query.first() diff --git a/murano/db/migration/alembic_migrations/versions/003_add_action_entry.py b/murano/db/migration/alembic_migrations/versions/003_add_action_entry.py new file mode 100644 index 00000000..ac2f88b4 --- /dev/null +++ b/murano/db/migration/alembic_migrations/versions/003_add_action_entry.py @@ -0,0 +1,92 @@ +# 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 action column to deployment table. + +Revision ID: 003 +Revises: table deployment +Create Date: 2014-07-30 16:11:33.244 + +""" + +# revision identifiers, used by Alembic. +revision = '003' +down_revision = '002' + +from alembic import op +import sqlalchemy as sa + +import murano.db.migration.helpers as helpers + +MYSQL_ENGINE = 'InnoDB' +MYSQL_CHARSET = 'utf8' + + +def upgrade(): + op.rename_table('deployment', 'task') + op.add_column( + 'task', + sa.Column('action', sa.types.Text()) + ) + op.create_table( + 'deployment', + sa.Column('id', sa.String(length=36), nullable=False)) + + helpers.transform_table( + 'status', {'deployment_id': 'task_id'}, {}, + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('updated', sa.DateTime(), nullable=False), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('entity_id', sa.String(length=255), nullable=True), + sa.Column('entity', sa.String(length=10), nullable=True), + sa.Column('task_id', sa.String(length=36), nullable=True), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('level', sa.String(length=32), nullable=False), + sa.Column('details', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['task_id'], ['task.id'], ), + sa.PrimaryKeyConstraint('id'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + + op.drop_table('deployment') + + +def downgrade(): + op.drop_column('task', 'action') + op.rename_table('task', 'deployment') + + op.create_table( + 'task', + sa.Column('id', sa.String(length=36), nullable=False)) + + helpers.transform_table( + 'status', {'task_id': 'deployment_id'}, {}, + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('updated', sa.DateTime(), nullable=False), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('entity_id', sa.String(length=255), nullable=True), + sa.Column('entity', sa.String(length=10), nullable=True), + sa.Column('deployment_id', sa.String(length=36), nullable=True), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('level', sa.String(length=32), nullable=False), + sa.Column('details', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['deployment_id'], ['deployment.id'], ), + sa.PrimaryKeyConstraint('id'), + mysql_engine=MYSQL_ENGINE, + mysql_charset=MYSQL_CHARSET + ) + + op.drop_table('task') + ### end Alembic commands ### diff --git a/murano/db/migration/helpers.py b/murano/db/migration/helpers.py new file mode 100644 index 00000000..5f6eecdd --- /dev/null +++ b/murano/db/migration/helpers.py @@ -0,0 +1,56 @@ +# Copyright (c) 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 alembic import op +import sqlalchemy as sa + + +def transform_table(name, renames, defaults, *columns, **kw): + def escape(val): + if isinstance(val, (str, unicode)): + return "'{0}'".format(val) + elif val is None: + return 'NULL' + else: + return val + + engine = op.get_bind() + meta = sa.MetaData(bind=engine) + meta.reflect() + new_name = name + '__tmp' + old_table = meta.tables[name] + mapping = dict( + (renames.get(col.name, col.name), col.name) for col in old_table.c + ) + + columns_to_select = [ + old_table.c[mapping[c.name]] + if c.name in mapping else escape(defaults.get(c.name)) + for c in columns if isinstance(c, sa.Column) + ] + select_as = [ + c.name for c in columns if isinstance(c, sa.Column) + ] + select = sa.sql.select(columns_to_select) + + op.create_table(new_name, *columns, **kw) + meta.reflect() + new_table = meta.tables[new_name] + insert = sa.sql.insert(new_table) + if engine.dialect.dialect_description == 'postgresql+psycopg2': + insert = insert.returning(next(iter(new_table.primary_key.columns))) + insert = insert.from_select(select_as, select) + engine.execute(insert) + op.drop_table(name) + op.rename_table(new_name, name) diff --git a/murano/db/models.py b/murano/db/models.py index 324146be..577a37de 100644 --- a/murano/db/models.py +++ b/murano/db/models.py @@ -68,8 +68,9 @@ class Environment(Base, TimestampMixin): sessions = sa_orm.relationship("Session", backref='environment', cascade='save-update, merge, delete') - deployments = sa_orm.relationship("Deployment", backref='environment', - cascade='save-update, merge, delete') + + tasks = sa_orm.relationship('Task', backref='environment', + cascade='save-update, merge, delete') def to_dict(self): dictionary = super(Environment, self).to_dict() @@ -99,22 +100,22 @@ class Session(Base, TimestampMixin): return dictionary -class Deployment(Base, TimestampMixin): - __tablename__ = 'deployment' +class Task(Base, TimestampMixin): + __tablename__ = 'task' - id = sa.Column(sa.String(36), - primary_key=True, + id = sa.Column(sa.String(36), primary_key=True, default=uuidutils.generate_uuid) started = sa.Column(sa.DateTime, default=timeutils.utcnow, nullable=False) finished = sa.Column(sa.DateTime, default=None, nullable=True) description = sa.Column(st.JsonBlob(), nullable=False) environment_id = sa.Column(sa.String(255), sa.ForeignKey('environment.id')) - statuses = sa_orm.relationship("Status", backref='deployment', + action = sa.Column(st.JsonBlob()) + + statuses = sa_orm.relationship("Status", backref='task', cascade='save-update, merge, delete') def to_dict(self): - dictionary = super(Deployment, self).to_dict() - # del dictionary["description"] + dictionary = super(Task, self).to_dict() if 'statuses' in dictionary: del dictionary['statuses'] if 'environment' in dictionary: @@ -130,8 +131,8 @@ class Status(Base, TimestampMixin): default=uuidutils.generate_uuid) entity_id = sa.Column(sa.String(255), nullable=True) entity = sa.Column(sa.String(10), nullable=True) - deployment_id = sa.Column(sa.String(36), sa.ForeignKey('deployment.id')) - text = sa.Column(sa.Text(), nullable=False) + task_id = sa.Column(sa.String(32), sa.ForeignKey('task.id')) + text = sa.Column(sa.String(), nullable=False) level = sa.Column(sa.String(32), nullable=False) details = sa.Column(sa.Text(), nullable=True) @@ -297,7 +298,7 @@ def register_models(engine): """ Creates database tables for all models with the given engine """ - models = (Environment, Status, Session, Deployment, + models = (Environment, Status, Session, Task, ApiStats, Package, Category, Class, Instance) for model in models: model.metadata.create_all(engine) @@ -307,7 +308,7 @@ def unregister_models(engine): """ Drops database tables for all models with the given engine """ - models = (Environment, Status, Session, Deployment, + models = (Environment, Status, Session, Task, ApiStats, Package, Category, Class) for model in models: model.metadata.drop_all(engine) diff --git a/murano/db/services/actions.py b/murano/db/services/actions.py new file mode 100644 index 00000000..b4a1f7e7 --- /dev/null +++ b/murano/db/services/actions.py @@ -0,0 +1,39 @@ +# 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 murano.common.helpers import token_sanitizer +from murano.db import models +from murano.services import state + + +def get_environment(session, unit): + environment = unit.query(models.Environment).get( + session.environment_id) + return environment + + +def update_task(action, session, task, unit): + session.state = state.SessionState.deploying + task_info = models.Task() + task_info.environment_id = session.environment_id + objects = session.description.get('Objects', None) + if objects: + task_info.description = token_sanitizer.TokenSanitizer().sanitize( + dict(session.description.get('Objects'))) + task_info.action = task['action'] + status = models.Status() + status.text = 'Action {0} is scheduled'.format(action) + status.level = 'info' + task_info.statuses.append(status) + with unit.begin(): + unit.add(session) + unit.add(task_info) diff --git a/murano/db/services/environments.py b/murano/db/services/environments.py index fdebd98e..8730d658 100644 --- a/murano/db/services/environments.py +++ b/murano/db/services/environments.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 Mirantis, Inc. +# Copyright (c) 2013 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 @@ -49,7 +49,7 @@ class EnvironmentServices(object): :return: Returns list of environments """ unit = db_session.get_session() - environments = unit.query(models.Environment).\ + environments = unit.query(models.Environment). \ filter_by(**filters).all() for env in environments: @@ -174,11 +174,11 @@ class EnvironmentServices(object): if session.state != sessions.SessionState.DEPLOYED: env_description = session.description else: - env = unit.query(models.Environment)\ + env = unit.query(models.Environment) \ .get(session.environment_id) env_description = env.description else: - env = unit.query(models.Environment)\ + env = unit.query(models.Environment) \ .get(session.environment_id) env_description = env.description else: @@ -229,3 +229,14 @@ class EnvironmentServices(object): 'flat': None } } + + @staticmethod + def deploy(session, unit, token): + environment = unit.query(models.Environment).get( + session.environment_id) + + if (session.description['Objects'] is None and + 'ObjectsCopy' not in session.description): + EnvironmentServices.remove(session.environment_id) + else: + sessions.SessionServices.deploy(session, environment, unit, token) diff --git a/murano/db/services/sessions.py b/murano/db/services/sessions.py index 8b91f937..97fe5f86 100644 --- a/murano/db/services/sessions.py +++ b/murano/db/services/sessions.py @@ -11,12 +11,12 @@ # 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 collections -from murano.common.helpers import token_sanitizer -from murano.common import rpc from murano.db import models from murano.db import session as db_session +from murano.services import actions SessionState = collections.namedtuple('SessionState', [ @@ -116,7 +116,7 @@ class SessionServices(object): return True @staticmethod - def deploy(session, unit, token): + def deploy(session, environment, unit, token): """ Prepares environment for deployment and send deployment command to orchestration engine @@ -127,47 +127,10 @@ class SessionServices(object): """ #Set X-Auth-Token for conductor - environment = unit.query(models.Environment).get( - session.environment_id) deleted = session.description['Objects'] is None - action = None - if not deleted: - action = { - 'object_id': environment.id, - 'method': 'deploy', - 'args': {} - } - - task = { - 'action': action, - 'model': session.description, - 'token': token, - 'tenant_id': environment.tenant_id, - 'id': environment.id - } - - if not deleted: - task['model']['Objects']['?']['id'] = environment.id - task['model']['Objects']['applications'] = \ - task['model']['Objects'].get('services', []) - - if 'services' in task['model']['Objects']: - del task['model']['Objects']['services'] - - session.state = SessionState.DELETING if deleted \ - else SessionState.DEPLOYING - deployment = models.Deployment() - deployment.environment_id = session.environment_id - deployment.description = token_sanitizer.TokenSanitizer().sanitize( - session.description.get('Objects')) - status = models.Status() - status.text = ('Delete' if deleted else 'Deployment') + ' scheduled' - status.level = 'info' - deployment.statuses.append(status) - - with unit.begin(): - unit.add(session) - unit.add(deployment) - - rpc.engine().handle_task(task) + action_name = None if deleted else 'deploy' + actions.ActionServices.submit_task( + action_name, environment.id, + {}, environment, session, + token, unit) diff --git a/murano/services/__init__.py b/murano/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/murano/services/actions.py b/murano/services/actions.py new file mode 100644 index 00000000..2350c7eb --- /dev/null +++ b/murano/services/actions.py @@ -0,0 +1,107 @@ +# 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 murano.common import rpc +from murano.db import models +from murano.db.services import actions as actions_db +from murano.services import state as states + + +class ActionServices(object): + @staticmethod + def create_action_task(action_name, target_obj, + args, environment, session, token): + action = None + if action_name and target_obj: + action = { + 'object_id': target_obj, + 'method': action_name, + 'args': args or {} + } + task = { + 'action': action, + 'model': session.description, + 'token': token, + 'tenant_id': environment.tenant_id, + 'id': environment.id + } + if session.description['Objects'] is not None: + task['model']['Objects']['?']['id'] = environment.id + task['model']['Objects']['applications'] = \ + task['model']['Objects'].pop('services', []) + + return task + + @staticmethod + def update_task(action, session, task, unit): + session.state = states.SessionState.deploying + task_info = models.Task() + task_info.environment_id = session.environment_id + task_info.description = dict(session.description.get('Objects')) + task_info.action = task['action'] + status = models.Status() + status.text = 'Action {0} is scheduled'.format(action[1]['name']) + status.level = 'info' + task_info.statuses.append(status) + with unit.begin(): + unit.add(session) + unit.add(task_info) + + @staticmethod + def submit_task(action_name, target_obj, + args, environment, session, token, unit): + task = ActionServices.create_action_task( + action_name, target_obj, args, + environment, session, token) + actions_db.update_task(action_name, session, task, unit) + rpc.engine().handle_task(task) + + @staticmethod + def execute(action_id, session, unit, token, args={}): + environment = actions_db.get_environment(session, unit) + action = ActionServices.find_action(session.description, action_id) + if action is None: + raise LookupError('Action is not found') + if not action[1].get('enabled', True): + raise ValueError('Cannot execute disabled action') + + ActionServices.submit_task(action[1]['name'], action[0], + args, environment, session, token, unit) + + @staticmethod + def find_action(model, action_id): + """ + Traverses object model looking for an object definition + containing specified action + + :param model: object model + :param action_id: ID of an action + :return: tuple (object id, {"name": "action_name_in_MuranoPL", + "enabled": True }) + """ + if isinstance(model, list): + for item in model: + result = ActionServices.find_action(item, action_id) + if result is not None: + return result + elif isinstance(model, dict): + if '?' in model and 'id' in model['?'] and \ + '_actions' in model['?'] and \ + action_id in model['?']['_actions']: + return model['?']['id'], model['?']['_actions'][action_id] + + for obj in model.values(): + result = ActionServices.find_action(obj, action_id) + if result is not None: + return result + else: + return None diff --git a/murano/services/state.py b/murano/services/state.py new file mode 100644 index 00000000..13aaafc7 --- /dev/null +++ b/murano/services/state.py @@ -0,0 +1,20 @@ +# Copyright (c) 2013 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 collections + +SessionState = collections.namedtuple('SessionState', [ + 'open', 'deploying', 'deployed' +])( + open='open', deploying='deploying', deployed='deployed' +) diff --git a/murano/tests/test_actions.py b/murano/tests/test_actions.py new file mode 100644 index 00000000..1afc8eb9 --- /dev/null +++ b/murano/tests/test_actions.py @@ -0,0 +1,135 @@ +# Copyright (c) 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 mock + +from murano.dsl import murano_method +from murano.dsl import results_serializer +from murano.services import actions +from murano import tests + + +class TestActionsSerializer(tests.base.MuranoTestCase): + def setUp(self): + super(TestActionsSerializer, self).setUp() + + def test_old_actions_deletion(self): + old = { + 'action1': {'name': 'name1', 'enabled': True}, + 'action2': {'name': 'name2', 'enabled': True}, + 'action3': {'name': 'name3', 'enabled': True}, + } + new = { + 'action2': {'name': 'name2', 'enabled': False}, + 'action3': {'name': 'name3', 'enabled': True}, + } + + result = results_serializer._merge_actions(old, new) + + self.assertEqual(2, len(result)) + self.assertNotIn('action1', result) + + def test_actions_state_update(self): + old = { + 'action1': {'name': 'name1', 'enabled': True}, + 'action2': {'name': 'name2', 'enabled': True}, + } + new = { + 'action1': {'name': 'name2', 'enabled': False}, + 'action2': {'name': 'name3', 'enabled': True}, + } + + result = results_serializer._merge_actions(old, new) + + self.assertFalse(result['action1']['enabled']) + + def _get_mocked_obj(self): + method1 = mock.Mock() + method1.usage = murano_method.MethodUsages.Action + method2 = mock.Mock() + method2.usage = murano_method.MethodUsages.Runtime + method3 = mock.Mock() + method3.usage = murano_method.MethodUsages.Action + + obj2_type = mock.Mock() + obj2_type.parents = [] + obj2_type.methods = {'method3': method3} + + obj = mock.Mock() + obj.object_id = 'id1' + obj.type.parents = [obj2_type] + obj.type.methods = {'method1': method1, 'method2': method2} + + return obj + + def test_object_actions_serialization(self): + obj = self._get_mocked_obj() + + obj_actions = results_serializer._serialize_available_action(obj) + + expected_result = {'name': 'method1', 'enabled': True} + self.assertIn('id1_method1', obj_actions) + self.assertEqual(expected_result, obj_actions['id1_method1']) + + def test_that_only_actions_are_serialized(self): + obj = self._get_mocked_obj() + obj_actions = results_serializer._serialize_available_action(obj) + self.assertNotIn('id1_method2', obj_actions) + + def test_parent_actions_are_serialized(self): + obj = self._get_mocked_obj() + + obj_actions = results_serializer._serialize_available_action(obj) + + expected_result = {'name': 'method3', 'enabled': True} + self.assertIn('id1_method3', obj_actions) + self.assertEqual(expected_result, obj_actions['id1_method3']) + + +class TestActionFinder(tests.base.MuranoTestCase): + def setUp(self): + super(TestActionFinder, self).setUp() + + def test_simple_root_level_search(self): + model = { + '?': { + 'id': 'id1', + '_actions': { + 'ad_deploy': { + 'enabled': True, + 'name': 'deploy' + } + } + } + } + action = actions.ActionServices.find_action(model, 'ad_deploy') + self.assertEqual('deploy', action[1]['name']) + + def test_recursive_action_search(self): + model = { + '?': { + 'id': 'id1', + '_actions': {'ad_deploy': {'enabled': True, 'name': 'deploy'}} + }, + 'property': { + '?': { + 'id': 'id2', + '_actions': { + 'ad_scale': {'enabled': True, 'name': 'scale'} + } + }, + } + } + action = actions.ActionServices.find_action(model, 'ad_scale') + self.assertEqual('scale', action[1]['name']) diff --git a/murano/tests/unit/api/v1/test_actions.py b/murano/tests/unit/api/v1/test_actions.py new file mode 100644 index 00000000..c8cf94b3 --- /dev/null +++ b/murano/tests/unit/api/v1/test_actions.py @@ -0,0 +1,87 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# 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 murano.api.v1 import actions +from murano.common import policy +from murano.db import models +from murano.openstack.common import timeutils +import murano.tests.unit.api.base as tb +import murano.tests.unit.utils as test_utils + + +@mock.patch.object(policy, 'check') +class TestActionsApi(tb.ControllerTest, tb.MuranoApiTestCase): + + def setUp(self): + super(TestActionsApi, self).setUp() + self.controller = actions.Controller() + + def test_execute_action(self, mock_policy_check): + """Test that action execution results in the correct rpc call""" + self._set_policy_rules( + {'execute_action': '@'} + ) + + fake_now = timeutils.utcnow() + expected = dict( + id='12345', + name='my-env', + version=0, + networking={}, + created=fake_now, + updated=fake_now, + tenant_id=self.tenant, + description={ + 'Objects': { + '?': {'id': '12345', + '_actions': { + 'actionsID_action': { + 'enabled': True, + 'name': 'Testaction' + } + }} + }, + 'Attributes': {} + } + ) + e = models.Environment(**expected) + test_utils.save_models(e) + + rpc_task = { + 'tenant_id': self.tenant, + 'model': {'Objects': {'applications': [], '?': + { + '_actions': {'actionsID_action': { + 'name': 'Testaction', 'enabled': True}}, + 'id': '12345'}}, 'Attributes': {}}, + 'action': { + 'method': 'Testaction', + 'object_id': '12345', + 'args': '{}'}, + 'token': None, + 'id': '12345' + } + + req = self._post('/environments/12345/actions/actionID_action', '{}') + result = self.controller.execute(req, '12345', 'actionsID_action', + '{}') + + self.mock_engine_rpc.handle_task.assert_called_once_with(rpc_task) + + # Should this be expected behavior? + self.assertEqual(None, result) diff --git a/murano/tests/unit/api/v1/test_environments.py b/murano/tests/unit/api/v1/test_environments.py index 400847c9..31b6080f 100644 --- a/murano/tests/unit/api/v1/test_environments.py +++ b/murano/tests/unit/api/v1/test_environments.py @@ -186,19 +186,9 @@ class TestEnvironmentApi(tb.ControllerTest, tb.MuranoApiTestCase): e = models.Environment(**expected) test_utils.save_models(e) - rpc_task = { - 'id': '12345', - 'action': None, - 'tenant_id': self.tenant, - 'model': {'Attributes': {}, 'Objects': None}, - 'token': None - } - req = self._delete('/environments/12345') result = req.get_response(self.api) - self.mock_engine_rpc.handle_task.assert_called_once_with(rpc_task) - # Should this be expected behavior? self.assertEqual('', result.body) self.assertEqual(200, result.status_code) diff --git a/murano/tests/unit/db/migration/test_migrations.py b/murano/tests/unit/db/migration/test_migrations.py index 41b835aa..39d5914f 100644 --- a/murano/tests/unit/db/migration/test_migrations.py +++ b/murano/tests/unit/db/migration/test_migrations.py @@ -118,3 +118,8 @@ class TestMigrations(base.BaseWalkMigrationTestCase, base.CommonTestsMixIn): def _check_002(self, engine, data): self.assertEqual('002', migration.version(engine)) self.assertColumnExists(engine, 'package', 'supplier_logo') + + def _check_003(self, engine, data): + self.assertEqual('003', migration.version(engine)) + self.assertColumnExists(engine, 'task', 'action') + self.assertColumnExists(engine, 'status', 'task_id')