diff --git a/samples/configs/destroy_world.json b/samples/configs/destroy_world.json index 09f97ba6..1e7f66ea 100644 --- a/samples/configs/destroy_world.json +++ b/samples/configs/destroy_world.json @@ -1,4 +1,5 @@ { + "name": "destroy_world", "script_path": "./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 diff --git a/samples/configs/mult_words.json b/samples/configs/mult_words.json index 056790ad..74f4bba1 100644 --- a/samples/configs/mult_words.json +++ b/samples/configs/mult_words.json @@ -1,8 +1,8 @@ { "name": "Multiple words", "script_path": "'mult words.py' 1 'word 2' '' 'word4'", - "description": "Executes a complex command specified as a scripts path and prints all incoming parameters", "working_directory": "./samples/scripts/", + "description": "Executes a complex command specified as a scripts path and prints all incoming parameters", "parameters": [ { "name": "Param1", @@ -15,4 +15,4 @@ "default": "a single parameter" } ] -} +} \ No newline at end of file diff --git a/samples/configs/parameterized.json b/samples/configs/parameterized.json index 7acfd7d5..53975df1 100644 --- a/samples/configs/parameterized.json +++ b/samples/configs/parameterized.json @@ -1,12 +1,12 @@ { "name": "Very parameterized", "script_path": "scripts/parameterized.sh", + "working_directory": "./samples", "description": "This script does nothing except accepting a lot of parameters and printing them", "allowed_users": [ "*" ], "include": "${Simple Text}.json", - "working_directory": "./samples", "parameters": [ { "name": "Simple Int", @@ -21,15 +21,15 @@ }, { "name": "Simple Text", + "required": true, "param": "--simple_text", - "description": "Parameter Four", - "required": true + "description": "Parameter Four" }, { "name": "Simple List", "param": "--simple_list", - "description": "Parameter Five", "type": "list", + "description": "Parameter Five", "values": [ "val1", "val3", @@ -38,15 +38,15 @@ }, { "name": "File upload", - "description": "File upload testing", + "param": "--file_upload", "type": "file_upload", - "param": "--file_upload" + "description": "File upload testing" }, { "name": "Multiple selection", - "description": "Multiselect list", - "type": "multiselect", "param": "--multiselect", + "type": "multiselect", + "description": "Multiselect list", "values": [ "Black cat", "Brown dog", @@ -56,16 +56,16 @@ }, { "name": "Required Text", + "required": true, "param": "--required_text", - "description": "Parameter One", - "required": true + "description": "Parameter One" }, { "name": "Required List", + "required": true, "param": "--required_list", - "description": "List with required value", "type": "list", - "required": true, + "description": "List with required value", "values": [ "v1", "v2" @@ -73,57 +73,57 @@ }, { "name": "Constrained Int", + "required": "true", "param": "--constrained_int", - "description": "Parameter Three", "type": "int", - "required": "true", "default": "5", + "description": "Parameter Three", "min": "-1", "max": "123" }, { "name": "Default Text", - "param": "--def_text", - "description": "Text with default value and required", "required": true, - "default": "some_text" + "param": "--def_text", + "default": "some_text", + "description": "Text with default value and required" }, { "name": "Default Boolean", "param": "--def_bool", "no_value": true, - "description": "Boolean Two", - "default": true + "default": true, + "description": "Boolean Two" }, { "name": "Constant Text", - "description": "Constant value", "param": "--const_text", + "default": "constOne", "constant": true, - "default": "constOne" + "description": "Constant value" }, { "name": "Command-based list", "param": "--var_file", - "description": "List parameter 2", "type": "list", + "description": "List parameter 2", "values": { "script": "ls /var" } }, { "name": "Secure Int", + "type": "int", "description": "Parameter Nine", - "secure": true, - "type": "int" + "secure": true }, { "name": "Secure List", - "description": "Parameter Ten", - "secure": true, + "param": "--secure_list", "type": "list", "default": "qwerty", - "param": "--secure_list", + "description": "Parameter Ten", + "secure": true, "values": [ "qwerty", "12345678", @@ -132,9 +132,9 @@ }, { "name": "Very long list", - "description": "List with very long values", - "type": "list", "param": "--very_long_list", + "type": "list", + "description": "List with very long values", "values": [ "some quite long line", "short", @@ -146,25 +146,25 @@ }, { "name": "Multiselect as secure arguments", - "description": "Multiselect list as multiple arguments", - "type": "multiselect", "required": true, - "multiple_arguments": true, - "secure": true, + "type": "multiselect", "default": [ "multi1", "multi 3" ], + "description": "Multiselect list as multiple arguments", + "secure": true, "values": [ "multi1", "multi2", "multi 3" - ] + ], + "multiple_arguments": true }, { "name": "Dependant list", - "description": "A list with values depending on other parameters", "type": "list", + "description": "A list with values depending on other parameters", "values": { "script": "ls /var/${Command-based list}/${Required Text}" } @@ -176,9 +176,9 @@ }, { "name": "Audit name", - "constant": true, "param": "--audit_name", - "default": "${auth.audit_name}" + "default": "${auth.audit_name}", + "constant": true }, { "name": "Any IP", @@ -200,28 +200,28 @@ "name": "Server file", "param": "--server_file", "type": "server_file", + "secure": true, "file_dir": "/var/log", "file_extensions": [ "log" - ], - "secure": true + ] }, { "name": "Recursive file", "param": "--recurs_file", "type": "server_file", + "default": [ + "samples", + "configs", + "parameterized.json" + ], + "secure": true, "file_dir": "..", "file_recursive": true, "file_extensions": [ "json", ".log", "TXT" - ], - "secure": true, - "default": [ - "samples", - "configs", - "parameterized.json" ] } ] diff --git a/samples/configs/write_file.json b/samples/configs/write_file.json index fa3e243c..f71c5b2e 100644 --- a/samples/configs/write_file.json +++ b/samples/configs/write_file.json @@ -7,6 +7,14 @@ "@admin_users", "127.0.0.1" ], + "output_files": [ + "##any_path#", + "~/${filename}", + { + "type": "inline-image", + "path": "##any_path.png#" + } + ], "parameters": [ { "name": "text", @@ -17,8 +25,8 @@ "name": "repeats", "param": "-r", "type": "int", - "min": 1, - "description": "How many times the text should be written to the file" + "description": "How many times the text should be written to the file", + "min": 1 }, { "name": "clear file", @@ -31,9 +39,5 @@ "param": "-f", "description": "Custom filename" } - ], - "output_files": [ - "##any_path#", - "~/${filename}" ] } \ No newline at end of file diff --git a/src/config/config_service.py b/src/config/config_service.py index f0c6f379..ec547f5c 100644 --- a/src/config/config_service.py +++ b/src/config/config_service.py @@ -1,13 +1,31 @@ import json import logging import os +import re from model import script_config +from model.model_helper import InvalidFileException +from model.script_config import get_sorted_config from utils import os_utils, file_utils +from utils.file_utils import to_filename +from utils.string_utils import is_blank LOGGER = logging.getLogger('config_service') +def _script_name_to_file_name(script_name): + escaped_whitespaces = re.sub('\\s', '_', script_name) + filename = to_filename(escaped_whitespaces) + return filename + '.json' + + +def _preprocess_incoming_config(config): + name = config.get('name') + if is_blank(name): + raise InvalidConfigException('Name is a required parameter for script') + config['name'] = name.strip() + + class ConfigService: def __init__(self, authorizer, conf_folder) -> None: self._authorizer = authorizer @@ -15,6 +33,60 @@ def __init__(self, authorizer, conf_folder) -> None: file_utils.prepare_folder(self._script_configs_folder) + def load_config(self, name, user): + self._check_admin_access(user) + + (short_config, path, config_object) = self._find_config(name) + + if path is None: + return None + + if config_object.get('name') is None: + config_object['name'] = short_config.name + + return {'config': config_object, 'filename': os.path.basename(path)} + + def create_config(self, user, config): + self._check_admin_access(user) + _preprocess_incoming_config(config) + + name = config['name'] + + (short_config, path, json_object) = self._find_config(name) + if path is not None: + raise InvalidConfigException('Another config with the same name already exists') + + path = os.path.join(self._script_configs_folder, _script_name_to_file_name(name)) + unique_path = file_utils.create_unique_filename(path, 100) + + LOGGER.info('Creating new script config "' + name + '" in ' + unique_path) + self._save_config(config, unique_path) + + def update_config(self, user, config, filename): + self._check_admin_access(user) + _preprocess_incoming_config(config) + + if is_blank(filename): + raise InvalidConfigException('Script filename should be specified') + + original_file_path = os.path.join(self._script_configs_folder, filename) + if not os.path.exists(original_file_path): + raise InvalidFileException(original_file_path, 'Failed to find script path: ' + original_file_path) + + name = config['name'] + + (short_config, found_config_path, json_object) = self._find_config(name) + if (found_config_path is not None) and (os.path.basename(found_config_path) != filename): + raise InvalidConfigException('Another script found with the same name: ' + name) + + LOGGER.info('Updating script config "' + name + '" in ' + original_file_path) + self._save_config(config, original_file_path) + + def _save_config(self, config, path): + sorted_config = get_sorted_config(config) + config_json = json.dumps(sorted_config, indent=2) + file_utils.write_file(path, config_json) + def list_configs(self, user): conf_service = self @@ -35,29 +107,12 @@ def load_script(path, content): return self._visit_script_configs(load_script) - def create_config_model(self, name, user, parameter_values=None): - def find_and_load(path, content): - try: - json_object = json.loads(content) - short_config = script_config.read_short(path, json_object) - - if short_config is None: - return None - except: - LOGGER.exception('Could not load script config: ' + path) - return None + def load_config_model(self, name, user, parameter_values=None): + (short_config, path, json_object) = self._find_config(name) - if short_config.name != name: - return None - - raise StopIteration((short_config, path, json_object)) - - configs = self._visit_script_configs(find_and_load) - if not configs: + if path is None: return None - (short_config, path, json_object) = configs[0] - if not self._can_access_script(user, short_config): raise ConfigNotAllowedException() @@ -90,6 +145,29 @@ def _visit_script_configs(self, visitor): return result + def _find_config(self, name): + def find_and_load(path, content): + try: + json_object = json.loads(content) + short_config = script_config.read_short(path, json_object) + + if short_config is None: + return None + except: + LOGGER.exception('Could not load script config: ' + path) + return None + + if short_config.name != name.strip(): + return None + + raise StopIteration((short_config, path, json_object)) + + configs = self._visit_script_configs(find_and_load) + if not configs: + return None, None, None + + return configs[0] + def _load_script_config(self, path, content_or_json_dict, user, parameter_values): if isinstance(content_or_json_dict, str): json_object = json.loads(content_or_json_dict) @@ -109,12 +187,21 @@ def _load_script_config(self, path, content_or_json_dict, user, parameter_values def _can_access_script(self, user, short_config): return self._authorizer.is_allowed(user.user_id, short_config.allowed_users) - -class ConfigNotFoundException(Exception): - def __init__(self, script_name) -> None: - self.script_name = script_name + def _check_admin_access(self, user): + if not self._authorizer.is_admin(user.user_id): + raise AdminAccessRequiredException('Access to script is prohibited for ' + str(user)) class ConfigNotAllowedException(Exception): def __init__(self): pass + + +class AdminAccessRequiredException(Exception): + def __init__(self, message): + super().__init__(message) + + +class InvalidConfigException(Exception): + def __init__(self, message): + super().__init__(message) diff --git a/src/model/parameter_config.py b/src/model/parameter_config.py index 18ad047f..8addf57f 100644 --- a/src/model/parameter_config.py +++ b/src/model/parameter_config.py @@ -1,5 +1,6 @@ import logging import os +from collections import OrderedDict from ipaddress import ip_address, IPv4Address, IPv6Address from config.constants import PARAM_TYPE_SERVER_FILE, FILE_TYPE_FILE, PARAM_TYPE_MULTISELECT, FILE_TYPE_DIR @@ -450,3 +451,18 @@ class WrongParameterUsageException(Exception): def __init__(self, param_name, error_message) -> None: super().__init__(error_message) self.param_name = param_name + + +def get_sorted_config(param_config): + key_order = ['name', 'required', 'param', 'type', 'no_value', 'default', 'constant', 'description', 'secure', + 'values', 'min', 'max', 'multiple_arguments', 'separator', 'file_dir', 'file_recursive', 'file_type', + 'file_extensions'] + + def get_order(key): + if key in key_order: + return key_order.index(key) + else: + return 100 + + sorted_config = OrderedDict(sorted(param_config.items(), key=lambda item: get_order(item[0]))) + return sorted_config diff --git a/src/model/script_config.py b/src/model/script_config.py index 7d4c6b63..af71865a 100644 --- a/src/model/script_config.py +++ b/src/model/script_config.py @@ -2,8 +2,10 @@ import logging import os import re +from collections import OrderedDict from auth.authorization import ANY_USER +from model import parameter_config from model.model_helper import is_empty, fill_parameter_values, read_bool_from_config, InvalidValueException from model.parameter_config import ParameterModel from react.properties import ObservableList, ObservableDict, observable_fields, Property @@ -225,7 +227,7 @@ def _read_name(file_path, json_object): filename = os.path.basename(file_path) name = os.path.splitext(filename)[0] - return name + return name.strip() def read_short(file_path, json_object): @@ -325,3 +327,23 @@ def unsubscribe(self, observer): def get(self): return self._value_property.get() + + +def get_sorted_config(config): + key_order = ['name', 'script_path', 'working_directory', 'hidden', 'description', 'allowed_users', 'include', + 'output_files', 'requires_terminal', 'bash_formatting', 'parameters'] + + def get_order(key): + if key == 'parameters': + return 99 + elif key in key_order: + return key_order.index(key) + else: + return 50 + + sorted_config = OrderedDict(sorted(config.items(), key=lambda item: get_order(item[0]))) + if config.get('parameters'): + for i, param in enumerate(config['parameters']): + config['parameters'][i] = parameter_config.get_sorted_config(param) + + return sorted_config diff --git a/src/tests/config_service_test.py b/src/tests/config_service_test.py index e08461e5..97bdf3a8 100644 --- a/src/tests/config_service_test.py +++ b/src/tests/config_service_test.py @@ -1,10 +1,13 @@ import json import os import unittest +from collections import OrderedDict from auth.authorization import Authorizer, EmptyGroupProvider from auth.user import User -from config.config_service import ConfigService, ConfigNotAllowedException +from config.config_service import ConfigService, ConfigNotAllowedException, AdminAccessRequiredException, \ + InvalidConfigException +from model.model_helper import InvalidFileException from tests import test_utils from tests.test_utils import AnyUserAuthorizer from utils import file_utils @@ -13,16 +16,16 @@ class ConfigServiceTest(unittest.TestCase): def test_list_configs_when_one(self): - _create_script_config('conf_x') + _create_script_config_file('conf_x') configs = self.config_service.list_configs(self.user) self.assertEqual(1, len(configs)) self.assertEqual('conf_x', configs[0].name) def test_list_configs_when_multiple(self): - _create_script_config('conf_x') - _create_script_config('conf_y') - _create_script_config('A B C') + _create_script_config_file('conf_x') + _create_script_config_file('conf_y') + _create_script_config_file('A B C') configs = self.config_service.list_configs(self.user) conf_names = [config.name for config in configs] @@ -33,37 +36,37 @@ def test_list_configs_when_no(self): self.assertEqual([], configs) def test_list_configs_when_one_broken(self): - broken_conf_path = _create_script_config('broken') + broken_conf_path = _create_script_config_file('broken') file_utils.write_file(broken_conf_path, '{ hello ?') - _create_script_config('correct') + _create_script_config_file('correct') configs = self.config_service.list_configs(self.user) self.assertEqual(1, len(configs)) self.assertEqual('correct', configs[0].name) def test_list_hidden_config(self): - _create_script_config('conf_x', hidden=True) + _create_script_config_file('conf_x', hidden=True) configs = self.config_service.list_configs(self.user) self.assertEqual([], configs) def test_load_config(self): - _create_script_config('conf_x') + _create_script_config_file('conf_x') - config = self.config_service.create_config_model('conf_x', self.user) + config = self.config_service.load_config_model('conf_x', self.user) self.assertIsNotNone(config) self.assertEqual('conf_x', config.name) def test_load_config_when_not_exists(self): - _create_script_config('conf_x') + _create_script_config_file('conf_x') - config = self.config_service.create_config_model('ABC', self.user) + config = self.config_service.load_config_model('ABC', self.user) self.assertIsNone(config) def test_load_hidden_config(self): - _create_script_config('conf_x', hidden=True) + _create_script_config_file('conf_x', hidden=True) - config = self.config_service.create_config_model('conf_x', self.user) + config = self.config_service.load_config_model('conf_x', self.user) self.assertIsNone(config) def tearDown(self): @@ -80,41 +83,41 @@ def setUp(self): class ConfigServiceAuthTest(unittest.TestCase): def test_list_configs_when_no_constraints(self): - _create_script_config('a1') - _create_script_config('c2') + _create_script_config_file('a1') + _create_script_config_file('c2') self.assert_list_configs(self.user1, ['a1', 'c2']) def test_list_configs_when_user_allowed(self): - _create_script_config('a1', allowed_users=['user1']) - _create_script_config('c2', allowed_users=['user1']) + _create_script_config_file('a1', allowed_users=['user1']) + _create_script_config_file('c2', allowed_users=['user1']) self.assert_list_configs(self.user1, ['a1', 'c2']) def test_list_configs_when_one_not_allowed(self): - _create_script_config('a1', allowed_users=['XYZ']) - _create_script_config('b2') - _create_script_config('c3', allowed_users=['user1']) + _create_script_config_file('a1', allowed_users=['XYZ']) + _create_script_config_file('b2') + _create_script_config_file('c3', allowed_users=['user1']) self.assert_list_configs(self.user1, ['b2', 'c3']) def test_list_configs_when_none_allowed(self): - _create_script_config('a1', allowed_users=['XYZ']) - _create_script_config('b2', allowed_users=['ABC']) + _create_script_config_file('a1', allowed_users=['XYZ']) + _create_script_config_file('b2', allowed_users=['ABC']) self.assert_list_configs(self.user1, []) def test_load_config_when_user_allowed(self): - _create_script_config('my_script', allowed_users=['ABC', 'user1', 'qwerty']) + _create_script_config_file('my_script', allowed_users=['ABC', 'user1', 'qwerty']) - config = self.config_service.create_config_model('my_script', self.user1) + config = self.config_service.load_config_model('my_script', self.user1) self.assertIsNotNone(config) self.assertEqual('my_script', config.name) def test_load_config_when_user_not_allowed(self): - _create_script_config('my_script', allowed_users=['ABC', 'qwerty']) + _create_script_config_file('my_script', allowed_users=['ABC', 'qwerty']) - self.assertRaises(ConfigNotAllowedException, self.config_service.create_config_model, 'my_script', self.user1) + self.assertRaises(ConfigNotAllowedException, self.config_service.load_config_model, 'my_script', self.user1) def assert_list_configs(self, user, expected_names): configs = self.config_service.list_configs(user) @@ -134,7 +137,213 @@ def setUp(self): self.config_service = ConfigService(authorizer, test_utils.temp_folder) -def _create_script_config(filename, *, name=None, allowed_users=None, hidden=None): +class ConfigServiceCreateConfigTest(unittest.TestCase): + + def setUp(self): + super().setUp() + test_utils.setup() + + authorizer = Authorizer([], ['admin_user'], EmptyGroupProvider()) + self.admin_user = User('admin_user', {}) + self.config_service = ConfigService(authorizer, test_utils.temp_folder) + + def tearDown(self): + super().tearDown() + test_utils.cleanup() + + def test_create_simple_config(self): + config = _prepare_script_config_object('conf1', description='My wonderful test config') + self.config_service.create_config(self.admin_user, config) + + _validate_config(self, 'conf1.json', config) + + def test_non_admin_access(self): + config = _prepare_script_config_object('conf1', description='My wonderful test config') + + self.assertRaises(AdminAccessRequiredException, self.config_service.create_config, + User('my_user', {}), config) + + def test_blank_name(self): + config = _prepare_script_config_object(' ', description='My wonderful test config') + + self.assertRaises(InvalidConfigException, self.config_service.create_config, self.admin_user, config) + + def test_strip_name(self): + config = _prepare_script_config_object(' conf1 ', description='My wonderful test config') + + self.config_service.create_config(self.admin_user, config) + config['name'] = 'conf1' + _validate_config(self, 'conf1.json', config) + + def test_name_already_exists(self): + _create_script_config_file('confX', name='confX') + config = _prepare_script_config_object('confX', description='My wonderful test config') + + self.assertRaisesRegex(InvalidConfigException, 'Another config with the same name already exists', + self.config_service.create_config, self.admin_user, config) + + def test_filename_already_exists(self): + existing_path = _create_script_config_file('confX', name='conf-Y') + existing_config = file_utils.read_file(existing_path) + + config = _prepare_script_config_object('confX', description='My wonderful test config') + + self.config_service.create_config(self.admin_user, config) + _validate_config(self, 'confX_0.json', config) + self.assertEqual(existing_config, file_utils.read_file(existing_path)) + + def test_insert_sorted_values(self): + config = _prepare_script_config_object('Conf X', + requires_terminal=False, + parameters=[{'name': 'param1'}], + description='Some description', + include='included', + allowed_users=[], + script_path='cd ~') + + self.config_service.create_config(self.admin_user, config) + _validate_config(self, 'Conf_X.json', OrderedDict([('name', 'Conf X'), + ('script_path', 'cd ~'), + ('description', 'Some description'), + ('allowed_users', []), + ('include', 'included'), + ('requires_terminal', False), + ('parameters', [{'name': 'param1'}])])) + + +class ConfigServiceUpdateConfigTest(unittest.TestCase): + + def setUp(self): + super().setUp() + test_utils.setup() + + authorizer = Authorizer([], ['admin_user'], EmptyGroupProvider()) + self.admin_user = User('admin_user', {}) + self.config_service = ConfigService(authorizer, test_utils.temp_folder) + + for suffix in 'XYZ': + _create_script_config_file('conf' + suffix, name='Conf ' + suffix) + + def tearDown(self): + super().tearDown() + test_utils.cleanup() + + def test_update_simple_config(self): + config = _prepare_script_config_object('Conf X', description='My wonderful test config') + self.config_service.update_config(self.admin_user, config, 'confX.json') + + _validate_config(self, 'confX.json', config) + + def test_save_another_name(self): + config = _prepare_script_config_object('Conf A', description='My wonderful test config') + self.config_service.update_config(self.admin_user, config, 'confX.json') + + _validate_config(self, 'confX.json', config) + configs_path = os.path.join(test_utils.temp_folder, 'runners') + self.assertEqual(3, len(os.listdir(configs_path))) + + def test_non_admin_access(self): + config = _prepare_script_config_object('conf1', description='My wonderful test config') + + self.assertRaises(AdminAccessRequiredException, self.config_service.update_config, + User('my_user', {}), config, 'confX.json') + + def test_blank_name(self): + config = _prepare_script_config_object(' ', description='My wonderful test config') + + self.assertRaises(InvalidConfigException, self.config_service.update_config, + self.admin_user, config, 'confX.json') + + def test_strip_name(self): + config = _prepare_script_config_object(' Conf X ', description='My wonderful test config') + + self.config_service.update_config(self.admin_user, config, 'confX.json') + config['name'] = 'Conf X' + _validate_config(self, 'confX.json', config) + + def test_name_already_exists(self): + config = _prepare_script_config_object('Conf Y', description='My wonderful test config') + + self.assertRaisesRegex(InvalidConfigException, 'Another script found with the same name: Conf Y', + self.config_service.update_config, self.admin_user, config, 'confX.json') + + def test_blank_filename(self): + config = _prepare_script_config_object('Conf X', description='My wonderful test config') + + self.assertRaises(InvalidConfigException, self.config_service.update_config, + self.admin_user, config, ' ') + + def test_filename_not_exists(self): + config = _prepare_script_config_object('Conf X', description='My wonderful test config') + + self.assertRaisesRegex(InvalidFileException, 'Failed to find script path', + self.config_service.update_config, self.admin_user, config, 'conf A.json') + + def test_filename_already_exists(self): + config = _prepare_script_config_object('Conf Y', description='My wonderful test config') + + self.assertRaisesRegex(InvalidConfigException, 'Another script found with the same name: Conf Y', + self.config_service.update_config, self.admin_user, config, 'confX.json') + + def test_update_sorted_values(self): + config = _prepare_script_config_object('Conf X', + requires_terminal=False, + parameters=[{'name': 'param1'}], + description='Some description', + include='included', + allowed_users=[], + script_path='cd ~') + + self.config_service.update_config(self.admin_user, config, 'confX.json') + body = OrderedDict([('name', 'Conf X'), + ('script_path', 'cd ~'), + ('description', 'Some description'), + ('allowed_users', []), + ('include', 'included'), + ('requires_terminal', False), + ('parameters', [{'name': 'param1'}])]) + _validate_config(self, 'confX.json', body) + + +class ConfigServiceLoadConfigForAdminTest(unittest.TestCase): + def setUp(self): + super().setUp() + test_utils.setup() + + authorizer = Authorizer([], ['admin_user'], EmptyGroupProvider()) + self.admin_user = User('admin_user', {}) + self.config_service = ConfigService(authorizer, test_utils.temp_folder) + + def tearDown(self): + super().tearDown() + test_utils.cleanup() + + def test_load_config(self): + _create_script_config_file('ConfX', script_path='my_script.sh', description='some desc') + config = self.config_service.load_config('ConfX', self.admin_user) + + self.assertEqual(config, {'filename': 'ConfX.json', 'config': {'name': 'ConfX', + 'script_path': 'my_script.sh', + 'description': 'some desc'}}) + + def test_load_config_when_name_different_from_filename(self): + _create_script_config_file('ConfX', name='my conf x') + config = self.config_service.load_config('my conf x', self.admin_user) + + self.assertEqual(config, {'filename': 'ConfX.json', 'config': {'name': 'my conf x'}}) + + def test_load_config_when_non_admin(self): + _create_script_config_file('ConfX') + user = User('user1', {}) + self.assertRaises(AdminAccessRequiredException, self.config_service.load_config, 'ConfX', user) + + def test_load_config_when_not_exists(self): + _create_script_config_file('ConfX', name='my conf x') + config = self.config_service.load_config('ConfX', self.admin_user) + self.assertIsNone(config) + + +def _create_script_config_file(filename, *, name=None, **kwargs): conf_folder = os.path.join(test_utils.temp_folder, 'runners') file_path = os.path.join(conf_folder, filename + '.json') @@ -142,13 +351,28 @@ def _create_script_config(filename, *, name=None, allowed_users=None, hidden=Non if name is not None: config['name'] = name - if allowed_users is not None: - config['allowed_users'] = allowed_users - - if hidden is not None: - config['hidden'] = hidden + if kwargs: + config.update(kwargs) config_json = json.dumps(config) file_utils.write_file(file_path, config_json) return file_path + +def _validate_config(test_case, expected_filename, expected_body): + configs_path = os.path.join(test_utils.temp_folder, 'runners') + path = os.path.join(configs_path, expected_filename) + all_paths = str(os.listdir(configs_path)) + test_case.assertTrue(os.path.exists(path), 'Failed to find path ' + path + '. Existing paths: ' + all_paths) + + actual_body = json.loads(file_utils.read_file(path)) + test_case.assertEqual(expected_body, actual_body) + + +def _prepare_script_config_object(name, **kwargs): + config = {'name': name} + + if kwargs: + config.update(kwargs) + + return config diff --git a/src/tests/parameter_config_test.py b/src/tests/parameter_config_test.py index d533ce05..cdd49ac6 100644 --- a/src/tests/parameter_config_test.py +++ b/src/tests/parameter_config_test.py @@ -1,8 +1,10 @@ import os import unittest +from collections import OrderedDict from config.constants import PARAM_TYPE_SERVER_FILE, PARAM_TYPE_MULTISELECT from model import parameter_config +from model.parameter_config import get_sorted_config from react.properties import ObservableDict from tests import test_utils from tests.test_utils import create_parameter_model, create_parameter_model_from_config @@ -653,6 +655,65 @@ def test_normalize_multiselect_when_none(self): self.assertEqual([], parameter.normalize_user_value(None)) +class GetSortedParamConfig(unittest.TestCase): + def test_get_sorted_when_3_fields(self): + config = get_sorted_config({'type': 'int', 'name': 'Param X', 'required': True}) + + expected = OrderedDict( + [('name', 'Param X'), + ('required', True), + ('type', 'int')]) + self.assertEqual(expected, config) + + def test_get_sorted_when_many_fields(self): + config = get_sorted_config({ + 'separator': '/', + 'constant': False, + 'min': -10, + 'type': 'int', + 'default': 3, + 'name': 'Param X', + 'values': [], + 'required': True, + 'max': 5, + 'param': '-i', + 'description': 'guess number' + }) + + expected = OrderedDict( + [('name', 'Param X'), + ('required', True), + ('param', '-i'), + ('type', 'int'), + ('default', 3), + ('constant', False), + ('description', 'guess number'), + ('values', []), + ('min', -10), + ('max', 5), + ('separator', '/')]) + self.assertEqual(expected, config) + + def test_get_sorted_when_unknown_fields(self): + config = get_sorted_config({ + 'key1': 'abc', + 'file_recursive': True, + 'key2': 123, + 'name': 'Param X', + 'key3': []}) + + expected = OrderedDict( + [('name', 'Param X'), + ('file_recursive', True), + ('key1', 'abc'), + ('key2', 123), + ('key3', [])]) + + self.assertEqual(expected.popitem(last=False), config.popitem(last=False)) + self.assertEqual(expected.popitem(last=False), config.popitem(last=False)) + self.assertCountEqual(expected.items(), config.items()) + + def _create_parameter_model(config, *, username=DEF_USERNAME, audit_name=DEF_AUDIT_NAME, all_parameters=None): return create_parameter_model_from_config(config, username=username, diff --git a/src/tests/script_config_test.py b/src/tests/script_config_test.py index 31909f27..d21ec6bf 100644 --- a/src/tests/script_config_test.py +++ b/src/tests/script_config_test.py @@ -1,8 +1,10 @@ import os import unittest +from collections import OrderedDict from config.constants import PARAM_TYPE_SERVER_FILE, PARAM_TYPE_MULTISELECT -from model.script_config import ConfigModel, InvalidValueException, _TemplateProperty, ParameterNotFoundException +from model.script_config import ConfigModel, InvalidValueException, _TemplateProperty, ParameterNotFoundException, \ + get_sorted_config from react.properties import ObservableDict, ObservableList from tests import test_utils from tests.test_utils import create_script_param_config, create_parameter_model, create_files @@ -733,6 +735,76 @@ def set_value(self, name, value): self.values[name] = value +class GetSortedConfigTest(unittest.TestCase): + def test_get_sorted_when_3_fields(self): + config = get_sorted_config({'script_path': 'cd ~', 'name': 'Conf X', 'description': 'My wonderful script'}) + + expected = OrderedDict( + [('name', 'Conf X'), + ('script_path', 'cd ~'), + ('description', 'My wonderful script')]) + self.assertEqual(expected, config) + + def test_get_sorted_when_many_fields(self): + config = get_sorted_config({ + 'output_files': ['~/my/file'], + 'name': 'Conf X', + 'include': 'included', + 'parameters': [], + 'description': 'My wonderful script', + 'requires_terminal': False, + 'allowed_users': [], + 'script_path': 'cd ~', + 'working_directory': '~'}) + + expected = OrderedDict([ + ('name', 'Conf X'), + ('script_path', 'cd ~'), + ('working_directory', '~'), + ('description', 'My wonderful script'), + ('allowed_users', []), + ('include', 'included'), + ('output_files', ['~/my/file']), + ('requires_terminal', False), + ('parameters', []), + ]) + self.assertEqual(expected, config) + + def test_get_sorted_when_unknown_fields(self): + config = get_sorted_config({ + 'parameters': [], + 'key1': 'abc', + 'requires_terminal': False, + 'key2': 123}) + + expected = OrderedDict([ + ('requires_terminal', False), + ('key1', 'abc'), + ('key2', 123), + ('parameters', []), + ]) + self.assertEqual(expected.popitem(False), config.popitem(False)) + self.assertEqual(expected.popitem(True), config.popitem(True)) + self.assertCountEqual(expected.items(), config.items()) + + def test_get_sorted_with_parameters(self): + config = get_sorted_config({ + 'parameters': [{'name': 'param2', 'description': 'desc 1'}, + {'type': 'int', 'name': 'paramA'}, + {'default': 'false', 'name': 'param1', 'no_value': True}], + 'name': 'Conf X'}) + + expected = OrderedDict([ + ('name', 'Conf X'), + ('parameters', [ + OrderedDict([('name', 'param2'), ('description', 'desc 1')]), + OrderedDict([('name', 'paramA'), ('type', 'int')]), + OrderedDict([('name', 'param1'), ('no_value', True), ('default', 'false')]) + ]), + ]) + self.assertEqual(expected, config) + + def _create_config_model(name, *, config=None, username=DEF_USERNAME, diff --git a/src/web/server.py b/src/web/server.py index d7438759..0f3e15c4 100755 --- a/src/web/server.py +++ b/src/web/server.py @@ -22,14 +22,14 @@ from auth.tornado_auth import TornadoAuth from auth.user import User from communications.alerts_service import AlertsService -from config.config_service import ConfigService, ConfigNotAllowedException +from config.config_service import ConfigService, ConfigNotAllowedException, InvalidConfigException from execution.execution_service import ExecutionService from execution.logging import ExecutionLoggingService from features.file_download_feature import FileDownloadFeature from features.file_upload_feature import FileUploadFeature from model import external_model from model.external_model import to_short_execution_log, to_long_execution_log, parameter_to_external -from model.model_helper import is_empty +from model.model_helper import is_empty, InvalidFileException from model.parameter_config import WrongParameterUsageException from model.script_config import InvalidValueException, ParameterNotFoundException from model.server_conf import ServerConfig @@ -190,6 +190,43 @@ def get(self, user): self.write(json.dumps(names)) +class AdminUpdateScriptEndpoint(BaseRequestHandler): + @requires_admin_rights + @inject_user + def post(self, user): + request = tornado_utils.get_request_body(self) + config = request.get('config') + + try: + self.application.config_service.create_config(user, config) + except (InvalidConfigException) as e: + raise tornado.web.HTTPError(422, str(e)) + + @requires_admin_rights + @inject_user + def put(self, user): + request = tornado_utils.get_request_body(self) + config = request.get('config') + filename = request.get('filename') + + try: + self.application.config_service.update_config(user, config, filename) + except (InvalidConfigException, InvalidFileException) as e: + raise tornado.web.HTTPError(422, str(e)) + + +class AdminGetScriptEndpoint(BaseRequestHandler): + @requires_admin_rights + @inject_user + def get(self, user, script_name): + config = self.application.config_service.load_config(script_name, user) + + if config is None: + raise tornado.web.HTTPError(404, str('Failed to find config for name: ' + script_name)) + + self.write(json.dumps(config)) + + class ScriptConfigSocket(tornado.websocket.WebSocketHandler): def __init__(self, application, request, **kwargs): @@ -202,7 +239,7 @@ def __init__(self, application, request, **kwargs): @inject_user def open(self, user, config_name): try: - self.config_model = self.application.config_service.create_config_model(config_name, user) + self.config_model = self.application.config_service.load_config_model(config_name, user) active_config_models[self.config_id] = {'model': self.config_model, 'user_id': user.user_id} except ConfigNotAllowedException: self.close(code=403, reason='Access to the script is denied') @@ -401,7 +438,7 @@ def post(self, user): script_name = execution_info.script - config_model = self.application.config_service.create_config_model(script_name, user) + config_model = self.application.config_service.load_config_model(script_name, user) if not config_model: message = 'Script with name "' + str(script_name) + '" not found' @@ -826,6 +863,8 @@ def init(server_config: ServerConfig, (r'/result_files/(.*)', DownloadResultFile, {'path': downloads_folder}), + (r'/admin/scripts', AdminUpdateScriptEndpoint), + (r'/admin/scripts/(.*)', AdminGetScriptEndpoint), (r"/", ProxiedRedirectHandler, {"url": "/index.html"})] if auth.is_enabled(): diff --git a/web-src/css/admin.css b/web-src/css/admin.css index e9e4df12..3572eef9 100644 --- a/web-src/css/admin.css +++ b/web-src/css/admin.css @@ -1,33 +1,13 @@ -body { - background: white; - font-family: "Roboto", sans-serif; - font-weight: normal; -} - -.page { - height: 100vh; -} - -.page-title { - width: 100%; - height: 64px; - line-height: 64px; - - padding-left: 32px; - - font-size: 18px; - font-weight: 400; - - vertical-align: baseline; - - color: #FFF; - - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; -} - -.page .section { - height: calc(100vh - 64px); - width: 100%; - overflow: auto; +.input-field:after { + content: attr(data-error); + color: #F44336; + position: absolute; + left: 0.9rem; + bottom: -0.9rem; + font-size: 0.9rem; +} + +#toast-container { + right: 5%; + left: unset; } diff --git a/web-src/js/admin.js b/web-src/js/admin.js index f4f551e1..94d3d529 100644 --- a/web-src/js/admin.js +++ b/web-src/js/admin.js @@ -1,35 +1,15 @@ import Vue from 'vue'; -import VueRouter from 'vue-router'; +import AdminApp from './admin/AdminApp'; +import router from './admin/router'; import './style_imports'; -import ExecutionsLogPage from './admin/executions-log-page'; -import ExecutionsLog from './admin/executions-log'; -import ExecutionDetails from './admin/execution-details'; - -Vue.use(VueRouter); document.addEventListener('DOMContentLoaded', function () { - var router = new VueRouter({ - mode: 'hash', - routes: [ - { - path: '/executions', - component: ExecutionsLogPage, - children: [ - {path: '', component: ExecutionsLog}, - {path: ':executionId', component: ExecutionDetails} - ] - }, - {path: '*', redirect: '/executions'} - ] - }); //noinspection JSAnnotator new Vue({ router, el: '#admin-page', - render(createElement) { - return createElement('div', {}, [createElement('router-view')]); - } + render: h => h(AdminApp) }); }, false); diff --git a/web-src/js/admin/AdminApp.vue b/web-src/js/admin/AdminApp.vue new file mode 100644 index 00000000..cc776e50 --- /dev/null +++ b/web-src/js/admin/AdminApp.vue @@ -0,0 +1,134 @@ + + + + + \ No newline at end of file diff --git a/web-src/js/admin/components/ChipsList.vue b/web-src/js/admin/components/ChipsList.vue new file mode 100644 index 00000000..7a5e3363 --- /dev/null +++ b/web-src/js/admin/components/ChipsList.vue @@ -0,0 +1,89 @@ + + + + + \ No newline at end of file diff --git a/web-src/js/admin/components/PageProgress.vue b/web-src/js/admin/components/PageProgress.vue new file mode 100644 index 00000000..9816c508 --- /dev/null +++ b/web-src/js/admin/components/PageProgress.vue @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/web-src/js/admin/components/PromisableButton.vue b/web-src/js/admin/components/PromisableButton.vue new file mode 100644 index 00000000..a88a9275 --- /dev/null +++ b/web-src/js/admin/components/PromisableButton.vue @@ -0,0 +1,70 @@ + + + + + \ No newline at end of file diff --git a/web-src/js/admin/execution-details.vue b/web-src/js/admin/execution-details.vue deleted file mode 100644 index 9b96b904..00000000 --- a/web-src/js/admin/execution-details.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web-src/js/admin/executions-log-page.vue b/web-src/js/admin/executions-log-page.vue deleted file mode 100644 index b76d29e2..00000000 --- a/web-src/js/admin/executions-log-page.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/web-src/js/admin/executions-log.vue b/web-src/js/admin/executions-log.vue deleted file mode 100644 index e579d1a5..00000000 --- a/web-src/js/admin/executions-log.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - diff --git a/web-src/js/admin/executions/execution-details.vue b/web-src/js/admin/executions/execution-details.vue new file mode 100644 index 00000000..b26a252b --- /dev/null +++ b/web-src/js/admin/executions/execution-details.vue @@ -0,0 +1,110 @@ + + + + + \ No newline at end of file diff --git a/web-src/js/admin/executions/executions-log-page.vue b/web-src/js/admin/executions/executions-log-page.vue new file mode 100644 index 00000000..03dbfb63 --- /dev/null +++ b/web-src/js/admin/executions/executions-log-page.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/web-src/js/admin/executions-log-table.vue b/web-src/js/admin/executions/executions-log-table.vue similarity index 100% rename from web-src/js/admin/executions-log-table.vue rename to web-src/js/admin/executions/executions-log-table.vue diff --git a/web-src/js/admin/executions/executions-log.vue b/web-src/js/admin/executions/executions-log.vue new file mode 100644 index 00000000..ecfc172a --- /dev/null +++ b/web-src/js/admin/executions/executions-log.vue @@ -0,0 +1,58 @@ + + + + + \ No newline at end of file diff --git a/web-src/js/admin/executions/executions-module.js b/web-src/js/admin/executions/executions-module.js new file mode 100644 index 00000000..d02fff52 --- /dev/null +++ b/web-src/js/admin/executions/executions-module.js @@ -0,0 +1,83 @@ +import axios from 'axios'; +import {isNull} from '../../common'; + +export default { + state: { + executions: [], + loading: false + }, + namespaced: true, + actions: { + init({commit}) { + commit('SET_LOADING', true); + + axios.get('admin/execution_log/short').then(({data}) => { + + sortExecutionLogs(data); + + let executions = data.map(log => translateExecutionLog(log)); + commit('SET_EXECUTIONS', executions); + commit('SET_LOADING', false); + }); + } + + }, + mutations: { + SET_LOADING(state, loading) { + state.loading = loading; + }, + + SET_EXECUTIONS(state, executions) { + state.executions = executions; + } + }, + getters: { + findById: (state) => (id) => { + return state.executions.find(execution => execution.id === id) + } + } +} + +function sortExecutionLogs(logs) { + logs.sort(function (v1, v2) { + if (isNull(v1.startTime)) { + if (isNull(v2.startTime)) { + return v1.user.localeCompare(v2.user); + } + return 1; + } else if (isNull(v2.startTime)) { + return -1; + } + + let dateCompare = Date.parse(v2.startTime) - Date.parse(v1.startTime); + if (dateCompare !== 0) { + return dateCompare; + } + + return v1.user.localeCompare(v2.user); + }); +} + +export function translateExecutionLog(log) { + log.startTimeString = getStartTimeString(log); + log.fullStatus = getFullStatus(log); + + return log; +} + +function getStartTimeString(log) { + if (!isNull(log.startTime)) { + const startTime = new Date(log.startTime); + return startTime.toLocaleDateString() + ' ' + startTime.toLocaleTimeString(); + } else { + return ''; + } +} + +function getFullStatus(log) { + if (!isNull(log.exitCode) && !isNull(log.status)) { + return log.status + ' (' + log.exitCode + ')' + } else if (!isNull(log.status)) { + return log.status; + } +} \ No newline at end of file diff --git a/web-src/js/admin/router.js b/web-src/js/admin/router.js new file mode 100644 index 00000000..515d230e --- /dev/null +++ b/web-src/js/admin/router.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import ExecutionDetails from './executions/execution-details'; +import ExecutionsLog from './executions/executions-log'; +import ExecutionsLogPage from './executions/executions-log-page'; +import ScriptConfig from './scripts-config/ScriptConfig'; +import ScriptConfigListPage from './scripts-config/ScriptConfigListPage'; +import ScriptsList from './scripts-config/ScriptsList'; + +Vue.use(VueRouter); + +const router = new VueRouter({ + mode: 'hash', + routes: [ + { + path: '/logs', + component: ExecutionsLogPage, + children: [ + {path: '', component: ExecutionsLog}, + {path: ':executionId', component: ExecutionDetails} + ] + }, + { + path: '/scripts', + component: ScriptConfigListPage, + children: [ + {path: '', component: ScriptsList}, + {path: ':scriptName', component: ScriptConfig, props: true} + ] + }, + {path: '*', redirect: '/logs'} + ], + linkActiveClass: 'active' +}); + +export default router \ No newline at end of file diff --git a/web-src/js/admin/scripts-config/ParamListItem.vue b/web-src/js/admin/scripts-config/ParamListItem.vue new file mode 100644 index 00000000..446206f8 --- /dev/null +++ b/web-src/js/admin/scripts-config/ParamListItem.vue @@ -0,0 +1,90 @@ + + + + + \ No newline at end of file diff --git a/web-src/js/admin/scripts-config/ParameterConfigForm.vue b/web-src/js/admin/scripts-config/ParameterConfigForm.vue new file mode 100644 index 00000000..a03f32bf --- /dev/null +++ b/web-src/js/admin/scripts-config/ParameterConfigForm.vue @@ -0,0 +1,329 @@ + + + + + \ No newline at end of file diff --git a/web-src/js/components/checkbox.vue b/web-src/js/components/checkbox.vue index 1c39654d..4b60888f 100644 --- a/web-src/js/components/checkbox.vue +++ b/web-src/js/components/checkbox.vue @@ -1,5 +1,5 @@