From 04a8201e476e489c4d5d98b12fa4781cf988e7fe Mon Sep 17 00:00:00 2001 From: yshepilov Date: Fri, 10 Mar 2023 16:03:58 +0100 Subject: [PATCH] #218 added possibility to show preload script before any execution --- samples/configs/destroy_world.json | 6 +- src/concurrency/threading_decorators.py | 14 +++++ src/model/script_config.py | 41 ++++++++++++- src/tests/script_config_test.py | 57 +++++++++++++++++++ src/tests/web/script_config_socket_test.py | 41 ++++++++++++- src/web/script_config_socket.py | 18 ++++++ .../components/scripts/script-view.vue | 11 ++++ web-src/src/main-app/store/scriptConfig.js | 22 +++++-- web-src/tests/unit/scriptConfig_test.js | 21 +++++++ 9 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 src/concurrency/threading_decorators.py diff --git a/samples/configs/destroy_world.json b/samples/configs/destroy_world.json index 4ed3ce74..210145a0 100644 --- a/samples/configs/destroy_world.json +++ b/samples/configs/destroy_world.json @@ -3,5 +3,9 @@ "script_path": "/usr/bin/python3 ./samples/scripts/destroy_world.py", "description": "This is a very dangerous script, please be careful when running. Don't forget your protective helmet.", "requires_terminal": false, - "output_format": "terminal" + "output_format": "text", + "preload_script": { + "script": "echo 'This is a preload script showing some info.
'", + "output_format": "html" + } } \ No newline at end of file diff --git a/src/concurrency/threading_decorators.py b/src/concurrency/threading_decorators.py new file mode 100644 index 00000000..f0930b73 --- /dev/null +++ b/src/concurrency/threading_decorators.py @@ -0,0 +1,14 @@ +import functools +import threading + + +def threaded(func): + """Decorator to automatically launch a function in a thread""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + thread = threading.Thread(target=func, args=args, kwargs=kwargs) + thread.start() + return thread + + return wrapper diff --git a/src/model/script_config.py b/src/model/script_config.py index 1f89c1ae..1e2f1597 100644 --- a/src/model/script_config.py +++ b/src/model/script_config.py @@ -53,7 +53,9 @@ def create_failed_short_config(path, has_admin_rights): 'output_format', 'output_files', 'schedulable', - '_included_config') + 'preload_script', + '_included_config', +) class ConfigModel: def __init__(self, @@ -213,6 +215,8 @@ def _reload_config(self): self.logging_config = LoggingConfig.from_json(config.get('logging')) + self.preload_script = self._read_preload_script_conf(config.get('preload_script')) + if not self.script_command: raise Exception('No script_path is specified for ' + self.name) @@ -285,6 +289,41 @@ def _path_to_json(self, path): LOGGER.warning('Failed to load included file, path does not exist: ' + path) return None + def _read_preload_script_conf(self, config): + if config is None: + return None + + error_message = 'Failed to load preload script for ' + self.name + ': ' + + if not isinstance(config, dict): + logging.warning(error_message + 'should be dict') + return None + + script = config.get('script') + if is_empty(script): + logging.warning(error_message + 'missing "script" field') + return + + try: + format = read_output_format(config) + except InvalidConfigException: + LOGGER.warning(error_message + 'invalid format specified') + format = OUTPUT_FORMAT_TERMINAL + + return {'script': script, 'output_format': format} + + def run_preload_script(self): + if not self.preload_script: + raise Exception('Cannot run preload script for ' + self.name + ': no preload_script is specified') + + return self._process_invoker.invoke(self.preload_script.get('script')) + + def get_preload_script_format(self): + if not self.preload_script: + return OUTPUT_FORMAT_TERMINAL + + return self.preload_script.get('output_format') + def _read_name(file_path, json_object): name = json_object.get('name') diff --git a/src/tests/script_config_test.py b/src/tests/script_config_test.py index b38079fa..09309784 100644 --- a/src/tests/script_config_test.py +++ b/src/tests/script_config_test.py @@ -12,6 +12,7 @@ from tests import test_utils from tests.test_utils import create_script_param_config, create_parameter_model, create_files from utils import file_utils, custom_json +from utils.process_utils import ExecutionException DEF_AUDIT_NAME = '127.0.0.1' DEF_USERNAME = 'user1' @@ -954,6 +955,62 @@ def tearDown(self) -> None: test_utils.cleanup() +class PreloadScriptTest(unittest.TestCase): + @parameterized.expand([ + ({'script': 'echo 123'}, 'echo 123', 'terminal'), + ({'script': 'echo 123', 'output_format': 'html'}, 'echo 123', 'html'), + ({'script': 'echo 123', 'output_format': 'weird'}, 'echo 123', 'terminal'), + ({'script': 'echo 123', 'unknown_field': 'html'}, 'echo 123', 'terminal'), + ]) + def test_load_config(self, configured_config, expected_script, expected_format): + config = _create_config_model('some_name', config={ + 'preload_script': configured_config + }) + + self.assertEqual( + config.preload_script, + {'script': expected_script, 'output_format': expected_format} + ) + + @parameterized.expand([ + ({'preload_script': None},), + ({},), + ({'preload_script': {}},), + ({'preload_script': {'some_field': 'echo 123'}},) + ]) + def test_load_config_when_none(self, configured_config): + config = _create_config_model('some_name', config={ + 'preload_script': configured_config + }) + + self.assertEqual(config.preload_script, None) + + def test_run_preload_script(self): + config = _create_config_model('some_name', config={ + 'preload_script': {'script': 'echo 123'} + }) + + output = config.run_preload_script() + self.assertEqual('123\n', output) + + def test_run_preload_script_when_not_configured(self): + config = _create_config_model('some_name', config={ + 'preload_script': None + }) + + self.assertRaisesRegex(Exception, '.+no preload_script is specified', config.run_preload_script) + + def test_run_preload_script_when_script_fails(self): + config = _create_config_model('some_name', config={ + 'preload_script': {'script': 'bash -c "exit -1"'} + }) + + self.assertRaises(ExecutionException, config.run_preload_script) + + def tearDown(self) -> None: + test_utils.cleanup() + + def _create_config_model(name, *, config=None, username=DEF_USERNAME, diff --git a/src/tests/web/script_config_socket_test.py b/src/tests/web/script_config_socket_test.py index 984352cd..889c0a3b 100644 --- a/src/tests/web/script_config_socket_test.py +++ b/src/tests/web/script_config_socket_test.py @@ -44,7 +44,12 @@ def test_initial_config_when_init_with_values(self): @testing.gen_test def test_reload_model(self): self.socket = yield self._connect('Test script 1') - _ = yield self.socket.read_message() + + message1 = yield self.socket.read_message() + self._assert_message_type(message1, 'initialConfig') + + message2 = yield self.socket.read_message() + self._assert_message_type(message2, 'preloadScript') self.socket.write_message(json.dumps({ 'event': 'reloadModelValues', @@ -61,7 +66,12 @@ def test_reload_model(self): @testing.gen_test def test_client_version(self): self.socket = yield self._connect('Test script 1') - _ = yield self.socket.read_message() + + message1 = yield self.socket.read_message() + self._assert_message_type(message1, 'initialConfig') + + message2 = yield self.socket.read_message() + self._assert_message_type(message2, 'preloadScript') self.socket.write_message(json.dumps({ 'event': 'parameterValue', @@ -121,6 +131,25 @@ def test_client_version(self): external_model_id='abcd', client_version=7) + @testing.gen_test + def test_preload_script(self): + self.socket = yield self._connect('Test script 1') + + message1 = yield self.socket.read_message() + self._assert_message_type(message1, 'initialConfig') + + message2 = yield self.socket.read_message() + event = json.loads(message2) + + self.assertEqual( + {'event': 'preloadScript', + 'data': { + 'clientStateVersion': None, + 'output': '123\n', + 'format': 'terminal' + }}, + event) + def assert_model(self, response, event_type, external_model_id=None, list2_values=None, client_version=None): event = json.loads(response) @@ -181,6 +210,11 @@ def _assert_version_beat(self, response, client_version): {'data': {'clientStateVersion': client_version}, 'event': 'clientStateVersionAccepted'}, event) + def _assert_message_type(self, message, expected_type): + event = json.loads(message) + + self.assertEqual(event.get('event'), expected_type) + def _connect(self, script_name, init_with_values=False): url = 'ws://localhost:{}/scripts/{}'.format(self.port, quote(script_name)) if init_with_values: @@ -218,6 +252,9 @@ def setUp(self): {'name': 'Test script 1', 'script_path': 'ls', 'include': '${text 1}.json', + 'preload_script': { + 'script': 'echo 123' + }, 'parameters': [ test_utils.create_script_param_config('text 1', required=True), test_utils.create_script_param_config('list 1', type='list', diff --git a/src/web/script_config_socket.py b/src/web/script_config_socket.py index 40009566..c742405a 100644 --- a/src/web/script_config_socket.py +++ b/src/web/script_config_socket.py @@ -13,11 +13,13 @@ from tornado import gen from auth.user import User +from concurrency.threading_decorators import threaded from config.config_service import ConfigNotAllowedException, CorruptConfigFileException from model import external_model from model.external_model import parameter_to_external from model.model_helper import read_bool from model.script_config import ConfigModel +from utils.process_utils import ExecutionException from web.web_auth_utils import check_authorization from web.web_utils import wrap_to_server_event, inject_user @@ -216,6 +218,22 @@ def _prepare_and_send_model(self, *, parameter_values=None, external_id=None, ev new_config = external_model.config_to_external(config_model, self.config_id, external_id) self.safe_write(self._create_event(event_type, new_config)) + config_model.preload_script_prop.subscribe(self._send_preload_script) + self._send_preload_script(None, None) + def _create_event(self, event_type, data): data['clientStateVersion'] = self._latest_client_state_version return wrap_to_server_event(event_type=event_type, data=data) + + @threaded + def _send_preload_script(self, _, __): + if not self.config_model.preload_script: + return + + try: + text = self.config_model.run_preload_script() + format = self.config_model.get_preload_script_format() + + self.safe_write(self._create_event('preloadScript', {'output': text, 'format': format})) + except ExecutionException: + LOGGER.exception('Failed to execute preload script for ' + self.config_model.name) diff --git a/web-src/src/main-app/components/scripts/script-view.vue b/web-src/src/main-app/components/scripts/script-view.vue index 822f4abb..3e3ead88 100644 --- a/web-src/src/main-app/components/scripts/script-view.vue +++ b/web-src/src/main-app/components/scripts/script-view.vue @@ -23,6 +23,9 @@ +
Validation failed. Errors list:
    @@ -102,6 +105,8 @@ export default { loading: 'loading', scriptConfig: 'scriptConfig', outputFormat: state => state.scriptConfig ? state.scriptConfig.outputFormat : undefined, + preloadOutput: state => state.preloadScript?.['output'], + preloadOutputFormat: state => state.preloadScript?.['format'] }), ...mapState('scriptSetup', { parameterErrors: 'errors' @@ -377,6 +382,12 @@ export default { } }, + preloadOutput: { + handler(newValue, _) { + this.$refs.preloadOutputPanel.setLog(newValue) + } + }, + inlineImages: { handler(newValue, oldValue) { const logPanel = this.$refs.logPanel; diff --git a/web-src/src/main-app/store/scriptConfig.js b/web-src/src/main-app/store/scriptConfig.js index 169e732e..be0a967e 100644 --- a/web-src/src/main-app/store/scriptConfig.js +++ b/web-src/src/main-app/store/scriptConfig.js @@ -60,7 +60,8 @@ export default () => ({ parameters: [], sentValues: {}, loading: false, - clientStateVersion: 0 + clientStateVersion: 0, + preloadScript: null }, namespaced: true, actions: { @@ -124,7 +125,7 @@ export default () => ({ } forEachKeyValue(state.sentValues, (key, value) => sendParameterValue(key, value, websocket)); - }, + } }, mutations: { RESET_CONFIG(state) { @@ -135,6 +136,7 @@ export default () => ({ state.loadError = null; state.loading = false; state.sentValues = {}; + state.preloadScript = null }, SET_ERROR(state, error) { @@ -268,6 +270,10 @@ export default () => ({ break } } + }, + + SET_PRELOAD_SCRIPT(state, preloadScript) { + state.preloadScript = preloadScript } } }) @@ -327,6 +333,12 @@ function reconnect(state, internalState, commit, dispatch, selectedScript) { if (eventType === 'parameterRemoved') { commit('REMOVE_PARAMETER', data); + return; + } + + if (eventType === 'preloadScript') { + commit('SET_PRELOAD_SCRIPT', data) + } }, @@ -337,11 +349,11 @@ function reconnect(state, internalState, commit, dispatch, selectedScript) { if (error.code === 422) { commit('SET_ERROR', `${error.reason} "${selectedScript}"`); - return; + return; } - + console.log('Socket closed. code=' + error.code + ', reason=' + error.reason); - + if (isNull(state.scriptConfig)) { commit('SET_ERROR', 'Failed to connect to the server'); return; diff --git a/web-src/tests/unit/scriptConfig_test.js b/web-src/tests/unit/scriptConfig_test.js index 32913685..c797cc3f 100644 --- a/web-src/tests/unit/scriptConfig_test.js +++ b/web-src/tests/unit/scriptConfig_test.js @@ -164,6 +164,7 @@ describe('Test scriptConfig module', function () { expect(store.state.scriptConfig.scriptConfig).toEqual(config); expect(store.state.scriptConfig.parameters).toEqual(config.parameters); + expect(store.state.scriptConfig.preloadScript).toBeNil() expect(observers[0].path).toEndWith('?initWithValues=false') }); @@ -217,6 +218,26 @@ describe('Test scriptConfig module', function () { expect(store.state.scriptConfig.parameters).toEqual(config.parameters) }); + + it('Test preload script', function () { + const store = createStore(); + store.dispatch('scriptConfig/reloadScript', {selectedScript: 'my script'}); + + const config = createConfig(); + + sendEventFromServer(createConfigEvent(config)); + + let preloadScript = { + 'output': '123', + 'format': 'terminal' + }; + sendEventFromServer(JSON.stringify({ + event: 'preloadScript', + data: preloadScript + })); + + expect(store.state.scriptConfig.preloadScript).toEqual(preloadScript) + }); }); describe('Test reconnection', function () {