diff --git a/lib/ansible/modules/network/radware/vdirect_runnable.py b/lib/ansible/modules/network/radware/vdirect_runnable.py new file mode 100644 index 00000000000..10bec6fae7b --- /dev/null +++ b/lib/ansible/modules/network/radware/vdirect_runnable.py @@ -0,0 +1,339 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright 2017 Radware LTD. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = {'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.1'} + +DOCUMENTATION = ''' +module: vdirect_runnable +author: Evgeny Fedoruk @ Radware LTD (@evgenyfedoruk) +short_description: Runs templates and workflow actions in Radware vDirect server +description: + - Runs configuration templates, creates workflows and runs workflow actions in Radware vDirect server. +notes: + - Requires the Radware vdirect-client Python package on the host. This is as easy as + C(pip install vdirect-client) +version_added: "2.5" +options: + vdirect_ip: + description: + - Primary vDirect server IP address, may be set as C(VDIRECT_IP) environment variable. + required: true + vdirect_user: + description: + - vDirect server username, may be set as C(VDIRECT_USER) environment variable. + required: true + default: None + vdirect_password: + description: + - vDirect server password, may be set as C(VDIRECT_PASSWORD) environment variable. + required: true + default: None + vdirect_secondary_ip: + description: + - Secondary vDirect server IP address, may be set as C(VDIRECT_SECONDARY_IP) environment variable. + required: false + default: None + vdirect_wait: + description: + - Wait for async operation to complete, may be set as C(VDIRECT_WAIT) environment variable. + required: false + type: bool + default: 'yes' + vdirect_https_port: + description: + - vDirect server HTTPS port number, may be set as C(VDIRECT_HTTPS_PORT) environment variable. + required: false + default: 2189 + vdirect_http_port: + description: + - vDirect server HTTP port number, may be set as C(VDIRECT_HTTP_PORT) environment variable. + required: false + default: 2188 + vdirect_timeout: + description: + - Amount of time to wait for async operation completion [seconds], + - may be set as C(VDIRECT_TIMEOUT) environment variable. + required: false + default: 60 + vdirect_use_ssl: + description: + - If C(no), an HTTP connection will be used instead of the default HTTPS connection, + - may be set as C(VDIRECT_HTTPS) or C(VDIRECT_USE_SSL) environment variable. + required: false + type: bool + default: 'yes' + vdirect_validate_certs: + description: + - If C(no), SSL certificates will not be validated, + - may be set as C(VDIRECT_VALIDATE_CERTS) or C(VDIRECT_VERIFY) environment variable. + - This should only set to C(no) used on personally controlled sites using self-signed certificates. + required: false + type: bool + default: 'yes' + runnable_type: + description: + - vDirect runnable type. + - May be ConfigurationTemplate, WorkflowTemplate or a Workflow. + required: true + runnable_name: + description: + - vDirect runnable name to run. + - May be configuration template name, workflow template name or workflow instance name. + required: true + action_name: + description: + - Workflow action name to run. + - Required if I(runnable_type=Workflow). + required: false + parameters: + description: + - Action parameters dictionary. In case of ConfigurationTemplate runnable type, + - the device connection details should always be passed as a parameter. + required: false + +requirements: + - "vdirect-client >= 4.1.1" +''' + +EXAMPLES = ''' +- name: vdirect_runnable + vdirect_runnable: + vdirect_primary_ip: 10.10.10.10 + vdirect_user: vDirect + vdirect_password: radware + runnable_type: ConfigurationTemplate + runnable_name: get_vlans + parameters: {'vlans_needed':1,'adc':[{'type':'Adc','name':'adc-1'}]} +''' + +RETURN = ''' +result: + description: Message detailing run result + returned: success + type: string + sample: "Workflow action run completed." +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback + +try: + from vdirect_client import rest_client + HAS_REST_CLIENT = True +except ImportError: + HAS_REST_CLIENT = False + +CONFIGURATION_TEMPLATE_RUNNABLE_TYPE = 'ConfigurationTemplate' +WORKFLOW_TEMPLATE_RUNNABLE_TYPE = 'WorkflowTemplate' +WORKFLOW_RUNNABLE_TYPE = 'Workflow' + +TEMPLATE_SUCCESS = 'Configuration template run completed.' +WORKFLOW_CREATION_SUCCESS = 'Workflow created.' +WORKFLOW_ACTION_SUCCESS = 'Workflow action run completed.' + +meta_args = dict( + vdirect_ip=dict( + required=True, fallback=(env_fallback, ['VDIRECT_IP']), + default=None), + vdirect_user=dict( + required=True, fallback=(env_fallback, ['VDIRECT_USER']), + default=None), + vdirect_password=dict( + required=True, fallback=(env_fallback, ['VDIRECT_PASSWORD']), + default=None, no_log=True, type='str'), + vdirect_secondary_ip=dict( + required=False, fallback=(env_fallback, ['VDIRECT_SECONDARY_IP']), + default=None), + vdirect_use_ssl=dict( + required=False, fallback=(env_fallback, ['VDIRECT_HTTPS', 'VDIRECT_USE_SSL']), + default=True, type='bool'), + vdirect_wait=dict( + required=False, fallback=(env_fallback, ['VDIRECT_WAIT']), + default=True, type='bool'), + vdirect_timeout=dict( + required=False, fallback=(env_fallback, ['VDIRECT_TIMEOUT']), + default=60, type='int'), + vdirect_validate_certs=dict( + required=False, fallback=(env_fallback, ['VDIRECT_VERIFY', 'VDIRECT_VALIDATE_CERTS']), + default=True, type='bool'), + vdirect_https_port=dict( + required=False, fallback=(env_fallback, ['VDIRECT_HTTPS_PORT']), + default=2189, type='int'), + vdirect_http_port=dict( + required=False, fallback=(env_fallback, ['VDIRECT_HTTP_PORT']), + default=2188, type='int'), + runnable_type=dict( + required=True, + choices=[CONFIGURATION_TEMPLATE_RUNNABLE_TYPE, WORKFLOW_TEMPLATE_RUNNABLE_TYPE, WORKFLOW_RUNNABLE_TYPE]), + runnable_name=dict(required=True, default=None), + action_name=dict(required=False, default=None), + parameters=dict(required=False, type='dict', default={}) +) + + +class RunnableException(Exception): + def __init__(self, reason, details): + self.reason = reason + self.details = details + + def __str__(self): + return 'Reason: {0}. Details:{1}.'.format(self.reason, self.details) + + +class WrongActionNameException(RunnableException): + def __init__(self, action, available_actions): + super(WrongActionNameException, self).__init__('Wrong action name ' + repr(action), + 'Available actions are: ' + repr(available_actions)) + + +class MissingActionParametersException(RunnableException): + def __init__(self, required_parameters): + super(MissingActionParametersException, self).__init__( + 'Action parameters missing', + 'Required parameters are: ' + repr(required_parameters)) + + +class MissingRunnableException(RunnableException): + def __init__(self, name): + super(MissingRunnableException, self).__init__( + 'Runnable missing', + 'Runnable ' + name + ' is missing') + + +class VdirectRunnable(object): + + CREATE_WORKFLOW_ACTION = 'createWorkflow' + RUN_ACTION = 'run' + + def __init__(self, params): + self.client = rest_client.RestClient(params['vdirect_ip'], + params['vdirect_user'], + params['vdirect_password'], + wait=params['vdirect_wait'], + secondary_vdirect_ip=params['vdirect_secondary_ip'], + https_port=params['vdirect_https_port'], + http_port=params['vdirect_http_port'], + timeout=params['vdirect_timeout'], + https=params['vdirect_use_ssl'], + verify=params['vdirect_validate_certs']) + self.params = params + self.type = self.params['runnable_type'] + self.name = self.params['runnable_name'] + if 'parameters' in self.params: + self.action_params = self.params['parameters'] + else: + self.action_params = [] + + def _validate_runnable_exists(self): + res = self.client.runnable.get_runnable_objects(self.type) + runnable_names = res[rest_client.RESP_DATA]['names'] + if self.name not in runnable_names: + raise MissingRunnableException(self.name) + + def _validate_action_name(self): + if self.type == WORKFLOW_TEMPLATE_RUNNABLE_TYPE: + self.action_name = VdirectRunnable.CREATE_WORKFLOW_ACTION + elif self.type == CONFIGURATION_TEMPLATE_RUNNABLE_TYPE: + self.action_name = VdirectRunnable.RUN_ACTION + else: + self.action_name = self.params['action_name'] + res = self.client.runnable.get_available_actions(self.type, self.name) + available_actions = res[rest_client.RESP_DATA]['names'] + if self.action_name not in available_actions: + raise WrongActionNameException(self.action_name, available_actions) + + def _validate_required_action_params(self): + action_params_names = [n for n in self.action_params] + + res = self.client.runnable.get_action_info(self.type, self.name, self.action_name) + if 'parameters' in res[rest_client.RESP_DATA]: + action_params_spec = res[rest_client.RESP_DATA]['parameters'] + else: + action_params_spec = [] + + required_action_params_dict = [{'name': p['name'], 'type': p['type']} for p in action_params_spec + if p['type'] == 'alteon' or + p['type'] == 'defensePro' or + p['type'] == 'appWall' or + p['direction'] != 'out'] + required_action_params_names = [n['name'] for n in required_action_params_dict] + + if set(required_action_params_names) & set(action_params_names) != set(required_action_params_names): + raise MissingActionParametersException(required_action_params_dict) + + def run(self): + self._validate_runnable_exists() + self._validate_action_name() + self._validate_required_action_params() + + data = self.action_params + + result = self.client.runnable.run(data, self.type, self.name, self.action_name) + result_to_return = {'msg': ''} + if result[rest_client.RESP_STATUS] == 200: + if result[rest_client.RESP_DATA]['status'] == 200 and result[rest_client.RESP_DATA]['success']: + if self.type == WORKFLOW_TEMPLATE_RUNNABLE_TYPE: + result_to_return['msg'] = WORKFLOW_CREATION_SUCCESS + elif self.type == CONFIGURATION_TEMPLATE_RUNNABLE_TYPE: + result_to_return['msg'] = TEMPLATE_SUCCESS + else: + result_to_return['msg'] = WORKFLOW_ACTION_SUCCESS + + if 'parameters' in result[rest_client.RESP_DATA]: + result_to_return['parameters'] = result[rest_client.RESP_DATA]['parameters'] + + else: + if 'exception' in result[rest_client.RESP_DATA]: + raise RunnableException(result[rest_client.RESP_DATA]['exception']['message'], + result[rest_client.RESP_STR]) + else: + raise RunnableException('The status returned ' + str(result[rest_client.RESP_DATA]['status']), + result[rest_client.RESP_STR]) + else: + raise RunnableException(result[rest_client.RESP_REASON], + result[rest_client.RESP_STR]) + + return result_to_return + + +def main(): + + if not HAS_REST_CLIENT: + raise ImportError("The python vdirect-client module is required") + + module = AnsibleModule(argument_spec=meta_args, + required_if=[['runnable_type', WORKFLOW_RUNNABLE_TYPE, ['action_name']]]) + + try: + vdirect_runnable = VdirectRunnable(module.params) + result = vdirect_runnable.run() + result = dict(result=result) + module.exit_json(**result) + except Exception as e: + module.fail_json(msg=str(e)) + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/radware/test_vdirect_runnable.py b/test/units/modules/network/radware/test_vdirect_runnable.py new file mode 100644 index 00000000000..4a391bff9fc --- /dev/null +++ b/test/units/modules/network/radware/test_vdirect_runnable.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2017 Radware LTD. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +import os +from ansible.compat.tests.mock import patch, MagicMock + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import patch + +BASE_PARAMS = {'vdirect_ip': None, 'vdirect_user': None, 'vdirect_password': None, + 'vdirect_wait': None, 'vdirect_secondary_ip': None, + 'vdirect_https_port': None, 'vdirect_http_port': None, + 'vdirect_timeout': None, 'vdirect_use_ssl': None, 'vdirect_validate_certs': None} + +RUNNABLE_PARAMS = {'runnable_type': 'ConfigurationTemplate', 'runnable_name': 'runnable', + 'action_name': None, 'parameters': None} + +RUNNABLE_OBJECTS_RESULT = [200, '', '', {'names': ['runnable']}] +AVAILABLE_ACTIONS_RESULT = [200, '', '', {'names': ['a', 'b']}] +ACTIONS_PARAMS_RESULT = [200, '', '', {'parameters': [{'name': 'pin', 'type': 'in', 'direction': 'in'}, + {'name': 'pout', 'type': 'out', 'direction': 'out'}, + {'name': 'alteon', 'type': 'alteon'}]}] + +RUN_RESULT = [200, '', '', { + "uri": "https://10.11.12.13:2189/api/status?token=Workflow%5Ca%5Capply%5Cc4b533a8-8764-4cbf-a19c-63b11b9ccc09", + "targetUri": "https://10.11.12.13:2189/api/workflow/a", + "complete": True, "status": 200, "success": True, "messages": [], "action": "apply", "parameters": {}, +}] + +MODULE_RESULT = {"msg": "Configuration template run completed.", "parameters": {}} + + +@patch('vdirect_client.rest_client.RestClient') +class RestClient (): + def __init__(self, vdirect_ip=None, vdirect_user=None, vdirect_password=None, wait=None, + secondary_vdirect_ip=None, https_port=None, http_port=None, + timeout=None, https=None, strict_http_results=None, + verify=None): + pass + + +@patch('vdirect_client.rest_client.Runnable') +class Runnable (): + available_actions_result = None + action_info_result = None + runnable_objects_result = None + run_result = None + + def __init__(self, client): + self.client = client + + @classmethod + def set_action_info_result(cls, result): + Runnable.action_info_result = result + + @classmethod + def set_available_actions_result(cls, result): + Runnable.available_actions_result = result + + @classmethod + def set_run_result(cls, result): + Runnable.run_result = result + + @classmethod + def set_runnable_objects_result(cls, result): + Runnable.runnable_objects_result = result + + def get_available_actions(self, type=None, name=None): + return Runnable.available_actions_result + + def get_action_info(self, type, name, action_name): + return Runnable.action_info_result + + def run(self, data, type, name, action_name): + return Runnable.run_result + + def get_runnable_objects(self, type): + return Runnable.runnable_objects_result + + +class TestManager(unittest.TestCase): + + def setUp(self): + self.module_mock = MagicMock() + self.module_mock.rest_client.RESP_STATUS = 0 + self.module_mock.rest_client.RESP_REASON = 1 + self.module_mock.rest_client.RESP_STR = 2 + self.module_mock.rest_client.RESP_DATA = 3 + + def test_missing_parameter(self, *args): + with patch.dict('sys.modules', **{ + 'vdirect_client': self.module_mock, + 'vdirect_client.rest_client': self.module_mock, + }): + from ansible.modules.network.radware import vdirect_runnable + + try: + params = BASE_PARAMS.copy() + vdirect_runnable.VdirectRunnable(params) + self.fail("KeyError was not thrown for missing parameter") + except KeyError: + assert True + + def test_validate_runnable_exists(self, *args): + with patch.dict('sys.modules', **{ + 'vdirect_client': self.module_mock, + 'vdirect_client.rest_client': self.module_mock, + }): + from ansible.modules.network.radware import vdirect_runnable + + Runnable.set_runnable_objects_result(RUNNABLE_OBJECTS_RESULT) + BASE_PARAMS.update(RUNNABLE_PARAMS) + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable.client.runnable = Runnable(vdirectRunnable.client) + vdirectRunnable._validate_runnable_exists() + assert True + + BASE_PARAMS.update(RUNNABLE_PARAMS) + BASE_PARAMS['runnable_name'] = "missing" + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable.client.runnable = Runnable(vdirectRunnable.client) + try: + vdirectRunnable._validate_runnable_exists() + self.fail("MissingRunnableException was not thrown for missing runnable name") + except vdirect_runnable.MissingRunnableException: + assert True + + def test_validate_action_name(self, *args): + with patch.dict('sys.modules', **{ + 'vdirect_client': self.module_mock, + 'vdirect_client.rest_client': self.module_mock, + }): + from ansible.modules.network.radware import vdirect_runnable + + Runnable.set_runnable_objects_result(RUNNABLE_OBJECTS_RESULT) + BASE_PARAMS.update(RUNNABLE_PARAMS) + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable._validate_action_name() + assert vdirectRunnable.action_name == vdirect_runnable.VdirectRunnable.RUN_ACTION + + BASE_PARAMS['runnable_type'] = vdirect_runnable.WORKFLOW_TEMPLATE_RUNNABLE_TYPE + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable._validate_action_name() + assert vdirectRunnable.action_name == vdirect_runnable.VdirectRunnable.CREATE_WORKFLOW_ACTION + + BASE_PARAMS['runnable_type'] = vdirect_runnable.WORKFLOW_RUNNABLE_TYPE + BASE_PARAMS['action_name'] = 'a' + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable.client.runnable = Runnable(vdirectRunnable.client) + Runnable.set_available_actions_result(AVAILABLE_ACTIONS_RESULT) + vdirectRunnable._validate_action_name() + assert vdirectRunnable.action_name == 'a' + + BASE_PARAMS['action_name'] = 'c' + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable.client.runnable = Runnable(vdirectRunnable.client) + Runnable.set_available_actions_result(AVAILABLE_ACTIONS_RESULT) + try: + vdirectRunnable._validate_action_name() + self.fail("WrongActionNameException was not thrown for wrong action name") + except vdirect_runnable.WrongActionNameException: + assert True + + def test_validate_required_action_params(self, *args): + with patch.dict('sys.modules', **{ + 'vdirect_client': self.module_mock, + 'vdirect_client.rest_client': self.module_mock, + }): + from ansible.modules.network.radware import vdirect_runnable + + Runnable.set_runnable_objects_result(RUNNABLE_OBJECTS_RESULT) + BASE_PARAMS.update(RUNNABLE_PARAMS) + BASE_PARAMS['runnable_type'] = vdirect_runnable.WORKFLOW_RUNNABLE_TYPE + BASE_PARAMS['action_name'] = 'a' + BASE_PARAMS['parameters'] = {"alteon": "x"} + + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable.client.runnable = Runnable(vdirectRunnable.client) + Runnable.set_available_actions_result(AVAILABLE_ACTIONS_RESULT) + Runnable.set_action_info_result(ACTIONS_PARAMS_RESULT) + + vdirectRunnable._validate_action_name() + try: + vdirectRunnable._validate_required_action_params() + self.fail("MissingActionParametersException was not thrown for missing parameters") + except vdirect_runnable.MissingActionParametersException: + assert True + + BASE_PARAMS['parameters'] = {"alteon": "x"} + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable._validate_action_name() + try: + vdirectRunnable._validate_required_action_params() + self.fail("MissingActionParametersException was not thrown for missing parameters") + except vdirect_runnable.MissingActionParametersException: + assert True + + BASE_PARAMS['parameters'] = {"pin": "x", "alteon": "x"} + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable._validate_action_name() + vdirectRunnable._validate_required_action_params() + assert True + + def test_run(self, *args): + with patch.dict('sys.modules', **{ + 'vdirect_client': self.module_mock, + 'vdirect_client.rest_client': self.module_mock, + }): + from ansible.modules.network.radware import vdirect_runnable + + Runnable.set_runnable_objects_result(RUNNABLE_OBJECTS_RESULT) + + BASE_PARAMS.update(RUNNABLE_PARAMS) + + BASE_PARAMS['runnable_type'] = vdirect_runnable.CONFIGURATION_TEMPLATE_RUNNABLE_TYPE + BASE_PARAMS['parameters'] = {"pin": "x", "alteon": "x"} + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable.client.runnable = Runnable(vdirectRunnable.client) + Runnable.set_available_actions_result(AVAILABLE_ACTIONS_RESULT) + Runnable.set_action_info_result(ACTIONS_PARAMS_RESULT) + Runnable.set_run_result(RUN_RESULT) + res = vdirectRunnable.run() + assert res == MODULE_RESULT + + BASE_PARAMS['runnable_type'] = vdirect_runnable.WORKFLOW_TEMPLATE_RUNNABLE_TYPE + MODULE_RESULT['msg'] = "Workflow created." + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable.client.runnable = Runnable(vdirectRunnable.client) + res = vdirectRunnable.run() + assert res == MODULE_RESULT + + BASE_PARAMS['runnable_type'] = vdirect_runnable.WORKFLOW_RUNNABLE_TYPE + BASE_PARAMS['action_name'] = 'a' + MODULE_RESULT['msg'] = "Workflow action run completed." + vdirectRunnable = vdirect_runnable.VdirectRunnable(BASE_PARAMS) + vdirectRunnable.client.runnable = Runnable(vdirectRunnable.client) + Runnable.set_available_actions_result(AVAILABLE_ACTIONS_RESULT) + Runnable.set_action_info_result(ACTIONS_PARAMS_RESULT) + res = vdirectRunnable.run() + assert res == MODULE_RESULT + + result_parameters = {"param1": "value1", "param2": "value2"} + RUN_RESULT[self.module_mock.rest_client.RESP_DATA]['parameters'] = result_parameters + MODULE_RESULT['parameters'] = result_parameters + res = vdirectRunnable.run() + assert res == MODULE_RESULT + + RUN_RESULT[self.module_mock.rest_client.RESP_STATUS] = 400 + RUN_RESULT[self.module_mock.rest_client.RESP_REASON] = "Reason" + RUN_RESULT[self.module_mock.rest_client.RESP_STR] = "Details" + try: + vdirectRunnable.run() + self.fail("RunnableException was not thrown for failed run.") + except vdirect_runnable.RunnableException as e: + assert str(e) == "Reason: Reason. Details:Details." + + RUN_RESULT[self.module_mock.rest_client.RESP_STATUS] = 200 + RUN_RESULT[self.module_mock.rest_client.RESP_DATA]["status"] = 400 + RUN_RESULT[self.module_mock.rest_client.RESP_DATA]["success"] = False + RUN_RESULT[self.module_mock.rest_client.RESP_DATA]["exception"] = {"message": "exception message"} + try: + vdirectRunnable.run() + self.fail("RunnableException was not thrown for failed run.") + except vdirect_runnable.RunnableException as e: + assert str(e) == "Reason: exception message. Details:Details."