From fe554bafd80d1c608015014c1f5f76db215b7805 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 13 Sep 2021 11:11:31 +0200 Subject: [PATCH 001/181] add: add parameter to check if a test exists #1864 Using a test name, it is searched recursively and print if it exists or not. Also, setup.py is updated. --- deps/wazuh_testing/setup.py | 5 ++-- .../wazuh_testing/qa_docs/doc_generator.py | 2 ++ .../wazuh_testing/scripts/qa_docs.py | 25 ++++++++++++------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/deps/wazuh_testing/setup.py b/deps/wazuh_testing/setup.py index 5dbedadd70..a85352b952 100644 --- a/deps/wazuh_testing/setup.py +++ b/deps/wazuh_testing/setup.py @@ -38,11 +38,11 @@ def get_files_from_directory(directory): paths = [] for (path, directories, filenames) in os.walk(directory): for filename in filenames: - paths.append(os.path.join(path, filename)) + paths.append(os.path.join('..', path, filename)) return paths -package_data_list.extend(get_files_from_directory('wazuh_testing/qa_docs/search_ui/')) +package_data_list.extend(get_files_from_directory('wazuh_testing/qa_docs/search_ui')) setup(name='wazuh_testing', version='4.3.0', @@ -57,3 +57,4 @@ def get_files_from_directory(directory): include_package_data=True, zip_safe=False ) + diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 9e26192281..1f12d98310 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -193,6 +193,8 @@ def locate_test(self): for filename in filenames: if filename == complete_test_name: return os.path.join(root, complete_test_name) + + print('test does not exist') return None def print_test_info(self, test): diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index a0e73e499d..a93b846066 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -27,9 +27,10 @@ def main(): parser.add_argument('-d', help="Enable debug messages.", action='count', dest='debug_level') parser.add_argument('-i', help="Indexes the data to elasticsearch.", dest='index_name') parser.add_argument('-l', help="Indexes the data and launch the application.", dest='launch_app') - parser.add_argument('-T', help="Test name or path to parse.", dest='test_input') + parser.add_argument('-T', help="Test name to parse.", dest='test_input') parser.add_argument('-o', help="Output directory path.", dest='output_path') - parser.add_argument('-I', help="Tests input directory", dest='test_dir', required=True) + parser.add_argument('-I', help="Path where tests are located", dest='test_dir', required=True) + parser.add_argument('-e', help="Checks if test exists", dest='test_exist') args = parser.parse_args() if args.debug_level: @@ -37,6 +38,11 @@ def main(): else: start_logging(LOG_PATH) + if args.test_exist: + doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_exist)) + if doc_check.locate_test() is not None: + print("test exists") + if args.version: print(f"qa-docs v{VERSION}") elif args.test_config: @@ -53,13 +59,14 @@ def main(): os.chdir(SEARCH_UI_PATH) os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") else: - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) - if args.test_input: - if args.output_path: - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_input)) - else: - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_input)) - docs.run() + if not args.test_exist: + docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + if args.test_input: + if args.output_path: + docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_input)) + else: + docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_input)) + docs.run() if __name__ == '__main__': main() From bad893ac6b9445c95c11ccb8f2798644252181ec Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 13 Sep 2021 12:02:36 +0200 Subject: [PATCH 002/181] style: change parameters code style #1876 --- .../wazuh_testing/scripts/qa_docs.py | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index a93b846066..e8c15f1472 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -13,24 +13,46 @@ LOG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'log') SEARCH_UI_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'search_ui') + def start_logging(folder, debug_level=logging.INFO): LOG_PATH = os.path.join(folder, f"{os.path.splitext(os.path.basename(__file__))[0]}.log") if not os.path.exists(folder): os.makedirs(folder) logging.basicConfig(filename=LOG_PATH, level=debug_level) + def main(): parser = argparse.ArgumentParser() - parser.add_argument('-s', help="Run a sanity check", action='store_true', dest='sanity') - parser.add_argument('-v', help="Print version", action='store_true', dest="version") - parser.add_argument('-t', help="Test configuration", action='store_true', dest='test_config') - parser.add_argument('-d', help="Enable debug messages.", action='count', dest='debug_level') - parser.add_argument('-i', help="Indexes the data to elasticsearch.", dest='index_name') - parser.add_argument('-l', help="Indexes the data and launch the application.", dest='launch_app') - parser.add_argument('-T', help="Test name to parse.", dest='test_input') - parser.add_argument('-o', help="Output directory path.", dest='output_path') - parser.add_argument('-I', help="Path where tests are located", dest='test_dir', required=True) - parser.add_argument('-e', help="Checks if test exists", dest='test_exist') + + parser.add_argument('-s', '--sanity-check', action='store_true', dest='sanity', + help="Run a sanity check") + + parser.add_argument('-v', '--version', action='store_true', dest="version", + help="Print qa-docs version") + + parser.add_argument('-t', action='store_true', dest='test_config', + help="Load test configuration.") + parser.add_argument('-d', action='count', dest='debug_level', + help="Enable debug messages.") + + parser.add_argument('-i', '--index-data', dest='index_name', + help="Indexes the data named as you specify as argument to elasticsearch.") + + parser.add_argument('-l', '--launch-ui', dest='launch_app', + help="Indexes the data named as you specify as argument and launch SearchUI.") + + parser.add_argument('-T', dest='test_input', + help="Parse the test that you pass as argument.") + + parser.add_argument('-o', dest='output_path', + help="Specifies the output directory for test parsed when -T is used.") + + parser.add_argument('-I', dest='test_dir', required=True, + help="Path where tests are located.") + + parser.add_argument('-e', dest='test_exist', + help="Checks if test exists or not",) + args = parser.parse_args() if args.debug_level: From 6342f612653fcdb374919b4afa0fc511f62aa0a1 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 13 Sep 2021 13:14:23 +0200 Subject: [PATCH 003/181] add: add parameters validation. #1876 Also, QADOCS logger added. Now we have to migrate from older logger to `qadocs_logger`. --- .../wazuh_testing/qa_docs/__init__.py | 1 + .../wazuh_testing/qa_docs/doc_generator.py | 8 +++ .../wazuh_testing/scripts/qa_docs.py | 49 +++++++++++++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py b/deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py index e69de29bb2..7eb8332720 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py @@ -0,0 +1 @@ +QADOCS_LOGGER = 'qadocs' \ No newline at end of file diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 1f12d98310..499d92e12b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -205,13 +205,20 @@ def print_test_info(self, test): # dump into file if self.conf.documentation_path: test_info = {} + # Need to be changed, it is hardcoded test_info['test_path'] = self.test_path[6:] + for field in self.conf.module_info: for name, schema_field in field.items(): test_info[name] = test[schema_field] for field in self.conf.test_info: for name, schema_field in field.items(): test_info[name] = test['tests'][0][schema_field] + + # If output path does not exist, it is created + if not os.path.exists(self.conf.documentation_path): + os.mkdir(self.conf.documentation_path) + # Dump data with open(os.path.join(self.conf.documentation_path, f"{self.conf.test_name}.json"), 'w') as fp: fp.write(json.dumps(test_info, indent=4)) fp.write('\n') @@ -223,6 +230,7 @@ def print_test_info(self, test): for field in self.conf.test_info: for name, schema_field in field.items(): print(str(name)+": "+str(test['tests'][0][schema_field])) + return None def run(self): diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index e8c15f1472..d836d5f470 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -6,8 +6,12 @@ from wazuh_testing.qa_docs.lib.index_data import IndexData from wazuh_testing.qa_docs.lib.sanity import Sanity from wazuh_testing.qa_docs.doc_generator import DocGenerator +from wazuh_testing.qa_docs import QADOCS_LOGGER +from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.exceptions import QAValueError VERSION = '0.1' +qactl_script_logger = Logging('QADOCS_SCRIPT', 'DEBUG', True) CONFIG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'config.yaml') OUTPUT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'output') LOG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'log') @@ -21,6 +25,31 @@ def start_logging(folder, debug_level=logging.INFO): logging.basicConfig(filename=LOG_PATH, level=debug_level) +def set_qadocs_logging(logging_level): + if not logging_level: + qadocs_logger = Logging(QADOCS_LOGGER) + qadocs_logger.disable() + else: + qadocs_logger = Logging(QADOCS_LOGGER, logging_level, True) + + +def validate_parameters(parameters): + qactl_script_logger.debug('Validating input parameters') + + # Check if the directory where the tests are located exist + if parameters.test_dir: + if not os.path.exists(parameters.test_dir): + raise QAValueError(f"{parameters.test_dir} does not exist. Tests directory not found.", + qactl_script_logger.error) + + # Check that test_input name exists + if parameters.test_input: + doc_check = DocGenerator(Config(CONFIG_PATH, parameters.test_dir, '', parameters.test_input)) + if doc_check.locate_test() is None: + raise QAValueError(f"{parameters.test_input} not found.", + qactl_script_logger.error) + + def main(): parser = argparse.ArgumentParser() @@ -32,9 +61,13 @@ def main(): parser.add_argument('-t', action='store_true', dest='test_config', help="Load test configuration.") + parser.add_argument('-d', action='count', dest='debug_level', help="Enable debug messages.") + parser.add_argument('-I', dest='test_dir', required=True, + help="Path where tests are located.") + parser.add_argument('-i', '--index-data', dest='index_name', help="Indexes the data named as you specify as argument to elasticsearch.") @@ -47,18 +80,19 @@ def main(): parser.add_argument('-o', dest='output_path', help="Specifies the output directory for test parsed when -T is used.") - parser.add_argument('-I', dest='test_dir', required=True, - help="Path where tests are located.") - parser.add_argument('-e', dest='test_exist', help="Checks if test exists or not",) args = parser.parse_args() + validate_parameters(args) + if args.debug_level: + # set_qadocs_logging('DEBUG') start_logging(LOG_PATH, logging.DEBUG) else: start_logging(LOG_PATH) + # set_qadocs_logging('INFO') if args.test_exist: doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_exist)) @@ -71,23 +105,32 @@ def main(): Config(CONFIG_PATH) elif args.sanity: sanity = Sanity(Config(CONFIG_PATH)) + qactl_script_logger.debug('Running sanity check') sanity.run() elif args.index_name: + qactl_script_logger.debug(f"Indexing {args.index_name}") indexData = IndexData(args.index_name, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) indexData.run() elif args.launch_app: + qactl_script_logger.debug(f"Indexing {args.index_name}") indexData = IndexData(args.launch_app, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) indexData.run() os.chdir(SEARCH_UI_PATH) + qactl_script_logger.debug('Running SearchUI') os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") else: if not args.test_exist: docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) if args.test_input: + qactl_script_logger.debug(f"Parsing the following test(s) {args.test_input}") if args.output_path: + qactl_script_logger.debug(f"{args.test_input}.json is going to be generated in {args.output_path}") docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_input)) else: docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_input)) + else: + qactl_script_logger.debug(f"Parsing all tests located in {args.test_dir}") + qactl_script_logger.debug('Running QADOCS') docs.run() if __name__ == '__main__': From 87f7502428fdacf8374617bfbc3c3987afa692ba Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 14 Sep 2021 13:12:13 +0200 Subject: [PATCH 004/181] add: Add `qa-docs` logger to script. #1879 Now it has to be used in all `qa-docs` modules. --- .../wazuh_testing/scripts/qa_docs.py | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index d836d5f470..cdad9ef400 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -1,5 +1,4 @@ import argparse -import logging import os from wazuh_testing.qa_docs.lib.config import Config @@ -11,43 +10,48 @@ from wazuh_testing.tools.exceptions import QAValueError VERSION = '0.1' -qactl_script_logger = Logging('QADOCS_SCRIPT', 'DEBUG', True) +qadocs_logger = Logging(QADOCS_LOGGER, 'INFO', False) CONFIG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'config.yaml') OUTPUT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'output') LOG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'log') SEARCH_UI_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'search_ui') -def start_logging(folder, debug_level=logging.INFO): - LOG_PATH = os.path.join(folder, f"{os.path.splitext(os.path.basename(__file__))[0]}.log") - if not os.path.exists(folder): - os.makedirs(folder) - logging.basicConfig(filename=LOG_PATH, level=debug_level) - - def set_qadocs_logging(logging_level): + """Set the QADOCS logging depending on the level specified. + + Args: + logging_level (string): Level used to initialize the logger. + """ if not logging_level: qadocs_logger = Logging(QADOCS_LOGGER) qadocs_logger.disable() else: - qadocs_logger = Logging(QADOCS_LOGGER, logging_level, True) + qadocs_logger = Logging(QADOCS_LOGGER, logging_level, False) def validate_parameters(parameters): - qactl_script_logger.debug('Validating input parameters') + """Validate the parameters that qa-docs recieves. + + Args: + parameters (list): List of input args. + """ + qadocs_logger.debug('Validating input parameters') # Check if the directory where the tests are located exist if parameters.test_dir: if not os.path.exists(parameters.test_dir): raise QAValueError(f"{parameters.test_dir} does not exist. Tests directory not found.", - qactl_script_logger.error) + qadocs_logger.error) # Check that test_input name exists if parameters.test_input: doc_check = DocGenerator(Config(CONFIG_PATH, parameters.test_dir, '', parameters.test_input)) if doc_check.locate_test() is None: raise QAValueError(f"{parameters.test_input} not found.", - qactl_script_logger.error) + qadocs_logger.error) + + qadocs_logger.debug('Input parameters validation successfully finished') def main(): @@ -85,14 +89,11 @@ def main(): args = parser.parse_args() - validate_parameters(args) - + # Set the qa-docs logger if args.debug_level: - # set_qadocs_logging('DEBUG') - start_logging(LOG_PATH, logging.DEBUG) - else: - start_logging(LOG_PATH) - # set_qadocs_logging('INFO') + set_qadocs_logging('DEBUG') + + validate_parameters(args) if args.test_exist: doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_exist)) @@ -101,36 +102,43 @@ def main(): if args.version: print(f"qa-docs v{VERSION}") + elif args.test_config: + qadocs_logger.debug('Loading qa-docs configuration') Config(CONFIG_PATH) + qadocs_logger.debug('qa-docs configuration loaded') + elif args.sanity: sanity = Sanity(Config(CONFIG_PATH)) - qactl_script_logger.debug('Running sanity check') + qadocs_logger.debug('Running sanity check') sanity.run() + elif args.index_name: - qactl_script_logger.debug(f"Indexing {args.index_name}") + qadocs_logger.debug(f"Indexing {args.index_name}") indexData = IndexData(args.index_name, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) indexData.run() + elif args.launch_app: - qactl_script_logger.debug(f"Indexing {args.index_name}") + qadocs_logger.debug(f"Indexing {args.index_name}") indexData = IndexData(args.launch_app, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) indexData.run() os.chdir(SEARCH_UI_PATH) - qactl_script_logger.debug('Running SearchUI') + qadocs_logger.debug('Running SearchUI') os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") + else: if not args.test_exist: docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) if args.test_input: - qactl_script_logger.debug(f"Parsing the following test(s) {args.test_input}") + qadocs_logger.info(f"Parsing the following test(s) {args.test_input}") if args.output_path: - qactl_script_logger.debug(f"{args.test_input}.json is going to be generated in {args.output_path}") + qadocs_logger.info(f"{args.test_input}.json is going to be generated in {args.output_path}") docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_input)) else: docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_input)) else: - qactl_script_logger.debug(f"Parsing all tests located in {args.test_dir}") - qactl_script_logger.debug('Running QADOCS') + qadocs_logger.info(f"Parsing all tests located in {args.test_dir}") + qadocs_logger.info('Running QADOCS') docs.run() if __name__ == '__main__': From 34db0f7dccb5ef0ec5c7f5075d9a9dd0ddf54daa Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 14 Sep 2021 13:56:55 +0200 Subject: [PATCH 005/181] refac: using now qa-docs logger to logging. #1879 --- .../wazuh_testing/qa_docs/lib/config.py | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 386e0c3528..da00826fe7 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -8,9 +8,11 @@ """ import yaml -import logging from enum import Enum import os +from wazuh_testing.qa_docs import QADOCS_LOGGER +from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.exceptions import QAValueError class Config(): @@ -18,6 +20,8 @@ class Config(): brief: Class that parses the configuration file and exposes the available configurations. It exists two modes of execution: Normal and Single test. """ + LOGGER = Logging.get_logger(QADOCS_LOGGER) + def __init__(self, *args): # If it is called using the config file self.mode = mode.DEFAULT @@ -36,8 +40,8 @@ def __init__(self, *args): with open(args[0]) as fd: self._config_data = yaml.safe_load(fd) except: - logging.error("Cannot load config file") - raise Exception("Cannot load config file") + Config.LOGGER.error('Cannot load config file') + raise QAValueError('Cannot load config file', Config.LOGGER.error) self._read_function_regex() self._read_output_fields() @@ -91,8 +95,8 @@ def _read_include_paths(self): brief: Reads from the config file all the paths to be included in the parsing. """ if not 'Include paths' in self._config_data: - logging.error("Config include paths are empty") - raise Exception("Config include paths are empty") + Config.LOGGER.error('Config include paths are empty') + raise QAValueError('Config include paths are empty', Config.LOGGER.error) include_paths = self._config_data['Include paths'] for path in include_paths: self.include_paths.append(os.path.join(self.project_path, path)) @@ -102,8 +106,8 @@ def _read_include_regex(self): brief: Reads from the config file the regexes used to identify test files. """ if not 'Include regex' in self._config_data: - logging.error("Config include regex is empty") - raise Exception("Config include regex is empty") + Config.LOGGER.error('Config include regex is empty') + raise QAValueError('Config include regex is empty', Config.LOGGER.error) self.include_regex = self._config_data['Include regex'] def _read_group_files(self): @@ -111,8 +115,8 @@ def _read_group_files(self): brief: Reads from the config file the file name to be identified with a group. """ if not 'Group files' in self._config_data: - logging.error("Config group files is empty") - raise Exception("Config group files is empty") + Config.LOGGER.error("Config group files is empty") + raise QAValueError('Config include paths are empty', Config.LOGGER.error) self.group_files = self._config_data['Group files'] def _read_function_regex(self): @@ -120,8 +124,8 @@ def _read_function_regex(self): brief: Reads from the config file the regexes used to identify a test method. """ if not 'Function regex' in self._config_data: - logging.error("Config function regex is empty") - raise Exception("Config function regex is empty") + Config.LOGGER.error('Config function regex is empty') + raise QAValueError('Config function regex is empty', Config.LOGGER.error) self.function_regex = self._config_data['Function regex'] def _read_ignore_paths(self): @@ -138,12 +142,12 @@ def _read_module_fields(self): brief: Reads from the config file the optional and mandatory fields for the test module. """ if not 'Module' in self._config_data['Output fields']: - logging.error("Config output module fields is missing") - raise Exception("Config output module fields is missing") + Config.LOGGER.error('Config output module fields is missing') + raise QAValueError('Config output module fields is missing', Config.LOGGER.error) module_fields = self._config_data['Output fields']['Module'] if not 'Mandatory' in module_fields and not 'Optional' in module_fields: - logging.error("Config output module fields are empty") - raise Exception("Config output module fields are empty") + Config.LOGGER.error('Config output module fields are empty') + raise QAValueError('Config output module fields are empty', Config.LOGGER.error) if 'Mandatory' in module_fields: self.module_fields.mandatory = module_fields['Mandatory'] if 'Optional' in module_fields: @@ -154,12 +158,12 @@ def _read_test_fields(self): brief: Reads from the config file the optional and mandatory fields for the test functions. """ if not 'Test' in self._config_data['Output fields']: - logging.error("Config output test fields is missing") - raise Exception("Config output test fields is missing") + Config.LOGGER.error('Config output test fields is missing') + raise QAValueError('Config output test fields is missing', Config.LOGGER.error) test_fields = self._config_data['Output fields']['Test'] if not 'Mandatory' in test_fields and not 'Optional' in test_fields: - logging.error("Config output test fields are empty") - raise Exception("Config output test fields are empty") + Config.LOGGER.error('Config output test fields are empty') + raise QAValueError('Config output test fields are empty', Config.LOGGER.error) if 'Mandatory' in test_fields: self.test_fields.mandatory = test_fields['Mandatory'] if 'Optional' in test_fields: @@ -170,8 +174,8 @@ def _read_output_fields(self): brief: Reads all the mandatory and optional fields. """ if not 'Output fields' in self._config_data: - logging.error("Config output fields is missing") - raise Exception("Config output fields is missing") + Config.LOGGER.error('Config output fields is missing') + raise QAValueError('Config output fields is missing', Config.LOGGER.error) self._read_module_fields() self._read_test_fields() From 762e83e81f94477a9891c637ba055835f7195d90 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 09:35:51 +0200 Subject: [PATCH 006/181] add: add debug logging to `config.py` #1879 --- .../wazuh_testing/qa_docs/lib/config.py | 99 +++++++++++++------ 1 file changed, 71 insertions(+), 28 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index da00826fe7..1684d373f6 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -37,23 +37,22 @@ def __init__(self, *args): self.test_cases_field = None try: + Config.LOGGER.debug('Loading config file') with open(args[0]) as fd: self._config_data = yaml.safe_load(fd) except: - Config.LOGGER.error('Cannot load config file') raise QAValueError('Cannot load config file', Config.LOGGER.error) self._read_function_regex() self._read_output_fields() self._read_test_cases_field() - self._read_documentation_path() self._read_include_paths() self._read_include_regex() self._read_group_files() self._read_ignore_paths() if len(args) >= 3: - self.documentation_path = args[2] + self._set_documentation_path(args[2]) if len(args) == 4: # It is called with a single test to parse self.mode = mode.SINGLE_TEST @@ -63,41 +62,66 @@ def __init__(self, *args): def _read_test_info(self): - ''' - brief: Reads from the config file the keys to be printed from test info - ''' + """Reads from the config file the keys to be printed from module info. + + This functionality is used to print any custom field(s) you want. + You can use it if you only need a few fields to parse when a single test is run. + + For example you have this in your config.yaml: + + Test info: + - test_wazuh_min_version: wazuh_min_version + """ + Config.LOGGER.debug('Reading test info from config file') if 'Test info' in self._config_data: self.test_info = self._config_data['Test info'] + else: + Config.LOGGER.warning('Cannot read test info fields') def _read_module_info(self): - ''' - brief: Reads from the config file the keys to be printed from module info - ''' + """Reads from the config file the fields to be printed from test info. + + This functionality is used to print any custom field(s) you want. + You can use it if you only need a few fields to parse when a single test is run. + + For example you have this in your config.yaml: + + Module info: + - test_system: os_platform + - test_vendor: os_vendor + - test_version: os_version + - test_target: component + """ + Config.LOGGER.debug('Reading module info from config file') + if 'Module info' in self._config_data: self.module_info = self._config_data['Module info'] + else: + Config.LOGGER.warning('Cannot read module info fields') - def _read_project_path(self): + def _set_documentation_path(self, path): """ - brief: Reads from the config file the path of the project. + brief: Sets the path of the documentation output. """ - if 'Project path' in self._config_data: - self.project_path = self._config_data['Project path'] + Config.LOGGER.debug('Setting the path documentation') - def _read_documentation_path(self): - """ - brief: Reads from the config file the path of the documentation output. - """ - if 'Output path' in self._config_data: - self.documentation_path = self._config_data['Output path'] + if path: + self.documentation_path = path + else: + Config.LOGGER.warning('You have not passed a path where the documentation data is dumped') def _read_include_paths(self): """ brief: Reads from the config file all the paths to be included in the parsing. """ + Config.LOGGER.debug('Reading include paths from config file') + + # Will be replaced by --type --module and --test , so you can run what you need if not 'Include paths' in self._config_data: - Config.LOGGER.error('Config include paths are empty') raise QAValueError('Config include paths are empty', Config.LOGGER.error) + include_paths = self._config_data['Include paths'] + for path in include_paths: self.include_paths.append(os.path.join(self.project_path, path)) @@ -105,35 +129,44 @@ def _read_include_regex(self): """ brief: Reads from the config file the regexes used to identify test files. """ + Config.LOGGER.debug('Reading the regular expressions to include files from config file') + if not 'Include regex' in self._config_data: - Config.LOGGER.error('Config include regex is empty') raise QAValueError('Config include regex is empty', Config.LOGGER.error) + self.include_regex = self._config_data['Include regex'] def _read_group_files(self): """ brief: Reads from the config file the file name to be identified with a group. """ + Config.LOGGER.debug('Reading group files from config file') + if not 'Group files' in self._config_data: - Config.LOGGER.error("Config group files is empty") raise QAValueError('Config include paths are empty', Config.LOGGER.error) + self.group_files = self._config_data['Group files'] def _read_function_regex(self): """ brief: Reads from the config file the regexes used to identify a test method. """ + Config.LOGGER.debug('Reading the regular expressions to include test methods from config file') + if not 'Function regex' in self._config_data: - Config.LOGGER.error('Config function regex is empty') raise QAValueError('Config function regex is empty', Config.LOGGER.error) + self.function_regex = self._config_data['Function regex'] def _read_ignore_paths(self): """ brief: Reads from the config file all the paths to be excluded from the parsing. """ + Config.LOGGER.debug('Reading the paths to be ignored from config file') + if 'Ignore paths' in self._config_data: ignore_paths = self._config_data['Ignore paths'] + for path in ignore_paths: self.ignore_paths.append(os.path.join(self.project_path, path)) @@ -141,15 +174,19 @@ def _read_module_fields(self): """ brief: Reads from the config file the optional and mandatory fields for the test module. """ + Config.LOGGER.debug('Reading mandatory and optional module fields from config file') + if not 'Module' in self._config_data['Output fields']: - Config.LOGGER.error('Config output module fields is missing') raise QAValueError('Config output module fields is missing', Config.LOGGER.error) + module_fields = self._config_data['Output fields']['Module'] + if not 'Mandatory' in module_fields and not 'Optional' in module_fields: - Config.LOGGER.error('Config output module fields are empty') raise QAValueError('Config output module fields are empty', Config.LOGGER.error) + if 'Mandatory' in module_fields: self.module_fields.mandatory = module_fields['Mandatory'] + if 'Optional' in module_fields: self.module_fields.optional = module_fields['Optional'] @@ -157,15 +194,19 @@ def _read_test_fields(self): """ brief: Reads from the config file the optional and mandatory fields for the test functions. """ + Config.LOGGER.debug('Reading mandatory and optional test fields from config file') + if not 'Test' in self._config_data['Output fields']: - Config.LOGGER.error('Config output test fields is missing') raise QAValueError('Config output test fields is missing', Config.LOGGER.error) + test_fields = self._config_data['Output fields']['Test'] + if not 'Mandatory' in test_fields and not 'Optional' in test_fields: - Config.LOGGER.error('Config output test fields are empty') raise QAValueError('Config output test fields are empty', Config.LOGGER.error) + if 'Mandatory' in test_fields: self.test_fields.mandatory = test_fields['Mandatory'] + if 'Optional' in test_fields: self.test_fields.optional = test_fields['Optional'] @@ -174,8 +215,8 @@ def _read_output_fields(self): brief: Reads all the mandatory and optional fields. """ if not 'Output fields' in self._config_data: - Config.LOGGER.error('Config output fields is missing') raise QAValueError('Config output fields is missing', Config.LOGGER.error) + self._read_module_fields() self._read_test_fields() @@ -183,6 +224,8 @@ def _read_test_cases_field(self): """ brief: Reads from the configuration file the key to identify a Test Case list. """ + Config.LOGGER.debug('Reading Test Case key from config file') + if 'Test cases field' in self._config_data: self.test_cases_field = self._config_data['Test cases field'] From 269e58e0dd07bf85572ae8435a0e2b61de4574b7 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 09:46:10 +0200 Subject: [PATCH 007/181] refac: add load config file method and fix private class method style. #1879 --- .../wazuh_testing/qa_docs/lib/config.py | 70 +++++++++---------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 1684d373f6..d8cd7e0092 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -36,32 +36,34 @@ def __init__(self, *args): self.test_fields = _fields() self.test_cases_field = None - try: - Config.LOGGER.debug('Loading config file') - with open(args[0]) as fd: - self._config_data = yaml.safe_load(fd) - except: - raise QAValueError('Cannot load config file', Config.LOGGER.error) - - self._read_function_regex() - self._read_output_fields() - self._read_test_cases_field() - self._read_include_paths() - self._read_include_regex() - self._read_group_files() - self._read_ignore_paths() + self.__load_config_file(args[0]) + self.__read_function_regex() + self.__read_output_fields() + self.__read_test_cases_field() + self.__read_include_paths() + self.__read_include_regex() + self.__read_group_files() + self.__read_ignore_paths() if len(args) >= 3: - self._set_documentation_path(args[2]) + self.__set_documentation_path(args[2]) if len(args) == 4: # It is called with a single test to parse self.mode = mode.SINGLE_TEST self.test_name = args[3] - self._read_test_info() - self._read_module_info() + self.__read_test_info() + self.__read_module_info() + + def __load_config_file(self, file): + try: + Config.LOGGER.debug('Loading config file') + with open(file) as config_file: + self._config_data = yaml.safe_load(config_file) + except: + raise QAValueError('Cannot load config file', Config.LOGGER.error) - def _read_test_info(self): + def __read_test_info(self): """Reads from the config file the keys to be printed from module info. This functionality is used to print any custom field(s) you want. @@ -78,7 +80,7 @@ def _read_test_info(self): else: Config.LOGGER.warning('Cannot read test info fields') - def _read_module_info(self): + def __read_module_info(self): """Reads from the config file the fields to be printed from test info. This functionality is used to print any custom field(s) you want. @@ -99,18 +101,14 @@ def _read_module_info(self): else: Config.LOGGER.warning('Cannot read module info fields') - def _set_documentation_path(self, path): + def __set_documentation_path(self, path): """ brief: Sets the path of the documentation output. """ Config.LOGGER.debug('Setting the path documentation') + self.documentation_path = path - if path: - self.documentation_path = path - else: - Config.LOGGER.warning('You have not passed a path where the documentation data is dumped') - - def _read_include_paths(self): + def __read_include_paths(self): """ brief: Reads from the config file all the paths to be included in the parsing. """ @@ -125,7 +123,7 @@ def _read_include_paths(self): for path in include_paths: self.include_paths.append(os.path.join(self.project_path, path)) - def _read_include_regex(self): + def __read_include_regex(self): """ brief: Reads from the config file the regexes used to identify test files. """ @@ -136,7 +134,7 @@ def _read_include_regex(self): self.include_regex = self._config_data['Include regex'] - def _read_group_files(self): + def __read_group_files(self): """ brief: Reads from the config file the file name to be identified with a group. """ @@ -147,7 +145,7 @@ def _read_group_files(self): self.group_files = self._config_data['Group files'] - def _read_function_regex(self): + def __read_function_regex(self): """ brief: Reads from the config file the regexes used to identify a test method. """ @@ -158,7 +156,7 @@ def _read_function_regex(self): self.function_regex = self._config_data['Function regex'] - def _read_ignore_paths(self): + def __read_ignore_paths(self): """ brief: Reads from the config file all the paths to be excluded from the parsing. """ @@ -170,7 +168,7 @@ def _read_ignore_paths(self): for path in ignore_paths: self.ignore_paths.append(os.path.join(self.project_path, path)) - def _read_module_fields(self): + def __read_module_fields(self): """ brief: Reads from the config file the optional and mandatory fields for the test module. """ @@ -190,7 +188,7 @@ def _read_module_fields(self): if 'Optional' in module_fields: self.module_fields.optional = module_fields['Optional'] - def _read_test_fields(self): + def __read_test_fields(self): """ brief: Reads from the config file the optional and mandatory fields for the test functions. """ @@ -210,17 +208,17 @@ def _read_test_fields(self): if 'Optional' in test_fields: self.test_fields.optional = test_fields['Optional'] - def _read_output_fields(self): + def __read_output_fields(self): """ brief: Reads all the mandatory and optional fields. """ if not 'Output fields' in self._config_data: raise QAValueError('Config output fields is missing', Config.LOGGER.error) - self._read_module_fields() - self._read_test_fields() + self.__read_module_fields() + self.__read_test_fields() - def _read_test_cases_field(self): + def __read_test_cases_field(self): """ brief: Reads from the configuration file the key to identify a Test Case list. """ From 2f122753a7071c5825fc0647467780e8f21a011c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 10:15:19 +0200 Subject: [PATCH 008/181] refac: add `qa-docs` logger to `DocGenerator` module. #1879 --- .../wazuh_testing/qa_docs/doc_generator.py | 80 +++++++++++++------ 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 499d92e12b..4f32da42d7 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -11,18 +11,20 @@ import re import json import yaml -from wazuh_testing.qa_docs.lib.config import mode +from wazuh_testing.qa_docs.lib.config import Config, mode from wazuh_testing.qa_docs.lib.code_parser import CodeParser from wazuh_testing.qa_docs.lib.utils import clean_folder -import warnings -import logging - +from wazuh_testing.qa_docs import QADOCS_LOGGER +from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.exceptions import QAValueError class DocGenerator: """ brief: Main class of DocGenerator tool. It´s in charge of walk every test file, and every group file to dump the parsed documentation. """ + LOGGER = Logging.get_logger(QADOCS_LOGGER) + def __init__(self, config): self.conf = config self.parser = CodeParser(self.conf) @@ -44,7 +46,9 @@ def is_valid_folder(self, path): """ for regex in self.ignore_regex: if regex.match(path): + DocGenerator.LOGGER.warning(f"Folder validation: {regex} not matchin with {path}") return False + return True def is_valid_file(self, file): @@ -55,12 +59,17 @@ def is_valid_file(self, file): - "file (str): File name to be controlled" returns: "boolean: False if the file should be ignored. True otherwise." """ + for regex in self.ignore_regex: if regex.match(file): + DocGenerator.LOGGER.warning(f"File validation: {regex} not matchin with {file}") return False + for regex in self.include_regex: if regex.match(file): + DocGenerator.LOGGER.warning(f"File validation: {regex} not matchin with {file}") return True + return False def is_group_file(self, path): @@ -70,9 +79,11 @@ def is_group_file(self, path): - "path (str): File location to be controlled" returns: "boolean: True if the file is a group file. False otherwise." """ + for group_file in self.conf.group_files: if path == group_file: return True + return False def get_group_doc_path(self, group): @@ -82,6 +93,7 @@ def get_group_doc_path(self, group): """ base_path = os.path.join(self.conf.documentation_path, os.path.basename(self.scan_path)) doc_path = os.path.join(base_path, group['name']+".group") + return doc_path def get_test_doc_path(self, path): @@ -94,6 +106,7 @@ def get_test_doc_path(self, path): base_path = os.path.join(self.conf.documentation_path, os.path.basename(self.scan_path)) relative_path = path.replace(self.scan_path, "") doc_path = os.path.splitext(base_path + relative_path)[0] + return doc_path def dump_output(self, content, doc_path): @@ -105,11 +118,22 @@ def dump_output(self, content, doc_path): - "doc_path (string): The path where the information should be dumped." """ if not os.path.exists(os.path.dirname(doc_path)): + DocGenerator.LOGGER.warning('Creating documentation folder') os.makedirs(os.path.dirname(doc_path)) - with open(doc_path + ".json", "w+") as outfile: - outfile.write(json.dumps(content, indent=4)) - with open(doc_path + ".yaml", "w+") as outfile: - outfile.write(yaml.dump(content)) + + try: + DocGenerator.LOGGER.debug(f"Writing {doc_path}.json") + with open(doc_path + ".json", "w+") as outfile: + outfile.write(json.dumps(content, indent=4)) + except IOError: + raise QAValueError(f"Cannot write in {doc_path}.json", DocGenerator.LOGGER.error) + + try: + DocGenerator.LOGGER.debug(f"Writing {doc_path}.yaml") + with open(doc_path + ".yaml", "w+") as outfile: + outfile.write(yaml.dump(content)) + except IOError: + raise QAValueError(f"Cannot write in {doc_path}.yaml", DocGenerator.LOGGER.error) def create_group(self, path, group_id): """ @@ -121,14 +145,14 @@ def create_group(self, path, group_id): """ self.__id_counter = self.__id_counter + 1 group = self.parser.parse_group(path, self.__id_counter, group_id) + if group: doc_path = self.get_group_doc_path(group) self.dump_output(group, doc_path) - logging.debug(f"New group file '{doc_path}' was created with ID:{self.__id_counter}") + DocGenerator.LOGGER.debug(f"New group file '{doc_path}' was created with ID:{self.__id_counter}") return self.__id_counter else: - warnings.warn(f"Content for {path} is empty, ignoring it", stacklevel=2) - logging.warning(f"Content for {path} is empty, ignoring it") + DocGenerator.LOGGER.warning(f"Content for {path} is empty, ignoring it") return None def create_test(self, path, group_id): @@ -141,19 +165,20 @@ def create_test(self, path, group_id): """ self.__id_counter = self.__id_counter + 1 test = self.parser.parse_test(path, self.__id_counter, group_id) + if test: if self.conf.mode == mode.DEFAULT: doc_path = self.get_test_doc_path(path) elif self.conf.mode == mode.SINGLE_TEST: doc_path = self.conf.documentation_path if self.print_test_info(test) is None: + # return self.dump_output(test, doc_path) - logging.debug(f"New documentation file '{doc_path}' was created with ID:{self.__id_counter}") + DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' was created with ID:{self.__id_counter}") return self.__id_counter else: - warnings.warn(f"Content for {path} is empty, ignoring it", stacklevel=2) - logging.warning(f"Content for {path} is empty, ignoring it") + DocGenerator.LOGGER.warning(f"Content for {path} is empty, ignoring it") return None def parse_folder(self, path, group_id): @@ -164,12 +189,13 @@ def parse_folder(self, path, group_id): - "group_id (string): The id of the group where the new elements belong." """ if not os.path.exists(path): - warnings.warn(f"Include path '{path}' doesn´t exist", stacklevel=2) - logging.warning(f"Include path '{path}' doesn´t exist") + DocGenerator.LOGGER.warning(f"Include path '{path}' doesn´t exist") return + if not self.is_valid_folder(path): - logging.debug(f"Ignoring files on '{path}'") + DocGenerator.LOGGER.debug(f"Ignoring files on '{path}'") return + (root, folders, files) = next(os.walk(path)) for file in files: if self.is_group_file(file): @@ -177,9 +203,11 @@ def parse_folder(self, path, group_id): if new_group: group_id = new_group break + for file in files: if self.is_valid_file(file): self.create_test(os.path.join(root, file), group_id) + for folder in folders: self.parse_folder(os.path.join(root, folder), group_id) @@ -188,7 +216,8 @@ def locate_test(self): brief: try to get the test path """ complete_test_name = f"{self.conf.test_name}.py" - logging.info(f"Looking for {complete_test_name}") + DocGenerator.LOGGER.info(f"Looking for {complete_test_name}") + for root, dirnames, filenames in os.walk(self.conf.project_path, topdown=True): for filename in filenames: if filename == complete_test_name: @@ -211,6 +240,7 @@ def print_test_info(self, test): for field in self.conf.module_info: for name, schema_field in field.items(): test_info[name] = test[schema_field] + for field in self.conf.test_info: for name, schema_field in field.items(): test_info[name] = test['tests'][0][schema_field] @@ -218,6 +248,7 @@ def print_test_info(self, test): # If output path does not exist, it is created if not os.path.exists(self.conf.documentation_path): os.mkdir(self.conf.documentation_path) + # Dump data with open(os.path.join(self.conf.documentation_path, f"{self.conf.test_name}.json"), 'w') as fp: fp.write(json.dumps(test_info, indent=4)) @@ -227,6 +258,7 @@ def print_test_info(self, test): for field in self.conf.module_info: for name, schema_field in field.items(): print(str(name)+": "+str(test[schema_field])) + for field in self.conf.test_info: for name, schema_field in field.items(): print(str(name)+": "+str(test['tests'][0][schema_field])) @@ -239,19 +271,21 @@ def run(self): Normal mode: expected behaviour, Single test mode: found the test required and par it """ if self.conf.mode == mode.DEFAULT: - logging.info("\nStarting documentation parsing") + DocGenerator.LOGGER.info("Starting documentation parsing") + DocGenerator.LOGGER.debug(f"Cleaning doc folder located in {self.conf.documentation_path}") clean_folder(self.conf.documentation_path) for path in self.conf.include_paths: self.scan_path = path - logging.debug(f"Going to parse files on '{path}'") + DocGenerator.LOGGER.debug(f"Going to parse files on '{path}'") self.parse_folder(path, self.__id_counter) + elif self.conf.mode == mode.SINGLE_TEST: - logging.info("\nStarting test documentation parsing") + DocGenerator.LOGGER.info("Starting test documentation parsing") self.test_path = self.locate_test() if self.test_path: - logging.debug(f"Parsing '{self.conf.test_name}'") + DocGenerator.LOGGER.debug(f"Parsing '{self.conf.test_name}'") self.create_test(self.test_path, 0) else: - logging.error(f"'{self.conf.test_name}' could not be found") + DocGenerator.LOGGER.error(f"'{self.conf.test_name}' could not be found") From 4b334bd6bc3b1ae4423eb7e5620dc78d815f1ace Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 11:12:20 +0200 Subject: [PATCH 009/181] fix: `qa_docs` logger was not setting the level correctly". #1879 --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index cdad9ef400..3aad05c3f3 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -10,7 +10,7 @@ from wazuh_testing.tools.exceptions import QAValueError VERSION = '0.1' -qadocs_logger = Logging(QADOCS_LOGGER, 'INFO', False) +qadocs_logger = Logging(QADOCS_LOGGER, 'INFO', True) CONFIG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'config.yaml') OUTPUT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'output') LOG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'log') @@ -23,11 +23,10 @@ def set_qadocs_logging(logging_level): Args: logging_level (string): Level used to initialize the logger. """ - if not logging_level: - qadocs_logger = Logging(QADOCS_LOGGER) + if logging_level is None: qadocs_logger.disable() else: - qadocs_logger = Logging(QADOCS_LOGGER, logging_level, False) + qadocs_logger.set_level(logging_level) def validate_parameters(parameters): @@ -41,15 +40,13 @@ def validate_parameters(parameters): # Check if the directory where the tests are located exist if parameters.test_dir: if not os.path.exists(parameters.test_dir): - raise QAValueError(f"{parameters.test_dir} does not exist. Tests directory not found.", - qadocs_logger.error) + raise QAValueError(f"{parameters.test_dir} does not exist. Tests directory not found.", qadocs_logger.error) # Check that test_input name exists if parameters.test_input: doc_check = DocGenerator(Config(CONFIG_PATH, parameters.test_dir, '', parameters.test_input)) if doc_check.locate_test() is None: - raise QAValueError(f"{parameters.test_input} not found.", - qadocs_logger.error) + raise QAValueError(f"{parameters.test_input} not found.", qadocs_logger.error) qadocs_logger.debug('Input parameters validation successfully finished') From 7080e3f800c74d0806ac9ddd7b01f92d78b50bae Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 11:25:24 +0200 Subject: [PATCH 010/181] add: now both output standard and log file working. #1879 --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 3aad05c3f3..ceb97542c7 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -1,5 +1,6 @@ import argparse import os +from datetime import datetime from wazuh_testing.qa_docs.lib.config import Config from wazuh_testing.qa_docs.lib.index_data import IndexData @@ -10,11 +11,12 @@ from wazuh_testing.tools.exceptions import QAValueError VERSION = '0.1' -qadocs_logger = Logging(QADOCS_LOGGER, 'INFO', True) CONFIG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'config.yaml') OUTPUT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'output') LOG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'log') SEARCH_UI_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'search_ui') +qadocs_logger = Logging(QADOCS_LOGGER, 'INFO', True, os.path.join(LOG_PATH, + f"{datetime.today().strftime('%Y-%m-%d-%H:%M:%S')}-qa-docs.log")) def set_qadocs_logging(logging_level): From 82c95d61773dffccb4fa387e5146aa1a66008f01 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 11:50:21 +0200 Subject: [PATCH 011/181] refac: migrate logging from `logging` logger to custom `qa-docs` logger. #1879 --- .../wazuh_testing/qa_docs/lib/code_parser.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 6dee28e04b..3cb97fe819 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -11,10 +11,11 @@ import os import re import yaml + from wazuh_testing.qa_docs.lib.pytest_wrap import PytestWrap from wazuh_testing.qa_docs.lib.utils import remove_inexistent -import warnings -import logging +from wazuh_testing.qa_docs import QADOCS_LOGGER +from wazuh_testing.tools.logging import Logging INTERNAL_FIELDS = ['id', 'group_id', 'name'] STOP_FIELDS = ['tests', 'test_cases'] @@ -24,6 +25,8 @@ class CodeParser: """ brief: Class that parses the content of the test files. """ + LOGGER = Logging.get_logger(QADOCS_LOGGER) + def __init__(self, config): self.conf = config self.pytest = PytestWrap() @@ -70,13 +73,10 @@ def parse_comment(self, function): except Exception as inst: if hasattr(function, 'name'): - warnings.warn(f"Failed to parse comment of function '{function.name}'' from module {self.scan_file}. \ - Error: {inst}", stacklevel=2) - logging.warning(f"Failed to parse comment of function '{function.name}'' from module {self.scan_file}. \ - Error: {inst}") + CodeParser.LOGGER.warning(f"Failed to parse comment of function {function.name} " + "from module {self.scan_file}. Error: {inst}") else: - warnings.warn(f"Failed to parse comment of module {self.scan_file}. Error: {inst}", stacklevel=2) - logging.warning(f"Failed to parse comment of module {self.scan_file}. Error: {inst}") + CodeParser.LOGGER.warning(f"Failed to parse comment of module {self.scan_file}. Error: {inst}") doc = None return doc @@ -89,7 +89,7 @@ def parse_test(self, code_file, id, group_id): -"id (integer): Id of the new test document" -"group_id (integer): Id of the group where the new test document belongs." """ - logging.debug(f"Parsing test file '{code_file}'") + CodeParser.LOGGER.debug(f"Parsing test file '{code_file}'") self.scan_file = code_file with open(code_file) as fd: file_content = fd.read() @@ -117,8 +117,7 @@ def parse_test(self, code_file, id, group_id): functions_doc.append(function_doc) if not functions_doc: - warnings.warn(f"Module '{module_doc['name']}' doesn´t contain any test function", stacklevel=2) - logging.warning(f"Module '{module_doc['name']}' doesn´t contain any test function") + CodeParser.LOGGER.warning(f"Module '{module_doc['name']}' doesn´t contain any test function") else: module_doc['tests'] = functions_doc @@ -135,14 +134,13 @@ def parse_group(self, group_file, id, group_id): -"group_id (integer): Id of the group where the new group document belongs." """ MD_HEADER = "# " - logging.debug(f"Parsing group file '{group_file}'") + CodeParser.LOGGER.debug(f"Parsing group file '{group_file}'") with open(group_file) as fd: file_header = fd.readline() file_content = fd.read() if not file_header.startswith(MD_HEADER): - warnings.warn(f"Group file '{group_file}' doesn´t contain a valid header", stacklevel=2) - logging.warning(f"Group file '{group_file}' doesn´t contain a valid header") + CodeParser.LOGGER.warning(f"Group file '{group_file}' doesn´t contain a valid header") return None group_doc = {} From d0ea3912ce96bc3bfe8645bab7cdbe844f0ae482 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 11:58:58 +0200 Subject: [PATCH 012/181] refac: migrate logging from `logging` logger to custom `qa-docs` logger. #1879 --- .../wazuh_testing/qa_docs/lib/index_data.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 5b50f804a3..127ed6b717 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -11,14 +11,19 @@ import re import json import requests -import logging from elasticsearch import Elasticsearch, helpers +from wazuh_testing.qa_docs import QADOCS_LOGGER +from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.exceptions import QAValueError + class IndexData: """ brief: Class that indexes the data from JSON files into ElasticSearch. """ + LOGGER = Logging.get_logger(QADOCS_LOGGER) + def __init__(self, index, config): self.path = config.documentation_path self.index = index @@ -34,9 +39,8 @@ def test_connection(self): res = requests.get("http://localhost:9200/_cluster/health") if res.status_code == 200: return True - except Exception as e: - logging.exception(f"Connection error:\n{e}") - return False + except Exception as exception: + raise QAValueError(f"Connection error: {exception}", IndexData.LOGGER.error) def get_files(self): """ @@ -63,7 +67,7 @@ def remove_index(self): brief: Deletes an index. """ delete = self.es.indices.delete(index=self.index, ignore=[400, 404]) - logging.info(f'Delete index {self.index}\n {delete}\n') + IndexData.LOGGER.info(f'Delete index {self.index}\n {delete}\n') def run(self): """ @@ -74,7 +78,7 @@ def run(self): self.read_files_content(files) if self.test_connection(): self.remove_index() - logging.info("Indexing data...\n") + IndexData.LOGGER.info("Indexing data...\n") helpers.bulk(self.es, self.output, index=self.index) out = json.dumps(self.es.cluster.health(wait_for_status='yellow', request_timeout=1), indent=4) - logging.info(out) + IndexData.LOGGER.info(out) From d15979a3a708fb797adc07bbae96444998bb9b59 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 12:03:26 +0200 Subject: [PATCH 013/181] refac: migrate logging from `logging` logger to custom `qa-docs` logger in `PytestWrap` module. #1879 --- .../wazuh_testing/qa_docs/lib/pytest_wrap.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py index fc53fa3401..bbaeb357d7 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py @@ -8,7 +8,10 @@ """ import pytest -import logging + +from wazuh_testing.qa_docs import QADOCS_LOGGER +from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.exceptions import QAValueError class PytestPlugin: """ @@ -28,6 +31,8 @@ class PytestWrap: """ brief: Class that wraps the execution of pytest. """ + LOGGER = Logging.get_logger(QADOCS_LOGGER) + def __init__(self): self.plugin = PytestPlugin() @@ -38,7 +43,7 @@ def collect_test_cases(self, path): - "path (string): Path of the test file to extract the test cases. returns: "dictionary: The output of pytest parsed into a dictionary" """ - logging.debug(f"Running pytest to collect testcases for '{path}'") + PytestWrap.LOGGER.debug(f"Running pytest to collect testcases for '{path}'") pytest.main(['--collect-only', "-qq", path], plugins=[self.plugin]) output = {} for item in self.plugin.collected: From 1ffba3543b971a5ad0e086f862ac7e8ccb6422f3 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 12:20:55 +0200 Subject: [PATCH 014/181] refac: migrate logging from `logging` logger to custom `qa-docs` logger in `Sanity` module. #1879 Also, sanity new lines style and `qa-docs` paths within wazuh framework fixed. --- .../wazuh_testing/qa_docs/lib/pytest_wrap.py | 2 +- .../wazuh_testing/qa_docs/lib/sanity.py | 34 +++++++++++-------- .../wazuh_testing/scripts/qa_docs.py | 10 +++--- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py index bbaeb357d7..ea7147a553 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py @@ -11,7 +11,7 @@ from wazuh_testing.qa_docs import QADOCS_LOGGER from wazuh_testing.tools.logging import Logging -from wazuh_testing.tools.exceptions import QAValueError + class PytestPlugin: """ diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index 8601eab5b1..dab53feee8 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -11,8 +11,11 @@ import re import json import ast -import logging + from wazuh_testing.qa_docs.lib.utils import check_existance +from wazuh_testing.qa_docs import QADOCS_LOGGER +from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.exceptions import QAValueError class Sanity(): @@ -20,6 +23,8 @@ class Sanity(): brief: Class in charge of performing a general sanity check on the already parsed documentation. It´s in charge of walk every documentation file, and every group file to dump the parsed documentation. """ + LOGGER = Logging.get_logger(QADOCS_LOGGER) + def __init__(self, config): self.conf = config self.files_regex = re.compile("^(?!.*group)test.*json$", re.IGNORECASE) @@ -39,8 +44,7 @@ def get_content(self, full_path): with open(full_path) as file: return json.load(file) except: - logging.error(f"Cannot load '{full_path}' file for sanity check") - raise Exception(f"Cannot load '{full_path}' file for sanity check") + raise QAValueError(f"Cannot load '{full_path}' file for sanity check", Sanity.LOGGER.error) def validate_fields(self, required_fields, available_fields): """ @@ -55,7 +59,7 @@ def validate_fields(self, required_fields, available_fields): for field in required_fields: if not check_existance(available_fields, field): self.add_report(f"Mandatory field '{field}' is missing in file {self.scan_file}") - logging.error(f"Mandatory field '{field}' is missing in file {self.scan_file}") + Sanity.LOGGER.error(f"Mandatory field '{field}' is missing in file {self.scan_file}") elif isinstance(required_fields[field], dict) or isinstance(required_fields[field], list): self.validate_fields(required_fields[field], available_fields) elif isinstance(required_fields, list): @@ -65,7 +69,7 @@ def validate_fields(self, required_fields, available_fields): else: if not check_existance(available_fields, field): self.add_report(f"Mandatory field '{field}' is missing in file {self.scan_file}") - logging.error(f"Mandatory field '{field}' is missing in file {self.scan_file}") + Sanity.LOGGER.error(f"Mandatory field '{field}' is missing in file {self.scan_file}") def validate_module_fields(self, fields): """ @@ -142,40 +146,40 @@ def print_report(self): """ brief: Makes a report with all the errors found, the coverage and the tags found. """ - print("") - print("During the sanity check:") + print("\nDuring the sanity check:") - print("") if self.error_reports: - print("The following errors were found:") + print("\nThe following errors were found:") for error in self.error_reports: print("- "+error) else: - print("No errors were found:") + print("\nNo errors were found:") if self.found_tags: - print("") - print("The following tags were found:") + print("\nThe following tags were found:") for tag in self.found_tags: print("- "+tag) - print("") modules_count = len(self.found_modules) tests_count = len(self.found_tests) tests_percentage = tests_count / self.project_tests * 100 - print(f"A total of {len(self.found_tests)} tests were found in {modules_count} modules") + print(f"\nA total of {len(self.found_tests)} tests were found in {modules_count} modules") print("A {:.2f}% from the tests of {} is covered.".format(tests_percentage, self.conf.project_path)) def run(self): """ brief: Runs a complete sanity check of each documentation file on the output folder. """ - logging.info("\nStarting documentation sanity check") + Sanity.LOGGER.info("Starting documentation sanity check") + for (root, *_, files) in os.walk(self.conf.documentation_path, topdown=True): files = list(filter(self.files_regex.match, files)) + for file in files: full_path = os.path.join(root, file) + print(full_path) content = self.get_content(full_path) + if content: self.scan_file = full_path self.validate_module_fields(content) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index ceb97542c7..a1f814954f 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -11,10 +11,10 @@ from wazuh_testing.tools.exceptions import QAValueError VERSION = '0.1' -CONFIG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'config.yaml') -OUTPUT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'output') -LOG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'log') -SEARCH_UI_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'qa_docs', 'search_ui') +CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'config.yaml') +OUTPUT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'output') +LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'log') +SEARCH_UI_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'search_ui') qadocs_logger = Logging(QADOCS_LOGGER, 'INFO', True, os.path.join(LOG_PATH, f"{datetime.today().strftime('%Y-%m-%d-%H:%M:%S')}-qa-docs.log")) @@ -108,7 +108,7 @@ def main(): qadocs_logger.debug('qa-docs configuration loaded') elif args.sanity: - sanity = Sanity(Config(CONFIG_PATH)) + sanity = Sanity(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) qadocs_logger.debug('Running sanity check') sanity.run() From 1c91d050447811f5e162444bed95afcc399393c6 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 12:28:09 +0200 Subject: [PATCH 015/181] refac: migrate logging from `logging` logger to custom `qa-docs` logger in `utils` module. #1879 --- .../wazuh_testing/qa_docs/lib/utils.py | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py index b280eb4f7f..521b4b1b5d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py @@ -8,8 +8,11 @@ """ import os, shutil -import logging -import warnings + +from wazuh_testing.qa_docs import QADOCS_LOGGER +from wazuh_testing.tools.logging import Logging + +utils_logger = Logging.get_logger(QADOCS_LOGGER) def check_existance(source, key): """ @@ -47,6 +50,7 @@ def remove_inexistent(source, check_list, stop_list=None): for element in list(source): if stop_list and element in stop_list: break + if not check_existance(check_list, element): del source[element] elif isinstance(source[element], dict): @@ -113,6 +117,7 @@ def find_item(search_item, check): else: if search_item == item: return item + return None def check_missing_field(source, check): @@ -123,29 +128,38 @@ def check_missing_field(source, check): - "check (list): The expected keys." """ missing_filed = None + for source_field in source: if isinstance(source_field, dict): key = list(source_field.keys())[0] found_item = find_item(key, check) + if not found_item: print(f"Missing key {source_field}") return key + missing_filed = check_missing_field(source_field[key], found_item) + if missing_filed: return missing_filed + elif isinstance(source_field, list): missing_filed = None + for check_element in check: missing_filed = check_missing_field(source_field, check_element) if not missing_filed: break + if missing_filed: return source_field else: found_item = find_item(source_field, check) + if not found_item: print(f"Missing key {source_field}") return source_field + return missing_filed def clean_folder(folder): @@ -156,14 +170,16 @@ def clean_folder(folder): """ if not os.path.exists(folder): return - logging.debug(f"Going to clean '{folder}' folder") + + utils_logger.debug(f"Going to clean '{folder}' folder") + for filename in os.listdir(folder): file_path = os.path.join(folder, filename) + try: if os.path.isfile(file_path) or os.path.islink(file_path): os.unlink(file_path) elif os.path.isdir(file_path): shutil.rmtree(file_path) except Exception as e: - warnings.warn(f"Failed to delete {file_path}. Reason: {e}") - logging.error(f"Failed to delete {file_path}. Reason: {e}") + utils_logger.error(f"Failed to delete {file_path}. Reason: {e}") From cb21617244231e5bd8ab3a170e718f78a61b2b9c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Sep 2021 12:33:02 +0200 Subject: [PATCH 016/181] fix: `-t` option now working. #1879 It only loads the config file within a Config instance. --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index a1f814954f..10b36a25ee 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -104,7 +104,7 @@ def main(): elif args.test_config: qadocs_logger.debug('Loading qa-docs configuration') - Config(CONFIG_PATH) + Config(CONFIG_PATH, args.test_dir) qadocs_logger.debug('qa-docs configuration loaded') elif args.sanity: From 86e5045beb49f8e91dbb42b6e59f8d3164a57523 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 16 Sep 2021 09:32:00 +0200 Subject: [PATCH 017/181] style: improve readability and clarity. --- .../wazuh_testing/qa_docs/doc_generator.py | 16 ++++----- .../wazuh_testing/qa_docs/lib/code_parser.py | 4 +-- .../wazuh_testing/qa_docs/lib/config.py | 34 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 4f32da42d7..144d70dd12 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -46,7 +46,7 @@ def is_valid_folder(self, path): """ for regex in self.ignore_regex: if regex.match(path): - DocGenerator.LOGGER.warning(f"Folder validation: {regex} not matchin with {path}") + DocGenerator.LOGGER.warning(f"Folder validation: {regex} not matching with {path}") return False return True @@ -62,12 +62,12 @@ def is_valid_file(self, file): for regex in self.ignore_regex: if regex.match(file): - DocGenerator.LOGGER.warning(f"File validation: {regex} not matchin with {file}") + DocGenerator.LOGGER.warning(f"File validation: {regex} not matching with {file}") return False for regex in self.include_regex: if regex.match(file): - DocGenerator.LOGGER.warning(f"File validation: {regex} not matchin with {file}") + DocGenerator.LOGGER.warning(f"File validation: {regex} not matching with {file}") return True return False @@ -118,20 +118,20 @@ def dump_output(self, content, doc_path): - "doc_path (string): The path where the information should be dumped." """ if not os.path.exists(os.path.dirname(doc_path)): - DocGenerator.LOGGER.warning('Creating documentation folder') + DocGenerator.LOGGER.debug('Creating documentation folder') os.makedirs(os.path.dirname(doc_path)) try: DocGenerator.LOGGER.debug(f"Writing {doc_path}.json") - with open(doc_path + ".json", "w+") as outfile: - outfile.write(json.dumps(content, indent=4)) + with open(doc_path + ".json", "w+") as out_file: + out_file.write(json.dumps(content, indent=4)) except IOError: raise QAValueError(f"Cannot write in {doc_path}.json", DocGenerator.LOGGER.error) try: DocGenerator.LOGGER.debug(f"Writing {doc_path}.yaml") - with open(doc_path + ".yaml", "w+") as outfile: - outfile.write(yaml.dump(content)) + with open(doc_path + ".yaml", "w+") as out_file: + out_file.write(yaml.dump(content)) except IOError: raise QAValueError(f"Cannot write in {doc_path}.yaml", DocGenerator.LOGGER.error) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 3cb97fe819..981f6b99c2 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -73,10 +73,10 @@ def parse_comment(self, function): except Exception as inst: if hasattr(function, 'name'): - CodeParser.LOGGER.warning(f"Failed to parse comment of function {function.name} " + CodeParser.LOGGER.warning(f"Failed to parse test documentation in {function.name} " "from module {self.scan_file}. Error: {inst}") else: - CodeParser.LOGGER.warning(f"Failed to parse comment of module {self.scan_file}. Error: {inst}") + CodeParser.LOGGER.warning(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}") doc = None return doc diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index d8cd7e0092..f711c83960 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -74,7 +74,7 @@ def __read_test_info(self): Test info: - test_wazuh_min_version: wazuh_min_version """ - Config.LOGGER.debug('Reading test info from config file') + Config.LOGGER.debug('Reading test info from the config file') if 'Test info' in self._config_data: self.test_info = self._config_data['Test info'] else: @@ -116,7 +116,7 @@ def __read_include_paths(self): # Will be replaced by --type --module and --test , so you can run what you need if not 'Include paths' in self._config_data: - raise QAValueError('Config include paths are empty', Config.LOGGER.error) + raise QAValueError('The include paths of the configuration file are empty', Config.LOGGER.error) include_paths = self._config_data['Include paths'] @@ -127,10 +127,10 @@ def __read_include_regex(self): """ brief: Reads from the config file the regexes used to identify test files. """ - Config.LOGGER.debug('Reading the regular expressions to include files from config file') + Config.LOGGER.debug('Reading the regular expressions from the config file to include test files') if not 'Include regex' in self._config_data: - raise QAValueError('Config include regex is empty', Config.LOGGER.error) + raise QAValueError('The include regex field is empty in the config file', Config.LOGGER.error) self.include_regex = self._config_data['Include regex'] @@ -138,10 +138,10 @@ def __read_group_files(self): """ brief: Reads from the config file the file name to be identified with a group. """ - Config.LOGGER.debug('Reading group files from config file') + Config.LOGGER.debug('Reading group files from the config file') if not 'Group files' in self._config_data: - raise QAValueError('Config include paths are empty', Config.LOGGER.error) + raise QAValueError('Group files field is empty in config file', Config.LOGGER.error) self.group_files = self._config_data['Group files'] @@ -149,10 +149,10 @@ def __read_function_regex(self): """ brief: Reads from the config file the regexes used to identify a test method. """ - Config.LOGGER.debug('Reading the regular expressions to include test methods from config file') + Config.LOGGER.debug('Reading the regular expressions to include test methods from the config file') if not 'Function regex' in self._config_data: - raise QAValueError('Config function regex is empty', Config.LOGGER.error) + raise QAValueError('The function regex field is empty in the config file', Config.LOGGER.error) self.function_regex = self._config_data['Function regex'] @@ -160,7 +160,7 @@ def __read_ignore_paths(self): """ brief: Reads from the config file all the paths to be excluded from the parsing. """ - Config.LOGGER.debug('Reading the paths to be ignored from config file') + Config.LOGGER.debug('Reading the paths to be ignored from the config file') if 'Ignore paths' in self._config_data: ignore_paths = self._config_data['Ignore paths'] @@ -172,15 +172,15 @@ def __read_module_fields(self): """ brief: Reads from the config file the optional and mandatory fields for the test module. """ - Config.LOGGER.debug('Reading mandatory and optional module fields from config file') + Config.LOGGER.debug('Reading mandatory and optional module fields from the config file') if not 'Module' in self._config_data['Output fields']: - raise QAValueError('Config output module fields is missing', Config.LOGGER.error) + raise QAValueError('Module fields are missing in the config file', Config.LOGGER.error) module_fields = self._config_data['Output fields']['Module'] if not 'Mandatory' in module_fields and not 'Optional' in module_fields: - raise QAValueError('Config output module fields are empty', Config.LOGGER.error) + raise QAValueError('Mandatory module fields are missing in the config file', Config.LOGGER.error) if 'Mandatory' in module_fields: self.module_fields.mandatory = module_fields['Mandatory'] @@ -192,15 +192,15 @@ def __read_test_fields(self): """ brief: Reads from the config file the optional and mandatory fields for the test functions. """ - Config.LOGGER.debug('Reading mandatory and optional test fields from config file') + Config.LOGGER.debug('Reading mandatory and optional test fields from the config file') if not 'Test' in self._config_data['Output fields']: - raise QAValueError('Config output test fields is missing', Config.LOGGER.error) + raise QAValueError('Test fields are missing in the config file', Config.LOGGER.error) test_fields = self._config_data['Output fields']['Test'] if not 'Mandatory' in test_fields and not 'Optional' in test_fields: - raise QAValueError('Config output test fields are empty', Config.LOGGER.error) + raise QAValueError('Mandatory module fields are missing in the config file', Config.LOGGER.error) if 'Mandatory' in test_fields: self.test_fields.mandatory = test_fields['Mandatory'] @@ -213,7 +213,7 @@ def __read_output_fields(self): brief: Reads all the mandatory and optional fields. """ if not 'Output fields' in self._config_data: - raise QAValueError('Config output fields is missing', Config.LOGGER.error) + raise QAValueError('Documentation schema not defined in the config file', Config.LOGGER.error) self.__read_module_fields() self.__read_test_fields() @@ -222,7 +222,7 @@ def __read_test_cases_field(self): """ brief: Reads from the configuration file the key to identify a Test Case list. """ - Config.LOGGER.debug('Reading Test Case key from config file') + Config.LOGGER.debug('Reading Test Case key from the config file') if 'Test cases field' in self._config_data: self.test_cases_field = self._config_data['Test cases field'] From 9e21347fdd74c17953f57a11cdf67be1d85b336f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 16 Sep 2021 10:03:12 +0200 Subject: [PATCH 018/181] style: improve readability and clarity in `pytest_wrap.py` and `sanity.py` --- .../wazuh_testing/qa_docs/lib/pytest_wrap.py | 2 +- deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py index ea7147a553..fb3876e992 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py @@ -43,7 +43,7 @@ def collect_test_cases(self, path): - "path (string): Path of the test file to extract the test cases. returns: "dictionary: The output of pytest parsed into a dictionary" """ - PytestWrap.LOGGER.debug(f"Running pytest to collect testcases for '{path}'") + PytestWrap.LOGGER.debug(f"Running pytest to collect test cases for '{path}'") pytest.main(['--collect-only', "-qq", path], plugins=[self.plugin]) output = {} for item in self.plugin.collected: diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index dab53feee8..3e9ea2d50e 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -58,8 +58,8 @@ def validate_fields(self, required_fields, available_fields): if isinstance(required_fields, dict): for field in required_fields: if not check_existance(available_fields, field): - self.add_report(f"Mandatory field '{field}' is missing in file {self.scan_file}") - Sanity.LOGGER.error(f"Mandatory field '{field}' is missing in file {self.scan_file}") + self.add_report(f"Mandatory field '{field}' is missing in the file {self.scan_file}") + Sanity.LOGGER.error(f"Mandatory field '{field}' is missing in the file {self.scan_file}") elif isinstance(required_fields[field], dict) or isinstance(required_fields[field], list): self.validate_fields(required_fields[field], available_fields) elif isinstance(required_fields, list): @@ -68,8 +68,8 @@ def validate_fields(self, required_fields, available_fields): self.validate_fields(field, available_fields) else: if not check_existance(available_fields, field): - self.add_report(f"Mandatory field '{field}' is missing in file {self.scan_file}") - Sanity.LOGGER.error(f"Mandatory field '{field}' is missing in file {self.scan_file}") + self.add_report(f"Mandatory field '{field}' is missing in the file {self.scan_file}") + Sanity.LOGGER.error(f"Mandatory field '{field}' is missing the in file {self.scan_file}") def validate_module_fields(self, fields): """ From fda1004a52e8b80a837151300e5c769f2e445e6e Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 16 Sep 2021 10:35:46 +0200 Subject: [PATCH 019/181] add: add QAExceptions and custom loggin class. --- .../wazuh_testing/tools/exceptions.py | 5 + .../wazuh_testing/tools/logging.py | 119 ++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 deps/wazuh_testing/wazuh_testing/tools/exceptions.py create mode 100644 deps/wazuh_testing/wazuh_testing/tools/logging.py diff --git a/deps/wazuh_testing/wazuh_testing/tools/exceptions.py b/deps/wazuh_testing/wazuh_testing/tools/exceptions.py new file mode 100644 index 0000000000..be1779d39e --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/tools/exceptions.py @@ -0,0 +1,5 @@ +class QAValueError(Exception): + def __init__(self, message, logger=None): + self.message = f"\033[91m{message}\033[0m" + logger(message) + super().__init__(self.message) \ No newline at end of file diff --git a/deps/wazuh_testing/wazuh_testing/tools/logging.py b/deps/wazuh_testing/wazuh_testing/tools/logging.py new file mode 100644 index 0000000000..c9f7eca226 --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/tools/logging.py @@ -0,0 +1,119 @@ + +import logging +import os + +class Logging: + """Class to handle modules logging. It is a wrapper class from logging standard python module. + Args: + logger_name (str): Logger name + level (str): Logger level: DEBUG, INFO, WARNING, ERROR or CRITICAL + stdout (boolean): True for add stodut stream handler False otherwise + log_file (str): True for add file handler, False otherwise + Attributes: + logger_name (str): Logger name + level (str): Logger level: DEBUG, INFO, WARNING, ERROR or CRITICAL + stdout (boolean): True for add stodut stream handler False otherwise + log_file (str): True for add file handler, False otherwise + """ + level_mapping = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL + } + + def __init__(self, logger_name, level='INFO', stdout=True, log_file=None): + self.logger = logging.getLogger(logger_name) + self.level = level + self.stdout = stdout + self.log_file = log_file + + self.__validate_parameters() + self.__initialize_parameters() + self.__default_config() + + def __validate_parameters(self): + """Verify class parameters value""" + if self.level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: + raise ValueError('LOGGER level must be one of the following values: DEBUG, INFO, WARNING, ERROR, CRITICAL') + + def __initialize_parameters(self): + """Set logger level, mapping the string into enum constant""" + + self.level = Logging.level_mapping[self.level] + + def __default_config(self): + """Set default handler configuration""" + if self.level == logging.DEBUG: + formatter = logging.Formatter(fmt='%(asctime)s - %(levelname)s - %(module)s - %(message)s') + else: + formatter = logging.Formatter(fmt='%(asctime)s - %(levelname)s - %(message)s') + self.logger.setLevel(self.level) + + if self.stdout: + handler = logging.StreamHandler() + handler.setFormatter(formatter) + self.logger.addHandler(handler) + + if self.log_file: + # Create folder path if not exist + if not os.path.exists(os.path.dirname(self.log_file)): + os.makedirs(os.path.dirname(self.log_file)) + + handler = logging.FileHandler(self.log_file) + handler.setFormatter(formatter) + self.logger.addHandler(handler) + + @staticmethod + def __logger_exists(logger_name): + """Get if logger exists or not. + Returns: + boolean: True if logger exists, false otherwise + """ + return logger_name in logging.Logger.manager.loggerDict + + @staticmethod + def get_logger(logger_name): + """Get the logger object if exists + Returns: + logging.Logger: Logger object + Raises: + ValueError: If logger not exists + """ + return logging.getLogger(logger_name) + + def set_level(self, logging_level): + try: + self.logger.level = Logging.level_mapping[logging_level] + except KeyError: + raise ValueError(f"{logging_level} is not allowed as logging level. " + f"Allowed values: {list(Logging.level_mapping.keys())}") + + def enable(self): + """Enable logger""" + self.logger.disabled = False + + def disable(self): + """Disable logger""" + self.logger.disabled = True + + def debug(self, message): + """Log DEBUG message""" + self.logger.debug(message) + + def info(self, message): + """Log INFO message""" + self.logger.info(message) + + def warning(self, message): + """Log WARNING message""" + self.logger.warning(message) + + def error(self, message): + """Log ERROR message""" + self.logger.error(message) + + def critical(self, message): + """Log CRITICAL message""" + self.logger.critical(message) \ No newline at end of file From 6a551ddfc5c7994785a032b40decffb8543e5344 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 16 Sep 2021 11:03:43 +0200 Subject: [PATCH 020/181] doc: update script documentation. #1899 --- .../wazuh_testing/scripts/qa_docs.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 10b36a25ee..6fd522838f 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -19,8 +19,8 @@ f"{datetime.today().strftime('%Y-%m-%d-%H:%M:%S')}-qa-docs.log")) -def set_qadocs_logging(logging_level): - """Set the QADOCS logging depending on the level specified. +def set_qadocs_logger_level(logging_level): + """Set the QADOCS logger lever depending on the level specified by the user. Args: logging_level (string): Level used to initialize the logger. @@ -34,6 +34,9 @@ def set_qadocs_logging(logging_level): def validate_parameters(parameters): """Validate the parameters that qa-docs recieves. + Since `config.yaml` will be `schema.yaml`, it runs as config file is correct. + So we only validate the parameters that the user introduces. + Args: parameters (list): List of input args. """ @@ -88,12 +91,13 @@ def main(): args = parser.parse_args() - # Set the qa-docs logger + # Set the qa-docs logger level if args.debug_level: - set_qadocs_logging('DEBUG') + set_qadocs_logger_level('DEBUG') validate_parameters(args) + # Print that test gave by the user(using `-e` option) exists or not. if args.test_exist: doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_exist)) if doc_check.locate_test() is not None: @@ -102,21 +106,25 @@ def main(): if args.version: print(f"qa-docs v{VERSION}") + # Load configuration if you want to test it elif args.test_config: qadocs_logger.debug('Loading qa-docs configuration') Config(CONFIG_PATH, args.test_dir) qadocs_logger.debug('qa-docs configuration loaded') + # Run a sanity check thru tests directory elif args.sanity: sanity = Sanity(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) qadocs_logger.debug('Running sanity check') sanity.run() + # Index the previous parsed tests into Elasticsearch elif args.index_name: qadocs_logger.debug(f"Indexing {args.index_name}") indexData = IndexData(args.index_name, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) indexData.run() + # Index the previous parsed tests into Elasticsearch and then launch SearchUI elif args.launch_app: qadocs_logger.debug(f"Indexing {args.index_name}") indexData = IndexData(args.launch_app, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) @@ -125,18 +133,25 @@ def main(): qadocs_logger.debug('Running SearchUI') os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") + # Parse tests else: if not args.test_exist: docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + + # Parse single test if args.test_input: qadocs_logger.info(f"Parsing the following test(s) {args.test_input}") + + # When output path is specified by user, a json is generated within that path if args.output_path: qadocs_logger.info(f"{args.test_input}.json is going to be generated in {args.output_path}") docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_input)) else: + # When no output is specified, it is printed docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_input)) else: qadocs_logger.info(f"Parsing all tests located in {args.test_dir}") + qadocs_logger.info('Running QADOCS') docs.run() From 7cc71c81e9bf98eb4731fa32732ad03df75ceffc Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 16 Sep 2021 11:52:59 +0200 Subject: [PATCH 021/181] refac: refactor the config module args. #1899 Also added `qa_docs` script copyright. --- .../wazuh_testing/qa_docs/lib/config.py | 53 ++++++++++++------- .../wazuh_testing/scripts/qa_docs.py | 10 ++-- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index f711c83960..4242ff0e28 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -1,11 +1,6 @@ -""" -brief: Wazuh DocGenerator config parser. -copyright: Copyright (C) 2015-2021, Wazuh Inc. -date: August 02, 2021 -license: This program is free software; you can redistribute it - and/or modify it under the terms of the GNU General Public - License (version 2) as published by the FSF - Free Software Foundation. -""" +# Copyright (C) 2015-2021, Wazuh Inc. +# Created by Wazuh, Inc. . +# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 import yaml from enum import Enum @@ -16,27 +11,46 @@ class Config(): - """ - brief: Class that parses the configuration file and exposes the available configurations. - It exists two modes of execution: Normal and Single test. + """Class that parses the configuration file and exposes the available configurations. + + It exists two modes of execution: Normal and Single test. + + Attributes: + mode: An enumeration that stores the `doc_generator` mode when it is running. + project_path: A string that specifies the path where the tests to parse are located. + include_paths: A list of strings that contains the directories to parse. + include_regex: A list of strings(regular expressions) used to find test files. + group_files: A string that specifies the group definition file. + function_regex: A list of strings(regular expressions) used to find test functions. + ignore_paths: A string that specifies which paths will be ignored. + module_fields: A struct that contains the module documentantion data. + test_fields: A struct that contains the test documentantion data. + test_cases_field: A string that contains the test cases key. + LOGGER: A custom qa-docs logger. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) - def __init__(self, *args): - # If it is called using the config file + def __init__(self, config_path, test_dir, output_path='', test_name=None): + """Constructor that load the data from the config file. + + Args: + config_path: + test_dir: + output_path: + test_name: + """ self.mode = mode.DEFAULT - self.project_path = args[1] + self.project_path = test_dir self.include_paths = [] self.include_regex = [] self.group_files = "" self.function_regex = [] self.ignore_paths = [] - self.valid_tags = [] self.module_fields = _fields() self.test_fields = _fields() self.test_cases_field = None - self.__load_config_file(args[0]) + self.__load_config_file(config_path) self.__read_function_regex() self.__read_output_fields() self.__read_test_cases_field() @@ -44,13 +58,12 @@ def __init__(self, *args): self.__read_include_regex() self.__read_group_files() self.__read_ignore_paths() + self.__set_documentation_path(output_path) - if len(args) >= 3: - self.__set_documentation_path(args[2]) - if len(args) == 4: + if test_name: # It is called with a single test to parse self.mode = mode.SINGLE_TEST - self.test_name = args[3] + self.test_name = test_name self.__read_test_info() self.__read_module_info() diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 6fd522838f..0b33121e81 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -1,3 +1,7 @@ +# Copyright (C) 2015-2021, Wazuh Inc. +# Created by Wazuh, Inc. . +# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + import argparse import os from datetime import datetime @@ -49,7 +53,7 @@ def validate_parameters(parameters): # Check that test_input name exists if parameters.test_input: - doc_check = DocGenerator(Config(CONFIG_PATH, parameters.test_dir, '', parameters.test_input)) + doc_check = DocGenerator(Config(CONFIG_PATH, parameters.test_dir, test_name=parameters.test_input)) if doc_check.locate_test() is None: raise QAValueError(f"{parameters.test_input} not found.", qadocs_logger.error) @@ -99,7 +103,7 @@ def main(): # Print that test gave by the user(using `-e` option) exists or not. if args.test_exist: - doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_exist)) + doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, test_name=args.test_exist)) if doc_check.locate_test() is not None: print("test exists") @@ -148,7 +152,7 @@ def main(): docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_input)) else: # When no output is specified, it is printed - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_input)) + docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, test_name=args.test_input)) else: qadocs_logger.info(f"Parsing all tests located in {args.test_dir}") From 6683a88ed6aadabd08feeb5b9f4089b0315e470f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 16 Sep 2021 13:37:08 +0200 Subject: [PATCH 022/181] doc: add `config` module documentation. #1899 Also, copyright has been added and doc improved. --- .../wazuh_testing/qa_docs/doc_generator.py | 23 ++- .../wazuh_testing/qa_docs/lib/config.py | 136 ++++++++++++------ .../wazuh_testing/scripts/qa_docs.py | 4 +- 3 files changed, 101 insertions(+), 62 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 144d70dd12..0f31a78f75 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -1,17 +1,12 @@ -""" -brief: Wazuh DocGenerator tool. -copyright: Copyright (C) 2015-2021, Wazuh Inc. -date: August 02, 2021 -license: This program is free software; you can redistribute it - and/or modify it under the terms of the GNU General Public - License (version 2) as published by the FSF - Free Software Foundation. -""" +# Copyright (C) 2015-2021, Wazuh Inc. +# Created by Wazuh, Inc. . +# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 import os import re import json import yaml -from wazuh_testing.qa_docs.lib.config import Config, mode +from wazuh_testing.qa_docs.lib.config import Config, Mode from wazuh_testing.qa_docs.lib.code_parser import CodeParser from wazuh_testing.qa_docs.lib.utils import clean_folder from wazuh_testing.qa_docs import QADOCS_LOGGER @@ -33,7 +28,7 @@ def __init__(self, config): for ignore_regex in self.conf.ignore_paths: self.ignore_regex.append(re.compile(ignore_regex)) self.include_regex = [] - if self.conf.mode == mode.DEFAULT: + if self.conf.mode == Mode.DEFAULT: for include_regex in self.conf.include_regex: self.include_regex.append(re.compile(include_regex)) @@ -167,9 +162,9 @@ def create_test(self, path, group_id): test = self.parser.parse_test(path, self.__id_counter, group_id) if test: - if self.conf.mode == mode.DEFAULT: + if self.conf.mode == Mode.DEFAULT: doc_path = self.get_test_doc_path(path) - elif self.conf.mode == mode.SINGLE_TEST: + elif self.conf.mode == Mode.SINGLE_TEST: doc_path = self.conf.documentation_path if self.print_test_info(test) is None: # @@ -270,7 +265,7 @@ def run(self): brief: Run a complete scan of each include path to parse every test and group found. Normal mode: expected behaviour, Single test mode: found the test required and par it """ - if self.conf.mode == mode.DEFAULT: + if self.conf.mode == Mode.DEFAULT: DocGenerator.LOGGER.info("Starting documentation parsing") DocGenerator.LOGGER.debug(f"Cleaning doc folder located in {self.conf.documentation_path}") clean_folder(self.conf.documentation_path) @@ -280,7 +275,7 @@ def run(self): DocGenerator.LOGGER.debug(f"Going to parse files on '{path}'") self.parse_folder(path, self.__id_counter) - elif self.conf.mode == mode.SINGLE_TEST: + elif self.conf.mode == Mode.SINGLE_TEST: DocGenerator.LOGGER.info("Starting test documentation parsing") self.test_path = self.locate_test() diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 4242ff0e28..d05aee783f 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -13,7 +13,10 @@ class Config(): """Class that parses the configuration file and exposes the available configurations. - It exists two modes of execution: Normal and Single test. + Exist two modes of execution: `default mode` and `single test mode`. + The following attributes may change because the config file will be deprecated soon. It will be renamed to + `schema.yaml` and it will specify the schema fields and pre-defined values that you can check here: + https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks Attributes: mode: An enumeration that stores the `doc_generator` mode when it is running. @@ -23,23 +26,31 @@ class Config(): group_files: A string that specifies the group definition file. function_regex: A list of strings(regular expressions) used to find test functions. ignore_paths: A string that specifies which paths will be ignored. - module_fields: A struct that contains the module documentantion data. - test_fields: A struct that contains the test documentantion data. + module_fields: A struct that contains the module documentation data. + test_fields: A struct that contains the test documentation data. test_cases_field: A string that contains the test cases key. LOGGER: A custom qa-docs logger. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) def __init__(self, config_path, test_dir, output_path='', test_name=None): - """Constructor that load the data from the config file. + """Constructor that loads the data from the config file. + + Also, if a test name is passed, it will be run in single test mode. + And if an output path is not received, when is running in single test mode, it will be printed using the + standard output. But if an output path is passed, there will be generated a JSON file with the same data that + would be printed in `single test` mode. + + The default output path for `default mode` is `qa_docs_installation/output`, it cannot be changed. Even when + you pass an output path, it has no effect in `default mode`. Args: - config_path: - test_dir: - output_path: - test_name: + config_path: A string that contains the config file path. + test_dir: A string that contains the path of the tests. + output_path: A string that contains the doc output path. + test_name: A string that represents a test name. """ - self.mode = mode.DEFAULT + self.mode = Mode.DEFAULT self.project_path = test_dir self.include_paths = [] self.include_regex = [] @@ -50,7 +61,7 @@ def __init__(self, config_path, test_dir, output_path='', test_name=None): self.test_fields = _fields() self.test_cases_field = None - self.__load_config_file(config_path) + self.__read_config_file(config_path) self.__read_function_regex() self.__read_output_fields() self.__read_test_cases_field() @@ -61,14 +72,19 @@ def __init__(self, config_path, test_dir, output_path='', test_name=None): self.__set_documentation_path(output_path) if test_name: - # It is called with a single test to parse - self.mode = mode.SINGLE_TEST + # When a name is passed, it is using just a single test. + self.mode = Mode.SINGLE_TEST self.test_name = test_name self.__read_test_info() self.__read_module_info() - def __load_config_file(self, file): + def __read_config_file(self, file): + """Reads configuration file. + + Raises: + QAValuerError: Cannot load config file. + """ try: Config.LOGGER.debug('Loading config file') with open(file) as config_file: @@ -82,7 +98,7 @@ def __read_test_info(self): This functionality is used to print any custom field(s) you want. You can use it if you only need a few fields to parse when a single test is run. - For example you have this in your config.yaml: + For example, you have this in your config.yaml: Test info: - test_wazuh_min_version: wazuh_min_version @@ -115,21 +131,21 @@ def __read_module_info(self): Config.LOGGER.warning('Cannot read module info fields') def __set_documentation_path(self, path): - """ - brief: Sets the path of the documentation output. - """ + """Sets the path of the documentation output.""" Config.LOGGER.debug('Setting the path documentation') self.documentation_path = path def __read_include_paths(self): - """ - brief: Reads from the config file all the paths to be included in the parsing. + """Reads from the config file all the paths to be included in the parsing process. + + Raises: + QAValueError: The include paths field is empty in the config file """ Config.LOGGER.debug('Reading include paths from config file') # Will be replaced by --type --module and --test , so you can run what you need if not 'Include paths' in self._config_data: - raise QAValueError('The include paths of the configuration file are empty', Config.LOGGER.error) + raise QAValueError('The include paths field is empty in the config file', Config.LOGGER.error) include_paths = self._config_data['Include paths'] @@ -137,8 +153,10 @@ def __read_include_paths(self): self.include_paths.append(os.path.join(self.project_path, path)) def __read_include_regex(self): - """ - brief: Reads from the config file the regexes used to identify test files. + """Reads from the config file the regexes used to identify test files. + + Raises: + QAValueError: The include regex field is empty in the config file """ Config.LOGGER.debug('Reading the regular expressions from the config file to include test files') @@ -148,19 +166,23 @@ def __read_include_regex(self): self.include_regex = self._config_data['Include regex'] def __read_group_files(self): - """ - brief: Reads from the config file the file name to be identified with a group. + """Reads from the config file the file name to be identified in a group. + + Raises: + QAValueError: The group files field is empty in config file """ Config.LOGGER.debug('Reading group files from the config file') if not 'Group files' in self._config_data: - raise QAValueError('Group files field is empty in config file', Config.LOGGER.error) + raise QAValueError('The group files field is empty in config file', Config.LOGGER.error) self.group_files = self._config_data['Group files'] def __read_function_regex(self): - """ - brief: Reads from the config file the regexes used to identify a test method. + """Reads from the config file the regexes used to identify a test method. + + Raises: + QAValueError: The function regex field is empty in the config file """ Config.LOGGER.debug('Reading the regular expressions to include test methods from the config file') @@ -170,10 +192,7 @@ def __read_function_regex(self): self.function_regex = self._config_data['Function regex'] def __read_ignore_paths(self): - """ - brief: Reads from the config file all the paths to be excluded from the parsing. - """ - Config.LOGGER.debug('Reading the paths to be ignored from the config file') + """Reads from the config file all the paths to be excluded from the parsing.""" if 'Ignore paths' in self._config_data: ignore_paths = self._config_data['Ignore paths'] @@ -182,8 +201,13 @@ def __read_ignore_paths(self): self.ignore_paths.append(os.path.join(self.project_path, path)) def __read_module_fields(self): - """ - brief: Reads from the config file the optional and mandatory fields for the test module. + """Reads from the config file the optional and mandatory fields for the test module. + + If the module block fields are not defined in the config file, an error will be raised. + + Raises: + QAValueError: Module fields are missing in the config file + QAValueError: Mandatory module fields are missing in the config file """ Config.LOGGER.debug('Reading mandatory and optional module fields from the config file') @@ -202,8 +226,13 @@ def __read_module_fields(self): self.module_fields.optional = module_fields['Optional'] def __read_test_fields(self): - """ - brief: Reads from the config file the optional and mandatory fields for the test functions. + """Reads from the config file the optional and mandatory fields for the test functions. + + If the test block fields are not defined in the config file, an error will be raised. + + Raises: + QAValueError: Test fields are missing in the config file + QAValueError: Mandatory module fields are missing in the config file """ Config.LOGGER.debug('Reading mandatory and optional test fields from the config file') @@ -222,8 +251,10 @@ def __read_test_fields(self): self.test_fields.optional = test_fields['Optional'] def __read_output_fields(self): - """ - brief: Reads all the mandatory and optional fields. + """Reads all the mandatory and optional fields from config file. + + Raises: + QAValueError: Documentation schema not defined in the config file """ if not 'Output fields' in self._config_data: raise QAValueError('Documentation schema not defined in the config file', Config.LOGGER.error) @@ -232,25 +263,38 @@ def __read_output_fields(self): self.__read_test_fields() def __read_test_cases_field(self): - """ - brief: Reads from the configuration file the key to identify a Test Case list. - """ + """Reads from the configuration file the key to identify a Test Case list.""" Config.LOGGER.debug('Reading Test Case key from the config file') if 'Test cases field' in self._config_data: self.test_cases_field = self._config_data['Test cases field'] class _fields: - """ - brief: Struct for the documentation fields. + """Struct for the documentation fields. + + Attributes: + mandatory: A list of strings that contains the mandatory block fields + optional: A list of strings that contains the optional block fields """ def __init__(self): self.mandatory = [] self.optional = [] -class mode(Enum): - ''' - brief: Enumeration for classificate differents behaviours for DocGenerator - ''' +class Mode(Enum): + """Enumeration for behaviour classification + + The current modes that `doc_generator` has are these: + + Modes: + DEFAULT: `default mode` parses all tests within tests directory + SINGLE_TEST: `single test mode` only uses a single test represented by its name + + For example, if you want to declare that it is running thru all tests directory, you must specify it by: + + mode = Mode.DEFAULT + + Args: + Enum: Base class for creating enumerated constants. + """ DEFAULT = 1 SINGLE_TEST = 2 \ No newline at end of file diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 0b33121e81..d8086a6d0a 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -24,7 +24,7 @@ def set_qadocs_logger_level(logging_level): - """Set the QADOCS logger lever depending on the level specified by the user. + """Sets the QADOCS logger lever depending on the level specified by the user. Args: logging_level (string): Level used to initialize the logger. @@ -36,7 +36,7 @@ def set_qadocs_logger_level(logging_level): def validate_parameters(parameters): - """Validate the parameters that qa-docs recieves. + """Validates the parameters that qa-docs recieves. Since `config.yaml` will be `schema.yaml`, it runs as config file is correct. So we only validate the parameters that the user introduces. From 18eb63ed039dafbf450c0e1988fdc3e92f80b363 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 16 Sep 2021 13:47:24 +0200 Subject: [PATCH 023/181] style: fix `config.py` style. #1899 It passes pep': pycodestyle --max-line-length=120 --show-source --show-pep8 --- .../wazuh_testing/qa_docs/lib/config.py | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index d05aee783f..0d913a8602 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -12,7 +12,7 @@ class Config(): """Class that parses the configuration file and exposes the available configurations. - + Exist two modes of execution: `default mode` and `single test mode`. The following attributes may change because the config file will be deprecated soon. It will be renamed to `schema.yaml` and it will specify the schema fields and pre-defined values that you can check here: @@ -78,10 +78,9 @@ def __init__(self, config_path, test_dir, output_path='', test_name=None): self.__read_test_info() self.__read_module_info() - def __read_config_file(self, file): """Reads configuration file. - + Raises: QAValuerError: Cannot load config file. """ @@ -89,7 +88,7 @@ def __read_config_file(self, file): Config.LOGGER.debug('Loading config file') with open(file) as config_file: self._config_data = yaml.safe_load(config_file) - except: + except Config.LOGGER.error: raise QAValueError('Cannot load config file', Config.LOGGER.error) def __read_test_info(self): @@ -97,7 +96,7 @@ def __read_test_info(self): This functionality is used to print any custom field(s) you want. You can use it if you only need a few fields to parse when a single test is run. - + For example, you have this in your config.yaml: Test info: @@ -108,13 +107,13 @@ def __read_test_info(self): self.test_info = self._config_data['Test info'] else: Config.LOGGER.warning('Cannot read test info fields') - + def __read_module_info(self): """Reads from the config file the fields to be printed from test info. This functionality is used to print any custom field(s) you want. You can use it if you only need a few fields to parse when a single test is run. - + For example you have this in your config.yaml: Module info: @@ -144,7 +143,7 @@ def __read_include_paths(self): Config.LOGGER.debug('Reading include paths from config file') # Will be replaced by --type --module and --test , so you can run what you need - if not 'Include paths' in self._config_data: + if 'Include paths' not in self._config_data: raise QAValueError('The include paths field is empty in the config file', Config.LOGGER.error) include_paths = self._config_data['Include paths'] @@ -160,7 +159,7 @@ def __read_include_regex(self): """ Config.LOGGER.debug('Reading the regular expressions from the config file to include test files') - if not 'Include regex' in self._config_data: + if 'Include regex' not in self._config_data: raise QAValueError('The include regex field is empty in the config file', Config.LOGGER.error) self.include_regex = self._config_data['Include regex'] @@ -173,7 +172,7 @@ def __read_group_files(self): """ Config.LOGGER.debug('Reading group files from the config file') - if not 'Group files' in self._config_data: + if 'Group files' not in self._config_data: raise QAValueError('The group files field is empty in config file', Config.LOGGER.error) self.group_files = self._config_data['Group files'] @@ -186,7 +185,7 @@ def __read_function_regex(self): """ Config.LOGGER.debug('Reading the regular expressions to include test methods from the config file') - if not 'Function regex' in self._config_data: + if 'Function regex' not in self._config_data: raise QAValueError('The function regex field is empty in the config file', Config.LOGGER.error) self.function_regex = self._config_data['Function regex'] @@ -202,7 +201,7 @@ def __read_ignore_paths(self): def __read_module_fields(self): """Reads from the config file the optional and mandatory fields for the test module. - + If the module block fields are not defined in the config file, an error will be raised. Raises: @@ -211,12 +210,12 @@ def __read_module_fields(self): """ Config.LOGGER.debug('Reading mandatory and optional module fields from the config file') - if not 'Module' in self._config_data['Output fields']: + if 'Module' not in self._config_data['Output fields']: raise QAValueError('Module fields are missing in the config file', Config.LOGGER.error) module_fields = self._config_data['Output fields']['Module'] - if not 'Mandatory' in module_fields and not 'Optional' in module_fields: + if 'Mandatory' not in module_fields and 'Optional' not in module_fields: raise QAValueError('Mandatory module fields are missing in the config file', Config.LOGGER.error) if 'Mandatory' in module_fields: @@ -236,12 +235,12 @@ def __read_test_fields(self): """ Config.LOGGER.debug('Reading mandatory and optional test fields from the config file') - if not 'Test' in self._config_data['Output fields']: + if 'Test' not in self._config_data['Output fields']: raise QAValueError('Test fields are missing in the config file', Config.LOGGER.error) - + test_fields = self._config_data['Output fields']['Test'] - if not 'Mandatory' in test_fields and not 'Optional' in test_fields: + if 'Mandatory' not in test_fields and 'Optional' not in test_fields: raise QAValueError('Mandatory module fields are missing in the config file', Config.LOGGER.error) if 'Mandatory' in test_fields: @@ -252,11 +251,11 @@ def __read_test_fields(self): def __read_output_fields(self): """Reads all the mandatory and optional fields from config file. - + Raises: QAValueError: Documentation schema not defined in the config file """ - if not 'Output fields' in self._config_data: + if 'Output fields' not in self._config_data: raise QAValueError('Documentation schema not defined in the config file', Config.LOGGER.error) self.__read_module_fields() @@ -269,6 +268,7 @@ def __read_test_cases_field(self): if 'Test cases field' in self._config_data: self.test_cases_field = self._config_data['Test cases field'] + class _fields: """Struct for the documentation fields. @@ -280,6 +280,7 @@ def __init__(self): self.mandatory = [] self.optional = [] + class Mode(Enum): """Enumeration for behaviour classification @@ -297,4 +298,4 @@ class Mode(Enum): Enum: Base class for creating enumerated constants. """ DEFAULT = 1 - SINGLE_TEST = 2 \ No newline at end of file + SINGLE_TEST = 2 From a311e06e5a944050a60fa4fca5126415d5643229 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 17 Sep 2021 11:53:59 +0200 Subject: [PATCH 024/181] doc: Add `doc_generator.py` documentation. #1899 Also, few incorrect sentences have been corrected because they may create doubts when a user would be read them. --- .../wazuh_testing/qa_docs/doc_generator.py | 188 ++++++++++++------ 1 file changed, 122 insertions(+), 66 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 0f31a78f75..8ecb1191e2 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -6,6 +6,7 @@ import re import json import yaml + from wazuh_testing.qa_docs.lib.config import Config, Mode from wazuh_testing.qa_docs.lib.code_parser import CodeParser from wazuh_testing.qa_docs.lib.utils import clean_folder @@ -14,13 +15,29 @@ from wazuh_testing.tools.exceptions import QAValueError class DocGenerator: - """ - brief: Main class of DocGenerator tool. - It´s in charge of walk every test file, and every group file to dump the parsed documentation. + """Main class of DocGenerator tool. + + It is in charge of walk every test file, and every group file to dump the parsed documentation. + Every folder is checked so they are ignored when the path matches. Then, every test from folders not ignored + that matches a include regex, is parsed. + + Attributes: + conf: A `Config` instance with data loaded from config file. + parser: A `CodeParser` instance with parsing utilities. + __id_counter: An integer that counts the test/group ID when it is created. + ignore_regex: A list with compiled paths to be ignored. + include_regex: A list with regular expressions used to parse a file or not. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) def __init__(self, config): + """Class constructor + + Initialize every attribute. + + Args: + config: A `Config` instance with data loaded from config file. + """ self.conf = config self.parser = CodeParser(self.conf) self.__id_counter = 0 @@ -33,48 +50,56 @@ def __init__(self, config): self.include_regex.append(re.compile(include_regex)) def is_valid_folder(self, path): - """ - brief: Checks if a path should be ignored because it is in the ignore list. - args: - - "path (str): Folder location to be controlled" - returns: "boolean: False if the path should be ignored. True otherwise." + """Check if a folder is included so it would be parsed. + + That happens when is not ignored using the ignore list. + + Args: + path: A string that contains the folder location to be controlled + + Return: + A boolean with False if the path should be ignored. True otherwise. """ for regex in self.ignore_regex: if regex.match(path): - DocGenerator.LOGGER.warning(f"Folder validation: {regex} not matching with {path}") + DocGenerator.LOGGER.debug(f"Ignoring path: {regex} matching with {path}") return False return True def is_valid_file(self, file): - """ - brief: Checks if a file name should be ignored because it's in the ignore list - or doesn´t match with the regexes. - args: - - "file (str): File name to be controlled" - returns: "boolean: False if the file should be ignored. True otherwise." - """ + """Check if a path file is included. + + Also, that file could be ignored(because it is in the ignore list or does not match with the regexes). + + Args: + file: A string that contains the file name to be checked. + Returns: + A boolean with True when matches with an include regex. False if the file should be ignored + (because it matches with an ignore path or does not match with any include regular expression). + """ for regex in self.ignore_regex: if regex.match(file): - DocGenerator.LOGGER.warning(f"File validation: {regex} not matching with {file}") + DocGenerator.LOGGER.debug(f"Ignoring file: {regex} matching with {file}") return False for regex in self.include_regex: if regex.match(file): - DocGenerator.LOGGER.warning(f"File validation: {regex} not matching with {file}") + DocGenerator.LOGGER.debug(f"Including file: {regex} matching with {file}") return True return False def is_group_file(self, path): - """ - brief: Checks if a file path should be considered as a file containing group information. - args: - - "path (str): File location to be controlled" - returns: "boolean: True if the file is a group file. False otherwise." - """ + """Check if a file path should be considered as a file containing group information. + Args: + path: A string that contains the file name to be checked + + Returns: + A boolean with True if the file is a group file. False otherwise." + """ for group_file in self.conf.group_files: if path == group_file: return True @@ -82,9 +107,9 @@ def is_group_file(self, path): return False def get_group_doc_path(self, group): - """ - brief: Returns the name of the group file in the documentation output based on the original file name. - returns: "string: The name of the documentation group file" + """Get the name of the group file in the documentation output based on the original file name. + + Returns: A string that contains the name of the documentation group file. """ base_path = os.path.join(self.conf.documentation_path, os.path.basename(self.scan_path)) doc_path = os.path.join(base_path, group['name']+".group") @@ -92,12 +117,15 @@ def get_group_doc_path(self, group): return doc_path def get_test_doc_path(self, path): + """Get the name of the test file in the documentation output based on the original file name. + + Args: + path: A string that contains the original file name. + + Returns: + A string with the name of the documentation test file. """ - brief: Returns the name of the test file in the documentation output based on the original file name. - args: - - "path (str): The original file name" - returns: "string: The name of the documentation test file" - """ + base_path = os.path.join(self.conf.documentation_path, os.path.basename(self.scan_path)) relative_path = path.replace(self.scan_path, "") doc_path = os.path.splitext(base_path + relative_path)[0] @@ -105,12 +133,17 @@ def get_test_doc_path(self, path): return doc_path def dump_output(self, content, doc_path): - """ - brief: Creates a JSON and a YAML file with the parsed content of a test module. - It also creates the containing folder if it doesn´t exists. - args: - - "content (dict): The parsed content of a test file." - - "doc_path (string): The path where the information should be dumped." + """Create a JSON and a YAML file with the parsed content of a test module. + + Also, create the containing folder if it does not exist. + + Args: + content: A dict that contains the parsed content of a test file. + doc_path: A string with the path where the information should be dumped. + + Raises: + QAValueError: Cannot write in {doc_path}.json + QAValueError: Cannot write in {doc_path}.yaml """ if not os.path.exists(os.path.dirname(doc_path)): DocGenerator.LOGGER.debug('Creating documentation folder') @@ -131,12 +164,15 @@ def dump_output(self, content, doc_path): raise QAValueError(f"Cannot write in {doc_path}.yaml", DocGenerator.LOGGER.error) def create_group(self, path, group_id): - """ - brief: Parses the content of a group file and dumps the content into a file. - args: - - "path (string): The path of the group file to be parsed." - - "group_id (string): The id of the group where the new group belongs." - return "integer: The ID of the new generated group document. + """Parse the content of a group file and dump the content into a file. + + Args: + path: A string with the path of the group file to be parsed. + group_id: A string with the id of the group where the new group belongs. + + Returns: + An integer with the ID of the newly generated group document. + None if the test does not have documentation. """ self.__id_counter = self.__id_counter + 1 group = self.parser.parse_group(path, self.__id_counter, group_id) @@ -151,12 +187,15 @@ def create_group(self, path, group_id): return None def create_test(self, path, group_id): - """ - brief: Parses the content of a test file and dumps the content into a file. - args: - - "path (string): The path of the test file to be parsed." - - "group_id (string): The id of the group where the new test belongs." - return "integer: The ID of the new generated test document. + """Parse the content of a test file and dumps the content into a file. + + Args: + path: A string with the path of the test file to be parsed. + group_id: A string with the id of the group where the new test belongs. + + Returns: + An integer with the ID of the new generated test document. + None if the test does not have documentation. """ self.__id_counter = self.__id_counter + 1 test = self.parser.parse_test(path, self.__id_counter, group_id) @@ -167,8 +206,9 @@ def create_test(self, path, group_id): elif self.conf.mode == Mode.SINGLE_TEST: doc_path = self.conf.documentation_path if self.print_test_info(test) is None: - # + # When only a test is parsed, exit return + self.dump_output(test, doc_path) DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' was created with ID:{self.__id_counter}") return self.__id_counter @@ -177,18 +217,17 @@ def create_test(self, path, group_id): return None def parse_folder(self, path, group_id): - """ - brief: Search in a specific folder to parse possible group files and each test file. - args: - - "path (string): The path of the folder to be parsed." - - "group_id (string): The id of the group where the new elements belong." + """Search in a specific folder to parse possible group files and each test file. + + Args: + path: A string with the path of the folder to be parsed. + group_id: A string with the id of the group where the new elements belong. """ if not os.path.exists(path): DocGenerator.LOGGER.warning(f"Include path '{path}' doesn´t exist") return if not self.is_valid_folder(path): - DocGenerator.LOGGER.debug(f"Ignoring files on '{path}'") return (root, folders, files) = next(os.walk(path)) @@ -207,8 +246,10 @@ def parse_folder(self, path, group_id): self.parse_folder(os.path.join(root, folder), group_id) def locate_test(self): - """ - brief: try to get the test path + """Get the test path when a test is specified by the user. + + Returns: + A string with the test path. """ complete_test_name = f"{self.conf.test_name}.py" DocGenerator.LOGGER.info(f"Looking for {complete_test_name}") @@ -222,9 +263,14 @@ def locate_test(self): return None def print_test_info(self, test): - """ - brief: Print the test info to standard output. If an output path is specified, - the output is redirected to `output_path/test_info.json`. + """Print the test info to standard output. If an output path is specified by the user, + the output is redirected to `output_path/{test_name}.json`. + + Return None to avoid the default parsing behaviour. + This method will change, will only print the test data but the JSON will be generated using `dump_output`. + + Args: + test: A dict with the parsed test data """ # dump into file if self.conf.documentation_path: @@ -258,12 +304,22 @@ def print_test_info(self, test): for name, schema_field in field.items(): print(str(name)+": "+str(test['tests'][0][schema_field])) - return None + return None def run(self): - """ - brief: Run a complete scan of each include path to parse every test and group found. - Normal mode: expected behaviour, Single test mode: found the test required and par it + """Run a complete scan of each included path to parse every test and group found. + + Default mode: parse the files within the included paths. + Single test mode: found the test required and parse it. + + For example: + qa-docs -I ../../tests/ -> It would be running as `default mode`. + + qa-docs -I ../../tests/ -T test_cache -> It would be running as `single test mode` + using the standard output + + qa-docs -I ../../tests/ -T test_cache -o /tmp -> It would be running as `single test mode` + creating `/tmp/test_cache.json` """ if self.conf.mode == Mode.DEFAULT: DocGenerator.LOGGER.info("Starting documentation parsing") From 8c6243ab1720b172305250d6754cf14fe17d54da Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 17 Sep 2021 11:59:36 +0200 Subject: [PATCH 025/181] style: fix `doc_generator.py` style. #1899 It passes `pycodestyle --max-line-length=120 --show-source --show-pep8`. --- .../wazuh_testing/qa_docs/doc_generator.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 8ecb1191e2..cf517567b4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -14,6 +14,7 @@ from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.exceptions import QAValueError + class DocGenerator: """Main class of DocGenerator tool. @@ -53,11 +54,11 @@ def is_valid_folder(self, path): """Check if a folder is included so it would be parsed. That happens when is not ignored using the ignore list. - + Args: path: A string that contains the folder location to be controlled - Return: + Return: A boolean with False if the path should be ignored. True otherwise. """ for regex in self.ignore_regex: @@ -134,7 +135,7 @@ def get_test_doc_path(self, path): def dump_output(self, content, doc_path): """Create a JSON and a YAML file with the parsed content of a test module. - + Also, create the containing folder if it does not exist. Args: @@ -285,7 +286,7 @@ def print_test_info(self, test): for field in self.conf.test_info: for name, schema_field in field.items(): test_info[name] = test['tests'][0][schema_field] - + # If output path does not exist, it is created if not os.path.exists(self.conf.documentation_path): os.mkdir(self.conf.documentation_path) @@ -303,12 +304,12 @@ def print_test_info(self, test): for field in self.conf.test_info: for name, schema_field in field.items(): print(str(name)+": "+str(test['tests'][0][schema_field])) - + return None def run(self): """Run a complete scan of each included path to parse every test and group found. - + Default mode: parse the files within the included paths. Single test mode: found the test required and parse it. @@ -334,7 +335,7 @@ def run(self): elif self.conf.mode == Mode.SINGLE_TEST: DocGenerator.LOGGER.info("Starting test documentation parsing") self.test_path = self.locate_test() - + if self.test_path: DocGenerator.LOGGER.debug(f"Parsing '{self.conf.test_name}'") self.create_test(self.test_path, 0) From db6d4abd1cd0c53194bb842c849f87de88912f73 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 17 Sep 2021 12:01:19 +0200 Subject: [PATCH 026/181] refac: remove an unnecessary module and fix style. --- deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py | 2 +- deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index cf517567b4..0cae526f21 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -7,7 +7,7 @@ import json import yaml -from wazuh_testing.qa_docs.lib.config import Config, Mode +from wazuh_testing.qa_docs.lib.config import Mode from wazuh_testing.qa_docs.lib.code_parser import CodeParser from wazuh_testing.qa_docs.lib.utils import clean_folder from wazuh_testing.qa_docs import QADOCS_LOGGER diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 0d913a8602..77445f053c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -5,6 +5,7 @@ import yaml from enum import Enum import os + from wazuh_testing.qa_docs import QADOCS_LOGGER from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.exceptions import QAValueError From c9b9d359194c3aedefd5cfd67e796bf09e6f3828 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 09:40:39 +0200 Subject: [PATCH 027/181] doc: Add `code_parse.py` documentation. #1899 --- .../wazuh_testing/qa_docs/lib/code_parser.py | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 981f6b99c2..48734fec4b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -1,11 +1,6 @@ -""" -brief: Wazuh DocGenerator code parser. -copyright: Copyright (C) 2015-2021, Wazuh Inc. -date: August 02, 2021 -license: This program is free software; you can redistribute it - and/or modify it under the terms of the GNU General Public - License (version 2) as published by the FSF - Free Software Foundation. -""" +# Copyright (C) 2015-2021, Wazuh Inc. +# Created by Wazuh, Inc. . +# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 import ast import os @@ -22,8 +17,12 @@ class CodeParser: - """ - brief: Class that parses the content of the test files. + """Class that parses the content of the test files. + + Attributes: + conf: A `Config` instance with the config file data. + pytest: A `PytestWrap` instance to wrap the pytest execution. + function_regexes: A list of strings(regular expressions) used to find test functions.. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -35,11 +34,12 @@ def __init__(self, config): self.function_regexes.append(re.compile(regex)) def is_documentable_function(self, function): - """ - brief: Checks if a specific method matches with the regexes to be documented. - args: - -"function (_ast.FunctionDef): Function class with all the information of the method" - returns: "boolean: True if the method should be documentd. False otherwise" + """Checks if a specific method matches with the regexes to be documented. + + Args: + function: Function class(_ast.FunctionDef) with all the information of the method. + Returns: + A boolean with True if the method should be documented. False otherwise """ for regex in self.function_regexes: if regex.match(function.name): @@ -47,27 +47,31 @@ def is_documentable_function(self, function): return False def remove_ignored_fields(self, doc): - """ - brief: Removes the fields from a parsed test file to delete the fields that are not mandatory or optional. - args: - -"doc (dict): The parsed documentation block" + """Removes the fields from a parsed test file to delete the fields that are not mandatory or optional. + + Args: + doc: A dict that contains the parsed documentation block" """ allowed_fields = self.conf.module_fields.mandatory + self.conf.module_fields.optional + INTERNAL_FIELDS remove_inexistent(doc, allowed_fields, STOP_FIELDS) + if 'tests' in doc: allowed_fields = self.conf.test_fields.mandatory + self.conf.test_fields.optional + INTERNAL_FIELDS + for test in doc['tests']: remove_inexistent(test, allowed_fields, STOP_FIELDS) def parse_comment(self, function): - """ - brief: Parses one self-contained documentation block. - args: - -"function (_ast.FunctionDef): Function class with all the information of the method" + """Parses one self-contained documentation block. + + Args: + function: Function class(_ast.FunctionDef) with all the information of the method" """ docstring = ast.get_docstring(function) + try: doc = yaml.safe_load(docstring) + if hasattr(function, 'name'): doc['name'] = function.name @@ -77,17 +81,18 @@ def parse_comment(self, function): "from module {self.scan_file}. Error: {inst}") else: CodeParser.LOGGER.warning(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}") + doc = None return doc def parse_test(self, code_file, id, group_id): - """ - brief: Parses the content of a test file. - args: - -"code_file (string): Path of the test file to be parsed." - -"id (integer): Id of the new test document" - -"group_id (integer): Id of the group where the new test document belongs." + """Parses the content of a test file. + + Args: + code_file: A string with the path of the test file to be parsed. + id: An integer with the ID of the new test document. + group_id: An integer with the ID of the group where the new test document belongs. """ CodeParser.LOGGER.debug(f"Parsing test file '{code_file}'") self.scan_file = code_file @@ -110,10 +115,12 @@ def parse_test(self, code_file, id, group_id): for function in functions: if self.is_documentable_function(function): function_doc = self.parse_comment(function) + if function_doc: if test_cases and not (self.conf.test_cases_field in function_doc) \ and test_cases[function.name]: function_doc[self.conf.test_cases_field] = test_cases[function.name] + functions_doc.append(function_doc) if not functions_doc: @@ -126,12 +133,12 @@ def parse_test(self, code_file, id, group_id): return module_doc def parse_group(self, group_file, id, group_id): - """ - brief: Parses the content of a group file. - args: - -"group_file (string): Path of the group file to be parsed." - -"id (integer): Id of the new group document" - -"group_id (integer): Id of the group where the new group document belongs." + """Parses the content of a group file. + + Args: + group_file: A string with the path of the group file to be parsed. + id: An integer with the ID of the new test document. + group_id: An integer with the ID of the group where the new test document belongs. """ MD_HEADER = "# " CodeParser.LOGGER.debug(f"Parsing group file '{group_file}'") From 0b6cd9d7df48fc1359964fcc3a79a4a31c194af0 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 09:56:13 +0200 Subject: [PATCH 028/181] doc: Add `index_data.py` documentation. #1899 --- .../wazuh_testing/qa_docs/doc_generator.py | 2 +- .../wazuh_testing/qa_docs/lib/code_parser.py | 7 +++ .../wazuh_testing/qa_docs/lib/index_data.py | 53 ++++++++++--------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 0cae526f21..884ae1c755 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -37,7 +37,7 @@ def __init__(self, config): Initialize every attribute. Args: - config: A `Config` instance with data loaded from config file. + config: A `Config` instance with the loaded data from config file. """ self.conf = config self.parser = CodeParser(self.conf) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 48734fec4b..0afbd72361 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -27,6 +27,13 @@ class CodeParser: LOGGER = Logging.get_logger(QADOCS_LOGGER) def __init__(self, config): + """Class constructor + + Initialize every attribute. + + Args: + config: A `Config` instance with the loaded data from config file. + """ self.conf = config self.pytest = PytestWrap() self.function_regexes = [] diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 127ed6b717..970ce0ae9b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -1,11 +1,6 @@ -""" -brief: Wazuh DocGenerator data indexer. -copyright: Copyright (C) 2015-2021, Wazuh Inc. -date: August 04, 2021 -license: This program is free software; you can redistribute it - and/or modify it under the terms of the GNU General Public - License (version 2) as published by the FSF - Free Software Foundation. -""" +# Copyright (C) 2015-2021, Wazuh Inc. +# Created by Wazuh, Inc. . +# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 import os import re @@ -19,12 +14,25 @@ class IndexData: - """ - brief: Class that indexes the data from JSON files into ElasticSearch. + """Class that indexes the data from JSON files into ElasticSearch. + + Attributes: + path: A string that contains the path where the parsed documentation is located. + index: A string with the index name to be indexed with Elasticsearch. + regex: A regular expression to get JSON files. + es: An `ElasticSearch` client instance. + output: A list to be indexed in Elasticsearch. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) def __init__(self, index, config): + """Class constructor + + Initialize every attribute. + + Args: + config: A `Config` instance with the loaded data from config file. + """ self.path = config.documentation_path self.index = index self.regex = re.compile(".*json") @@ -32,9 +40,7 @@ def __init__(self, index, config): self.output = [] def test_connection(self): - """ - brief: It verifies with an HTTP request that an OK response is received from ElasticSearch. - """ + """Verify with an HTTP request that an OK response is received from ElasticSearch.""" try: res = requests.get("http://localhost:9200/_cluster/health") if res.status_code == 200: @@ -43,19 +49,21 @@ def test_connection(self): raise QAValueError(f"Connection error: {exception}", IndexData.LOGGER.error) def get_files(self): - """ - brief: Finds all the files inside the documentation path that matches with doc_file_regex. - """ + """Find all the files inside the documentation path that matches with the JSON regex.""" doc_files = [] + for (root, *_, files) in os.walk(self.path): for file in files: if self.regex.match(file): doc_files.append(os.path.join(root, file)) + return doc_files def read_files_content(self, files): - """ - brief: Opens every file found in the path and appends the content into a list. + """Open every file found in the path and appends the content into a list. + + Args: + files: A list with the files that matched with the regex. """ for file in files: with open(file) as test_file: @@ -63,19 +71,16 @@ def read_files_content(self, files): self.output.append(lines) def remove_index(self): - """ - brief: Deletes an index. - """ + """Deletes an index.""" delete = self.es.indices.delete(index=self.index, ignore=[400, 404]) IndexData.LOGGER.info(f'Delete index {self.index}\n {delete}\n') def run(self): - """ - brief: Collects all the documentation files and makes a request to the BULK API to index the new data. - """ + """Collects all the documentation files and makes a request to the BULK API to index the new data.""" self.test_connection() files = self.get_files() self.read_files_content(files) + if self.test_connection(): self.remove_index() IndexData.LOGGER.info("Indexing data...\n") From 528028bc3e40683487feb875ef979d8f43be7788 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 09:57:23 +0200 Subject: [PATCH 029/181] style: fix `index_data.py` style. #1899 Also rename Camel Case variable in `qa_docs.py`. --- .../wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py | 2 +- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 970ce0ae9b..1a7f5942c4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -61,7 +61,7 @@ def get_files(self): def read_files_content(self, files): """Open every file found in the path and appends the content into a list. - + Args: files: A list with the files that matched with the regex. """ diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index d8086a6d0a..eff01a1ba7 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -125,14 +125,14 @@ def main(): # Index the previous parsed tests into Elasticsearch elif args.index_name: qadocs_logger.debug(f"Indexing {args.index_name}") - indexData = IndexData(args.index_name, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) - indexData.run() + index_data = IndexData(args.index_name, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + index_data.run() # Index the previous parsed tests into Elasticsearch and then launch SearchUI elif args.launch_app: qadocs_logger.debug(f"Indexing {args.index_name}") - indexData = IndexData(args.launch_app, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) - indexData.run() + index_data = IndexData(args.launch_app, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + index_data.run() os.chdir(SEARCH_UI_PATH) qadocs_logger.debug('Running SearchUI') os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") From 5e3efaa9a965b256aed61cc52be6ba6fb9def7d9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 10:06:02 +0200 Subject: [PATCH 030/181] doc: Add `pytest_wrap.py` documentation. #1899 --- .../wazuh_testing/qa_docs/lib/pytest_wrap.py | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py index fb3876e992..9a00357806 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py @@ -1,11 +1,6 @@ -""" -brief: Wazuh pytest wrapper. -copyright: Copyright (C) 2015-2021, Wazuh Inc. -date: August 02, 2021 -license: This program is free software; you can redistribute it - and/or modify it under the terms of the GNU General Public - License (version 2) as published by the FSF - Free Software Foundation. -""" +# Copyright (C) 2015-2021, Wazuh Inc. +# Created by Wazuh, Inc. . +# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 import pytest @@ -14,22 +9,28 @@ class PytestPlugin: - """ - brief: Plugin to extract information from a pytest execution. + """Plugin to extract information from a pytest execution. + + Attributes: + collected: A list with the collected data from pytest execution """ def __init__(self): self.collected = [] def pytest_collection_modifyitems(self, items): - """ - brief: Callback to receive the output of a pytest execution. + """Callback to receive the output of a pytest execution. + + Args: + items: A list with the metadata from each test case. """ for item in items: self.collected.append(item.nodeid) class PytestWrap: - """ - brief: Class that wraps the execution of pytest. + """Class that wraps the execution of pytest. + + Attributes: + plugin: A `PytestPlugin` instance. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -37,23 +38,28 @@ def __init__(self): self.plugin = PytestPlugin() def collect_test_cases(self, path): - """ - brief: "Executes pytest in 'collect-only' mode to extract all the test cases found for a test file. - args: - - "path (string): Path of the test file to extract the test cases. - returns: "dictionary: The output of pytest parsed into a dictionary" + """Executes pytest in 'collect-only' mode to extract all the test cases found for a test file. + + Args: + path: A string with the path of the test file to extract the test cases. + + Returns: A dictionary that contains the pytest parsed output. """ PytestWrap.LOGGER.debug(f"Running pytest to collect test cases for '{path}'") pytest.main(['--collect-only', "-qq", path], plugins=[self.plugin]) output = {} + for item in self.plugin.collected: tmp = item.split("::") tmp = tmp[1].split("[") test = tmp[0] + if not test in output: output[test] = [] + if len(tmp) >= 2: tmp = tmp[1].split("]") test_case = tmp[0] output[test].append(test_case) + return output From 3755a454776b6068777500aced01e06d6074706a Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 10:07:56 +0200 Subject: [PATCH 031/181] style: fix `pytest_wrap.py` style. #1899 --- deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py index 9a00357806..6dc3dd2a21 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py @@ -26,6 +26,7 @@ def pytest_collection_modifyitems(self, items): for item in items: self.collected.append(item.nodeid) + class PytestWrap: """Class that wraps the execution of pytest. @@ -54,7 +55,7 @@ def collect_test_cases(self, path): tmp = tmp[1].split("[") test = tmp[0] - if not test in output: + if test not in output: output[test] = [] if len(tmp) >= 2: From 55d9fab3205bfb68b9746f35e2063f018df66722 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 10:17:43 +0200 Subject: [PATCH 032/181] doc: Add `Returns` to some `code_parser.py` methods. --- .../wazuh_testing/qa_docs/lib/code_parser.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 0afbd72361..9a366a2df1 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -45,6 +45,7 @@ def is_documentable_function(self, function): Args: function: Function class(_ast.FunctionDef) with all the information of the method. + Returns: A boolean with True if the method should be documented. False otherwise """ @@ -73,6 +74,9 @@ def parse_comment(self, function): Args: function: Function class(_ast.FunctionDef) with all the information of the method" + + Returns: + A dictionary with the documentation block parsed. """ docstring = ast.get_docstring(function) @@ -100,6 +104,9 @@ def parse_test(self, code_file, id, group_id): code_file: A string with the path of the test file to be parsed. id: An integer with the ID of the new test document. group_id: An integer with the ID of the group where the new test document belongs. + + Returns: + A dictionary with the documentation block parsed with module and tests fields. """ CodeParser.LOGGER.debug(f"Parsing test file '{code_file}'") self.scan_file = code_file @@ -146,6 +153,9 @@ def parse_group(self, group_file, id, group_id): group_file: A string with the path of the group file to be parsed. id: An integer with the ID of the new test document. group_id: An integer with the ID of the group where the new test document belongs. + + Returns: + A dictionary with the parsed information from `group_file`. """ MD_HEADER = "# " CodeParser.LOGGER.debug(f"Parsing group file '{group_file}'") From d80a15f4e48f48b9bc0340c074197e851edbaf84 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 10:39:14 +0200 Subject: [PATCH 033/181] doc: Add `sanity.py` documentation. #1899 --- .../wazuh_testing/qa_docs/lib/sanity.py | 116 ++++++++++-------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index 3e9ea2d50e..cd088e00d8 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -1,11 +1,6 @@ -""" -brief: Wazuh DocGeneratot sanity check module -copyright: Copyright (C) 2015-2021, Wazuh Inc. -date: August 02, 2021 -license: This program is free software; you can redistribute it - and/or modify it under the terms of the GNU General Public - License (version 2) as published by the FSF - Free Software Foundation. -""" +# Copyright (C) 2015-2021, Wazuh Inc. +# Created by Wazuh, Inc. . +# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 import os import re @@ -19,13 +14,29 @@ class Sanity(): - """ - brief: Class in charge of performing a general sanity check on the already parsed documentation. - It´s in charge of walk every documentation file, and every group file to dump the parsed documentation. + """Class in charge of performing a general sanity check on the already parsed documentation. + + It is in charge of walk every documentation file, and every group file to dump the parsed documentation. + + Attributes: + conf: A `Config` instance with the loaded data from the config file. + files_regex: A regular expression to get the JSON files previously generated. + error_reports: A list that contains all the errors obtained within the check. + found_tags: A set with all the tags found within the check. + found_tests: A set with all the tests found within the check. + found_modules: A set with all the modules found within the check. + project_tests: An integer that contains the tests count within the project folder. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) def __init__(self, config): + """Class constructor + + Initialize every attribute. + + Args: + config: A `Config` instance with the loaded data from the config file. + """ self.conf = config self.files_regex = re.compile("^(?!.*group)test.*json$", re.IGNORECASE) self.error_reports = [] @@ -35,10 +46,16 @@ def __init__(self, config): self.project_tests = 0 def get_content(self, full_path): - """ - brief: Loads a documentation file into a JSON dictionary. - args: - - "full_path (str): The documentation file." + """Load a documentation file into a JSON dictionary. + + Args: + full_path: A string with the documentation file. + + Returns: + Test file content. + + Raises: + QaValueError: Cannot load '{full_path}' file for sanity check. """ try: with open(full_path) as file: @@ -47,13 +64,14 @@ def get_content(self, full_path): raise QAValueError(f"Cannot load '{full_path}' file for sanity check", Sanity.LOGGER.error) def validate_fields(self, required_fields, available_fields): - """ - brief: Method to check if all the required fields are present into the found ones. - This method will be called recursively for nested dictionaries. - If a required field isn´t found, the error is logged and written in a report structure for future print. - args: - - "required_fields (dict): The fields that must exist." - - "available_fields (dict): The fields found into the documentation file." + """Check if all the required fields are present into the found ones. + + This method will be called recursively for nested dictionaries. + If a required field is not found, the error is logged and written in a report structure for future print. + + Args: + required_fields: A dictionary that contains the fields that must exist. + available_fields: A dictionary that contains the fields found into the documentation file. """ if isinstance(required_fields, dict): for field in required_fields: @@ -72,47 +90,47 @@ def validate_fields(self, required_fields, available_fields): Sanity.LOGGER.error(f"Mandatory field '{field}' is missing the in file {self.scan_file}") def validate_module_fields(self, fields): - """ - brief: Checks if all the mandatory module fields are present. - args: - - "fields(dict): The module fields found in the documentation file." + """Check if all the mandatory module fields are present. + + Args: + fields: A dictionary that contains the module fields found in the documentation file. """ self.validate_fields(self.conf.module_fields.mandatory, fields) def validate_test_fields(self, fields): - """ - brief: Checks if all the mandatory test fields are present. - args: - - "fields(dict): The test fields found in the documentation file." + """Checks if all the mandatory test fields are present. + + Args: + fields: A dictionary that contains the test fields found in the documentation file. """ if 'tests' in fields: for test_fields in fields['tests']: self.validate_fields(self.conf.test_fields.mandatory, test_fields) def identify_tags(self, content): - """ - brief: Identifies every new tag found in the documentation files and saves it for future reporting. - args: - - "content(dict): The dictionary content of a documentation file." + """Identify every new tag found in the documentation files and saves it for future reporting. + + Args: + content: A dictionary that contains the dictionary content of a documentation file. """ if 'metadata' in content and 'tags' in content['metadata']: for tag in content['metadata']['tags']: self.found_tags.add(tag) def identify_tests(self, content): - """ - brief: Identifies every new test found in the documentation files and saves it for future reporting. - args: - - "content(dict): The dictionary content of a documentation file." + """Identify every new test found in the documentation files and saves it for future reporting. + + Args: + content: A dictionary that contains the dictionary content of a documentation file. """ if 'tests' in content: for test in content['tests']: self.found_tests.add(test['name']) def count_project_tests(self): - """ - brief: Count how many tests are into every test file into the Project folder. - This information will be used for a coverage report. + """Count how many tests are into every test file into the Project folder. + + This information will be used for a coverage report. """ file_regexes = [] function_regexes = [] @@ -135,17 +153,15 @@ def count_project_tests(self): self.project_tests = self.project_tests + 1 def add_report(self, message): - """ - brief: Adds a new entry to the report. - args: - - "message (string): Message to be included in the report." + """Add a new entry to the report. + + Args: + message: A string that contains the message to be included in the report. """ self.error_reports.append(message) def print_report(self): - """ - brief: Makes a report with all the errors found, the coverage and the tags found. - """ + """Make a report with all the errors found, the coverage and the tags found.""" print("\nDuring the sanity check:") if self.error_reports: @@ -167,9 +183,7 @@ def print_report(self): print("A {:.2f}% from the tests of {} is covered.".format(tests_percentage, self.conf.project_path)) def run(self): - """ - brief: Runs a complete sanity check of each documentation file on the output folder. - """ + """Run a complete sanity check of each documentation file on the output folder.""" Sanity.LOGGER.info("Starting documentation sanity check") for (root, *_, files) in os.walk(self.conf.documentation_path, topdown=True): From 9f65d5e4f048b074474b2a270455a6853f36c19f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 10:44:49 +0200 Subject: [PATCH 034/181] fix: Remove third person usage and add except error. #1899 --- .../wazuh_testing/qa_docs/lib/code_parser.py | 12 ++++---- .../wazuh_testing/qa_docs/lib/config.py | 28 +++++++++---------- .../wazuh_testing/qa_docs/lib/index_data.py | 2 +- .../wazuh_testing/qa_docs/lib/pytest_wrap.py | 2 +- .../wazuh_testing/qa_docs/lib/sanity.py | 16 +++++------ 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 9a366a2df1..6a33afc132 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -32,7 +32,7 @@ def __init__(self, config): Initialize every attribute. Args: - config: A `Config` instance with the loaded data from config file. + config: A `Config` instance with the loaded data from the config file. """ self.conf = config self.pytest = PytestWrap() @@ -41,7 +41,7 @@ def __init__(self, config): self.function_regexes.append(re.compile(regex)) def is_documentable_function(self, function): - """Checks if a specific method matches with the regexes to be documented. + """Check if a specific method matches with the regexes to be documented. Args: function: Function class(_ast.FunctionDef) with all the information of the method. @@ -55,7 +55,7 @@ def is_documentable_function(self, function): return False def remove_ignored_fields(self, doc): - """Removes the fields from a parsed test file to delete the fields that are not mandatory or optional. + """Remove the fields from a parsed test file to delete the fields that are not mandatory or optional. Args: doc: A dict that contains the parsed documentation block" @@ -70,7 +70,7 @@ def remove_ignored_fields(self, doc): remove_inexistent(test, allowed_fields, STOP_FIELDS) def parse_comment(self, function): - """Parses one self-contained documentation block. + """Parse one self-contained documentation block. Args: function: Function class(_ast.FunctionDef) with all the information of the method" @@ -98,7 +98,7 @@ def parse_comment(self, function): return doc def parse_test(self, code_file, id, group_id): - """Parses the content of a test file. + """Parse the content of a test file. Args: code_file: A string with the path of the test file to be parsed. @@ -147,7 +147,7 @@ def parse_test(self, code_file, id, group_id): return module_doc def parse_group(self, group_file, id, group_id): - """Parses the content of a group file. + """Parse the content of a group file. Args: group_file: A string with the path of the group file to be parsed. diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 77445f053c..cecafcf9db 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -80,7 +80,7 @@ def __init__(self, config_path, test_dir, output_path='', test_name=None): self.__read_module_info() def __read_config_file(self, file): - """Reads configuration file. + """Read configuration file. Raises: QAValuerError: Cannot load config file. @@ -89,11 +89,11 @@ def __read_config_file(self, file): Config.LOGGER.debug('Loading config file') with open(file) as config_file: self._config_data = yaml.safe_load(config_file) - except Config.LOGGER.error: + except IOError: raise QAValueError('Cannot load config file', Config.LOGGER.error) def __read_test_info(self): - """Reads from the config file the keys to be printed from module info. + """Read from the config file the keys to be printed from module info. This functionality is used to print any custom field(s) you want. You can use it if you only need a few fields to parse when a single test is run. @@ -110,7 +110,7 @@ def __read_test_info(self): Config.LOGGER.warning('Cannot read test info fields') def __read_module_info(self): - """Reads from the config file the fields to be printed from test info. + """Read from the config file the fields to be printed from test info. This functionality is used to print any custom field(s) you want. You can use it if you only need a few fields to parse when a single test is run. @@ -131,12 +131,12 @@ def __read_module_info(self): Config.LOGGER.warning('Cannot read module info fields') def __set_documentation_path(self, path): - """Sets the path of the documentation output.""" + """Set the path of the documentation output.""" Config.LOGGER.debug('Setting the path documentation') self.documentation_path = path def __read_include_paths(self): - """Reads from the config file all the paths to be included in the parsing process. + """Read from the config file all the paths to be included in the parsing process. Raises: QAValueError: The include paths field is empty in the config file @@ -153,7 +153,7 @@ def __read_include_paths(self): self.include_paths.append(os.path.join(self.project_path, path)) def __read_include_regex(self): - """Reads from the config file the regexes used to identify test files. + """Read from the config file the regexes used to identify test files. Raises: QAValueError: The include regex field is empty in the config file @@ -166,7 +166,7 @@ def __read_include_regex(self): self.include_regex = self._config_data['Include regex'] def __read_group_files(self): - """Reads from the config file the file name to be identified in a group. + """Read from the config file the file name to be identified in a group. Raises: QAValueError: The group files field is empty in config file @@ -179,7 +179,7 @@ def __read_group_files(self): self.group_files = self._config_data['Group files'] def __read_function_regex(self): - """Reads from the config file the regexes used to identify a test method. + """Read from the config file the regexes used to identify a test method. Raises: QAValueError: The function regex field is empty in the config file @@ -192,7 +192,7 @@ def __read_function_regex(self): self.function_regex = self._config_data['Function regex'] def __read_ignore_paths(self): - """Reads from the config file all the paths to be excluded from the parsing.""" + """Read from the config file all the paths to be excluded from the parsing.""" if 'Ignore paths' in self._config_data: ignore_paths = self._config_data['Ignore paths'] @@ -201,7 +201,7 @@ def __read_ignore_paths(self): self.ignore_paths.append(os.path.join(self.project_path, path)) def __read_module_fields(self): - """Reads from the config file the optional and mandatory fields for the test module. + """Read from the config file the optional and mandatory fields for the test module. If the module block fields are not defined in the config file, an error will be raised. @@ -226,7 +226,7 @@ def __read_module_fields(self): self.module_fields.optional = module_fields['Optional'] def __read_test_fields(self): - """Reads from the config file the optional and mandatory fields for the test functions. + """Read from the config file the optional and mandatory fields for the test functions. If the test block fields are not defined in the config file, an error will be raised. @@ -251,7 +251,7 @@ def __read_test_fields(self): self.test_fields.optional = test_fields['Optional'] def __read_output_fields(self): - """Reads all the mandatory and optional fields from config file. + """Read all the mandatory and optional fields from config file. Raises: QAValueError: Documentation schema not defined in the config file @@ -263,7 +263,7 @@ def __read_output_fields(self): self.__read_test_fields() def __read_test_cases_field(self): - """Reads from the configuration file the key to identify a Test Case list.""" + """Read from the configuration file the key to identify a Test Case list.""" Config.LOGGER.debug('Reading Test Case key from the config file') if 'Test cases field' in self._config_data: diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 1a7f5942c4..d322cd6ab3 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -31,7 +31,7 @@ def __init__(self, index, config): Initialize every attribute. Args: - config: A `Config` instance with the loaded data from config file. + config: A `Config` instance with the loaded data from the config file. """ self.path = config.documentation_path self.index = index diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py index 6dc3dd2a21..a5d454876d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py @@ -39,7 +39,7 @@ def __init__(self): self.plugin = PytestPlugin() def collect_test_cases(self, path): - """Executes pytest in 'collect-only' mode to extract all the test cases found for a test file. + """Execute pytest in 'collect-only' mode to extract all the test cases found for a test file. Args: path: A string with the path of the test file to extract the test cases. diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index cd088e00d8..7825118df5 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -15,9 +15,9 @@ class Sanity(): """Class in charge of performing a general sanity check on the already parsed documentation. - + It is in charge of walk every documentation file, and every group file to dump the parsed documentation. - + Attributes: conf: A `Config` instance with the loaded data from the config file. files_regex: A regular expression to get the JSON files previously generated. @@ -55,17 +55,17 @@ def get_content(self, full_path): Test file content. Raises: - QaValueError: Cannot load '{full_path}' file for sanity check. + IOError: Cannot load '{full_path}' file for sanity check. """ try: with open(full_path) as file: return json.load(file) - except: + except IOError: raise QAValueError(f"Cannot load '{full_path}' file for sanity check", Sanity.LOGGER.error) def validate_fields(self, required_fields, available_fields): """Check if all the required fields are present into the found ones. - + This method will be called recursively for nested dictionaries. If a required field is not found, the error is logged and written in a report structure for future print. @@ -78,7 +78,7 @@ def validate_fields(self, required_fields, available_fields): if not check_existance(available_fields, field): self.add_report(f"Mandatory field '{field}' is missing in the file {self.scan_file}") Sanity.LOGGER.error(f"Mandatory field '{field}' is missing in the file {self.scan_file}") - elif isinstance(required_fields[field], dict) or isinstance(required_fields[field], list): + elif isinstance(required_fields[field], dict) or isinstance(required_fields[field], list): self.validate_fields(required_fields[field], available_fields) elif isinstance(required_fields, list): for field in required_fields: @@ -129,7 +129,7 @@ def identify_tests(self, content): def count_project_tests(self): """Count how many tests are into every test file into the Project folder. - + This information will be used for a coverage report. """ file_regexes = [] @@ -143,7 +143,7 @@ def count_project_tests(self): for regex in file_regexes: test_files = list(filter(regex.match, files)) for test_file in test_files: - with open(os.path.join(root,test_file)) as fd: + with open(os.path.join(root, test_file)) as fd: file_content = fd.read() module = ast.parse(file_content) functions = [node for node in module.body if isinstance(node, ast.FunctionDef)] From 820234928fa211367f12b8f35110ff5834c0fe35 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 10:50:40 +0200 Subject: [PATCH 035/181] style: Change few methods style. --- deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index 7825118df5..7c44ed77b8 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -78,12 +78,15 @@ def validate_fields(self, required_fields, available_fields): if not check_existance(available_fields, field): self.add_report(f"Mandatory field '{field}' is missing in the file {self.scan_file}") Sanity.LOGGER.error(f"Mandatory field '{field}' is missing in the file {self.scan_file}") + elif isinstance(required_fields[field], dict) or isinstance(required_fields[field], list): self.validate_fields(required_fields[field], available_fields) + elif isinstance(required_fields, list): for field in required_fields: if isinstance(field, dict) or isinstance(field, list): self.validate_fields(field, available_fields) + else: if not check_existance(available_fields, field): self.add_report(f"Mandatory field '{field}' is missing in the file {self.scan_file}") @@ -142,11 +145,13 @@ def count_project_tests(self): for (root, *_, files) in os.walk(self.conf.project_path, topdown=True): for regex in file_regexes: test_files = list(filter(regex.match, files)) + for test_file in test_files: with open(os.path.join(root, test_file)) as fd: file_content = fd.read() module = ast.parse(file_content) functions = [node for node in module.body if isinstance(node, ast.FunctionDef)] + for function in functions: for regex in function_regexes: if regex.match(function.name): From 47e45e6e189b8f454267b61e162ac0f8a64ea783 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 11:15:46 +0200 Subject: [PATCH 036/181] doc: Add `utils.py` documentation. #1899 Also style fixed. --- .../wazuh_testing/qa_docs/lib/utils.py | 125 +++++++++++------- 1 file changed, 77 insertions(+), 48 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py index 521b4b1b5d..747b8b75bb 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py @@ -1,51 +1,57 @@ -""" -brief: Wazuh DocGeneratot utils module -copyright: Copyright (C) 2015-2021, Wazuh Inc. -date: August 02, 2021 -license: This program is free software; you can redistribute it - and/or modify it under the terms of the GNU General Public - License (version 2) as published by the FSF - Free Software Foundation. -""" +# Copyright (C) 2015-2021, Wazuh Inc. +# Created by Wazuh, Inc. . +# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 -import os, shutil +import os +import shutil from wazuh_testing.qa_docs import QADOCS_LOGGER from wazuh_testing.tools.logging import Logging utils_logger = Logging.get_logger(QADOCS_LOGGER) + def check_existance(source, key): - """ - brief: Checks recursively if a key exists into a dictionary. - args: - - "source (dict): The source dictionary where the key should be found." - - "key (string): The name of the key to look into the source dictionary." + """Check recursively if a key exists into a dictionary. + + Args: + source: The source dictionary where the key should be found. + key: A string with the name of the key to look into the source dictionary. + + Returns: + A boolean with True if it exists. False otherwise. """ if not isinstance(source, dict) and not isinstance(source, list): return False if key in source: return True + elif isinstance(source, dict): for item in source: if check_existance(source[item], key): return True + return False + elif isinstance(source, list): for item in source: if check_existance(item, key): return True + return False + else: return False + def remove_inexistent(source, check_list, stop_list=None): - """ - brief: Checks recursively if a source dictionary contains invalid keys that must be deleted. - args: - - "source (dict): The source dictionary where the key should be found." - - "check_list (dict): Dictionary with all the valid keys." - - "check_list (list): Keys where the recursive must finish" + """Check recursively if a source dictionary contains invalid keys that must be deleted. + + Args: + source: The source dictionary where the key should be found. + check_list: A dictionary with all the valid keys. + stop_list: A list with the keys that ends the recursivity. """ for element in list(source): if stop_list and element in stop_list: @@ -56,21 +62,29 @@ def remove_inexistent(source, check_list, stop_list=None): elif isinstance(source[element], dict): remove_inexistent(source[element], check_list, stop_list) + def get_keys_dict(_dic): - """ - brief: Flat a dictionary into a list of its keys. - args: - - "_dic (dict): The source dictionary to be flattened." + """Flat a dictionary into a list of its keys. + + Args: + _dic: The source dictionary to be flattened." + + Returns: + A list of flattened keys. If there is only a key, that one is returned. """ keys = [] + for item in _dic: value = _dic[item] + if isinstance(value, dict): result = get_keys_dict(value) - keys.append({item : result}) + keys.append({item: result}) + elif isinstance(value, list): result = get_keys_list(value) - keys.append({item : result}) + keys.append({item: result}) + else: keys.append(item) @@ -79,20 +93,27 @@ def get_keys_dict(_dic): else: return keys + def get_keys_list(_list): - """ - brief: Flat a list of dictionaries into a list of its keys. - args: - - "_list (list): The source list to be flattened." + """Flat a list of dictionaries into a list of its keys. + + Args: + _list: The source list to be flattened. + + Returns: + A list of flattened keys. If there is only a key, that one is returned. """ keys = [] + for item in _list: if isinstance(item, dict): result = get_keys_dict(item) keys.append(result) + elif isinstance(item, list): result = get_keys_list(item) keys.append(result) + else: keys.append(item) @@ -101,14 +122,17 @@ def get_keys_list(_list): else: return keys + def find_item(search_item, check): - """ - brief: Search for a specific key into a list of dictionaries or values. - args: - - "search_item (string): The key to be found." - - "check (list): A list of dictionaries or values where the key should be found." - returns: None if the key couldn´t be found. The value of the finding. - """ + """Search for a specific key into a list of dictionaries or values. + + Args: + search_item: A string that contains the key to be found. + check: A list of dictionaries or values where the key should be found. + + Returns: + The value of the finding. None if the key could not be found. +""" for item in check: if isinstance(item, dict): list_element = list(item.keys()) @@ -120,12 +144,16 @@ def find_item(search_item, check): return None + def check_missing_field(source, check): - """ - brief: Checks recursively if a source dictionary contains all the expected keys. - args: - - "source (dict): The source dictionary where the key should be found." - - "check (list): The expected keys." + """Check recursively if a source dictionary contains all the expected keys. + + Args: + source: The source dictionary where the key should be found. + check: A list with the expected keys. + + Returns: + If not found, the missing key is returned. None otherwise. """ missing_filed = None @@ -135,7 +163,7 @@ def check_missing_field(source, check): found_item = find_item(key, check) if not found_item: - print(f"Missing key {source_field}") + utils_logger.warning(f"Missing key {source_field}") return key missing_filed = check_missing_field(source_field[key], found_item) @@ -157,16 +185,17 @@ def check_missing_field(source, check): found_item = find_item(source_field, check) if not found_item: - print(f"Missing key {source_field}") + utils_logger.warning(f"Missing key {source_field}") return source_field return missing_filed + def clean_folder(folder): - """ - brief: Completely cleans the content of a folder. - args: - - "folder (string): The path of the folder to be cleaned." + """Completely clean the content of a folder. + + Args: + folder: A string with the path of the folder to be cleaned. """ if not os.path.exists(folder): return From d787b3d3918e1c05c2a660bd15d3699a7cee4d77 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 11:20:00 +0200 Subject: [PATCH 037/181] style: Add missing new line to qa-docs `__init__.py` --- deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py b/deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py index 7eb8332720..2a87528c3f 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/__init__.py @@ -1 +1 @@ -QADOCS_LOGGER = 'qadocs' \ No newline at end of file +QADOCS_LOGGER = 'qadocs' From 423a80ecc40e4b0b0c9db9b98e0758a4ea761a52 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 11:37:33 +0200 Subject: [PATCH 038/181] style: Remove third person usage from `qa_docs.py` documentation description. --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index eff01a1ba7..7b26c20ab9 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -24,7 +24,7 @@ def set_qadocs_logger_level(logging_level): - """Sets the QADOCS logger lever depending on the level specified by the user. + """Set the QADOCS logger lever depending on the level specified by the user. Args: logging_level (string): Level used to initialize the logger. @@ -36,13 +36,13 @@ def set_qadocs_logger_level(logging_level): def validate_parameters(parameters): - """Validates the parameters that qa-docs recieves. + """Validate the parameters that qa-docs recieves. Since `config.yaml` will be `schema.yaml`, it runs as config file is correct. So we only validate the parameters that the user introduces. Args: - parameters (list): List of input args. + parameters: A list of input args. """ qadocs_logger.debug('Validating input parameters') From 01f8b7b327160f1cf119f2155d874fccc598095f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 12:10:34 +0200 Subject: [PATCH 039/181] doc: Remove third person usage in `index_data.py` and `sanity.py`. --- deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py | 4 ++-- deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index d322cd6ab3..6e5edf344b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -71,12 +71,12 @@ def read_files_content(self, files): self.output.append(lines) def remove_index(self): - """Deletes an index.""" + """Delete an index.""" delete = self.es.indices.delete(index=self.index, ignore=[400, 404]) IndexData.LOGGER.info(f'Delete index {self.index}\n {delete}\n') def run(self): - """Collects all the documentation files and makes a request to the BULK API to index the new data.""" + """Collect all the documentation files and makes a request to the BULK API to index the new data.""" self.test_connection() files = self.get_files() self.read_files_content(files) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index 7c44ed77b8..b74ce75562 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -101,7 +101,7 @@ def validate_module_fields(self, fields): self.validate_fields(self.conf.module_fields.mandatory, fields) def validate_test_fields(self, fields): - """Checks if all the mandatory test fields are present. + """Check if all the mandatory test fields are present. Args: fields: A dictionary that contains the test fields found in the documentation file. From 01ba67c30bbcf75f28afc3dec057bd8d165f6232 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 12:25:07 +0200 Subject: [PATCH 040/181] refac: Rename `code_file` variable to `path`. Not a good name. --- .../wazuh_testing/qa_docs/lib/code_parser.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 6a33afc132..2653030632 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -97,33 +97,33 @@ def parse_comment(self, function): return doc - def parse_test(self, code_file, id, group_id): + def parse_test(self, path, id, group_id): """Parse the content of a test file. Args: - code_file: A string with the path of the test file to be parsed. + path: A string with the path of the test file to be parsed. id: An integer with the ID of the new test document. group_id: An integer with the ID of the group where the new test document belongs. Returns: A dictionary with the documentation block parsed with module and tests fields. """ - CodeParser.LOGGER.debug(f"Parsing test file '{code_file}'") - self.scan_file = code_file - with open(code_file) as fd: + CodeParser.LOGGER.debug(f"Parsing test file '{path}'") + self.scan_file = path + with open(path) as fd: file_content = fd.read() module = ast.parse(file_content) functions = [node for node in module.body if isinstance(node, ast.FunctionDef)] module_doc = self.parse_comment(module) if module_doc: - module_doc['name'] = os.path.basename(code_file) + module_doc['name'] = os.path.basename(path) module_doc['id'] = id module_doc['group_id'] = group_id test_cases = None if self.conf.test_cases_field: - test_cases = self.pytest.collect_test_cases(code_file) + test_cases = self.pytest.collect_test_cases(path) functions_doc = [] for function in functions: From bc05b1c40cf221a3dd219cd9cb8c8560793f3413 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 13:42:23 +0200 Subject: [PATCH 041/181] doc: Add type in `Attributes` and `Args`. --- .../wazuh_testing/qa_docs/doc_generator.py | 53 ++++++++++--------- .../wazuh_testing/qa_docs/lib/code_parser.py | 34 ++++++------ .../wazuh_testing/qa_docs/lib/config.py | 34 ++++++------ .../wazuh_testing/qa_docs/lib/index_data.py | 24 ++++++--- .../wazuh_testing/qa_docs/lib/pytest_wrap.py | 11 ++-- .../wazuh_testing/qa_docs/lib/sanity.py | 36 ++++++------- .../wazuh_testing/qa_docs/lib/utils.py | 34 ++++++------ .../wazuh_testing/scripts/qa_docs.py | 2 +- 8 files changed, 119 insertions(+), 109 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 884ae1c755..24c851b746 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -23,11 +23,11 @@ class DocGenerator: that matches a include regex, is parsed. Attributes: - conf: A `Config` instance with data loaded from config file. - parser: A `CodeParser` instance with parsing utilities. - __id_counter: An integer that counts the test/group ID when it is created. - ignore_regex: A list with compiled paths to be ignored. - include_regex: A list with regular expressions used to parse a file or not. + conf (Config): A `Config` instance with data loaded from config file. + parser (CodeParser): A `CodeParser` instance with parsing utilities. + __id_counter (int): An integer that counts the test/group ID when it is created. + ignore_regex (list): A list with compiled paths to be ignored. + include_regex (list): A list with regular expressions used to parse a file or not. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -37,7 +37,7 @@ def __init__(self, config): Initialize every attribute. Args: - config: A `Config` instance with the loaded data from config file. + config (Config): A `Config` instance with the loaded data from config file. """ self.conf = config self.parser = CodeParser(self.conf) @@ -56,10 +56,10 @@ def is_valid_folder(self, path): That happens when is not ignored using the ignore list. Args: - path: A string that contains the folder location to be controlled + path (str): A string that contains the folder location to be controlled Return: - A boolean with False if the path should be ignored. True otherwise. + boolean: A boolean with False if the path should be ignored. True otherwise. """ for regex in self.ignore_regex: if regex.match(path): @@ -74,10 +74,10 @@ def is_valid_file(self, file): Also, that file could be ignored(because it is in the ignore list or does not match with the regexes). Args: - file: A string that contains the file name to be checked. + file (str): A string that contains the file name to be checked. Returns: - A boolean with True when matches with an include regex. False if the file should be ignored + boolean: A boolean with True when matches with an include regex. False if the file should be ignored (because it matches with an ignore path or does not match with any include regular expression). """ for regex in self.ignore_regex: @@ -96,10 +96,10 @@ def is_group_file(self, path): """Check if a file path should be considered as a file containing group information. Args: - path: A string that contains the file name to be checked + path (str): A string that contains the file name to be checked Returns: - A boolean with True if the file is a group file. False otherwise." + boolean: A boolean with True if the file is a group file. False otherwise." """ for group_file in self.conf.group_files: if path == group_file: @@ -110,7 +110,8 @@ def is_group_file(self, path): def get_group_doc_path(self, group): """Get the name of the group file in the documentation output based on the original file name. - Returns: A string that contains the name of the documentation group file. + Returns: + doc_path (str): A string that contains the name of the documentation group file. """ base_path = os.path.join(self.conf.documentation_path, os.path.basename(self.scan_path)) doc_path = os.path.join(base_path, group['name']+".group") @@ -121,10 +122,10 @@ def get_test_doc_path(self, path): """Get the name of the test file in the documentation output based on the original file name. Args: - path: A string that contains the original file name. + path (str): A string that contains the original file name. Returns: - A string with the name of the documentation test file. + doc_path (str): A string with the name of the documentation test file. """ base_path = os.path.join(self.conf.documentation_path, os.path.basename(self.scan_path)) @@ -139,8 +140,8 @@ def dump_output(self, content, doc_path): Also, create the containing folder if it does not exist. Args: - content: A dict that contains the parsed content of a test file. - doc_path: A string with the path where the information should be dumped. + content (dict): A dict that contains the parsed content of a test file. + doc_path (str): A string with the path where the information should be dumped. Raises: QAValueError: Cannot write in {doc_path}.json @@ -168,11 +169,11 @@ def create_group(self, path, group_id): """Parse the content of a group file and dump the content into a file. Args: - path: A string with the path of the group file to be parsed. - group_id: A string with the id of the group where the new group belongs. + path (str): A string with the path of the group file to be parsed. + group_id (str): A string with the id of the group where the new group belongs. Returns: - An integer with the ID of the newly generated group document. + __id.counter (int): An integer with the ID of the newly generated group document. None if the test does not have documentation. """ self.__id_counter = self.__id_counter + 1 @@ -191,11 +192,11 @@ def create_test(self, path, group_id): """Parse the content of a test file and dumps the content into a file. Args: - path: A string with the path of the test file to be parsed. - group_id: A string with the id of the group where the new test belongs. + path (str): A string with the path of the test file to be parsed. + group_id (str): A string with the id of the group where the new test belongs. Returns: - An integer with the ID of the new generated test document. + __id.counter (int): An integer with the ID of the new generated test document. None if the test does not have documentation. """ self.__id_counter = self.__id_counter + 1 @@ -221,8 +222,8 @@ def parse_folder(self, path, group_id): """Search in a specific folder to parse possible group files and each test file. Args: - path: A string with the path of the folder to be parsed. - group_id: A string with the id of the group where the new elements belong. + path (str): A string with the path of the folder to be parsed. + group_id (str): A string with the id of the group where the new elements belong. """ if not os.path.exists(path): DocGenerator.LOGGER.warning(f"Include path '{path}' doesn´t exist") @@ -250,7 +251,7 @@ def locate_test(self): """Get the test path when a test is specified by the user. Returns: - A string with the test path. + str: A string with the test path. """ complete_test_name = f"{self.conf.test_name}.py" DocGenerator.LOGGER.info(f"Looking for {complete_test_name}") diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 2653030632..28be272261 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -20,9 +20,9 @@ class CodeParser: """Class that parses the content of the test files. Attributes: - conf: A `Config` instance with the config file data. - pytest: A `PytestWrap` instance to wrap the pytest execution. - function_regexes: A list of strings(regular expressions) used to find test functions.. + conf (Config): A `Config` instance with the config file data. + pytest (PytestWrap): A `PytestWrap` instance to wrap the pytest execution. + function_regexes (list): A list of regular expressions used to find test functions. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -32,7 +32,7 @@ def __init__(self, config): Initialize every attribute. Args: - config: A `Config` instance with the loaded data from the config file. + config (Config): A `Config` instance with the loaded data from the config file. """ self.conf = config self.pytest = PytestWrap() @@ -44,10 +44,10 @@ def is_documentable_function(self, function): """Check if a specific method matches with the regexes to be documented. Args: - function: Function class(_ast.FunctionDef) with all the information of the method. + function (_ast.FunctionDef): Function class with all the information of the method. Returns: - A boolean with True if the method should be documented. False otherwise + boolean: A boolean with True if the method should be documented. False otherwise """ for regex in self.function_regexes: if regex.match(function.name): @@ -58,7 +58,7 @@ def remove_ignored_fields(self, doc): """Remove the fields from a parsed test file to delete the fields that are not mandatory or optional. Args: - doc: A dict that contains the parsed documentation block" + doc (dict): A dict that contains the parsed documentation block" """ allowed_fields = self.conf.module_fields.mandatory + self.conf.module_fields.optional + INTERNAL_FIELDS remove_inexistent(doc, allowed_fields, STOP_FIELDS) @@ -73,10 +73,10 @@ def parse_comment(self, function): """Parse one self-contained documentation block. Args: - function: Function class(_ast.FunctionDef) with all the information of the method" + function (_ast.FunctionDef): Function class with all the information of the method" Returns: - A dictionary with the documentation block parsed. + doc (dict): A dictionary with the documentation block parsed. """ docstring = ast.get_docstring(function) @@ -101,12 +101,12 @@ def parse_test(self, path, id, group_id): """Parse the content of a test file. Args: - path: A string with the path of the test file to be parsed. - id: An integer with the ID of the new test document. - group_id: An integer with the ID of the group where the new test document belongs. + path (str): A string with the path of the test file to be parsed. + id (str): An integer with the ID of the new test document. + group_id (int): An integer with the ID of the group where the new test document belongs. Returns: - A dictionary with the documentation block parsed with module and tests fields. + module_doc (dict): A dictionary with the documentation block parsed with module and tests fields. """ CodeParser.LOGGER.debug(f"Parsing test file '{path}'") self.scan_file = path @@ -150,12 +150,12 @@ def parse_group(self, group_file, id, group_id): """Parse the content of a group file. Args: - group_file: A string with the path of the group file to be parsed. - id: An integer with the ID of the new test document. - group_id: An integer with the ID of the group where the new test document belongs. + group_file (str): A string with the path of the group file to be parsed. + id (int): An integer with the ID of the new test document. + group_id (int): An integer with the ID of the group where the new test document belongs. Returns: - A dictionary with the parsed information from `group_file`. + group_doc (dict): A dictionary with the parsed information from `group_file`. """ MD_HEADER = "# " CodeParser.LOGGER.debug(f"Parsing group file '{group_file}'") diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index cecafcf9db..da6bcfdcef 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -20,17 +20,17 @@ class Config(): https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks Attributes: - mode: An enumeration that stores the `doc_generator` mode when it is running. - project_path: A string that specifies the path where the tests to parse are located. - include_paths: A list of strings that contains the directories to parse. - include_regex: A list of strings(regular expressions) used to find test files. - group_files: A string that specifies the group definition file. - function_regex: A list of strings(regular expressions) used to find test functions. - ignore_paths: A string that specifies which paths will be ignored. - module_fields: A struct that contains the module documentation data. - test_fields: A struct that contains the test documentation data. - test_cases_field: A string that contains the test cases key. - LOGGER: A custom qa-docs logger. + mode (Mode): An enumeration that stores the `doc_generator` mode when it is running. + project_path (str): A string that specifies the path where the tests to parse are located. + include_paths (str): A list of strings that contains the directories to parse. + include_regex (str): A list of strings(regular expressions) used to find test files. + group_files (str): A string that specifies the group definition file. + function_regex (list): A list of regular expressions used to find test functions. + ignore_paths (str): A string that specifies which paths will be ignored. + module_fields (_fields): A struct that contains the module documentation data. + test_fields (_fields): A struct that contains the test documentation data. + test_cases_field (_fields): A string that contains the test cases key. + LOGGER (_fields): A custom qa-docs logger. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -46,10 +46,10 @@ def __init__(self, config_path, test_dir, output_path='', test_name=None): you pass an output path, it has no effect in `default mode`. Args: - config_path: A string that contains the config file path. - test_dir: A string that contains the path of the tests. - output_path: A string that contains the doc output path. - test_name: A string that represents a test name. + config_path (str): A string that contains the config file path. + test_dir (str): A string that contains the path of the tests. + output_path (str): A string that contains the doc output path. + test_name (str): A string that represents a test name. """ self.mode = Mode.DEFAULT self.project_path = test_dir @@ -83,7 +83,7 @@ def __read_config_file(self, file): """Read configuration file. Raises: - QAValuerError: Cannot load config file. + QAValuerError (IOError): Cannot load config file. """ try: Config.LOGGER.debug('Loading config file') @@ -296,7 +296,7 @@ class Mode(Enum): mode = Mode.DEFAULT Args: - Enum: Base class for creating enumerated constants. + Enum (Class): Base class for creating enumerated constants. """ DEFAULT = 1 SINGLE_TEST = 2 diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 6e5edf344b..69616db307 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -17,11 +17,11 @@ class IndexData: """Class that indexes the data from JSON files into ElasticSearch. Attributes: - path: A string that contains the path where the parsed documentation is located. - index: A string with the index name to be indexed with Elasticsearch. + path (str): A string that contains the path where the parsed documentation is located. + index (str): A string with the index name to be indexed with Elasticsearch. regex: A regular expression to get JSON files. - es: An `ElasticSearch` client instance. - output: A list to be indexed in Elasticsearch. + es (ElasticSearch): An `ElasticSearch` client instance. + output (list): A list to be indexed in Elasticsearch. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -31,7 +31,7 @@ def __init__(self, index, config): Initialize every attribute. Args: - config: A `Config` instance with the loaded data from the config file. + config (Config): A `Config` instance with the loaded data from the config file. """ self.path = config.documentation_path self.index = index @@ -40,7 +40,11 @@ def __init__(self, index, config): self.output = [] def test_connection(self): - """Verify with an HTTP request that an OK response is received from ElasticSearch.""" + """Verify with an HTTP request that an OK response is received from ElasticSearch. + + Returns: + boolean: A boolean with True if the request response is OK. + """ try: res = requests.get("http://localhost:9200/_cluster/health") if res.status_code == 200: @@ -49,7 +53,11 @@ def test_connection(self): raise QAValueError(f"Connection error: {exception}", IndexData.LOGGER.error) def get_files(self): - """Find all the files inside the documentation path that matches with the JSON regex.""" + """Find all the files inside the documentation path that matches with the JSON regex. + + Returns: + doc_files (list): A list with all the files inside the path. + """ doc_files = [] for (root, *_, files) in os.walk(self.path): @@ -63,7 +71,7 @@ def read_files_content(self, files): """Open every file found in the path and appends the content into a list. Args: - files: A list with the files that matched with the regex. + files (list): A list with the files that matched with the regex. """ for file in files: with open(file) as test_file: diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py index a5d454876d..663ff378b0 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/pytest_wrap.py @@ -12,7 +12,7 @@ class PytestPlugin: """Plugin to extract information from a pytest execution. Attributes: - collected: A list with the collected data from pytest execution + collected (list): A list with the collected data from pytest execution """ def __init__(self): self.collected = [] @@ -21,7 +21,7 @@ def pytest_collection_modifyitems(self, items): """Callback to receive the output of a pytest execution. Args: - items: A list with the metadata from each test case. + items (list): A list with the metadata from each test case. """ for item in items: self.collected.append(item.nodeid) @@ -31,7 +31,7 @@ class PytestWrap: """Class that wraps the execution of pytest. Attributes: - plugin: A `PytestPlugin` instance. + plugin (PytestPlugin): A `PytestPlugin` instance. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -42,9 +42,10 @@ def collect_test_cases(self, path): """Execute pytest in 'collect-only' mode to extract all the test cases found for a test file. Args: - path: A string with the path of the test file to extract the test cases. + path (str): A string with the path of the test file to extract the test cases. - Returns: A dictionary that contains the pytest parsed output. + Returns: + outpout (dict): A dictionary that contains the pytest parsed output. """ PytestWrap.LOGGER.debug(f"Running pytest to collect test cases for '{path}'") pytest.main(['--collect-only', "-qq", path], plugins=[self.plugin]) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index b74ce75562..6f46e7665b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -19,13 +19,13 @@ class Sanity(): It is in charge of walk every documentation file, and every group file to dump the parsed documentation. Attributes: - conf: A `Config` instance with the loaded data from the config file. - files_regex: A regular expression to get the JSON files previously generated. - error_reports: A list that contains all the errors obtained within the check. - found_tags: A set with all the tags found within the check. - found_tests: A set with all the tests found within the check. - found_modules: A set with all the modules found within the check. - project_tests: An integer that contains the tests count within the project folder. + conf (Config): A `Config` instance with the loaded data from the config file. + files_regex (re): A regular expression to get the JSON files previously generated. + error_reports (list): A list that contains all the errors obtained within the check. + found_tags (set): A set with all the tags found within the check. + found_tests (set): A set with all the tests found within the check. + found_modules (set): A set with all the modules found within the check. + project_tests (int): An integer that contains the tests count within the project folder. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -35,7 +35,7 @@ def __init__(self, config): Initialize every attribute. Args: - config: A `Config` instance with the loaded data from the config file. + config (Config): A `Config` instance with the loaded data from the config file. """ self.conf = config self.files_regex = re.compile("^(?!.*group)test.*json$", re.IGNORECASE) @@ -49,13 +49,13 @@ def get_content(self, full_path): """Load a documentation file into a JSON dictionary. Args: - full_path: A string with the documentation file. + full_path (str): A string with the documentation file. Returns: - Test file content. + dict: A dictionary with the test file content. Raises: - IOError: Cannot load '{full_path}' file for sanity check. + QAValueError (IOError): Cannot load '{full_path}' file for sanity check. """ try: with open(full_path) as file: @@ -70,8 +70,8 @@ def validate_fields(self, required_fields, available_fields): If a required field is not found, the error is logged and written in a report structure for future print. Args: - required_fields: A dictionary that contains the fields that must exist. - available_fields: A dictionary that contains the fields found into the documentation file. + required_fields (dict): A dictionary that contains the fields that must exist. + available_fields (dict): A dictionary that contains the fields found into the documentation file. """ if isinstance(required_fields, dict): for field in required_fields: @@ -96,7 +96,7 @@ def validate_module_fields(self, fields): """Check if all the mandatory module fields are present. Args: - fields: A dictionary that contains the module fields found in the documentation file. + fields (dict): A dictionary that contains the module fields found in the documentation file. """ self.validate_fields(self.conf.module_fields.mandatory, fields) @@ -104,7 +104,7 @@ def validate_test_fields(self, fields): """Check if all the mandatory test fields are present. Args: - fields: A dictionary that contains the test fields found in the documentation file. + fields (dict): A dictionary that contains the test fields found in the documentation file. """ if 'tests' in fields: for test_fields in fields['tests']: @@ -114,7 +114,7 @@ def identify_tags(self, content): """Identify every new tag found in the documentation files and saves it for future reporting. Args: - content: A dictionary that contains the dictionary content of a documentation file. + content (dict): A dictionary that contains the dictionary content of a documentation file. """ if 'metadata' in content and 'tags' in content['metadata']: for tag in content['metadata']['tags']: @@ -124,7 +124,7 @@ def identify_tests(self, content): """Identify every new test found in the documentation files and saves it for future reporting. Args: - content: A dictionary that contains the dictionary content of a documentation file. + content (dict): A dictionary that contains the dictionary content of a documentation file. """ if 'tests' in content: for test in content['tests']: @@ -161,7 +161,7 @@ def add_report(self, message): """Add a new entry to the report. Args: - message: A string that contains the message to be included in the report. + message(str): A string that contains the message to be included in the report. """ self.error_reports.append(message) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py index 747b8b75bb..e8b04af78b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/utils.py @@ -15,11 +15,11 @@ def check_existance(source, key): """Check recursively if a key exists into a dictionary. Args: - source: The source dictionary where the key should be found. - key: A string with the name of the key to look into the source dictionary. + source (dict): The source dictionary where the key should be found. + key (str): A string with the name of the key to look into the source dictionary. Returns: - A boolean with True if it exists. False otherwise. + boolean: A boolean with True if it exists. False otherwise. """ if not isinstance(source, dict) and not isinstance(source, list): return False @@ -49,9 +49,9 @@ def remove_inexistent(source, check_list, stop_list=None): """Check recursively if a source dictionary contains invalid keys that must be deleted. Args: - source: The source dictionary where the key should be found. - check_list: A dictionary with all the valid keys. - stop_list: A list with the keys that ends the recursivity. + source (dict): The source dictionary where the key should be found. + check_list (dict): A dictionary with all the valid keys. + stop_list (list): A list with the keys that ends the recursivity. """ for element in list(source): if stop_list and element in stop_list: @@ -67,10 +67,10 @@ def get_keys_dict(_dic): """Flat a dictionary into a list of its keys. Args: - _dic: The source dictionary to be flattened." + _dic (dict): The source dictionary to be flattened." Returns: - A list of flattened keys. If there is only a key, that one is returned. + keys (list): A list of flattened keys. If there is only a key, that one is returned. """ keys = [] @@ -98,10 +98,10 @@ def get_keys_list(_list): """Flat a list of dictionaries into a list of its keys. Args: - _list: The source list to be flattened. + _list (list): The source list to be flattened. Returns: - A list of flattened keys. If there is only a key, that one is returned. + keys (list): A list of flattened keys. If there is only a key, that one is returned. """ keys = [] @@ -127,11 +127,11 @@ def find_item(search_item, check): """Search for a specific key into a list of dictionaries or values. Args: - search_item: A string that contains the key to be found. - check: A list of dictionaries or values where the key should be found. + search_item (str): A string that contains the key to be found. + check (list): A list of dictionaries or values where the key should be found. Returns: - The value of the finding. None if the key could not be found. + item (str): The value of the finding. None if the key could not be found. """ for item in check: if isinstance(item, dict): @@ -149,11 +149,11 @@ def check_missing_field(source, check): """Check recursively if a source dictionary contains all the expected keys. Args: - source: The source dictionary where the key should be found. - check: A list with the expected keys. + source (dict): The source dictionary where the key should be found. + check (list): A list with the expected keys. Returns: - If not found, the missing key is returned. None otherwise. + str: If not found, the missing key is returned. None otherwise. """ missing_filed = None @@ -195,7 +195,7 @@ def clean_folder(folder): """Completely clean the content of a folder. Args: - folder: A string with the path of the folder to be cleaned. + folder (str): A string with the path of the folder to be cleaned. """ if not os.path.exists(folder): return diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 7b26c20ab9..92d33f78ec 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -42,7 +42,7 @@ def validate_parameters(parameters): So we only validate the parameters that the user introduces. Args: - parameters: A list of input args. + parameters (list): A list of input args. """ qadocs_logger.debug('Validating input parameters') From 883f8ba34c9f921fe665210b57a48faf000cf584 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 20 Sep 2021 14:04:10 +0200 Subject: [PATCH 042/181] add: Add `path` autogeneration. --- .../wazuh_testing/qa_docs/config.yaml | 2 ++ .../wazuh_testing/qa_docs/doc_generator.py | 7 +++++-- .../wazuh_testing/qa_docs/lib/code_parser.py | 15 ++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml index 4fbb845326..76859965e3 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml @@ -36,6 +36,7 @@ Output fields: Module: Mandatory: - brief + - path - category - modules - daemons @@ -59,6 +60,7 @@ Output fields: Test cases field: test_cases Module info: + - path: path - test_system: os_platform - test_vendor: os_vendor - test_version: os_version diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 144d70dd12..eeaf97a916 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -231,11 +231,12 @@ def print_test_info(self, test): brief: Print the test info to standard output. If an output path is specified, the output is redirected to `output_path/test_info.json`. """ + relative_path = re.sub(r'.*wazuh-qa\/', '', self.test_path) + # dump into file if self.conf.documentation_path: test_info = {} - # Need to be changed, it is hardcoded - test_info['test_path'] = self.test_path[6:] + test_info['path'] = relative_path for field in self.conf.module_info: for name, schema_field in field.items(): @@ -255,6 +256,8 @@ def print_test_info(self, test): fp.write('\n') else: # Use the key that QACTL needs + test['path'] = relative_path + for field in self.conf.module_info: for name, schema_field in field.items(): print(str(name)+": "+str(test[schema_field])) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 981f6b99c2..b2c24d0095 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -81,30 +81,31 @@ def parse_comment(self, function): return doc - def parse_test(self, code_file, id, group_id): + def parse_test(self, path, id, group_id): """ brief: Parses the content of a test file. args: - -"code_file (string): Path of the test file to be parsed." + -"path (string): Path of the test file to be parsed." -"id (integer): Id of the new test document" -"group_id (integer): Id of the group where the new test document belongs." """ - CodeParser.LOGGER.debug(f"Parsing test file '{code_file}'") - self.scan_file = code_file - with open(code_file) as fd: + CodeParser.LOGGER.debug(f"Parsing test file '{path}'") + self.scan_file = path + with open(path) as fd: file_content = fd.read() module = ast.parse(file_content) functions = [node for node in module.body if isinstance(node, ast.FunctionDef)] module_doc = self.parse_comment(module) if module_doc: - module_doc['name'] = os.path.basename(code_file) + module_doc['name'] = os.path.basename(path) module_doc['id'] = id module_doc['group_id'] = group_id + module_doc['path'] = re.sub(r'.*wazuh-qa\/', '', path) test_cases = None if self.conf.test_cases_field: - test_cases = self.pytest.collect_test_cases(code_file) + test_cases = self.pytest.collect_test_cases(path) functions_doc = [] for function in functions: From f4f68423024d7ad1c4e6d53ac2fbf4b96614af89 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 21 Sep 2021 10:57:24 +0200 Subject: [PATCH 043/181] refac: Refactorize `create_test` and `print_test_info`. #1864 Now when a user wants to parse a single test, the info is printed or dumped into a file if an output directory is specified, but now reutilizing the `dump_output` method to create the file and the print method only prints the related test info. The test info that is printed now is the same that is dumped. --- .../wazuh_testing/qa_docs/doc_generator.py | 63 +++++++------------ 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 41fe5c7056..87bc3f7c43 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -6,6 +6,7 @@ import re import json import yaml +import pprint from wazuh_testing.qa_docs.lib.config import Mode from wazuh_testing.qa_docs.lib.code_parser import CodeParser @@ -154,7 +155,7 @@ def dump_output(self, content, doc_path): try: DocGenerator.LOGGER.debug(f"Writing {doc_path}.json") with open(doc_path + ".json", "w+") as out_file: - out_file.write(json.dumps(content, indent=4)) + out_file.write(("{}\n".format(json.dumps(content, indent=4)))) except IOError: raise QAValueError(f"Cannot write in {doc_path}.json", DocGenerator.LOGGER.error) @@ -191,6 +192,14 @@ def create_group(self, path, group_id): def create_test(self, path, group_id): """Parse the content of a test file and dumps the content into a file. + Modes: + Single test: + When a single test is going to be parsed, if it has not an output directory, the content is printed. + If it has an output dir, the content is dumped into that dir. + + Default: + The content is dumped into the corresponding directory. + Args: path (str): A string with the path of the test file to be parsed. group_id (str): A string with the id of the group where the new test belongs. @@ -205,11 +214,17 @@ def create_test(self, path, group_id): if test: if self.conf.mode == Mode.DEFAULT: doc_path = self.get_test_doc_path(path) + elif self.conf.mode == Mode.SINGLE_TEST: doc_path = self.conf.documentation_path - if self.print_test_info(test) is None: - # When only a test is parsed, exit + + # If the user does not specify an output dir + if not doc_path: + self.print_test_info(test) return + # If the user specifies an output dir + else: + doc_path = os.path.join(doc_path, self.conf.test_name) self.dump_output(test, doc_path) DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' was created with ID:{self.__id_counter}") @@ -265,51 +280,15 @@ def locate_test(self): return None def print_test_info(self, test): - """Print the test info to standard output. If an output path is specified by the user, - the output is redirected to `output_path/{test_name}.json`. - - Return None to avoid the default parsing behaviour. - This method will change, will only print the test data but the JSON will be generated using `dump_output`. + """Print the test info to standard output. Args: test: A dict with the parsed test data """ relative_path = re.sub(r'.*wazuh-qa\/', '', self.test_path) + test['path'] = relative_path - # dump into file - if self.conf.documentation_path: - test_info = {} - test_info['path'] = relative_path - - for field in self.conf.module_info: - for name, schema_field in field.items(): - test_info[name] = test[schema_field] - - for field in self.conf.test_info: - for name, schema_field in field.items(): - test_info[name] = test['tests'][0][schema_field] - - # If output path does not exist, it is created - if not os.path.exists(self.conf.documentation_path): - os.mkdir(self.conf.documentation_path) - - # Dump data - with open(os.path.join(self.conf.documentation_path, f"{self.conf.test_name}.json"), 'w') as fp: - fp.write(json.dumps(test_info, indent=4)) - fp.write('\n') - else: - # Use the key that QACTL needs - test['path'] = relative_path - - for field in self.conf.module_info: - for name, schema_field in field.items(): - print(str(name)+": "+str(test[schema_field])) - - for field in self.conf.test_info: - for name, schema_field in field.items(): - print(str(name)+": "+str(test['tests'][0][schema_field])) - - return None + print(json.dumps(test, indent=4)) def run(self): """Run a complete scan of each included path to parse every test and group found. From 7a61ccdd78a9a8db41393f0a60b9f9a4d7fc6376 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 21 Sep 2021 11:34:16 +0200 Subject: [PATCH 044/181] style: Rename `config.yaml` fields. #1864 --- .../wazuh_testing/qa_docs/config.yaml | 36 ++---- .../wazuh_testing/qa_docs/lib/config.py | 112 ++++++------------ 2 files changed, 49 insertions(+), 99 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml index 76859965e3..24f3ef75b6 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml @@ -1,4 +1,4 @@ -Include paths: +include_paths: - "integration/test_active_response" - "integration/test_api" - "integration/test_wazuh_db" @@ -6,16 +6,16 @@ Include paths: - "integration/test_remoted" - "integration/test_agentd" -Include regex: +include_regex: - "^test_.*py$" -Group files: +group_files: - "README.md" -Function regex: +function_regex: - "^test_" -Ignore paths: +ignore_paths: - "integration/test_active_response/test_execd/data" - "integration/test_api/test_config/test_cache/data" - "integration/test_api/test_config/test_cors/data" @@ -32,9 +32,9 @@ Ignore paths: - "integration/test_wazuh_db/data" - "integration/test_api/test_config/test_bruteforce_blocking_system/data" -Output fields: - Module: - Mandatory: +output_fields: + module: + mandatory: - brief - path - category @@ -45,26 +45,16 @@ Output fields: - os_vendor - os_version - tiers - Optional: + optional: - tags - Test: - Mandatory: + test: + mandatory: - description - wazuh_min_version - parameters - behaviour - expected_behaviour - Optional: + optional: - status -Test cases field: test_cases - -Module info: - - path: path - - test_system: os_platform - - test_vendor: os_vendor - - test_version: os_version - - test_target: component - -Test info: - - test_wazuh_min_version: wazuh_min_version \ No newline at end of file +test_cases_field: test_cases \ No newline at end of file diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index da6bcfdcef..4c0d9e8996 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -29,7 +29,7 @@ class Config(): ignore_paths (str): A string that specifies which paths will be ignored. module_fields (_fields): A struct that contains the module documentation data. test_fields (_fields): A struct that contains the test documentation data. - test_cases_field (_fields): A string that contains the test cases key. + test_cases_field (_fields): A string that contains the test_cases key. LOGGER (_fields): A custom qa-docs logger. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) @@ -76,8 +76,6 @@ def __init__(self, config_path, test_dir, output_path='', test_name=None): # When a name is passed, it is using just a single test. self.mode = Mode.SINGLE_TEST self.test_name = test_name - self.__read_test_info() - self.__read_module_info() def __read_config_file(self, file): """Read configuration file. @@ -92,44 +90,6 @@ def __read_config_file(self, file): except IOError: raise QAValueError('Cannot load config file', Config.LOGGER.error) - def __read_test_info(self): - """Read from the config file the keys to be printed from module info. - - This functionality is used to print any custom field(s) you want. - You can use it if you only need a few fields to parse when a single test is run. - - For example, you have this in your config.yaml: - - Test info: - - test_wazuh_min_version: wazuh_min_version - """ - Config.LOGGER.debug('Reading test info from the config file') - if 'Test info' in self._config_data: - self.test_info = self._config_data['Test info'] - else: - Config.LOGGER.warning('Cannot read test info fields') - - def __read_module_info(self): - """Read from the config file the fields to be printed from test info. - - This functionality is used to print any custom field(s) you want. - You can use it if you only need a few fields to parse when a single test is run. - - For example you have this in your config.yaml: - - Module info: - - test_system: os_platform - - test_vendor: os_vendor - - test_version: os_version - - test_target: component - """ - Config.LOGGER.debug('Reading module info from config file') - - if 'Module info' in self._config_data: - self.module_info = self._config_data['Module info'] - else: - Config.LOGGER.warning('Cannot read module info fields') - def __set_documentation_path(self, path): """Set the path of the documentation output.""" Config.LOGGER.debug('Setting the path documentation') @@ -144,10 +104,10 @@ def __read_include_paths(self): Config.LOGGER.debug('Reading include paths from config file') # Will be replaced by --type --module and --test , so you can run what you need - if 'Include paths' not in self._config_data: + if 'include_paths' not in self._config_data: raise QAValueError('The include paths field is empty in the config file', Config.LOGGER.error) - include_paths = self._config_data['Include paths'] + include_paths = self._config_data['include_paths'] for path in include_paths: self.include_paths.append(os.path.join(self.project_path, path)) @@ -160,10 +120,10 @@ def __read_include_regex(self): """ Config.LOGGER.debug('Reading the regular expressions from the config file to include test files') - if 'Include regex' not in self._config_data: + if 'include_regex' not in self._config_data: raise QAValueError('The include regex field is empty in the config file', Config.LOGGER.error) - self.include_regex = self._config_data['Include regex'] + self.include_regex = self._config_data['include_regex'] def __read_group_files(self): """Read from the config file the file name to be identified in a group. @@ -173,10 +133,10 @@ def __read_group_files(self): """ Config.LOGGER.debug('Reading group files from the config file') - if 'Group files' not in self._config_data: + if 'group_files' not in self._config_data: raise QAValueError('The group files field is empty in config file', Config.LOGGER.error) - self.group_files = self._config_data['Group files'] + self.group_files = self._config_data['group_files'] def __read_function_regex(self): """Read from the config file the regexes used to identify a test method. @@ -186,16 +146,16 @@ def __read_function_regex(self): """ Config.LOGGER.debug('Reading the regular expressions to include test methods from the config file') - if 'Function regex' not in self._config_data: + if 'function_regex' not in self._config_data: raise QAValueError('The function regex field is empty in the config file', Config.LOGGER.error) - self.function_regex = self._config_data['Function regex'] + self.function_regex = self._config_data['function_regex'] def __read_ignore_paths(self): """Read from the config file all the paths to be excluded from the parsing.""" - if 'Ignore paths' in self._config_data: - ignore_paths = self._config_data['Ignore paths'] + if 'ignore_paths' in self._config_data: + ignore_paths = self._config_data['ignore_paths'] for path in ignore_paths: self.ignore_paths.append(os.path.join(self.project_path, path)) @@ -206,24 +166,24 @@ def __read_module_fields(self): If the module block fields are not defined in the config file, an error will be raised. Raises: - QAValueError: Module fields are missing in the config file - QAValueError: Mandatory module fields are missing in the config file + QAValueError: module fields are missing in the config file + QAValueError: mandatory module fields are missing in the config file """ Config.LOGGER.debug('Reading mandatory and optional module fields from the config file') - if 'Module' not in self._config_data['Output fields']: - raise QAValueError('Module fields are missing in the config file', Config.LOGGER.error) + if 'module' not in self._config_data['output_fields']: + raise QAValueError('module fields are missing in the config file', Config.LOGGER.error) - module_fields = self._config_data['Output fields']['Module'] + module_fields = self._config_data['output_fields']['module'] - if 'Mandatory' not in module_fields and 'Optional' not in module_fields: - raise QAValueError('Mandatory module fields are missing in the config file', Config.LOGGER.error) + if 'mandatory' not in module_fields and 'optional' not in module_fields: + raise QAValueError('mandatory module fields are missing in the config file', Config.LOGGER.error) - if 'Mandatory' in module_fields: - self.module_fields.mandatory = module_fields['Mandatory'] + if 'mandatory' in module_fields: + self.module_fields.mandatory = module_fields['mandatory'] - if 'Optional' in module_fields: - self.module_fields.optional = module_fields['Optional'] + if 'optional' in module_fields: + self.module_fields.optional = module_fields['optional'] def __read_test_fields(self): """Read from the config file the optional and mandatory fields for the test functions. @@ -231,24 +191,24 @@ def __read_test_fields(self): If the test block fields are not defined in the config file, an error will be raised. Raises: - QAValueError: Test fields are missing in the config file - QAValueError: Mandatory module fields are missing in the config file + QAValueError: test_fields are missing in the config file + QAValueError: mandatory module fields are missing in the config file """ Config.LOGGER.debug('Reading mandatory and optional test fields from the config file') - if 'Test' not in self._config_data['Output fields']: - raise QAValueError('Test fields are missing in the config file', Config.LOGGER.error) + if 'test' not in self._config_data['output_fields']: + raise QAValueError('test_fields are missing in the config file', Config.LOGGER.error) - test_fields = self._config_data['Output fields']['Test'] + test_fields = self._config_data['output_fields']['test'] - if 'Mandatory' not in test_fields and 'Optional' not in test_fields: - raise QAValueError('Mandatory module fields are missing in the config file', Config.LOGGER.error) + if 'mandatory' not in test_fields and 'optional' not in test_fields: + raise QAValueError('mandatory module fields are missing in the config file', Config.LOGGER.error) - if 'Mandatory' in test_fields: - self.test_fields.mandatory = test_fields['Mandatory'] + if 'mandatory' in test_fields: + self.test_fields.mandatory = test_fields['mandatory'] - if 'Optional' in test_fields: - self.test_fields.optional = test_fields['Optional'] + if 'optional' in test_fields: + self.test_fields.optional = test_fields['optional'] def __read_output_fields(self): """Read all the mandatory and optional fields from config file. @@ -256,7 +216,7 @@ def __read_output_fields(self): Raises: QAValueError: Documentation schema not defined in the config file """ - if 'Output fields' not in self._config_data: + if 'output_fields' not in self._config_data: raise QAValueError('Documentation schema not defined in the config file', Config.LOGGER.error) self.__read_module_fields() @@ -266,8 +226,8 @@ def __read_test_cases_field(self): """Read from the configuration file the key to identify a Test Case list.""" Config.LOGGER.debug('Reading Test Case key from the config file') - if 'Test cases field' in self._config_data: - self.test_cases_field = self._config_data['Test cases field'] + if 'test_cases_field' in self._config_data: + self.test_cases_field = self._config_data['test_cases_field'] class _fields: From 55ec6119da23d47fa8bcfeea41f01ce21c647c1f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 21 Sep 2021 11:37:33 +0200 Subject: [PATCH 045/181] refac: Fix debug messages and change f-string. #1864 --- .../wazuh_testing/qa_docs/doc_generator.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 87bc3f7c43..7b0083d99b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -152,15 +152,17 @@ def dump_output(self, content, doc_path): DocGenerator.LOGGER.debug('Creating documentation folder') os.makedirs(os.path.dirname(doc_path)) + DocGenerator.LOGGER.debug(f"Writing {doc_path}.json") + try: - DocGenerator.LOGGER.debug(f"Writing {doc_path}.json") - with open(doc_path + ".json", "w+") as out_file: - out_file.write(("{}\n".format(json.dumps(content, indent=4)))) + with open(f"{doc_path}.json", 'w+') as out_file: + out_file.write(f"{json.dumps(content, indent=4)}\n") except IOError: raise QAValueError(f"Cannot write in {doc_path}.json", DocGenerator.LOGGER.error) + DocGenerator.LOGGER.debug(f"Writing {doc_path}.yaml") + try: - DocGenerator.LOGGER.debug(f"Writing {doc_path}.yaml") with open(doc_path + ".yaml", "w+") as out_file: out_file.write(yaml.dump(content)) except IOError: From 529e1f78554eda11cde31f44cccd62ec0b1579fa Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 22 Sep 2021 10:18:54 +0200 Subject: [PATCH 046/181] add: `-T`,`--tests` and `-e`,`--exist` options receives now a list of test names. #1864 Now these options use a list of names to parse them or check if they exist. --- .../wazuh_testing/qa_docs/doc_generator.py | 33 +++++++++++-------- .../wazuh_testing/qa_docs/lib/config.py | 8 ++--- .../wazuh_testing/scripts/qa_docs.py | 31 +++++++++-------- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 7b0083d99b..9e810d4fe1 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -191,7 +191,7 @@ def create_group(self, path, group_id): DocGenerator.LOGGER.warning(f"Content for {path} is empty, ignoring it") return None - def create_test(self, path, group_id): + def create_test(self, path, group_id, test_name=None): """Parse the content of a test file and dumps the content into a file. Modes: @@ -205,6 +205,7 @@ def create_test(self, path, group_id): Args: path (str): A string with the path of the test file to be parsed. group_id (str): A string with the id of the group where the new test belongs. + test_name (str): A string with the name of the test that is going to be parsed. Returns: __id.counter (int): An integer with the ID of the new generated test document. @@ -226,7 +227,7 @@ def create_test(self, path, group_id): return # If the user specifies an output dir else: - doc_path = os.path.join(doc_path, self.conf.test_name) + doc_path = os.path.join(doc_path, test_name) self.dump_output(test, doc_path) DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' was created with ID:{self.__id_counter}") @@ -264,13 +265,23 @@ def parse_folder(self, path, group_id): for folder in folders: self.parse_folder(os.path.join(root, folder), group_id) - def locate_test(self): + def parse_test_list(self): + """Parse the tests that the user has specified.""" + for test_name in self.conf.test_names: + self.test_path = self.locate_test(test_name) + + if self.test_path: + self.create_test(self.test_path, 0, test_name) + else: + DocGenerator.LOGGER.error(f"'{self.conf.test_name}' could not be found") + + def locate_test(self, test_name): """Get the test path when a test is specified by the user. Returns: str: A string with the test path. """ - complete_test_name = f"{self.conf.test_name}.py" + complete_test_name = f"{test_name}.py" DocGenerator.LOGGER.info(f"Looking for {complete_test_name}") for root, dirnames, filenames in os.walk(self.conf.project_path, topdown=True): @@ -278,7 +289,7 @@ def locate_test(self): if filename == complete_test_name: return os.path.join(root, complete_test_name) - print('test does not exist') + print(f"{test_name} does not exist") return None def print_test_info(self, test): @@ -307,8 +318,9 @@ def run(self): qa-docs -I ../../tests/ -T test_cache -o /tmp -> It would be running as `single test mode` creating `/tmp/test_cache.json` """ + DocGenerator.LOGGER.info("Starting test documentation parsing") + if self.conf.mode == Mode.DEFAULT: - DocGenerator.LOGGER.info("Starting documentation parsing") DocGenerator.LOGGER.debug(f"Cleaning doc folder located in {self.conf.documentation_path}") clean_folder(self.conf.documentation_path) @@ -318,11 +330,4 @@ def run(self): self.parse_folder(path, self.__id_counter) elif self.conf.mode == Mode.SINGLE_TEST: - DocGenerator.LOGGER.info("Starting test documentation parsing") - self.test_path = self.locate_test() - - if self.test_path: - DocGenerator.LOGGER.debug(f"Parsing '{self.conf.test_name}'") - self.create_test(self.test_path, 0) - else: - DocGenerator.LOGGER.error(f"'{self.conf.test_name}' could not be found") + self.parse_test_list() diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 4c0d9e8996..4716bdeaa0 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -34,7 +34,7 @@ class Config(): """ LOGGER = Logging.get_logger(QADOCS_LOGGER) - def __init__(self, config_path, test_dir, output_path='', test_name=None): + def __init__(self, config_path, test_dir, output_path='', test_names=None): """Constructor that loads the data from the config file. Also, if a test name is passed, it will be run in single test mode. @@ -49,7 +49,7 @@ def __init__(self, config_path, test_dir, output_path='', test_name=None): config_path (str): A string that contains the config file path. test_dir (str): A string that contains the path of the tests. output_path (str): A string that contains the doc output path. - test_name (str): A string that represents a test name. + test_names (list): A list that contains the test names that the user specifies. """ self.mode = Mode.DEFAULT self.project_path = test_dir @@ -72,10 +72,10 @@ def __init__(self, config_path, test_dir, output_path='', test_name=None): self.__read_ignore_paths() self.__set_documentation_path(output_path) - if test_name: + if test_names is not None: # When a name is passed, it is using just a single test. self.mode = Mode.SINGLE_TEST - self.test_name = test_name + self.test_names = test_names def __read_config_file(self, file): """Read configuration file. diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 92d33f78ec..14eeb01be8 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -52,10 +52,12 @@ def validate_parameters(parameters): raise QAValueError(f"{parameters.test_dir} does not exist. Tests directory not found.", qadocs_logger.error) # Check that test_input name exists - if parameters.test_input: - doc_check = DocGenerator(Config(CONFIG_PATH, parameters.test_dir, test_name=parameters.test_input)) - if doc_check.locate_test() is None: - raise QAValueError(f"{parameters.test_input} not found.", qadocs_logger.error) + if parameters.test_names: + doc_check = DocGenerator(Config(CONFIG_PATH, parameters.test_dir, test_names=parameters.test_names)) + + for test_name in parameters.test_names: + if doc_check.locate_test(test_name) is None: + raise QAValueError(f"{test_name} not found.", qadocs_logger.error) qadocs_logger.debug('Input parameters validation successfully finished') @@ -84,13 +86,13 @@ def main(): parser.add_argument('-l', '--launch-ui', dest='launch_app', help="Indexes the data named as you specify as argument and launch SearchUI.") - parser.add_argument('-T', dest='test_input', + parser.add_argument('-T', '--tests', nargs='+', default=[], dest='test_names', help="Parse the test that you pass as argument.") parser.add_argument('-o', dest='output_path', help="Specifies the output directory for test parsed when -T is used.") - parser.add_argument('-e', dest='test_exist', + parser.add_argument('-e', '--exist', nargs='+', default=[], dest='test_exist', help="Checks if test exists or not",) args = parser.parse_args() @@ -103,9 +105,11 @@ def main(): # Print that test gave by the user(using `-e` option) exists or not. if args.test_exist: - doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, test_name=args.test_exist)) - if doc_check.locate_test() is not None: - print("test exists") + doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, test_names=args.test_exist)) + + for test_name in args.test_exist: + if doc_check.locate_test(test_name) is not None: + print(f"{test_name} exists") if args.version: print(f"qa-docs v{VERSION}") @@ -143,16 +147,15 @@ def main(): docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) # Parse single test - if args.test_input: - qadocs_logger.info(f"Parsing the following test(s) {args.test_input}") + if args.test_names: + qadocs_logger.info(f"Parsing the following test(s) {args.test_names}") # When output path is specified by user, a json is generated within that path if args.output_path: - qadocs_logger.info(f"{args.test_input}.json is going to be generated in {args.output_path}") - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_input)) + docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_names)) else: # When no output is specified, it is printed - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, test_name=args.test_input)) + docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, test_names=args.test_names)) else: qadocs_logger.info(f"Parsing all tests located in {args.test_dir}") From 56eb6708d873dbca1d8c64b22ca39c269a5cb52f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 22 Sep 2021 10:44:10 +0200 Subject: [PATCH 047/181] refac: Enhance `-h`,`--help` option. #1864 --- .../wazuh_testing/scripts/qa_docs.py | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 14eeb01be8..3d21123cd3 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -63,37 +63,40 @@ def validate_parameters(parameters): def main(): - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(add_help=False) + + parser.add_argument('-h', '--help', action='help', default=argparse.SUPPRESS, + help='Show this help message and exit.') parser.add_argument('-s', '--sanity-check', action='store_true', dest='sanity', - help="Run a sanity check") + help="Run a sanity check.") parser.add_argument('-v', '--version', action='store_true', dest="version", - help="Print qa-docs version") + help="Print qa-docs version.") - parser.add_argument('-t', action='store_true', dest='test_config', + parser.add_argument('-t', '--test-cfg', action='store_true', dest='test_config', help="Load test configuration.") - parser.add_argument('-d', action='count', dest='debug_level', + parser.add_argument('-d', '--debug', action='count', dest='debug_level', help="Enable debug messages.") - parser.add_argument('-I', dest='test_dir', required=True, + parser.add_argument('-I', '--tests_path', dest='test_dir', help="Path where tests are located.") parser.add_argument('-i', '--index-data', dest='index_name', help="Indexes the data named as you specify as argument to elasticsearch.") - parser.add_argument('-l', '--launch-ui', dest='launch_app', + parser.add_argument('-l', '--launch-ui', dest='app_index_name', help="Indexes the data named as you specify as argument and launch SearchUI.") parser.add_argument('-T', '--tests', nargs='+', default=[], dest='test_names', - help="Parse the test that you pass as argument.") + help="Parse the test(s) that you pass as argument.") parser.add_argument('-o', dest='output_path', - help="Specifies the output directory for test parsed when -T is used.") + help="Specifies the output directory for test parsed when `-T, --tests` is used.") parser.add_argument('-e', '--exist', nargs='+', default=[], dest='test_exist', - help="Checks if test exists or not",) + help="Checks if test(s) exist or not.",) args = parser.parse_args() @@ -101,7 +104,8 @@ def main(): if args.debug_level: set_qadocs_logger_level('DEBUG') - validate_parameters(args) + if args: + validate_parameters(args) # Print that test gave by the user(using `-e` option) exists or not. if args.test_exist: @@ -133,9 +137,9 @@ def main(): index_data.run() # Index the previous parsed tests into Elasticsearch and then launch SearchUI - elif args.launch_app: + elif args.app_index_name: qadocs_logger.debug(f"Indexing {args.index_name}") - index_data = IndexData(args.launch_app, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + index_data = IndexData(args.app_index_name, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) index_data.run() os.chdir(SEARCH_UI_PATH) qadocs_logger.debug('Running SearchUI') From 8502d714f4b5c0014fff93d854d58e4a524f0d95 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 22 Sep 2021 12:17:44 +0200 Subject: [PATCH 048/181] add: Add check incompatibilities between parameters. #1864 --- .../wazuh_testing/scripts/qa_docs.py | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 3d21123cd3..cd62da7b66 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -34,6 +34,29 @@ def set_qadocs_logger_level(logging_level): else: qadocs_logger.set_level(logging_level) +def check_incompatible_parameters(parameters): + """Check the parameters that qa-docs receives and check any incompatibilities. + + Args: + parameters (argparse.Namespace): The parameters that the tool receives. + """ + if parameters.test_config and (parameters.index_name or parameters.app_index_name or parameters.test_names + or parameters.test_exist): + raise QAValueError('The -t, --test-config parameter is incompatible with -T, -i, -l, -T, -e options. ' + 'This option tests the configuration loaded for debugging purposes.', + qadocs_logger.error) + + if parameters.tests_path is None and (parameters.test_config or parameters.test_names or parameters.test_exist + or parameters.sanity): + raise QAValueError('The following options need the path where the tests are located: -t, -T, --test, ' + ' -e, --exist, -s, --sanity-check. You must specify it by using ' + '-I, --tests-path path_to_tests.', + qadocs_logger.error) + + if parameters.output_path and parameters.test_names is None: + raise QAValueError('The -o parameter is used to set where the parsed data with -T, --tests options ' + ' will be written. -T, --tests are not used.', + qadocs_logger.error) def validate_parameters(parameters): """Validate the parameters that qa-docs recieves. @@ -46,19 +69,29 @@ def validate_parameters(parameters): """ qadocs_logger.debug('Validating input parameters') + check_incompatible_parameters(parameters) + # Check if the directory where the tests are located exist - if parameters.test_dir: - if not os.path.exists(parameters.test_dir): - raise QAValueError(f"{parameters.test_dir} does not exist. Tests directory not found.", qadocs_logger.error) + if parameters.tests_path: + if not os.path.exists(parameters.tests_path): + raise QAValueError(f"{parameters.tests_path} does not exist. Tests directory not found.", qadocs_logger.error) # Check that test_input name exists if parameters.test_names: - doc_check = DocGenerator(Config(CONFIG_PATH, parameters.test_dir, test_names=parameters.test_names)) + doc_check = DocGenerator(Config(CONFIG_PATH, parameters.tests_path, test_names=parameters.test_names)) for test_name in parameters.test_names: if doc_check.locate_test(test_name) is None: raise QAValueError(f"{test_name} not found.", qadocs_logger.error) + # Check that the index exists + if parameters.index_name or parameters.app_index_name: + check_index_name_exists = None + + # Check that the output path has permissions to write the file(s) + if parameters.output_path: + check_output_path_has_permissions = None + qadocs_logger.debug('Input parameters validation successfully finished') @@ -80,7 +113,7 @@ def main(): parser.add_argument('-d', '--debug', action='count', dest='debug_level', help="Enable debug messages.") - parser.add_argument('-I', '--tests_path', dest='test_dir', + parser.add_argument('-I', '--tests-path', dest='tests_path', help="Path where tests are located.") parser.add_argument('-i', '--index-data', dest='index_name', @@ -109,7 +142,7 @@ def main(): # Print that test gave by the user(using `-e` option) exists or not. if args.test_exist: - doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, test_names=args.test_exist)) + doc_check = DocGenerator(Config(CONFIG_PATH, args.tests_path, test_names=args.test_exist)) for test_name in args.test_exist: if doc_check.locate_test(test_name) is not None: @@ -121,25 +154,25 @@ def main(): # Load configuration if you want to test it elif args.test_config: qadocs_logger.debug('Loading qa-docs configuration') - Config(CONFIG_PATH, args.test_dir) + Config(CONFIG_PATH, args.tests_path) qadocs_logger.debug('qa-docs configuration loaded') # Run a sanity check thru tests directory elif args.sanity: - sanity = Sanity(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + sanity = Sanity(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) qadocs_logger.debug('Running sanity check') sanity.run() # Index the previous parsed tests into Elasticsearch elif args.index_name: qadocs_logger.debug(f"Indexing {args.index_name}") - index_data = IndexData(args.index_name, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + index_data = IndexData(args.index_name, Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() # Index the previous parsed tests into Elasticsearch and then launch SearchUI elif args.app_index_name: qadocs_logger.debug(f"Indexing {args.index_name}") - index_data = IndexData(args.app_index_name, Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + index_data = IndexData(args.app_index_name, Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() os.chdir(SEARCH_UI_PATH) qadocs_logger.debug('Running SearchUI') @@ -148,7 +181,7 @@ def main(): # Parse tests else: if not args.test_exist: - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, OUTPUT_PATH)) + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) # Parse single test if args.test_names: @@ -156,12 +189,12 @@ def main(): # When output path is specified by user, a json is generated within that path if args.output_path: - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, args.output_path, args.test_names)) + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, args.output_path, args.test_names)) else: # When no output is specified, it is printed - docs = DocGenerator(Config(CONFIG_PATH, args.test_dir, test_names=args.test_names)) + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, test_names=args.test_names)) else: - qadocs_logger.info(f"Parsing all tests located in {args.test_dir}") + qadocs_logger.info(f"Parsing all tests located in {args.tests_path}") qadocs_logger.info('Running QADOCS') docs.run() From c3e8211f032ab3397361b2356d8f82571aab9a10 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 22 Sep 2021 12:44:36 +0200 Subject: [PATCH 049/181] refac: When `qa-docs` runs without any parameter, it raises an error and prints the help message. #1864 Also, the `-o` option incompatibility check has been fixed. --- .../wazuh_testing/wazuh_testing/scripts/qa_docs.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index cd62da7b66..31411d21af 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -5,6 +5,7 @@ import argparse import os from datetime import datetime +import sys from wazuh_testing.qa_docs.lib.config import Config from wazuh_testing.qa_docs.lib.index_data import IndexData @@ -53,12 +54,12 @@ def check_incompatible_parameters(parameters): '-I, --tests-path path_to_tests.', qadocs_logger.error) - if parameters.output_path and parameters.test_names is None: + if parameters.output_path and not parameters.test_names: raise QAValueError('The -o parameter is used to set where the parsed data with -T, --tests options ' ' will be written. -T, --tests are not used.', qadocs_logger.error) -def validate_parameters(parameters): +def validate_parameters(parameters, parser): """Validate the parameters that qa-docs recieves. Since `config.yaml` will be `schema.yaml`, it runs as config file is correct. @@ -69,6 +70,12 @@ def validate_parameters(parameters): """ qadocs_logger.debug('Validating input parameters') + # If qa-docs runs without any parameter or just `-d` option, it raises an error and prints the help message. + if len(sys.argv) < 2 or (len(sys.argv) < 3 and parameters.debug_level): + qadocs_logger.error('qa-docs has been run without any parameter.') + parser.print_help() + exit(1) + check_incompatible_parameters(parameters) # Check if the directory where the tests are located exist @@ -137,8 +144,7 @@ def main(): if args.debug_level: set_qadocs_logger_level('DEBUG') - if args: - validate_parameters(args) + validate_parameters(args, parser) # Print that test gave by the user(using `-e` option) exists or not. if args.test_exist: From 07ce0159274dd07e5ffb0b69970756445ce187a2 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 23 Sep 2021 10:50:05 +0200 Subject: [PATCH 050/181] add: Add `--types` parameter to `qa-docs` tool. #1864 Now `qa-docs` can receive a parameter to specify the test types that want to parse. All the types are selected by default. --- .../wazuh_testing/qa_docs/config.yaml | 14 +---- .../wazuh_testing/qa_docs/doc_generator.py | 6 +- .../wazuh_testing/qa_docs/lib/config.py | 56 ++++++++++++------- .../wazuh_testing/scripts/qa_docs.py | 44 +++++++++------ 4 files changed, 69 insertions(+), 51 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml index 24f3ef75b6..2eb3ac68d2 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml @@ -1,20 +1,12 @@ -include_paths: - - "integration/test_active_response" - - "integration/test_api" - - "integration/test_wazuh_db" - - "integration/test_vulnerability_detector" - - "integration/test_remoted" - - "integration/test_agentd" - include_regex: - "^test_.*py$" -group_files: - - "README.md" - function_regex: - "^test_" +group_files: + - "README.md" + ignore_paths: - "integration/test_active_response/test_execd/data" - "integration/test_api/test_config/test_cache/data" diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 9e810d4fe1..0dcbfcc541 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -6,7 +6,6 @@ import re import json import yaml -import pprint from wazuh_testing.qa_docs.lib.config import Mode from wazuh_testing.qa_docs.lib.code_parser import CodeParser @@ -128,7 +127,6 @@ def get_test_doc_path(self, path): Returns: doc_path (str): A string with the name of the documentation test file. """ - base_path = os.path.join(self.conf.documentation_path, os.path.basename(self.scan_path)) relative_path = path.replace(self.scan_path, "") doc_path = os.path.splitext(base_path + relative_path)[0] @@ -218,7 +216,7 @@ def create_test(self, path, group_id, test_name=None): if self.conf.mode == Mode.DEFAULT: doc_path = self.get_test_doc_path(path) - elif self.conf.mode == Mode.SINGLE_TEST: + elif self.conf.mode == Mode.PARSE_TESTS: doc_path = self.conf.documentation_path # If the user does not specify an output dir @@ -329,5 +327,5 @@ def run(self): DocGenerator.LOGGER.debug(f"Going to parse files on '{path}'") self.parse_folder(path, self.__id_counter) - elif self.conf.mode == Mode.SINGLE_TEST: + elif self.conf.mode == Mode.PARSE_TESTS: self.parse_test_list() diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 4716bdeaa0..369e2e1fb4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -5,6 +5,7 @@ import yaml from enum import Enum import os +import re from wazuh_testing.qa_docs import QADOCS_LOGGER from wazuh_testing.tools.logging import Logging @@ -34,7 +35,7 @@ class Config(): """ LOGGER = Logging.get_logger(QADOCS_LOGGER) - def __init__(self, config_path, test_dir, output_path='', test_names=None): + def __init__(self, config_path, test_dir, output_path='', test_types=None, test_names=None): """Constructor that loads the data from the config file. Also, if a test name is passed, it will be run in single test mode. @@ -49,7 +50,8 @@ def __init__(self, config_path, test_dir, output_path='', test_names=None): config_path (str): A string that contains the config file path. test_dir (str): A string that contains the path of the tests. output_path (str): A string that contains the doc output path. - test_names (list): A list that contains the test names that the user specifies. + test_types (list): A list that contains the tests type(s) to be parsed. + test_names (list): A list that contains the test name(s) that the user specifies. """ self.mode = Mode.DEFAULT self.project_path = test_dir @@ -66,7 +68,6 @@ def __init__(self, config_path, test_dir, output_path='', test_names=None): self.__read_function_regex() self.__read_output_fields() self.__read_test_cases_field() - self.__read_include_paths() self.__read_include_regex() self.__read_group_files() self.__read_ignore_paths() @@ -74,9 +75,19 @@ def __init__(self, config_path, test_dir, output_path='', test_names=None): if test_names is not None: # When a name is passed, it is using just a single test. - self.mode = Mode.SINGLE_TEST + self.mode = Mode.PARSE_TESTS self.test_names = test_names + # Add all the types within the tests directory by default + if test_types is None: + self.__get_test_types() + # Add the user types to include_paths + else: + self.test_types = test_types + + # Get the tests types to parse + self.__get_include_paths() + def __read_config_file(self, file): """Read configuration file. @@ -95,22 +106,28 @@ def __set_documentation_path(self, path): Config.LOGGER.debug('Setting the path documentation') self.documentation_path = path - def __read_include_paths(self): - """Read from the config file all the paths to be included in the parsing process. + def __get_test_types(self): + """Get all the test types within wazuh-qa framework.""" + self.test_types = [] - Raises: - QAValueError: The include paths field is empty in the config file - """ - Config.LOGGER.debug('Reading include paths from config file') + for name in os.listdir(self.project_path): + if os.path.isdir(os.path.join(self.project_path, name)): + self.test_types.append(name) - # Will be replaced by --type --module and --test , so you can run what you need - if 'include_paths' not in self._config_data: - raise QAValueError('The include paths field is empty in the config file', Config.LOGGER.error) + def __get_include_paths(self): + """Get all the modules to include within all the specified types. + + The paths to be included are generated using this info. + """ + dir_regex = re.compile("test_.") + self.include_paths = [] - include_paths = self._config_data['include_paths'] + for type in self.test_types: + subset_tests = os.path.join(self.project_path, type) - for path in include_paths: - self.include_paths.append(os.path.join(self.project_path, path)) + for name in os.listdir(subset_tests): + if os.path.isdir(os.path.join(subset_tests, name)) and dir_regex.match(name): + self.include_paths.append(os.path.join(subset_tests, name)) def __read_include_regex(self): """Read from the config file the regexes used to identify test files. @@ -248,8 +265,9 @@ class Mode(Enum): The current modes that `doc_generator` has are these: Modes: - DEFAULT: `default mode` parses all tests within tests directory - SINGLE_TEST: `single test mode` only uses a single test represented by its name + DEFAULT: `default mode` parses all tests within tests directory. + PARSE_TESTS: `single tests mode` parses a list of tests. + PARSE_TYPES For example, if you want to declare that it is running thru all tests directory, you must specify it by: @@ -259,4 +277,4 @@ class Mode(Enum): Enum (Class): Base class for creating enumerated constants. """ DEFAULT = 1 - SINGLE_TEST = 2 + PARSE_TESTS = 2 diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 31411d21af..593119399a 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -129,6 +129,9 @@ def main(): parser.add_argument('-l', '--launch-ui', dest='app_index_name', help="Indexes the data named as you specify as argument and launch SearchUI.") + parser.add_argument('--types', nargs='+', default=[], dest='test_types', + help="Parse the tests from type(s) that you pass as argument.") + parser.add_argument('-T', '--tests', nargs='+', default=[], dest='test_names', help="Parse the test(s) that you pass as argument.") @@ -185,25 +188,32 @@ def main(): os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") # Parse tests - else: - if not args.test_exist: - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) - - # Parse single test - if args.test_names: - qadocs_logger.info(f"Parsing the following test(s) {args.test_names}") - - # When output path is specified by user, a json is generated within that path - if args.output_path: - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, args.output_path, args.test_names)) - else: - # When no output is specified, it is printed - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, test_names=args.test_names)) + elif not args.test_exist: + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) + + # Parse a list of tests + if args.test_names: + qadocs_logger.info(f"Parsing the following test(s) {args.test_names}") + + # When output path is specified by user, a json is generated within that path + if args.output_path: + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, args.output_path, test_names=args.test_names)) + # When no output is specified, it is printed else: - qadocs_logger.info(f"Parsing all tests located in {args.tests_path}") + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, test_names=args.test_names)) + + # Parse a list of modules + elif args.test_types: + qadocs_logger.info(f"Parsing the following test(s) type(s): {args.test_types}") + + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) + + # Parse the whole path + else: + qadocs_logger.info(f"Parsing all tests located in {args.tests_path}") - qadocs_logger.info('Running QADOCS') - docs.run() + qadocs_logger.info('Running QADOCS') + docs.run() if __name__ == '__main__': main() From d6047504f873ef6ce877251d59620d4945420148 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 23 Sep 2021 11:20:32 +0200 Subject: [PATCH 051/181] add: Add `--types` cases to `check_incompatible_parameters()`. #1864 --- .../wazuh_testing/scripts/qa_docs.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 593119399a..97ecd6b168 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -42,22 +42,28 @@ def check_incompatible_parameters(parameters): parameters (argparse.Namespace): The parameters that the tool receives. """ if parameters.test_config and (parameters.index_name or parameters.app_index_name or parameters.test_names - or parameters.test_exist): + or parameters.test_exist or parameters.test_types): raise QAValueError('The -t, --test-config parameter is incompatible with -T, -i, -l, -T, -e options. ' 'This option tests the configuration loaded for debugging purposes.', qadocs_logger.error) if parameters.tests_path is None and (parameters.test_config or parameters.test_names or parameters.test_exist - or parameters.sanity): + or parameters.sanity or parameters.test_types): raise QAValueError('The following options need the path where the tests are located: -t, -T, --test, ' - ' -e, --exist, -s, --sanity-check. You must specify it by using ' + ' -e, --exist, --types, -s, --sanity-check. You must specify it by using ' '-I, --tests-path path_to_tests.', qadocs_logger.error) - if parameters.output_path and not parameters.test_names: - raise QAValueError('The -o parameter is used to set where the parsed data with -T, --tests options ' - ' will be written. -T, --tests are not used.', + if parameters.output_path and (parameters.test_config or parameters.test_exist or parameters.sanity + or parameters.test_types or not parameters.test_names): + raise QAValueError('The -o parameter only works with -T, --tests options in isolation. The default output ' + 'path is generated within the qa-docs tool to index it and visualize it.', qadocs_logger.error) + + if parameters.test_types and parameters.test_names: + raise QAValueError('The --type parameter parse the data, index it, and visualize it, so it cannot be used with ' + '-T, --tests because they get specific tests information.', + qadocs_logger.error) def validate_parameters(parameters, parser): """Validate the parameters that qa-docs recieves. From caf81d65cf22eb5b4e18e277374cbc4f5999bad6 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 23 Sep 2021 13:00:46 +0200 Subject: [PATCH 052/181] add: Add the `--modules` parameter to `qa-docs`. #1864 Also the parameters validation and incompatible parameters check has been added. --- .../wazuh_testing/qa_docs/lib/config.py | 22 +++++++++--- .../wazuh_testing/scripts/qa_docs.py | 35 +++++++++++++------ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 369e2e1fb4..c9c1469b1c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -31,11 +31,14 @@ class Config(): module_fields (_fields): A struct that contains the module documentation data. test_fields (_fields): A struct that contains the test documentation data. test_cases_field (_fields): A string that contains the test_cases key. + test_types (list): A list with the types to be parsed. + test_modules (list): A list with the modules to be parsed. + test_names (list): A list with the tests to be parsed. LOGGER (_fields): A custom qa-docs logger. """ LOGGER = Logging.get_logger(QADOCS_LOGGER) - def __init__(self, config_path, test_dir, output_path='', test_types=None, test_names=None): + def __init__(self, config_path, test_dir, output_path='', test_types=None, test_modules=None, test_names=None): """Constructor that loads the data from the config file. Also, if a test name is passed, it will be run in single test mode. @@ -51,6 +54,8 @@ def __init__(self, config_path, test_dir, output_path='', test_types=None, test_ test_dir (str): A string that contains the path of the tests. output_path (str): A string that contains the doc output path. test_types (list): A list that contains the tests type(s) to be parsed. + test_types (list): A list that contains the test type(s) that the user specifies. + test_modules (list): A list that contains the test module(s) that the user specifies. test_names (list): A list that contains the test name(s) that the user specifies. """ self.mode = Mode.DEFAULT @@ -63,6 +68,8 @@ def __init__(self, config_path, test_dir, output_path='', test_types=None, test_ self.module_fields = _fields() self.test_fields = _fields() self.test_cases_field = None + self.test_types = [] + self.test_modules = [] self.__read_config_file(config_path) self.__read_function_regex() @@ -85,6 +92,9 @@ def __init__(self, config_path, test_dir, output_path='', test_types=None, test_ else: self.test_types = test_types + if test_modules: + self.test_modules = test_modules + # Get the tests types to parse self.__get_include_paths() @@ -108,8 +118,6 @@ def __set_documentation_path(self, path): def __get_test_types(self): """Get all the test types within wazuh-qa framework.""" - self.test_types = [] - for name in os.listdir(self.project_path): if os.path.isdir(os.path.join(self.project_path, name)): self.test_types.append(name) @@ -125,9 +133,13 @@ def __get_include_paths(self): for type in self.test_types: subset_tests = os.path.join(self.project_path, type) - for name in os.listdir(subset_tests): - if os.path.isdir(os.path.join(subset_tests, name)) and dir_regex.match(name): + if self.test_modules: + for name in self.test_modules: self.include_paths.append(os.path.join(subset_tests, name)) + else: + for name in os.listdir(subset_tests): + if os.path.isdir(os.path.join(subset_tests, name)) and dir_regex.match(name): + self.include_paths.append(os.path.join(subset_tests, name)) def __read_include_regex(self): """Read from the config file the regexes used to identify test files. diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 97ecd6b168..4bc530c2cf 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -41,27 +41,27 @@ def check_incompatible_parameters(parameters): Args: parameters (argparse.Namespace): The parameters that the tool receives. """ - if parameters.test_config and (parameters.index_name or parameters.app_index_name or parameters.test_names - or parameters.test_exist or parameters.test_types): + default_run = parameters.index_name or parameters.app_index_name or parameters.test_names \ + or parameters.test_exist or parameters.test_types or parameters.test_modules + + if parameters.test_config and default_run: raise QAValueError('The -t, --test-config parameter is incompatible with -T, -i, -l, -T, -e options. ' 'This option tests the configuration loaded for debugging purposes.', qadocs_logger.error) - if parameters.tests_path is None and (parameters.test_config or parameters.test_names or parameters.test_exist - or parameters.sanity or parameters.test_types): + if parameters.tests_path is None and default_run: raise QAValueError('The following options need the path where the tests are located: -t, -T, --test, ' - ' -e, --exist, --types, -s, --sanity-check. You must specify it by using ' + ' -e, --exist, --types, --modules, -s, --sanity-check. You must specify it by using ' '-I, --tests-path path_to_tests.', qadocs_logger.error) - if parameters.output_path and (parameters.test_config or parameters.test_exist or parameters.sanity - or parameters.test_types or not parameters.test_names): + if parameters.output_path and default_run: raise QAValueError('The -o parameter only works with -T, --tests options in isolation. The default output ' 'path is generated within the qa-docs tool to index it and visualize it.', qadocs_logger.error) - if parameters.test_types and parameters.test_names: - raise QAValueError('The --type parameter parse the data, index it, and visualize it, so it cannot be used with ' + if (parameters.test_types or parameters.test_modules) and parameters.test_names: + raise QAValueError('The --types, --modules parameters parse the data, index it, and visualize it, so it cannot be used with ' '-T, --tests because they get specific tests information.', qadocs_logger.error) @@ -105,6 +105,12 @@ def validate_parameters(parameters, parser): if parameters.output_path: check_output_path_has_permissions = None + # Check that modules selection is done within a test type + if parameters.test_modules and len(parameters.test_types) != 1: + raise QAValueError('The --modules option work when is only parsing a single test type. Use --types with just one' + ' type if you want to parse some modules within a test type.', + qadocs_logger.error) + qadocs_logger.debug('Input parameters validation successfully finished') @@ -138,6 +144,9 @@ def main(): parser.add_argument('--types', nargs='+', default=[], dest='test_types', help="Parse the tests from type(s) that you pass as argument.") + parser.add_argument('--modules', nargs='+', default=[], dest='test_modules', + help="Parse the tests from modules(s) that you pass as argument.") + parser.add_argument('-T', '--tests', nargs='+', default=[], dest='test_names', help="Parse the test(s) that you pass as argument.") @@ -208,11 +217,15 @@ def main(): else: docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, test_names=args.test_names)) - # Parse a list of modules + # Parse a list of test types elif args.test_types: qadocs_logger.info(f"Parsing the following test(s) type(s): {args.test_types}") - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) + # Parse a list of test modules + if args.test_modules: + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH, args.test_types, args.test_modules)) + else: + docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) # Parse the whole path else: From 01c87ea96d45245140b584728df2dbef4ef88f4d Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 23 Sep 2021 13:59:31 +0200 Subject: [PATCH 053/181] doc: Include and fix a few changes. #1864 --- .../wazuh_testing/qa_docs/doc_generator.py | 2 ++ .../wazuh_testing/qa_docs/lib/config.py | 12 ++++-------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 0dcbfcc541..81702a727a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -22,6 +22,8 @@ class DocGenerator: Every folder is checked so they are ignored when the path matches. Then, every test from folders not ignored that matches a include regex, is parsed. + The included paths are generated using the types and modules from the wazuh-qa framework. + Attributes: conf (Config): A `Config` instance with data loaded from config file. parser (CodeParser): A `CodeParser` instance with parsing utilities. diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index c9c1469b1c..1e02643c7b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -15,7 +15,7 @@ class Config(): """Class that parses the configuration file and exposes the available configurations. - Exist two modes of execution: `default mode` and `single test mode`. + Two modes of execution exist : `default mode` and `single test mode`. The following attributes may change because the config file will be deprecated soon. It will be renamed to `schema.yaml` and it will specify the schema fields and pre-defined values that you can check here: https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks @@ -41,13 +41,12 @@ class Config(): def __init__(self, config_path, test_dir, output_path='', test_types=None, test_modules=None, test_names=None): """Constructor that loads the data from the config file. - Also, if a test name is passed, it will be run in single test mode. + If a test name is passed, it would be run in `single test mode`. And if an output path is not received, when is running in single test mode, it will be printed using the standard output. But if an output path is passed, there will be generated a JSON file with the same data that would be printed in `single test` mode. - The default output path for `default mode` is `qa_docs_installation/output`, it cannot be changed. Even when - you pass an output path, it has no effect in `default mode`. + The default output path for `default mode` is `qa_docs_installation/output`, it cannot be changed. Args: config_path (str): A string that contains the config file path. @@ -85,17 +84,15 @@ def __init__(self, config_path, test_dir, output_path='', test_types=None, test_ self.mode = Mode.PARSE_TESTS self.test_names = test_names - # Add all the types within the tests directory by default if test_types is None: self.__get_test_types() - # Add the user types to include_paths else: self.test_types = test_types if test_modules: self.test_modules = test_modules - # Get the tests types to parse + # Get the paths to parse self.__get_include_paths() def __read_config_file(self, file): @@ -279,7 +276,6 @@ class Mode(Enum): Modes: DEFAULT: `default mode` parses all tests within tests directory. PARSE_TESTS: `single tests mode` parses a list of tests. - PARSE_TYPES For example, if you want to declare that it is running thru all tests directory, you must specify it by: From daac4b2cda3b1cea073b50e23a57d83924c12525 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 1 Oct 2021 12:39:31 +0200 Subject: [PATCH 054/181] refac: remove `config.yaml` and use `schema.yaml` instead. #1864 The `ignore_paths` field from `config.yaml` has been deleted. If it is necessary, we could use a regular expression to detect the `data/` directories to ignore. The regular expressions fields have been added in the `Config` module as arrays. --- deps/wazuh_testing/setup.py | 2 +- .../wazuh_testing/qa_docs/config.yaml | 52 ------------------- .../wazuh_testing/qa_docs/lib/config.py | 27 +++++----- .../wazuh_testing/qa_docs/schema.yaml | 30 +++++++++++ .../wazuh_testing/scripts/qa_docs.py | 24 ++++----- 5 files changed, 55 insertions(+), 80 deletions(-) delete mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml create mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml diff --git a/deps/wazuh_testing/setup.py b/deps/wazuh_testing/setup.py index a85352b952..5b5bddc961 100644 --- a/deps/wazuh_testing/setup.py +++ b/deps/wazuh_testing/setup.py @@ -20,7 +20,7 @@ 'data/sslmanager.key', 'data/sslmanager.cert', 'tools/macos_log/log_generator.m', - 'qa_docs/config.yaml' + 'qa_docs/schema.yaml' ] scripts_list = [ diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml deleted file mode 100644 index 2eb3ac68d2..0000000000 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/config.yaml +++ /dev/null @@ -1,52 +0,0 @@ -include_regex: - - "^test_.*py$" - -function_regex: - - "^test_" - -group_files: - - "README.md" - -ignore_paths: - - "integration/test_active_response/test_execd/data" - - "integration/test_api/test_config/test_cache/data" - - "integration/test_api/test_config/test_cors/data" - - "integration/test_api/test_config/test_DOS_blocking_system/data" - - "integration/test_api/test_config/test_drop_privileges/data" - - "integration/test_api/test_config/test_experimental_features/data" - - "integration/test_api/test_config/test_host_port/data" - - "integration/test_api/test_config/test_https/data" - - "integration/test_api/test_config/test_jwt_token_exp_timeout/data" - - "integration/test_api/test_config/test_logs/data" - - "integration/test_api/test_config/test_rbac/data" - - "integration/test_api/test_config/test_request_timeout/data" - - "integration/test_api/test_config/test_use_only_authd/data" - - "integration/test_wazuh_db/data" - - "integration/test_api/test_config/test_bruteforce_blocking_system/data" - -output_fields: - module: - mandatory: - - brief - - path - - category - - modules - - daemons - - component - - os_platform - - os_vendor - - os_version - - tiers - optional: - - tags - test: - mandatory: - - description - - wazuh_min_version - - parameters - - behaviour - - expected_behaviour - optional: - - status - -test_cases_field: test_cases \ No newline at end of file diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 1e02643c7b..bbfc1d7e74 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -38,8 +38,8 @@ class Config(): """ LOGGER = Logging.get_logger(QADOCS_LOGGER) - def __init__(self, config_path, test_dir, output_path='', test_types=None, test_modules=None, test_names=None): - """Constructor that loads the data from the config file. + def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_modules=None, test_names=None): + """Constructor that loads the schema file. If a test name is passed, it would be run in `single test mode`. And if an output path is not received, when is running in single test mode, it will be printed using the @@ -49,7 +49,7 @@ def __init__(self, config_path, test_dir, output_path='', test_types=None, test_ The default output path for `default mode` is `qa_docs_installation/output`, it cannot be changed. Args: - config_path (str): A string that contains the config file path. + schema_path (str): A string that contains the schema file path. test_dir (str): A string that contains the path of the tests. output_path (str): A string that contains the doc output path. test_types (list): A list that contains the tests type(s) to be parsed. @@ -60,9 +60,9 @@ def __init__(self, config_path, test_dir, output_path='', test_types=None, test_ self.mode = Mode.DEFAULT self.project_path = test_dir self.include_paths = [] - self.include_regex = [] - self.group_files = "" - self.function_regex = [] + self.include_regex = ["^test_.*py$"] + self.group_files = "README.md" + self.function_regex = ["^test_"] self.ignore_paths = [] self.module_fields = _fields() self.test_fields = _fields() @@ -70,12 +70,9 @@ def __init__(self, config_path, test_dir, output_path='', test_types=None, test_ self.test_types = [] self.test_modules = [] - self.__read_config_file(config_path) - self.__read_function_regex() + self.__read_schema_file(schema_path) self.__read_output_fields() self.__read_test_cases_field() - self.__read_include_regex() - self.__read_group_files() self.__read_ignore_paths() self.__set_documentation_path(output_path) @@ -95,18 +92,18 @@ def __init__(self, config_path, test_dir, output_path='', test_types=None, test_ # Get the paths to parse self.__get_include_paths() - def __read_config_file(self, file): - """Read configuration file. + def __read_schema_file(self, file): + """Read schema file. Raises: - QAValuerError (IOError): Cannot load config file. + QAValuerError (IOError): Cannot load schema file. """ try: - Config.LOGGER.debug('Loading config file') + Config.LOGGER.debug('Loading schema file') with open(file) as config_file: self._config_data = yaml.safe_load(config_file) except IOError: - raise QAValueError('Cannot load config file', Config.LOGGER.error) + raise QAValueError('Cannot load schema file', Config.LOGGER.error) def __set_documentation_path(self, path): """Set the path of the documentation output.""" diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml new file mode 100644 index 0000000000..b518c6c156 --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml @@ -0,0 +1,30 @@ +output_fields: + module: + mandatory: + - copyright + - type + - brief + - tier + - modules + - daemons + - components + - path + - os_platform + - os_version + optional: + - references + - pytest_args + - tags + test: + mandatory: + - description + - wazuh_min_version + - parameters + - assertions + - inputs + - input_description + - expected_behaviour + optional: + - tags + +test_cases_field: test_cases \ No newline at end of file diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 4bc530c2cf..42fd089845 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -16,7 +16,7 @@ from wazuh_testing.tools.exceptions import QAValueError VERSION = '0.1' -CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'config.yaml') +SCHEMA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'schema.yaml') OUTPUT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'output') LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'log') SEARCH_UI_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'search_ui') @@ -91,7 +91,7 @@ def validate_parameters(parameters, parser): # Check that test_input name exists if parameters.test_names: - doc_check = DocGenerator(Config(CONFIG_PATH, parameters.tests_path, test_names=parameters.test_names)) + doc_check = DocGenerator(Config(SCHEMA_PATH, parameters.tests_path, test_names=parameters.test_names)) for test_name in parameters.test_names: if doc_check.locate_test(test_name) is None: @@ -166,7 +166,7 @@ def main(): # Print that test gave by the user(using `-e` option) exists or not. if args.test_exist: - doc_check = DocGenerator(Config(CONFIG_PATH, args.tests_path, test_names=args.test_exist)) + doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) for test_name in args.test_exist: if doc_check.locate_test(test_name) is not None: @@ -178,25 +178,25 @@ def main(): # Load configuration if you want to test it elif args.test_config: qadocs_logger.debug('Loading qa-docs configuration') - Config(CONFIG_PATH, args.tests_path) + Config(SCHEMA_PATH, args.tests_path) qadocs_logger.debug('qa-docs configuration loaded') # Run a sanity check thru tests directory elif args.sanity: - sanity = Sanity(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) + sanity = Sanity(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) qadocs_logger.debug('Running sanity check') sanity.run() # Index the previous parsed tests into Elasticsearch elif args.index_name: qadocs_logger.debug(f"Indexing {args.index_name}") - index_data = IndexData(args.index_name, Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) + index_data = IndexData(args.index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() # Index the previous parsed tests into Elasticsearch and then launch SearchUI elif args.app_index_name: qadocs_logger.debug(f"Indexing {args.index_name}") - index_data = IndexData(args.app_index_name, Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) + index_data = IndexData(args.app_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() os.chdir(SEARCH_UI_PATH) qadocs_logger.debug('Running SearchUI') @@ -204,7 +204,7 @@ def main(): # Parse tests elif not args.test_exist: - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH)) + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) # Parse a list of tests if args.test_names: @@ -212,10 +212,10 @@ def main(): # When output path is specified by user, a json is generated within that path if args.output_path: - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, args.output_path, test_names=args.test_names)) + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, args.output_path, test_names=args.test_names)) # When no output is specified, it is printed else: - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, test_names=args.test_names)) + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_names)) # Parse a list of test types elif args.test_types: @@ -223,9 +223,9 @@ def main(): # Parse a list of test modules if args.test_modules: - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH, args.test_types, args.test_modules)) + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types, args.test_modules)) else: - docs = DocGenerator(Config(CONFIG_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) # Parse the whole path else: From eea5045f0f9a70349b770ebd74079051870e332d Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 1 Oct 2021 13:06:17 +0200 Subject: [PATCH 055/181] fix: Change the documentation and messages. #1864 --- .../wazuh_testing/qa_docs/doc_generator.py | 4 +- .../wazuh_testing/qa_docs/lib/code_parser.py | 4 +- .../wazuh_testing/qa_docs/lib/config.py | 96 +++++-------------- .../wazuh_testing/qa_docs/lib/index_data.py | 2 +- .../wazuh_testing/qa_docs/lib/sanity.py | 4 +- .../wazuh_testing/scripts/qa_docs.py | 7 +- 6 files changed, 33 insertions(+), 84 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 81702a727a..e705d41b17 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -25,7 +25,7 @@ class DocGenerator: The included paths are generated using the types and modules from the wazuh-qa framework. Attributes: - conf (Config): A `Config` instance with data loaded from config file. + conf (Config): A `Config` instance with the loaded configuration. parser (CodeParser): A `CodeParser` instance with parsing utilities. __id_counter (int): An integer that counts the test/group ID when it is created. ignore_regex (list): A list with compiled paths to be ignored. @@ -39,7 +39,7 @@ def __init__(self, config): Initialize every attribute. Args: - config (Config): A `Config` instance with the loaded data from config file. + config (Config): A `Config` instance with the loaded configuration. """ self.conf = config self.parser = CodeParser(self.conf) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index d3ef170c86..243d8f1a7c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -20,7 +20,7 @@ class CodeParser: """Class that parses the content of the test files. Attributes: - conf (Config): A `Config` instance with the config file data. + conf (Config): A `Config` instance with the loaded configuration. pytest (PytestWrap): A `PytestWrap` instance to wrap the pytest execution. function_regexes (list): A list of regular expressions used to find test functions. """ @@ -32,7 +32,7 @@ def __init__(self, config): Initialize every attribute. Args: - config (Config): A `Config` instance with the loaded data from the config file. + config (Config): A `Config` instance with the loaded configuration. """ self.conf = config self.pytest = PytestWrap() diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index bbfc1d7e74..4875968622 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -13,11 +13,12 @@ class Config(): - """Class that parses the configuration file and exposes the available configurations. + """Class that parses the schema file and exposes the available configurations. Two modes of execution exist : `default mode` and `single test mode`. - The following attributes may change because the config file will be deprecated soon. It will be renamed to - `schema.yaml` and it will specify the schema fields and pre-defined values that you can check here: + Predefined values are still missing, they will be added soon. + + The schema fields and pre-defined values can be checked here: https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks Attributes: @@ -39,7 +40,7 @@ class Config(): LOGGER = Logging.get_logger(QADOCS_LOGGER) def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_modules=None, test_names=None): - """Constructor that loads the schema file. + """Constructor that loads the schema file and set the `qa-docs` configuration. If a test name is passed, it would be run in `single test mode`. And if an output path is not received, when is running in single test mode, it will be printed using the @@ -73,7 +74,6 @@ def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_ self.__read_schema_file(schema_path) self.__read_output_fields() self.__read_test_cases_field() - self.__read_ignore_paths() self.__set_documentation_path(output_path) if test_names is not None: @@ -135,72 +135,24 @@ def __get_include_paths(self): if os.path.isdir(os.path.join(subset_tests, name)) and dir_regex.match(name): self.include_paths.append(os.path.join(subset_tests, name)) - def __read_include_regex(self): - """Read from the config file the regexes used to identify test files. - - Raises: - QAValueError: The include regex field is empty in the config file - """ - Config.LOGGER.debug('Reading the regular expressions from the config file to include test files') - - if 'include_regex' not in self._config_data: - raise QAValueError('The include regex field is empty in the config file', Config.LOGGER.error) - - self.include_regex = self._config_data['include_regex'] - - def __read_group_files(self): - """Read from the config file the file name to be identified in a group. - - Raises: - QAValueError: The group files field is empty in config file - """ - Config.LOGGER.debug('Reading group files from the config file') - - if 'group_files' not in self._config_data: - raise QAValueError('The group files field is empty in config file', Config.LOGGER.error) - - self.group_files = self._config_data['group_files'] - - def __read_function_regex(self): - """Read from the config file the regexes used to identify a test method. - - Raises: - QAValueError: The function regex field is empty in the config file - """ - Config.LOGGER.debug('Reading the regular expressions to include test methods from the config file') - - if 'function_regex' not in self._config_data: - raise QAValueError('The function regex field is empty in the config file', Config.LOGGER.error) - - self.function_regex = self._config_data['function_regex'] - - def __read_ignore_paths(self): - """Read from the config file all the paths to be excluded from the parsing.""" - - if 'ignore_paths' in self._config_data: - ignore_paths = self._config_data['ignore_paths'] - - for path in ignore_paths: - self.ignore_paths.append(os.path.join(self.project_path, path)) - def __read_module_fields(self): - """Read from the config file the optional and mandatory fields for the test module. + """Read from the schema file the optional and mandatory fields for the test module. - If the module block fields are not defined in the config file, an error will be raised. + If the module block fields are not defined in the schema file, an error will be raised. Raises: - QAValueError: module fields are missing in the config file - QAValueError: mandatory module fields are missing in the config file + QAValueError: module fields are missing in the schema file + QAValueError: mandatory module fields are missing in the schema file """ - Config.LOGGER.debug('Reading mandatory and optional module fields from the config file') + Config.LOGGER.debug('Reading mandatory and optional module fields from the schema file') if 'module' not in self._config_data['output_fields']: - raise QAValueError('module fields are missing in the config file', Config.LOGGER.error) + raise QAValueError('module fields are missing in the schema file', Config.LOGGER.error) module_fields = self._config_data['output_fields']['module'] if 'mandatory' not in module_fields and 'optional' not in module_fields: - raise QAValueError('mandatory module fields are missing in the config file', Config.LOGGER.error) + raise QAValueError('mandatory module fields are missing in the schema file', Config.LOGGER.error) if 'mandatory' in module_fields: self.module_fields.mandatory = module_fields['mandatory'] @@ -209,23 +161,23 @@ def __read_module_fields(self): self.module_fields.optional = module_fields['optional'] def __read_test_fields(self): - """Read from the config file the optional and mandatory fields for the test functions. + """Read from the schema file the optional and mandatory fields for the test functions. - If the test block fields are not defined in the config file, an error will be raised. + If the test block fields are not defined in the schema file, an error will be raised. Raises: - QAValueError: test_fields are missing in the config file - QAValueError: mandatory module fields are missing in the config file + QAValueError: test_fields are missing in the schema file + QAValueError: mandatory module fields are missing in the schema file """ - Config.LOGGER.debug('Reading mandatory and optional test fields from the config file') + Config.LOGGER.debug('Reading mandatory and optional test fields from the schema file') if 'test' not in self._config_data['output_fields']: - raise QAValueError('test_fields are missing in the config file', Config.LOGGER.error) + raise QAValueError('test_fields are missing in the schema file', Config.LOGGER.error) test_fields = self._config_data['output_fields']['test'] if 'mandatory' not in test_fields and 'optional' not in test_fields: - raise QAValueError('mandatory module fields are missing in the config file', Config.LOGGER.error) + raise QAValueError('mandatory module fields are missing in the schema file', Config.LOGGER.error) if 'mandatory' in test_fields: self.test_fields.mandatory = test_fields['mandatory'] @@ -234,20 +186,20 @@ def __read_test_fields(self): self.test_fields.optional = test_fields['optional'] def __read_output_fields(self): - """Read all the mandatory and optional fields from config file. + """Read all the mandatory and optional fields from schema file. Raises: - QAValueError: Documentation schema not defined in the config file + QAValueError: Documentation schema not defined in the schema file """ if 'output_fields' not in self._config_data: - raise QAValueError('Documentation schema not defined in the config file', Config.LOGGER.error) + raise QAValueError('Documentation schema not defined in the schema file', Config.LOGGER.error) self.__read_module_fields() self.__read_test_fields() def __read_test_cases_field(self): - """Read from the configuration file the key to identify a Test Case list.""" - Config.LOGGER.debug('Reading Test Case key from the config file') + """Read from the schema file the key to identify a Test Case list.""" + Config.LOGGER.debug('Reading Test Case key from the schema file') if 'test_cases_field' in self._config_data: self.test_cases_field = self._config_data['test_cases_field'] diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 69616db307..9ab0411df5 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -31,7 +31,7 @@ def __init__(self, index, config): Initialize every attribute. Args: - config (Config): A `Config` instance with the loaded data from the config file. + config (Config): A `Config` instance with the loaded configuration. """ self.path = config.documentation_path self.index = index diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index 6f46e7665b..a2b11285a2 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -19,7 +19,7 @@ class Sanity(): It is in charge of walk every documentation file, and every group file to dump the parsed documentation. Attributes: - conf (Config): A `Config` instance with the loaded data from the config file. + conf (Config): A `Config` instance with the loaded configuration. files_regex (re): A regular expression to get the JSON files previously generated. error_reports (list): A list that contains all the errors obtained within the check. found_tags (set): A set with all the tags found within the check. @@ -35,7 +35,7 @@ def __init__(self, config): Initialize every attribute. Args: - config (Config): A `Config` instance with the loaded data from the config file. + config (Config): A `Config` instance with the loaded configuration. """ self.conf = config self.files_regex = re.compile("^(?!.*group)test.*json$", re.IGNORECASE) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 42fd089845..3aeb8fbcf8 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -45,7 +45,7 @@ def check_incompatible_parameters(parameters): or parameters.test_exist or parameters.test_types or parameters.test_modules if parameters.test_config and default_run: - raise QAValueError('The -t, --test-config parameter is incompatible with -T, -i, -l, -T, -e options. ' + raise QAValueError('The -t, --temst-config paraeter is incompatible with -T, -i, -l, -T, -e options. ' 'This option tests the configuration loaded for debugging purposes.', qadocs_logger.error) @@ -66,10 +66,7 @@ def check_incompatible_parameters(parameters): qadocs_logger.error) def validate_parameters(parameters, parser): - """Validate the parameters that qa-docs recieves. - - Since `config.yaml` will be `schema.yaml`, it runs as config file is correct. - So we only validate the parameters that the user introduces. + """Validate the parameters that qa-docs receives. Args: parameters (list): A list of input args. From 98c4e1324038c7eaa382a4128c78168afc818adb Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 1 Oct 2021 13:13:38 +0200 Subject: [PATCH 056/181] refac: Remove the old `-t` behaviour. #1864 As `qa-docs` does not have `config.yaml` anymore, this argument has been deprecated. Now the `-t` parameter is going to be used as the old `-T` to parse a list of tests. --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 3aeb8fbcf8..738c12de2f 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -123,9 +123,6 @@ def main(): parser.add_argument('-v', '--version', action='store_true', dest="version", help="Print qa-docs version.") - parser.add_argument('-t', '--test-cfg', action='store_true', dest='test_config', - help="Load test configuration.") - parser.add_argument('-d', '--debug', action='count', dest='debug_level', help="Enable debug messages.") @@ -144,7 +141,7 @@ def main(): parser.add_argument('--modules', nargs='+', default=[], dest='test_modules', help="Parse the tests from modules(s) that you pass as argument.") - parser.add_argument('-T', '--tests', nargs='+', default=[], dest='test_names', + parser.add_argument('-t', '--tests', nargs='+', default=[], dest='test_names', help="Parse the test(s) that you pass as argument.") parser.add_argument('-o', dest='output_path', @@ -172,12 +169,6 @@ def main(): if args.version: print(f"qa-docs v{VERSION}") - # Load configuration if you want to test it - elif args.test_config: - qadocs_logger.debug('Loading qa-docs configuration') - Config(SCHEMA_PATH, args.tests_path) - qadocs_logger.debug('qa-docs configuration loaded') - # Run a sanity check thru tests directory elif args.sanity: sanity = Sanity(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) From 1047b33482c099012c78d4ac4a2466e080db1cd5 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 1 Oct 2021 13:22:33 +0200 Subject: [PATCH 057/181] fix: Remove old `-t` reference. --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 738c12de2f..e66d82fe14 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -44,13 +44,8 @@ def check_incompatible_parameters(parameters): default_run = parameters.index_name or parameters.app_index_name or parameters.test_names \ or parameters.test_exist or parameters.test_types or parameters.test_modules - if parameters.test_config and default_run: - raise QAValueError('The -t, --temst-config paraeter is incompatible with -T, -i, -l, -T, -e options. ' - 'This option tests the configuration loaded for debugging purposes.', - qadocs_logger.error) - if parameters.tests_path is None and default_run: - raise QAValueError('The following options need the path where the tests are located: -t, -T, --test, ' + raise QAValueError('The following options need the path where the tests are located: -t, --test, ' ' -e, --exist, --types, --modules, -s, --sanity-check. You must specify it by using ' '-I, --tests-path path_to_tests.', qadocs_logger.error) From 7900c315a0280e915c21f049d7cbcff3748b87f6 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 1 Oct 2021 13:31:29 +0200 Subject: [PATCH 058/181] fix: Fix and refac the parameters checking. #1864 --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index e66d82fe14..c2eea9538c 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -41,10 +41,10 @@ def check_incompatible_parameters(parameters): Args: parameters (argparse.Namespace): The parameters that the tool receives. """ - default_run = parameters.index_name or parameters.app_index_name or parameters.test_names \ - or parameters.test_exist or parameters.test_types or parameters.test_modules + default_run = parameters.index_name or parameters.app_index_name or parameters.test_types or parameters.test_modules - if parameters.tests_path is None and default_run: + + if parameters.tests_path is None and (default_run or parameters.test_names or parameters.test_exist): raise QAValueError('The following options need the path where the tests are located: -t, --test, ' ' -e, --exist, --types, --modules, -s, --sanity-check. You must specify it by using ' '-I, --tests-path path_to_tests.', From 1dd8ad676928ff8f4653d49fb1075f0104b5d7bb Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 1 Oct 2021 13:49:39 +0200 Subject: [PATCH 059/181] refac: Refactorize some args and add `-il` arg. #1864 Now you can run independently the indexing, the launching, and also both together. --- .../wazuh_testing/scripts/qa_docs.py | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index c2eea9538c..7c3fe36e5b 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -51,13 +51,13 @@ def check_incompatible_parameters(parameters): qadocs_logger.error) if parameters.output_path and default_run: - raise QAValueError('The -o parameter only works with -T, --tests options in isolation. The default output ' + raise QAValueError('The -o parameter only works with -t, --tests options in isolation. The default output ' 'path is generated within the qa-docs tool to index it and visualize it.', qadocs_logger.error) if (parameters.test_types or parameters.test_modules) and parameters.test_names: raise QAValueError('The --types, --modules parameters parse the data, index it, and visualize it, so it cannot be used with ' - '-T, --tests because they get specific tests information.', + '-t, --tests because they get specific tests information.', qadocs_logger.error) def validate_parameters(parameters, parser): @@ -124,11 +124,8 @@ def main(): parser.add_argument('-I', '--tests-path', dest='tests_path', help="Path where tests are located.") - parser.add_argument('-i', '--index-data', dest='index_name', - help="Indexes the data named as you specify as argument to elasticsearch.") - - parser.add_argument('-l', '--launch-ui', dest='app_index_name', - help="Indexes the data named as you specify as argument and launch SearchUI.") + parser.add_argument('-t', '--tests', nargs='+', default=[], dest='test_names', + help="Parse the test(s) that you pass as argument.") parser.add_argument('--types', nargs='+', default=[], dest='test_types', help="Parse the tests from type(s) that you pass as argument.") @@ -136,11 +133,17 @@ def main(): parser.add_argument('--modules', nargs='+', default=[], dest='test_modules', help="Parse the tests from modules(s) that you pass as argument.") - parser.add_argument('-t', '--tests', nargs='+', default=[], dest='test_names', - help="Parse the test(s) that you pass as argument.") + parser.add_argument('-i', '--index-data', dest='index_name', + help="Indexes the data named as you specify as argument to elasticsearch.") + + parser.add_argument('-l', '--launch-ui', dest='app_index_name', + help="Launch SearchUI using the index that you specify.") + + parser.add_argument('-il', dest='launching_index_name', + help="Indexes the data named as you specify as argument and launch SearchUI.") parser.add_argument('-o', dest='output_path', - help="Specifies the output directory for test parsed when `-T, --tests` is used.") + help="Specifies the output directory for test parsed when `-t, --tests` is used.") parser.add_argument('-e', '--exist', nargs='+', default=[], dest='test_exist', help="Checks if test(s) exist or not.",) @@ -176,11 +179,19 @@ def main(): index_data = IndexData(args.index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() - # Index the previous parsed tests into Elasticsearch and then launch SearchUI + # Launch SearchUI with the elif args.app_index_name: - qadocs_logger.debug(f"Indexing {args.index_name}") - index_data = IndexData(args.app_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + # When SearchUI index is not hardcoded, it will be use args.app_index_name + os.chdir(SEARCH_UI_PATH) + qadocs_logger.debug('Running SearchUI') + os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") + + # Index the previous parsed tests into Elasticsearch and then launch SearchUI + elif args.launching_index_name: + qadocs_logger.debug(f"Indexing {args.launching_index_name}") + index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() + # When SearchUI index is not hardcoded, it will be use args.launching_index_name os.chdir(SEARCH_UI_PATH) qadocs_logger.debug('Running SearchUI') os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") From 2876ce4dcc381d6a3608aa46132605dedbf7ce2b Mon Sep 17 00:00:00 2001 From: mdengra Date: Mon, 27 Sep 2021 13:44:02 +0200 Subject: [PATCH 060/181] fix: Replace backslash in paths used as arguments Replace backslash with the slash in the paths where the input or output directory is specified. It seems that from Python version 3.7 onwards backslashes are interpreted as escape characters by the re module. This causes problems in Windows paths. Closes: #1930 Also, bc610a325dd4c9ffd5cf8459b870a0ebc5b7c275 added --- deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py | 4 ++-- deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index e705d41b17..ba9bde04fc 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -46,11 +46,11 @@ def __init__(self, config): self.__id_counter = 0 self.ignore_regex = [] for ignore_regex in self.conf.ignore_paths: - self.ignore_regex.append(re.compile(ignore_regex)) + self.ignore_regex.append(re.compile(ignore_regex.replace('\\','/'))) self.include_regex = [] if self.conf.mode == Mode.DEFAULT: for include_regex in self.conf.include_regex: - self.include_regex.append(re.compile(include_regex)) + self.include_regex.append(re.compile(include_regex.replace('\\','/'))) def is_valid_folder(self, path): """Check if a folder is included so it would be parsed. diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 4875968622..4dee939a3f 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -74,7 +74,7 @@ def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_ self.__read_schema_file(schema_path) self.__read_output_fields() self.__read_test_cases_field() - self.__set_documentation_path(output_path) + self.__set_documentation_path(output_path.replace('\\','/')) if test_names is not None: # When a name is passed, it is using just a single test. From f39dfbbf0a47f8dfc088f4fb854ca5b3d7e848ce Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 4 Oct 2021 09:13:42 +0200 Subject: [PATCH 061/181] add: Install the SearchUI dependencies if necessary. #1877 --- .../wazuh_testing/qa_docs/doc_generator.py | 4 ++-- .../wazuh_testing/scripts/qa_docs.py | 21 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index ba9bde04fc..a975a1d1f7 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -46,11 +46,11 @@ def __init__(self, config): self.__id_counter = 0 self.ignore_regex = [] for ignore_regex in self.conf.ignore_paths: - self.ignore_regex.append(re.compile(ignore_regex.replace('\\','/'))) + self.ignore_regex.append(re.compile(ignore_regex.replace('\\', '/'))) self.include_regex = [] if self.conf.mode == Mode.DEFAULT: for include_regex in self.conf.include_regex: - self.include_regex.append(re.compile(include_regex.replace('\\','/'))) + self.include_regex.append(re.compile(include_regex.replace('\\', '/'))) def is_valid_folder(self, path): """Check if a folder is included so it would be parsed. diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 7c3fe36e5b..9b635a9df3 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -105,6 +105,19 @@ def validate_parameters(parameters, parser): qadocs_logger.debug('Input parameters validation successfully finished') +def install_searchui_deps(): + """Install SearchUI dependencies if needed""" + if not os.path.exists(os.path.join(SEARCH_UI_PATH, 'node_modules')): + qadocs_logger.info('Installing SearchUI dependencies') + os.system("npm install") + +def run_searchui(): + """Run SearchUI installing its dependencies if necessary""" + os.chdir(SEARCH_UI_PATH) + install_searchui_deps() + qadocs_logger.debug('Running SearchUI') + os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") + def main(): parser = argparse.ArgumentParser(add_help=False) @@ -182,9 +195,7 @@ def main(): # Launch SearchUI with the elif args.app_index_name: # When SearchUI index is not hardcoded, it will be use args.app_index_name - os.chdir(SEARCH_UI_PATH) - qadocs_logger.debug('Running SearchUI') - os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") + run_searchui() # Index the previous parsed tests into Elasticsearch and then launch SearchUI elif args.launching_index_name: @@ -192,9 +203,7 @@ def main(): index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() # When SearchUI index is not hardcoded, it will be use args.launching_index_name - os.chdir(SEARCH_UI_PATH) - qadocs_logger.debug('Running SearchUI') - os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") + run_searchui() # Parse tests elif not args.test_exist: From 750c77ba5395e09c3aeaee2f20e5bfa92e0a1aaf Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 4 Oct 2021 09:40:50 +0200 Subject: [PATCH 062/181] fix: Fix index name hardcoding. #1864 Now you can use the index name you want. --- .../wazuh_testing/qa_docs/search_ui/functions/search.js | 3 ++- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/search_ui/functions/search.js b/deps/wazuh_testing/wazuh_testing/qa_docs/search_ui/functions/search.js index 977b62c003..165bfc0bbf 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/search_ui/functions/search.js +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/search_ui/functions/search.js @@ -15,8 +15,9 @@ const httpAgent = new http.Agent(); exports.handler = function(event, context, callback) { const host = process.env.ELASTICSEARCH_HOST; const agent = host.startsWith("http:") ? httpAgent : httpsAgent; + const index = process.env.INDEX; - fetch(`${host}/qa-doc/_search`, { + fetch(`${host}/${index}/_search`, { method: "POST", headers: { "content-type": "application/json" }, body: event.body, diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 9b635a9df3..8e85f806a5 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -111,12 +111,12 @@ def install_searchui_deps(): qadocs_logger.info('Installing SearchUI dependencies') os.system("npm install") -def run_searchui(): +def run_searchui(index): """Run SearchUI installing its dependencies if necessary""" os.chdir(SEARCH_UI_PATH) install_searchui_deps() qadocs_logger.debug('Running SearchUI') - os.system("ELASTICSEARCH_HOST=http://localhost:9200 npm start") + os.system(f"ELASTICSEARCH_HOST=http://localhost:9200 INDEX={index} npm start") def main(): @@ -195,7 +195,7 @@ def main(): # Launch SearchUI with the elif args.app_index_name: # When SearchUI index is not hardcoded, it will be use args.app_index_name - run_searchui() + run_searchui(args.app_index_name) # Index the previous parsed tests into Elasticsearch and then launch SearchUI elif args.launching_index_name: @@ -203,7 +203,7 @@ def main(): index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() # When SearchUI index is not hardcoded, it will be use args.launching_index_name - run_searchui() + run_searchui(args.launching_index_name) # Parse tests elif not args.test_exist: From 8bd5fa0001d60588e140b113b7a589926405da45 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 4 Oct 2021 12:48:18 +0200 Subject: [PATCH 063/181] add: Add the predefined values checking. #1864 Now, the field values that `qa-docs` parse are compared with the predefined values located in https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#pre-defined-values. --- .../wazuh_testing/qa_docs/lib/code_parser.py | 70 +++++- .../wazuh_testing/qa_docs/lib/config.py | 60 +++-- .../wazuh_testing/qa_docs/schema.yaml | 213 +++++++++++++++++- 3 files changed, 315 insertions(+), 28 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 243d8f1a7c..a9e42d33b4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -11,6 +11,7 @@ from wazuh_testing.qa_docs.lib.utils import remove_inexistent from wazuh_testing.qa_docs import QADOCS_LOGGER from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.exceptions import QAValueError INTERNAL_FIELDS = ['id', 'group_id', 'name'] STOP_FIELDS = ['tests', 'test_cases'] @@ -69,16 +70,65 @@ def remove_ignored_fields(self, doc): for test in doc['tests']: remove_inexistent(test, allowed_fields, STOP_FIELDS) - def parse_comment(self, function): + def check_predefined_values(self, doc, doc_type, path): + """Check if the documentation block follows the predefined values. + + It iterates through the predefined values and checks if the documentation fields contain correct values. + If the field does not exist or does not contain a predefined value, it would log it. + + The predefined values are stored in + https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#pre-defined-values. + + Args: + doc (dict): A dict with the documentation block parsed. + doc_type (str): A string that specifies which type of documentation block is. + path (str): A string with the file path. + """ + for field in self.conf.predefined_values[f"{doc_type}_fields"]: + try: + doc_field = doc[field] + except KeyError: + CodeParser.LOGGER.error(f"{field} field missing in {path} {doc_type}") + doc_field = None + + # If the field is a list, iterate thru predefined values + if isinstance(doc_field, list): + for value in doc_field: + if value not in self.conf.predefined_values[field]: + CodeParser.LOGGER.error(f"{field} field in {path} {doc_type} documentation block " + f"has an invalid value: {value}. " + f"Follow the predefined values: {self.conf.predefined_values[field]}." + "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" + "Documenting-tests-using-the-qadocs-schema#pre-defined-values.") + # raise QAValueError(f"{field} field in {path} {doc_type} has an invalid value: {value}. " + # f"Follow the predefined values: {self.conf.predefined_values[field]}" + # , CodeParser.LOGGER.error) + else: + if doc_field not in self.conf.predefined_values[field] and doc_field is not None: + CodeParser.LOGGER.error(f"{field} field in {path} {doc_type} documentation block " + f"has an invalid value: {doc_type}. " + f"Follow the predefined values: {self.conf.predefined_values[field]}" + "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" + "Documenting-tests-using-the-qadocs-schema#pre-defined-values.") + # raise QAValueError(f"{field} field in {path} {doc_type} has an invalid value: {doc_field}. " + # f"Follow the predefined values: {self.conf.predefined_values[field]}" + # , CodeParser.LOGGER.error) + + def parse_comment(self, function, doc_type, path): """Parse one self-contained documentation block. Args: function (_ast.FunctionDef): Function class with all the information of the method" + doc_type (str): A string that specifies which type of documentation block is. + path (str): A string with the file path. Returns: doc (dict): A dictionary with the documentation block parsed. """ docstring = ast.get_docstring(function) + if not docstring: + CodeParser.LOGGER.error(f"Documentation block not found in {path}") + # raise QAValueError(f"Documentation block not found in {path}", CodeParser.LOGGER.error) try: doc = yaml.safe_load(docstring) @@ -88,12 +138,18 @@ def parse_comment(self, function): except Exception as inst: if hasattr(function, 'name'): - CodeParser.LOGGER.warning(f"Failed to parse test documentation in {function.name} " - "from module {self.scan_file}. Error: {inst}") + CodeParser.LOGGER.error(f"Failed to parse test documentation in {function.name} " + "from module {self.scan_file}. Error: {inst}") + # raise QAValueError(f"Failed to parse test documentation in {function.name} " + # "from module {self.scan_file}. Error: {inst}", CodeParser.LOGGER.error) else: - CodeParser.LOGGER.warning(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}") + CodeParser.LOGGER.error(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}") + # raise QAValueError(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}" + # , CodeParser.LOGGER.error) + return None - doc = None + CodeParser.LOGGER.debug(f"Checking that the documentation block within {path} follow the predefined values.") + self.check_predefined_values(doc, doc_type, path) return doc @@ -115,7 +171,7 @@ def parse_test(self, path, id, group_id): module = ast.parse(file_content) functions = [node for node in module.body if isinstance(node, ast.FunctionDef)] - module_doc = self.parse_comment(module) + module_doc = self.parse_comment(module, 'module', path) if module_doc: module_doc['name'] = os.path.basename(path) module_doc['id'] = id @@ -129,7 +185,7 @@ def parse_test(self, path, id, group_id): functions_doc = [] for function in functions: if self.is_documentable_function(function): - function_doc = self.parse_comment(function) + function_doc = self.parse_comment(function, 'test', path) if function_doc: if test_cases and not (self.conf.test_cases_field in function_doc) \ diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 4dee939a3f..756e639e60 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -70,11 +70,13 @@ def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_ self.test_cases_field = None self.test_types = [] self.test_modules = [] + self.predefined_values = {} self.__read_schema_file(schema_path) self.__read_output_fields() self.__read_test_cases_field() self.__set_documentation_path(output_path.replace('\\','/')) + self.__read_predefined_values() if test_names is not None: # When a name is passed, it is using just a single test. @@ -92,19 +94,6 @@ def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_ # Get the paths to parse self.__get_include_paths() - def __read_schema_file(self, file): - """Read schema file. - - Raises: - QAValuerError (IOError): Cannot load schema file. - """ - try: - Config.LOGGER.debug('Loading schema file') - with open(file) as config_file: - self._config_data = yaml.safe_load(config_file) - except IOError: - raise QAValueError('Cannot load schema file', Config.LOGGER.error) - def __set_documentation_path(self, path): """Set the path of the documentation output.""" Config.LOGGER.debug('Setting the path documentation') @@ -135,6 +124,37 @@ def __get_include_paths(self): if os.path.isdir(os.path.join(subset_tests, name)) and dir_regex.match(name): self.include_paths.append(os.path.join(subset_tests, name)) + def __read_schema_file(self, file): + """Read schema file. + + Args: + file (string): A string that contains the file name. + + Raises: + QAValuerError (IOError): Cannot load schema file. + """ + try: + Config.LOGGER.debug('Loading schema file') + with open(file) as config_file: + self._schema_data = yaml.safe_load(config_file) + except IOError: + raise QAValueError('Cannot load schema file', Config.LOGGER.error) + + def __read_predefined_values(self): + """Read from the schema file the predefined values for the documentation fields. + + If predefined values are not defined in the schema file, an error will be raised. + + Raises: + QAValueError: predefined values are missing in the schema file + """ + Config.LOGGER.debug('Reading predefined values from the schema file') + + if not self._schema_data['predefined_values']: + raise QAValueError('predefined values are missing in the schema file', Config.LOGGER.error) + + self.predefined_values = self._schema_data['predefined_values'] + def __read_module_fields(self): """Read from the schema file the optional and mandatory fields for the test module. @@ -146,10 +166,10 @@ def __read_module_fields(self): """ Config.LOGGER.debug('Reading mandatory and optional module fields from the schema file') - if 'module' not in self._config_data['output_fields']: + if 'module' not in self._schema_data['output_fields']: raise QAValueError('module fields are missing in the schema file', Config.LOGGER.error) - module_fields = self._config_data['output_fields']['module'] + module_fields = self._schema_data['output_fields']['module'] if 'mandatory' not in module_fields and 'optional' not in module_fields: raise QAValueError('mandatory module fields are missing in the schema file', Config.LOGGER.error) @@ -171,10 +191,10 @@ def __read_test_fields(self): """ Config.LOGGER.debug('Reading mandatory and optional test fields from the schema file') - if 'test' not in self._config_data['output_fields']: + if 'test' not in self._schema_data['output_fields']: raise QAValueError('test_fields are missing in the schema file', Config.LOGGER.error) - test_fields = self._config_data['output_fields']['test'] + test_fields = self._schema_data['output_fields']['test'] if 'mandatory' not in test_fields and 'optional' not in test_fields: raise QAValueError('mandatory module fields are missing in the schema file', Config.LOGGER.error) @@ -191,7 +211,7 @@ def __read_output_fields(self): Raises: QAValueError: Documentation schema not defined in the schema file """ - if 'output_fields' not in self._config_data: + if 'output_fields' not in self._schema_data: raise QAValueError('Documentation schema not defined in the schema file', Config.LOGGER.error) self.__read_module_fields() @@ -201,8 +221,8 @@ def __read_test_cases_field(self): """Read from the schema file the key to identify a Test Case list.""" Config.LOGGER.debug('Reading Test Case key from the schema file') - if 'test_cases_field' in self._config_data: - self.test_cases_field = self._config_data['test_cases_field'] + if 'test_cases_field' in self._schema_data: + self.test_cases_field = self._schema_data['test_cases_field'] class _fields: diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml index b518c6c156..6e4f14841f 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml @@ -27,4 +27,215 @@ output_fields: optional: - tags -test_cases_field: test_cases \ No newline at end of file +test_cases_field: test_cases + +predefined_values: + module_fields: + - type + - modules + - components + - daemons + - os_platform + - os_version + - tags + test_fields: + - wazuh_min_version + - tags + type: + - integration + - performance + - system + - unit + modules: + - active_response + - agentd + - analysisd + - api + - authd + - cluster + - fim + - gcloud + - github + - logcollector + - logtest + - office365 + - remoted + - rids + - rootcheck + - vulnerability_detector + - wazuh_db + - wpk + os_platform: + - aix + - linux + - hp-ux + - macos + - solaris + - windows + os_version: + - Amazon Linux 1 + - Amazon Linux 2 + - Arch Linux + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - CentOS 6 + - CentOS 7 + - CentOS 8 + - Fedora 31 + - Fedora 32 + - Fedora 33 + - Fedora 34 + - openSUSE 42 + - Oracle 6 + - Oracle 7 + - Oracle 8 + - Red Hat 6 + - Red Hat 7 + - Red Hat 8 + - Solaris 10 + - Solaris 11 + - SUSE 12 + - SUSE 13 + - SUSE 14 + - SUSE 15 + - Ubuntu Bionic + - Ubuntu Trusty + - Ubuntu Xenial + - Ubuntu Focal + - macOS Server + - macOS Catalina + - Windows XP + - Windows 7 + - Windows 8 + - Windows 10 + - Windows Server 2003 + - Windows server 2012 + - Windows server 2016 + components: + - agent + - manager + daemons: + - ossec-agentd + - ossec-agentlessd + - ossec-analysisd + - ossec-authd + - ossec-csyslogd + - ossec-dbd + - ossec-execd + - ossec-integratord + - ossec-logcollector + - ossec-maild + - ossec-monitord + - ossec-remoted + - ossec-reportd + - ossec-syscheckd + - wazuh-agentd + - wazuh-agentlessd + - wazuh-analysisd + - wazuh-authd + - wazuh-csyslogd + - wazuh-apid + - wazuh-clusterd + - wazuh-db + - wazuh-dbd + - wazuh-execd + - wazuh-integratord + - wazuh-logcollector + - wazuh-maild + - wazuh-monitord + - wazuh-modulesd + - wazuh-remoted + - wazuh-reportd + - wazuh-syscheckd + wazuh_min_version: + - 2.1.0 + - 3.0.0 + - 3.1.0 + - 3.2.0 + - 3.3.0 + - 3.4.0 + - 3.5.0 + - 3.6.0 + - 3.7.0 + - 3.8.0 + - 3.9.0 + - 3.10.0 + - 3.11.0 + - 3.12.0 + - 3.13.0 + - 4.0.0 + - 4.1.0 + - 4.2.0 + - 4.3.0 + tags: + - active_response + - agentd + - alerts + - analysisd + - api + - ar_analysisd + - ar_execd + - auditd + - audit_keys + - audit_rules + - authd + - aws + - brute_force_attack + - cache + - cluster + - cors + - cpe + - dos_attack + - download + - enrollment + - errors + - events + - experimental + - feeds + - fernet + - fim + - fim_ambiguous_confs + - fim_audit + - fim_basic_usage + - fim_benchmark + - fim_checks + - fim_env_variables + - fim_file_limit + - fim_follow_symbolic_link + - gcloud + - github + - integrity + - keys + - key_polling + - logcollector + - logs + - logtest + - man_in_the_middle + - master + - mitre + - msu + - nvd + - office365 + - oval + - rbac + - realtime + - remoted + - rids + - rootcheck + - rules + - scan + - settings + - simulator + - ssl + - stats_file + - time_travel + - token + - vulnerability + - vulnerability_detector + - wazuh_db + - wdb_socket + - who_data + - worker + - wpk \ No newline at end of file From 56286885f9144a1a4ad886cfcfd544b957341adc Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 5 Oct 2021 10:18:04 +0200 Subject: [PATCH 064/181] add: Add `qa-docs` docker deployment. #1797 It is working using these tests as input: #1796. --- .../wazuh_testing/qa_docs/deploy_qa_docs.sh | 2 + .../qa_docs/dockerfiles/Dockerfile | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100755 deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh create mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh new file mode 100755 index 0000000000..f78544caf4 --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh @@ -0,0 +1,2 @@ +docker build -t qadocs:0.1 dockerfiles/ +docker run qadocs:0.1 diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile new file mode 100644 index 0000000000..fc6d06a017 --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile @@ -0,0 +1,55 @@ +FROM ubuntu:focal +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y \ + git \ + python \ + python3-pip \ + curl \ + npm \ + apt-transport-https \ + lsb-release \ + gnupg + +# install ES +RUN curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | apt-key add - && \ + echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | tee -a /etc/apt/sources.list.d/elastic-7.x.list && \ + apt update && \ + apt install -y elasticsearch + +# install wazuh manager +RUN curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH | apt-key add - && \ + echo "deb https://packages.wazuh.com/4.x/apt/ stable main" | tee -a /etc/apt/sources.list.d/wazuh.list && \ + apt-get update && \ + apt-get install wazuh-manager + +RUN mkdir tests + +WORKDIR tests + +# cloning parsed tests +RUN git clone https://github.com/wazuh/wazuh-qa.git + +WORKDIR wazuh-qa + +RUN git checkout 1796-migrate-doc-schema-2 + +WORKDIR / + +# cloning and installing qa reqs +RUN git clone https://github.com/wazuh/wazuh-qa.git + +WORKDIR wazuh-qa/deps/wazuh_testing/ + +RUN git checkout 1864-qa-docs-fixes && \ + pip install -r ../../requirements.txt && \ + pip install -r wazuh_testing/qa_docs/requirements.txt && \ + python3 setup.py install + +# start services, parse some tests and launch the api +CMD service elasticsearch start && \ + service wazuh-manager start && \ + sleep 8 && \ + qa-docs -I /tests/wazuh-qa/tests --type integration --modules test_active_response test_agentd test_analysisd test_api test_authd test_cluster && \ + qa-docs -I /tests/wazuh-qa/tests -il qa-docs From 8f8c8cff864dc97f047ff96f818db1a2ca8d2048 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 5 Oct 2021 11:00:27 +0200 Subject: [PATCH 065/181] refac: Now you can specify the branch you want to parse. #1797 It uses two repos, one with the updated `qa-docs` tool and another one with the tests to parse. --- deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh | 6 ++++-- .../wazuh_testing/qa_docs/dockerfiles/Dockerfile | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh index f78544caf4..1e91c0cc11 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh @@ -1,2 +1,4 @@ -docker build -t qadocs:0.1 dockerfiles/ -docker run qadocs:0.1 +branch_name=$1 + +docker build -t qadocs/$branch_name:0.1 --build-arg BRANCH=$branch_name dockerfiles/ +docker run qadocs/$branch_name:0.1 diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile index fc6d06a017..88576d513a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile @@ -33,7 +33,9 @@ RUN git clone https://github.com/wazuh/wazuh-qa.git WORKDIR wazuh-qa -RUN git checkout 1796-migrate-doc-schema-2 +ARG BRANCH + +RUN git checkout ${BRANCH} WORKDIR / @@ -51,5 +53,5 @@ RUN git checkout 1864-qa-docs-fixes && \ CMD service elasticsearch start && \ service wazuh-manager start && \ sleep 8 && \ - qa-docs -I /tests/wazuh-qa/tests --type integration --modules test_active_response test_agentd test_analysisd test_api test_authd test_cluster && \ + qa-docs -I /tests/wazuh-qa/tests --types integration && \ qa-docs -I /tests/wazuh-qa/tests -il qa-docs From 1f2bb30348ca8a835b3a758487189cd3be9607b1 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 5 Oct 2021 11:18:55 +0200 Subject: [PATCH 066/181] refac: Remove useless dockerfile line. #1797 --- deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh index 1e91c0cc11..229a96ef30 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh @@ -1,4 +1,3 @@ branch_name=$1 docker build -t qadocs/$branch_name:0.1 --build-arg BRANCH=$branch_name dockerfiles/ -docker run qadocs/$branch_name:0.1 From 2297dbdc5a447d4b4dffdfe75d5c8afdc09e6ac4 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 5 Oct 2021 12:56:44 +0200 Subject: [PATCH 067/181] refac: Separate the build into two images. #1797 One for the dependencies installation and another one to parse the tests from the specified branch. --- .../wazuh_testing/qa_docs/deploy_qa_docs.sh | 11 +++++- .../{Dockerfile => qa_docs_base.Dockerfile} | 34 ++++++------------- .../dockerfiles/qa_docs_tool.Dockerfile | 23 +++++++++++++ 3 files changed, 43 insertions(+), 25 deletions(-) rename deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/{Dockerfile => qa_docs_base.Dockerfile} (67%) create mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh index 229a96ef30..fafb4af263 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh @@ -1,3 +1,12 @@ +#!/bin/sh branch_name=$1 -docker build -t qadocs/$branch_name:0.1 --build-arg BRANCH=$branch_name dockerfiles/ +if [ "$#" -ne 1 ]; +then + echo "The branch where the tests to parse are located is missing:\n\n$0 BRANCH" >&2 + exit 1 +fi + +docker build -t qa-docs_base:0.1 -f dockerfiles/qa_docs_base.Dockerfile dockerfiles/ +docker build -t qa-docs/$branch_name:0.1 --build-arg BRANCH=$branch_name -f dockerfiles/qa_docs_tool.Dockerfile dockerfiles/ +docker run qa-docs/$branch_name:0.1 diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile similarity index 67% rename from deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile rename to deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile index 88576d513a..cfbfe0820d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile @@ -1,6 +1,7 @@ FROM ubuntu:focal ENV DEBIAN_FRONTEND=noninteractive +# install packages RUN apt-get update && \ apt-get install -y \ git \ @@ -24,34 +25,19 @@ RUN curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH | apt-key add - && \ apt-get update && \ apt-get install wazuh-manager -RUN mkdir tests - -WORKDIR tests - -# cloning parsed tests -RUN git clone https://github.com/wazuh/wazuh-qa.git - -WORKDIR wazuh-qa - -ARG BRANCH - -RUN git checkout ${BRANCH} - -WORKDIR / - # cloning and installing qa reqs +WORKDIR /home + RUN git clone https://github.com/wazuh/wazuh-qa.git -WORKDIR wazuh-qa/deps/wazuh_testing/ +WORKDIR /home/wazuh-qa/deps/wazuh_testing/ RUN git checkout 1864-qa-docs-fixes && \ pip install -r ../../requirements.txt && \ pip install -r wazuh_testing/qa_docs/requirements.txt && \ - python3 setup.py install - -# start services, parse some tests and launch the api -CMD service elasticsearch start && \ - service wazuh-manager start && \ - sleep 8 && \ - qa-docs -I /tests/wazuh-qa/tests --types integration && \ - qa-docs -I /tests/wazuh-qa/tests -il qa-docs + python3 setup.py install + +# install npm deps +WORKDIR /home/wazuh-qa/deps/wazuh_testing/wazuh_testing/qa_docs/search_ui + +RUN npm install diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile new file mode 100644 index 0000000000..57cd4cbb27 --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile @@ -0,0 +1,23 @@ +FROM qa-docs_base:0.1 + +RUN mkdir tests + +WORKDIR /home/tests + +# cloning parsed tests +RUN git clone https://github.com/wazuh/wazuh-qa.git + +WORKDIR /home/tests/wazuh-qa + +ARG BRANCH + +RUN git checkout ${BRANCH} + +WORKDIR /home/wazuh-qa/deps/wazuh_testing + +# start services, parse some tests and launch the api +CMD service elasticsearch start && \ + service wazuh-manager start && \ + sleep 8 && \ + qa-docs -I /home/tests/wazuh-qa/tests --types integration && \ + qa-docs -I /home/tests/wazuh-qa/tests -il qa-docs From 78a62edb82577e09f6ab3c9f875ef9fd5e01247a Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 5 Oct 2021 13:16:40 +0200 Subject: [PATCH 068/181] fix: Fix `npm install` path. #1797 The `sleep 8` has been deleted because now that the wazuh manager is starting is not needed. --- .../wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile | 2 +- .../wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile index cfbfe0820d..27a46b63d0 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile @@ -38,6 +38,6 @@ RUN git checkout 1864-qa-docs-fixes && \ python3 setup.py install # install npm deps -WORKDIR /home/wazuh-qa/deps/wazuh_testing/wazuh_testing/qa_docs/search_ui +WORKDIR /home/wazuh-qa/deps/wazuh_testing/build/lib/wazuh_testing/qa_docs/search_ui RUN npm install diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile index 57cd4cbb27..91933b60de 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile @@ -18,6 +18,5 @@ WORKDIR /home/wazuh-qa/deps/wazuh_testing # start services, parse some tests and launch the api CMD service elasticsearch start && \ service wazuh-manager start && \ - sleep 8 && \ qa-docs -I /home/tests/wazuh-qa/tests --types integration && \ qa-docs -I /home/tests/wazuh-qa/tests -il qa-docs From da2fdbad1a51b3d91d3eb5274ecd2dac4f54056f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 6 Oct 2021 12:46:45 +0200 Subject: [PATCH 069/181] refac: Update `qa-docs` README. #1864 --- .../wazuh_testing/qa_docs/README.md | 236 ++++++++---------- 1 file changed, 103 insertions(+), 133 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index ed18a09e24..16f67b3f92 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -1,25 +1,30 @@ -# Wazuh QA DocGenerator -Wazuh - Quality assurance automation self-contained documentation parsing tool. +# Wazuh `qa-docs` +Wazuh - Quality Assurance automation self-contained documentation parsing tool. ## Rational Wazuh QA documentation is designed to be self-contained into the source code of each test. -DocGenerator is the tool in charge of parsing the documentation block from each source code and generate data capable -to be indexed and displayed. +`qa-docs` is the tool in charge of parsing the documentation block from each source code and generate data capable +to be indexed and displayed. It has two modules well defined: `DocGenerator` and `SearchUI`. + +### DocGenerator + +It is the module that parses and indexes the data to ElasticSearch. + +### SearchUI + +It is a search engine that allows to search and visualize the data previously indexed to ElasticSearch. ## Design ### Input -DocGenerator parses the information from test files containing a specific comment format: +`DocGenerator` parses the information from test files containing a specific comment format: Each test file has self-contained documentation comment blocks in **YAML** format. -These blocks can contain **Mandatory**, **Optional**, and **Ignored** fields. -In the [Configuration section](#configuration) it's explained how to configure these fields. +These blocks can contain **Mandatory** and **Optional** fields. - **Mandatory** fields must be present in the documentation block and will be added to the final documentation output files. - **Optional** fields, if present, will be parsed and added to the final documentation output files. -- **Ignored** fields are the ones not listed in the configuration file. They can be added by the developer to explain -a specific functionality without including this information in the final documentation output files. Each test file has a header docstring that details information related to the whole test file. And each test method inside the file will have a test documentation block with the specific information of @@ -31,16 +36,17 @@ a group, and every test file at the same or lower folder level is considered as Also, groups could be nested, so a README file found under the level of a group will generate a new group that belongs to the first one. -The specific content of each block is defined in the [Documentation schema section](#documentation-schema) +The specific content of each block is defined in the [Documentation schema section](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema) +within the `qa-docs` wiki. ### Parsing -Running DocGenerator as specified in the [Usage section](#usage) will scan every test and group file found into the +Running `qa-docs` as specified in the [Usage section](#usage) will scan every test and group file found into the include paths of the documentation, it will extract the module and tests blocks from each test file and discard any non-documentable field. Also, complementary test-cases information will be extracted from a dry-run of pytest if there isn´t a description for them. ### Output -The parsed and filtered documentation information will be added to the Output folder defined by configuration. +The parsed and filtered documentation information will be added to the output folder within `qa-docs` build installation. Each test file will generate a JSON and a YAML file with the documentation information. Each of these files contains a structure with the module description and every test function in the module. Each test function @@ -53,7 +59,7 @@ After the generation of the documentation output, a sanity check can be executed any missing mandatory field. ### Indexing -The JSON files generated by DocGenerator are intended to be indexed into elasticsearch and later be displayed by the Search-UI App. +The JSON files generated by `qa-docs` are intended to be indexed into elasticsearch and later be displayed by the Search-UI App. So, DocGenerator treats each JSON file as a document that will be added to elasticsearch index. ### Local launch @@ -63,114 +69,56 @@ documentation content into the App UI. ### Diagram ![DocGenerator](DocGenerator_diagram.png) -### Documentation schema -| Version | -| --- | -| 0.1 | - -**Module block** This block will be at the top of the python module: -|**Name**|**Type**|**Requirement**|**Description**| -|---|---|---|---| -|brief|String|Mandatory|Module description| -|metada|Block|Mandatory|Canister for below items| -|component|List|Mandatory|The Wazuh component (Manager/Agent) aimed with the test| -|modules|List|Mandatory|Modules tested| -|daemons|List|Mandatory|Daemons running during the test| -|operating_systems|List|Mandatory|OS where the tests should be run| -|tiers|List|Optional|Useful tags for searching purposes| -|tags|List|Optional|Useful tags for searching purposes| - -Here is an example of how this Documentation block should look like and the hierarchy of these fields. - - ''' - brief: Module description - metadata: - modules: - - Wazuh DB - daemons: - - wazuh_db - component: - - Manager - operating_systems: - - Windows - - Ubuntu - tiers: - - 0 - - 1 - tags: - - Enrollment - ''' - -**Tests block** This block will be at the description comment of each function test: -|**Name**|**Type**|**Requirement**|**Description**| -|---|---|---|---| -|test_logic|String|Mandatory|The main description of what the test does| -|checks|List|Mandatory|A list of what the test checks| - -Here is an example of how this Documentation block should look like and the hierarchy of these fields. - - """ - test_logic: - "Check that Wazuh DB creates the agent database when a query with a new agent ID is sent. - - Also..." - checks: - - The received output must match with... - - The received output with regex must match with... - """ - ## Content - ├── README.md - ├── DocGenerator.py | The main module and the entry point of the tool execution - ├── config.yaml | The configuration file of the tool - ├── lib - │ ├── Config.py | The module in charge of parsing the configuration file - │ ├── CodeParser.py | The module in charge of parsing documentation blocks - │ ├── PytestWrap.py | The module in charge of dry-running pytest to collect complementary information - │ ├── Sanity.py | The module in charge of performing a sanity check - │ ├── Utils.py | The module with utility functions - ├── logs - │ ├── DocGenerator.log | Tool logging file - └── output | The default path where the parse documentation is dumped - -## Configuration -The configuration file of the tool is located at **./config.yaml**. - -### Configurable values -- **Project path**: The path of the complete project from where the documentation will be extracted. This path will be -used during a sanity check to count every existent test in the project and calculate the coverage of the documentation. + ├── wazuh-testing + . ├── qa-docs + . | ├── dockerfiles + . | │ ├── qa_docs_base.Dockerfile | The dockerfile that builds a docker image with the `qa-docs` dependencies properly installed + | | └── qa_docs_tool.Dockerfile | The dockerfile that builds a docker image with the `qa-docs` running a specific branch + | ├── lib + | | ├── __init__.py + | │ ├── code_parser.py | The module in charge of parsing documentation blocks + | │ ├── config.py | The module in charge of parsing the configuration file + | | ├── index_data.py | The module in charge of the index management + | │ ├── pytest_wrap.py | The module in charge of dry-running pytest to collect complementary information + | │ ├── sanity.py | The module in charge of performing a sanity check + | │ └── utils.py | The module with utility functions + | ├── search_ui | search-ui module directory + | ├── __init__.py + | ├── deploy_qa_docs.sh | Script that build the qa-docs images and run them using a specific branch + | ├── doc_generator.py | The main module and the entry point of the tool execution + | ├── requirements.txt | Contais the modules that qa-docs needs + | └── schema.yaml | The configuration file of the tool + ├── scripts + | ├── qa_docs.py | Tool script used by qa framework + . . + . . + +## Schema +The schema file of the tool is located at **qa-docs/schema.yaml**. + +The shema fields are specified in the [qa-docs wiki](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks) + +## Installation + +To install `qa-docs` you have to install the wazuh-qa framework by running: -- **Output path**: The path where all the parsed documentation blocks will be dumped. - -- **Include paths**: A list with all the paths from where the tool will look to extract and parse the documentation -blocks. - -- **Include regex**: A list of regexes to identify which files must be considered a test file. - -- **Group files**: A list of regexes to identify which files must be considered a group file. - -- **Function regex**: A list of regexes to identify which methods inside a test file must be considered a test method. - -- **Ignore paths**: A list with the paths that should be bypassed during the parsing scan. - -- **Output fields**: Lists with the expected fields in the documentation. There is a list for the modules fields and -another for test fields. In turn, each of them is divided into Mandatory and Optional fields. -During a sanity check, it will be checked that every Mandatory field is present in the respective documentation block. -During the parsing, every field not present in the Mandatory or the Optional fields will be ignored. +``` +python3 setup.py install +``` -- **Test cases field**: Key to identify the test cases of each test. If this field is missing in the documentation block -of a test, the tool will execute a dry run of pytest to automatically find all the test cases of the test and append -this information with the key specified. If this option is missing, or if the key is found in the test documentation -block, this action is avoided. +This `setup.py` is located in `wazuh-qa/deps/wazuh_testing/setup.py` ## Usage ### Dependencies +First of all, the wazuh-qa framework must be installed following the [`installation section`](#installation). The `requirements.txt` file specifies the required Python modules that need to be installed before running the tool. + Also before indexing is mandatory to have `ElasticSearch` up and running. -##### Installation on Linux: +##### ES installation on Linux: - ArchLinux ``` @@ -190,7 +138,7 @@ apt install elasticsearch systemctl start elasticsearch.service ``` -##### Installation on Windows: +##### ES installation on Windows: - Using `Chocolatey` ``` @@ -201,40 +149,62 @@ choco install elasticsearch https://www.elastic.co/es/downloads/elasticsearch ### Parsing - python3 DocGenerator.py -Without using any flag, the tool will load the configuration file and run a complete parse of the paths in the -configuration to dump the content into the output folder. +#### Complete run + qa-docs -I /path-to-tests-to-parse/ + +Using just the `-I` flag , the tool will load the schema file and run a complete parse of the paths in the +configuration to dump the content into the output folder located in the `qa-docs` build directory. + +#### Parse specific type(s) + qa-docs -I /path-to-tests-to-parse/ --types + +Using `--type` flag you can parse only the tests inside the type(s) folder(s) you want. + +#### Parse specific module(s) + qa-docs -I /path-to-tests-to-parse/ --types --modules + +Using `--modules` flag you can parse only the tests inside the modules(s) folder(s) you want. It also needs the type of tests where the tests are located. + +#### Parse specific test(s) + qa-docs -I /path-to-tests-to-parse/ -t(--test) TEST_NAME1 TEST_NAME2 + +Using `-t, --test` flag you can parse only the tests that you want. The documentation parsed will be printed, if you want to save it you have to use the `-o` +flag and specify the output directory. e.g: `qa-docs -I /wazuh-qa/tests/ -t test_cache test_cors -o /tmp` ### Sanity Check - python3 DocGenerator.py -s + qa-docs -I /path-to-tests-to-parse/ -s -Using **-s**, the tool will run a sanity check of the content in the output folder. +Using `-s`, the tool will run a sanity check of the content in the output folder. -It will check the coverage of the already parsed files in the **Output path** comparing it with the tests found in -**Project path**. -Also, it will validate that the output files have every Mandatory field. -It will also list and report every tag found. +It will check the coverage of the already parsed files in the **output path** comparing it with the tests found within +the **tests path**. + +Also, it will validate that the output files have every Mandatory field and check that the documentation parsed has no +wrong values following the `qa-docs` [predefined values](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#pre-defined-values). ### Debug - python3 DocGenerator.py -d + qa-docs -I /path-to-tests-to-parse/ -d -Using **-d**, the tool runs in DEBUG mode, logging extra information in the log file. +Using `-d`, the tool runs in DEBUG mode, logging extra information in the log file(created within the `qa-docs` build directory) or console output. ### Version - python3 DocGenerator.py -v - -Using **-v**, the tool will print its current version. + qa-docs -v -### Config Test - python3 DocGenerator.py -t -Using **-t** option, the tool will load the config file to test the correct content. +Using `-v`, the tool will print its current version. ### Index output data - python3 DocGenerator.py -i -Using **-i** option, the tool indexes the content of each file output as a document into ElasticSearch. The name of the index + qa-docs -I /path-to-tests-to-parse/ -i + +Using `-i` option, the tool indexes the content of each file output as a document into ElasticSearch. The name of the index must be provided as a parameter. -### Local launch output data - python3 DocGenerator.py -l -Using **-l** option, the tool indexes the content of each file output as a document into ElasticSearch and launches the application. The name of the index must be provided as a parameter. +### Local api launch using index + qa-docs -I /path-to-tests-to-parse/ -l + +Using `-l` option, the tool launches the application with the index previously indexed. The name of the index must be provided as a parameter. + +### Index output data and launch the api + qa-docs -I /path-to-tests-to-parse/ -il + +Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the api. The name of the index must be provided as a parameter. \ No newline at end of file From f55b97a45b3a1d81b33156a34b427e2b45406148 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 6 Oct 2021 12:51:54 +0200 Subject: [PATCH 070/181] refac: README changes. #1864 `qa-docs` wiki references. --- deps/wazuh_testing/wazuh_testing/qa_docs/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 16f67b3f92..4c5da2339b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -97,7 +97,7 @@ documentation content into the App UI. ## Schema The schema file of the tool is located at **qa-docs/schema.yaml**. -The shema fields are specified in the [qa-docs wiki](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks) +The shema fields are specified in the `qa-docs documenting test`[wiki](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks) in the schema section. ## Installation @@ -111,6 +111,8 @@ This `setup.py` is located in `wazuh-qa/deps/wazuh_testing/setup.py` ## Usage +For a detailed usage visit the `qa-docs documentation generation` [wiki](https://github.com/wazuh/wazuh-qa/wiki/Documentation-generation-with-qadocs-tool) + ### Dependencies First of all, the wazuh-qa framework must be installed following the [`installation section`](#installation). @@ -206,5 +208,5 @@ Using `-l` option, the tool launches the application with the index previously i ### Index output data and launch the api qa-docs -I /path-to-tests-to-parse/ -il - + Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the api. The name of the index must be provided as a parameter. \ No newline at end of file From 60008cd7c5cc4bc550bb14b8da164247ecb58cf0 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 6 Oct 2021 13:08:00 +0200 Subject: [PATCH 071/181] add: Add `qa-docs` VERSION.json. #1864 --- deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json b/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json new file mode 100644 index 0000000000..25a64fb4b5 --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json @@ -0,0 +1,4 @@ +{ + "version": "0.1", + "revision": 1 +} \ No newline at end of file From 1f6359414b4924dd3d0c2e5de75b772e3b549b23 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 6 Oct 2021 13:24:34 +0200 Subject: [PATCH 072/181] refac: Update `qa-docs` README. #1864 Docker deployment added. --- deps/wazuh_testing/wazuh_testing/qa_docs/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 4c5da2339b..22869de81a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -209,4 +209,12 @@ Using `-l` option, the tool launches the application with the index previously i ### Index output data and launch the api qa-docs -I /path-to-tests-to-parse/ -il -Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the api. The name of the index must be provided as a parameter. \ No newline at end of file +Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the api. The name of the index must be provided as a parameter. + +## Docker deployment + +If you prefer, you can run the script inside the `qa-docs/` directory, which will parse the tests of a branch you pass as an argument: + +``` +./deploy_qa_docs.sh 1796-migrate-doc-schema-2 +``` \ No newline at end of file From 67489c3861423100761dab4ec7b08c106a6a91ee Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 6 Oct 2021 13:34:27 +0200 Subject: [PATCH 073/181] refac: Update `qa-docs` README. Add some run examples. --- deps/wazuh_testing/wazuh_testing/qa_docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 22869de81a..f918658567 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -156,7 +156,7 @@ https://www.elastic.co/es/downloads/elasticsearch qa-docs -I /path-to-tests-to-parse/ Using just the `-I` flag , the tool will load the schema file and run a complete parse of the paths in the -configuration to dump the content into the output folder located in the `qa-docs` build directory. +configuration to dump the content into the output folder located in the `qa-docs` build directory. e.g: `qa-docs -I /wazuh-qa/tests/` #### Parse specific type(s) qa-docs -I /path-to-tests-to-parse/ --types @@ -209,7 +209,7 @@ Using `-l` option, the tool launches the application with the index previously i ### Index output data and launch the api qa-docs -I /path-to-tests-to-parse/ -il -Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the api. The name of the index must be provided as a parameter. +Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the api. The name of the index must be provided as a parameter. e.g: `qa-docs -I /wazuh-qa/tests/ -il qa-tests`. A previous run must be performed, e.g.`qa-docs -I /wazuh-qa/tests/` so the output data is previously generated. ## Docker deployment From 91097db73aa7897bdc942b613a9667ad2c35693f Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 6 Oct 2021 13:51:14 +0200 Subject: [PATCH 074/181] refac: Update `qa-docs` README. Add `SearchUI` deps. --- deps/wazuh_testing/wazuh_testing/qa_docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index f918658567..94cacc80bb 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -118,7 +118,7 @@ For a detailed usage visit the `qa-docs documentation generation` [wiki](https:/ First of all, the wazuh-qa framework must be installed following the [`installation section`](#installation). The `requirements.txt` file specifies the required Python modules that need to be installed before running the tool. -Also before indexing is mandatory to have `ElasticSearch` up and running. +Before indexing is mandatory to have `ElasticSearch` up and running. Also, before launching the api is mandatory to have `npm` installed. ##### ES installation on Linux: From a11410f5f65ff48ec36f6ea352d1019719760bf4 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 08:40:18 +0200 Subject: [PATCH 075/181] refac: Change `qa-docs` logging. Log as a warning instead of an error. --- .../wazuh_testing/qa_docs/lib/code_parser.py | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index a9e42d33b4..9d5a3a3558 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -11,7 +11,6 @@ from wazuh_testing.qa_docs.lib.utils import remove_inexistent from wazuh_testing.qa_docs import QADOCS_LOGGER from wazuh_testing.tools.logging import Logging -from wazuh_testing.tools.exceptions import QAValueError INTERNAL_FIELDS = ['id', 'group_id', 'name'] STOP_FIELDS = ['tests', 'test_cases'] @@ -88,31 +87,25 @@ def check_predefined_values(self, doc, doc_type, path): try: doc_field = doc[field] except KeyError: - CodeParser.LOGGER.error(f"{field} field missing in {path} {doc_type}") + CodeParser.LOGGER.warning(f"{field} field missing in {path} {doc_type}") doc_field = None # If the field is a list, iterate thru predefined values if isinstance(doc_field, list): for value in doc_field: if value not in self.conf.predefined_values[field]: - CodeParser.LOGGER.error(f"{field} field in {path} {doc_type} documentation block " + CodeParser.LOGGER.warning(f"{field} field in {path} {doc_type} documentation block " f"has an invalid value: {value}. " - f"Follow the predefined values: {self.conf.predefined_values[field]}." + f"Follow the predefined values: {self.conf.predefined_values[field]}. " "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" "Documenting-tests-using-the-qadocs-schema#pre-defined-values.") - # raise QAValueError(f"{field} field in {path} {doc_type} has an invalid value: {value}. " - # f"Follow the predefined values: {self.conf.predefined_values[field]}" - # , CodeParser.LOGGER.error) else: if doc_field not in self.conf.predefined_values[field] and doc_field is not None: - CodeParser.LOGGER.error(f"{field} field in {path} {doc_type} documentation block " + CodeParser.LOGGER.warning(f"{field} field in {path} {doc_type} documentation block " f"has an invalid value: {doc_type}. " - f"Follow the predefined values: {self.conf.predefined_values[field]}" + f"Follow the predefined values: {self.conf.predefined_values[field]} " "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" "Documenting-tests-using-the-qadocs-schema#pre-defined-values.") - # raise QAValueError(f"{field} field in {path} {doc_type} has an invalid value: {doc_field}. " - # f"Follow the predefined values: {self.conf.predefined_values[field]}" - # , CodeParser.LOGGER.error) def parse_comment(self, function, doc_type, path): """Parse one self-contained documentation block. @@ -127,7 +120,7 @@ def parse_comment(self, function, doc_type, path): """ docstring = ast.get_docstring(function) if not docstring: - CodeParser.LOGGER.error(f"Documentation block not found in {path}") + CodeParser.LOGGER.warning(f"Documentation block not found in {path}") # raise QAValueError(f"Documentation block not found in {path}", CodeParser.LOGGER.error) try: @@ -138,12 +131,12 @@ def parse_comment(self, function, doc_type, path): except Exception as inst: if hasattr(function, 'name'): - CodeParser.LOGGER.error(f"Failed to parse test documentation in {function.name} " + CodeParser.LOGGER.warning(f"Failed to parse test documentation in {function.name} " "from module {self.scan_file}. Error: {inst}") # raise QAValueError(f"Failed to parse test documentation in {function.name} " # "from module {self.scan_file}. Error: {inst}", CodeParser.LOGGER.error) else: - CodeParser.LOGGER.error(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}") + CodeParser.LOGGER.warning(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}") # raise QAValueError(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}" # , CodeParser.LOGGER.error) return None From b82032c18844a7f1ba9b9c15965bb6ce10a185fe Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 08:47:50 +0200 Subject: [PATCH 076/181] refac: Check if the index exists before launch the API. #1864 If the index does not exist, an error will be raised. In addition, the index deletion has been fixed. --- .../wazuh_testing/qa_docs/lib/index_data.py | 8 +++++++- .../wazuh_testing/scripts/qa_docs.py | 16 +++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 9ab0411df5..8f01d1bc1d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -2,6 +2,7 @@ # Created by Wazuh, Inc. . # This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 +from logging import exception import os import re import json @@ -90,7 +91,12 @@ def run(self): self.read_files_content(files) if self.test_connection(): - self.remove_index() + try: + if self.es.count(index=self.index): + self.remove_index() + except Exception: + pass + IndexData.LOGGER.info("Indexing data...\n") helpers.bulk(self.es, self.output, index=self.index) out = json.dumps(self.es.cluster.health(wait_for_status='yellow', request_timeout=1), indent=4) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 8e85f806a5..d4bfe7a124 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -6,6 +6,7 @@ import os from datetime import datetime import sys +from elasticsearch import Elasticsearch from wazuh_testing.qa_docs.lib.config import Config from wazuh_testing.qa_docs.lib.index_data import IndexData @@ -90,12 +91,17 @@ def validate_parameters(parameters, parser): raise QAValueError(f"{test_name} not found.", qadocs_logger.error) # Check that the index exists - if parameters.index_name or parameters.app_index_name: - check_index_name_exists = None + if parameters.app_index_name: + es = Elasticsearch() + try: + es.count(index=parameters.app_index_name) + except Exception as index_exception: + raise QAValueError(f"Index exception: {index_exception}", qadocs_logger.error) # Check that the output path has permissions to write the file(s) - if parameters.output_path: - check_output_path_has_permissions = None + if parameters.output_path and not os.access(parameters.output_path, os.W_OK): + raise QAValueError(f"You cannot write within this directory {parameters.output_path}, you need write permission.", + qadocs_logger.error) # Check that modules selection is done within a test type if parameters.test_modules and len(parameters.test_types) != 1: @@ -103,7 +109,7 @@ def validate_parameters(parameters, parser): ' type if you want to parse some modules within a test type.', qadocs_logger.error) - qadocs_logger.debug('Input parameters validation successfully finished') + qadocs_logger.debug('Input parameters validation completed') def install_searchui_deps(): """Install SearchUI dependencies if needed""" From c0d97d45c3780827679f7205dea84938a4070a97 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 09:25:13 +0200 Subject: [PATCH 077/181] Add: Add `qa-docs` CHANGELOG.md. #1864 Two beta versions have been added. One from epic #1536 and another one that has been added to the wazuh-testing framework with some enhancements. --- .../wazuh_testing/qa_docs/CHANGELOG.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md b/deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md new file mode 100644 index 0000000000..d75bed4ee6 --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md @@ -0,0 +1,28 @@ +# Change Log + +## [v0.1] + +First tool implementation. + +### Added + - Included dependencies installation details. ([#1693](https://github.com/wazuh/wazuh-qa/pull/1693)) + - Added DocGenerator documentation. ([#1686](https://github.com/wazuh/wazuh-qa/pull/1686)) + - Added exploratory testing fixes. ([#1685](https://github.com/wazuh/wazuh-qa/pull/1685)) + - Implemented Mandatory and Optional fields functionality. ([#1665](https://github.com/wazuh/wazuh-qa/pull/1665)) + - Added TestCaseParser module to DocGenerator. ([#1651](https://github.com/wazuh/wazuh-qa/pull/1651)) + - Implemented the DocGenerator main module and test search. ([#1623](https://github.com/wazuh/wazuh-qa/pull/1623) + - Added Config to DocGenerator. ([#1619](https://github.com/wazuh/wazuh-qa/pull/1619)) + - Implemented a sanity check module for DocGenerator. ([#1649])(https://github.com/wazuh/wazuh-qa/pull/1649) + - Added DocGenerator code to master branch. ([#1762](https://github.com/wazuh/wazuh-qa/pull/1762)) + +## [v0.2] + +Tool added to wazuh-testing framework. Also, added new features and behaviours. + +### Added + - Added single test parse. ([#1854](https://github.com/wazuh/wazuh-qa/pull/1854)) + - Integrate qa-docs into wazuh-qa framework. ([#1854](https://github.com/wazuh/wazuh-qa/pull/1854)) + - Added custom qa-docs logger. ([#1896])(https://github.com/wazuh/wazuh-qa/pull/1896) + - Created qa-docs code documentation. ([#1907])(https://github.com/wazuh/wazuh-qa/pull/1907) + - Automatized the SearchUI dependencies installation if necessary. ([#1968])(https://github.com/wazuh/wazuh-qa/pull/1968) + - Added qa-docs docker deployment. ([#1983])(https://github.com/wazuh/wazuh-qa/pull/1983) \ No newline at end of file From d80ce302f2fbc7c06af3d4c1aa28f94a1b1644b9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 09:29:16 +0200 Subject: [PATCH 078/181] refac: Update `qa-docs` VERSION.json. #1864 --- deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json b/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json index 25a64fb4b5..8e2077def4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json @@ -1,4 +1,4 @@ { - "version": "0.1", + "version": "0.2", "revision": 1 } \ No newline at end of file From 3a8bf9c849c325b500434e3a1b29bd26b06d452c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 09:40:33 +0200 Subject: [PATCH 079/181] refac: Update `qa-docs` README.md. #1864 --- .../wazuh_testing/qa_docs/README.md | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 94cacc80bb..50c7f4cd6a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -77,18 +77,21 @@ documentation content into the App UI. | | └── qa_docs_tool.Dockerfile | The dockerfile that builds a docker image with the `qa-docs` running a specific branch | ├── lib | | ├── __init__.py - | │ ├── code_parser.py | The module in charge of parsing documentation blocks - | │ ├── config.py | The module in charge of parsing the configuration file - | | ├── index_data.py | The module in charge of the index management - | │ ├── pytest_wrap.py | The module in charge of dry-running pytest to collect complementary information - | │ ├── sanity.py | The module in charge of performing a sanity check - | │ └── utils.py | The module with utility functions - | ├── search_ui | search-ui module directory + | │ ├── code_parser.py | The module in charge of parsing documentation blocks + | │ ├── config.py | The module in charge of parsing the configuration file + | | ├── index_data.py | The module in charge of the index management + | │ ├── pytest_wrap.py | The module in charge of dry-running pytest to collect complementary information + | │ ├── sanity.py | The module in charge of performing a sanity check + | │ └── utils.py | The module with utility functions + | ├── search_ui | search-ui module directory | ├── __init__.py + | ├── CHANGELOG.md | Record of all notable changes made | ├── deploy_qa_docs.sh | Script that build the qa-docs images and run them using a specific branch | ├── doc_generator.py | The main module and the entry point of the tool execution - | ├── requirements.txt | Contais the modules that qa-docs needs - | └── schema.yaml | The configuration file of the tool + | ├── requirements.txt | Contains the modules that qa-docs needs + | ├── README.MD + | ├── schema.yaml | The configuration file of the tool + | └── VERSION.json | Tool version ├── scripts | ├── qa_docs.py | Tool script used by qa framework . . From a8ab62dd6a5e71d46d82ff34a7877a347c1eb47d Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 09:54:55 +0200 Subject: [PATCH 080/181] refac: Update `qa-docs` version parameter. #1864 --- deps/wazuh_testing/setup.py | 3 ++- .../wazuh_testing/wazuh_testing/qa_docs/VERSION.json | 2 +- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 12 ++++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/deps/wazuh_testing/setup.py b/deps/wazuh_testing/setup.py index 5b5bddc961..25af4be8dd 100644 --- a/deps/wazuh_testing/setup.py +++ b/deps/wazuh_testing/setup.py @@ -20,7 +20,8 @@ 'data/sslmanager.key', 'data/sslmanager.cert', 'tools/macos_log/log_generator.m', - 'qa_docs/schema.yaml' + 'qa_docs/schema.yaml', + 'qa_docs/VERSION.json' ] scripts_list = [ diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json b/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json index 8e2077def4..aeeb7caeaf 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json @@ -1,4 +1,4 @@ { "version": "0.2", "revision": 1 -} \ No newline at end of file +} diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index d4bfe7a124..20cd1a2876 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -4,8 +4,9 @@ import argparse import os -from datetime import datetime import sys +import json +from datetime import datetime from elasticsearch import Elasticsearch from wazuh_testing.qa_docs.lib.config import Config @@ -16,7 +17,7 @@ from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.exceptions import QAValueError -VERSION = '0.1' +VERSION_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'VERSION.json') SCHEMA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'schema.yaml') OUTPUT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'output') LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'log') @@ -96,7 +97,7 @@ def validate_parameters(parameters, parser): try: es.count(index=parameters.app_index_name) except Exception as index_exception: - raise QAValueError(f"Index exception: {index_exception}", qadocs_logger.error) + raise QAValueError(f"Index exception: {index_exception}") # Check that the output path has permissions to write the file(s) if parameters.output_path and not os.access(parameters.output_path, os.W_OK): @@ -184,7 +185,10 @@ def main(): print(f"{test_name} exists") if args.version: - print(f"qa-docs v{VERSION}") + with open(VERSION_PATH, 'r') as version_file: + version_data = version_file.read() + version = json.loads(version_data) + print(f"qa-docs v{version['version']}") # Run a sanity check thru tests directory elif args.sanity: From 575cfbdfddc15e4ad59b5fa1871e2e68ed7c729c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 11:43:29 +0200 Subject: [PATCH 081/181] refac: Refactorize the `qa-docs` script. #1864 --- .../wazuh_testing/scripts/qa_docs.py | 119 ++++++++++-------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 20cd1a2876..8bd7e9286d 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -37,6 +37,57 @@ def set_qadocs_logger_level(logging_level): else: qadocs_logger.set_level(logging_level) + +def get_parameters(): + """Capture the script parameters + + Returns: + argparse.Namespace: Object with the script parameters. + argparse.ArgumentParser: Object with from the parser class. + """ + parser = argparse.ArgumentParser(add_help=False) + + parser.add_argument('-h', '--help', action='help', default=argparse.SUPPRESS, + help='Show this help message and exit.') + + parser.add_argument('-s', '--sanity-check', action='store_true', dest='sanity', + help="Run a sanity check.") + + parser.add_argument('-v', '--version', action='store_true', dest="version", + help="Print qa-docs version.") + + parser.add_argument('-d', '--debug', action='count', dest='debug_level', + help="Enable debug messages.") + + parser.add_argument('-I', '--tests-path', dest='tests_path', + help="Path where tests are located.") + + parser.add_argument('-t', '--tests', nargs='+', default=[], dest='test_names', + help="Parse the test(s) that you pass as argument.") + + parser.add_argument('--types', nargs='+', default=[], dest='test_types', + help="Parse the tests from type(s) that you pass as argument.") + + parser.add_argument('--modules', nargs='+', default=[], dest='test_modules', + help="Parse the tests from modules(s) that you pass as argument.") + + parser.add_argument('-i', '--index-data', dest='index_name', + help="Indexes the data named as you specify as argument to elasticsearch.") + + parser.add_argument('-l', '--launch-ui', dest='app_index_name', + help="Launch SearchUI using the index that you specify.") + + parser.add_argument('-il', dest='launching_index_name', + help="Indexes the data named as you specify as argument and launch SearchUI.") + + parser.add_argument('-o', dest='output_path', + help="Specifies the output directory for test parsed when `-t, --tests` is used.") + + parser.add_argument('-e', '--exist', nargs='+', default=[], dest='test_exist', + help="Checks if test(s) exist or not.",) + + return parser.parse_args(), parser + def check_incompatible_parameters(parameters): """Check the parameters that qa-docs receives and check any incompatibilities. @@ -58,8 +109,8 @@ def check_incompatible_parameters(parameters): qadocs_logger.error) if (parameters.test_types or parameters.test_modules) and parameters.test_names: - raise QAValueError('The --types, --modules parameters parse the data, index it, and visualize it, so it cannot be used with ' - '-t, --tests because they get specific tests information.', + raise QAValueError('The --types, --modules parameters parse the data so you can index it and visualize it. ' + '-t, --tests get specific tests information.', qadocs_logger.error) def validate_parameters(parameters, parser): @@ -97,7 +148,7 @@ def validate_parameters(parameters, parser): try: es.count(index=parameters.app_index_name) except Exception as index_exception: - raise QAValueError(f"Index exception: {index_exception}") + raise QAValueError(f"Index exception: {index_exception}", qadocs_logger.error) # Check that the output path has permissions to write the file(s) if parameters.output_path and not os.access(parameters.output_path, os.W_OK): @@ -127,63 +178,14 @@ def run_searchui(index): def main(): - parser = argparse.ArgumentParser(add_help=False) - - parser.add_argument('-h', '--help', action='help', default=argparse.SUPPRESS, - help='Show this help message and exit.') + args, parser = get_parameters() - parser.add_argument('-s', '--sanity-check', action='store_true', dest='sanity', - help="Run a sanity check.") - - parser.add_argument('-v', '--version', action='store_true', dest="version", - help="Print qa-docs version.") - - parser.add_argument('-d', '--debug', action='count', dest='debug_level', - help="Enable debug messages.") - - parser.add_argument('-I', '--tests-path', dest='tests_path', - help="Path where tests are located.") - - parser.add_argument('-t', '--tests', nargs='+', default=[], dest='test_names', - help="Parse the test(s) that you pass as argument.") - - parser.add_argument('--types', nargs='+', default=[], dest='test_types', - help="Parse the tests from type(s) that you pass as argument.") - - parser.add_argument('--modules', nargs='+', default=[], dest='test_modules', - help="Parse the tests from modules(s) that you pass as argument.") - - parser.add_argument('-i', '--index-data', dest='index_name', - help="Indexes the data named as you specify as argument to elasticsearch.") - - parser.add_argument('-l', '--launch-ui', dest='app_index_name', - help="Launch SearchUI using the index that you specify.") - - parser.add_argument('-il', dest='launching_index_name', - help="Indexes the data named as you specify as argument and launch SearchUI.") - - parser.add_argument('-o', dest='output_path', - help="Specifies the output directory for test parsed when `-t, --tests` is used.") - - parser.add_argument('-e', '--exist', nargs='+', default=[], dest='test_exist', - help="Checks if test(s) exist or not.",) - - args = parser.parse_args() + validate_parameters(args, parser) # Set the qa-docs logger level if args.debug_level: set_qadocs_logger_level('DEBUG') - validate_parameters(args, parser) - - # Print that test gave by the user(using `-e` option) exists or not. - if args.test_exist: - doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) - - for test_name in args.test_exist: - if doc_check.locate_test(test_name) is not None: - print(f"{test_name} exists") - if args.version: with open(VERSION_PATH, 'r') as version_file: version_data = version_file.read() @@ -215,8 +217,15 @@ def main(): # When SearchUI index is not hardcoded, it will be use args.launching_index_name run_searchui(args.launching_index_name) + elif args.test_exist: + doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) + + for test_name in args.test_exist: + if doc_check.locate_test(test_name) is not None: + print(f"{test_name} exists") + # Parse tests - elif not args.test_exist: + else: docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) # Parse a list of tests From 5a125f7878e325d8cc3dc85fc1d09592064f56b6 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 12:14:01 +0200 Subject: [PATCH 082/181] refac: Update `qa-docs` script. #1864 Now the parsing and API behaviour can be used together. The `qa_docs_tool.Dockerfile` has been changed. --- .../dockerfiles/qa_docs_tool.Dockerfile | 3 +- .../wazuh_testing/scripts/qa_docs.py | 45 ++++++++++--------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile index 91933b60de..17d8a99126 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile @@ -18,5 +18,4 @@ WORKDIR /home/wazuh-qa/deps/wazuh_testing # start services, parse some tests and launch the api CMD service elasticsearch start && \ service wazuh-manager start && \ - qa-docs -I /home/tests/wazuh-qa/tests --types integration && \ - qa-docs -I /home/tests/wazuh-qa/tests -il qa-docs + qa-docs -I /home/tests/wazuh-qa/tests --types integration -il qa-docs diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 8bd7e9286d..fa6a9164ab 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -94,8 +94,8 @@ def check_incompatible_parameters(parameters): Args: parameters (argparse.Namespace): The parameters that the tool receives. """ - default_run = parameters.index_name or parameters.app_index_name or parameters.test_types or parameters.test_modules - + default_run = parameters.test_types or parameters.test_modules + api_run = parameters.index_name or parameters.app_index_name or parameters.launching_index_name if parameters.tests_path is None and (default_run or parameters.test_names or parameters.test_exist): raise QAValueError('The following options need the path where the tests are located: -t, --test, ' @@ -103,6 +103,10 @@ def check_incompatible_parameters(parameters): '-I, --tests-path path_to_tests.', qadocs_logger.error) + if api_run and (parameters.test_names or parameters.test_exist): + raise QAValueError('The -e, -t options do not support API usage.', + qadocs_logger.error) + if parameters.output_path and default_run: raise QAValueError('The -o parameter only works with -t, --tests options in isolation. The default output ' 'path is generated within the qa-docs tool to index it and visualize it.', @@ -198,25 +202,6 @@ def main(): qadocs_logger.debug('Running sanity check') sanity.run() - # Index the previous parsed tests into Elasticsearch - elif args.index_name: - qadocs_logger.debug(f"Indexing {args.index_name}") - index_data = IndexData(args.index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) - index_data.run() - - # Launch SearchUI with the - elif args.app_index_name: - # When SearchUI index is not hardcoded, it will be use args.app_index_name - run_searchui(args.app_index_name) - - # Index the previous parsed tests into Elasticsearch and then launch SearchUI - elif args.launching_index_name: - qadocs_logger.debug(f"Indexing {args.launching_index_name}") - index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) - index_data.run() - # When SearchUI index is not hardcoded, it will be use args.launching_index_name - run_searchui(args.launching_index_name) - elif args.test_exist: doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) @@ -256,5 +241,23 @@ def main(): qadocs_logger.info('Running QADOCS') docs.run() + # Index the previous parsed tests into Elasticsearch + if args.index_name: + index_data = IndexData(args.index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + index_data.run() + + # Launch SearchUI with the + elif args.app_index_name: + # When SearchUI index is not hardcoded, it will be use args.app_index_name + run_searchui(args.app_index_name) + + # Index the previous parsed tests into Elasticsearch and then launch SearchUI + elif args.launching_index_name: + qadocs_logger.debug(f"Indexing {args.launching_index_name}") + index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + index_data.run() + # When SearchUI index is not hardcoded, it will be use args.launching_index_name + run_searchui(args.launching_index_name) + if __name__ == '__main__': main() From d0d1f65111f4c7a590d85373e71d4c5edb729444 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 12:20:16 +0200 Subject: [PATCH 083/181] refac: Update `deploy_qa_docs.sh` image version. --- deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh index fafb4af263..e329b897f7 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh @@ -7,6 +7,6 @@ then exit 1 fi -docker build -t qa-docs_base:0.1 -f dockerfiles/qa_docs_base.Dockerfile dockerfiles/ -docker build -t qa-docs/$branch_name:0.1 --build-arg BRANCH=$branch_name -f dockerfiles/qa_docs_tool.Dockerfile dockerfiles/ -docker run qa-docs/$branch_name:0.1 +docker build -t qa-docs_base:0.2 -f dockerfiles/qa_docs_base.Dockerfile dockerfiles/ +docker build -t qa-docs/$branch_name:0.2 --build-arg BRANCH=$branch_name -f dockerfiles/qa_docs_tool.Dockerfile dockerfiles/ +docker run qa-docs/$branch_name:0.2 From 3e4975782e351604ef9c65e5fed726c25807a241 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 7 Oct 2021 12:27:59 +0200 Subject: [PATCH 084/181] refac: Update `qa-docs` README.md. --- deps/wazuh_testing/wazuh_testing/qa_docs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 50c7f4cd6a..5095feeb83 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -114,6 +114,8 @@ This `setup.py` is located in `wazuh-qa/deps/wazuh_testing/setup.py` ## Usage +You can parse the tests and run the API with just one command, e.g. `qa-docs -I /path-to-tests/ --type integration --modules test_active_response -il active-response-index` + For a detailed usage visit the `qa-docs documentation generation` [wiki](https://github.com/wazuh/wazuh-qa/wiki/Documentation-generation-with-qadocs-tool) ### Dependencies From bc2d6892f60ab62db962a8e5e334559ac51a3112 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 8 Oct 2021 08:35:41 +0200 Subject: [PATCH 085/181] fix: Fix log file format. #1864 `:` character was making `qa-docs` crash in windows. Log file format has been replaced to: %Y-%m-%d_%H-%M --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index fa6a9164ab..11e40dcfc4 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -23,7 +23,7 @@ LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'log') SEARCH_UI_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'qa_docs', 'search_ui') qadocs_logger = Logging(QADOCS_LOGGER, 'INFO', True, os.path.join(LOG_PATH, - f"{datetime.today().strftime('%Y-%m-%d-%H:%M:%S')}-qa-docs.log")) + f"{datetime.today().strftime('%Y-%m-%d_%H-%M')}-qa-docs.log")) def set_qadocs_logger_level(logging_level): From 8b4c1e9704cc5a2877ad3f0c411fd6293b8d0c7c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 8 Oct 2021 08:47:19 +0200 Subject: [PATCH 086/181] fix: Fix sanity check in windows. #1864 The file is encoded in utf-8 but read using a different encoder. --- deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py index a2b11285a2..8b0182545d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/sanity.py @@ -147,7 +147,7 @@ def count_project_tests(self): test_files = list(filter(regex.match, files)) for test_file in test_files: - with open(os.path.join(root, test_file)) as fd: + with open(os.path.join(root, test_file), encoding="utf8") as fd: file_content = fd.read() module = ast.parse(file_content) functions = [node for node in module.body if isinstance(node, ast.FunctionDef)] From 988b299fec0aee527d4554177324d05c2faec18c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 8 Oct 2021 09:34:37 +0200 Subject: [PATCH 087/181] fix: Fix schema `expected_output` name. --- deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml index 6e4f14841f..52a23947b4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml @@ -21,10 +21,10 @@ output_fields: - wazuh_min_version - parameters - assertions - - inputs - input_description - - expected_behaviour + - expected_output optional: + - inputs - tags test_cases_field: test_cases From f1ec96adbda4c97e5cfe7f3553e05abc0a6bfe67 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 8 Oct 2021 09:50:31 +0200 Subject: [PATCH 088/181] fix: Fix qa-docs image version tag. --- .../wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile index 17d8a99126..cdd371b5bb 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile @@ -1,4 +1,4 @@ -FROM qa-docs_base:0.1 +FROM qa-docs_base:0.2 RUN mkdir tests From ad44ae93390a81b0e991812a4958a6eb3aad0c09 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 8 Oct 2021 11:41:07 +0200 Subject: [PATCH 089/181] fix: Fix `qa-docs` script. #1864 Fix style and parameters validations. --- .../wazuh_testing/scripts/qa_docs.py | 69 +++++++++++-------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 11e40dcfc4..31e77df7f3 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -40,7 +40,7 @@ def set_qadocs_logger_level(logging_level): def get_parameters(): """Capture the script parameters - + Returns: argparse.Namespace: Object with the script parameters. argparse.ArgumentParser: Object with from the parser class. @@ -88,22 +88,32 @@ def get_parameters(): return parser.parse_args(), parser + def check_incompatible_parameters(parameters): """Check the parameters that qa-docs receives and check any incompatibilities. - + Args: parameters (argparse.Namespace): The parameters that the tool receives. """ default_run = parameters.test_types or parameters.test_modules api_run = parameters.index_name or parameters.app_index_name or parameters.launching_index_name + test_run = parameters.test_names or parameters.test_exist + + if parameters.version and (default_run or api_run or parameters.tests_path or test_run): + raise QAValueError('The -v, --version option must be run in isolation.', + qadocs_logger.error) + + if parameters.sanity and (default_run or api_run or test_run): + raise QAValueError('The -s, --sanity-check option must be run with -I, --tests-path option.', + qadocs_logger.error) - if parameters.tests_path is None and (default_run or parameters.test_names or parameters.test_exist): + if parameters.tests_path is None and (default_run or test_run): raise QAValueError('The following options need the path where the tests are located: -t, --test, ' ' -e, --exist, --types, --modules, -s, --sanity-check. You must specify it by using ' '-I, --tests-path path_to_tests.', qadocs_logger.error) - if api_run and (parameters.test_names or parameters.test_exist): + if api_run and (test_run): raise QAValueError('The -e, -t options do not support API usage.', qadocs_logger.error) @@ -111,11 +121,12 @@ def check_incompatible_parameters(parameters): raise QAValueError('The -o parameter only works with -t, --tests options in isolation. The default output ' 'path is generated within the qa-docs tool to index it and visualize it.', qadocs_logger.error) - - if (parameters.test_types or parameters.test_modules) and parameters.test_names: + + if (parameters.test_types or parameters.test_modules) and test_run: raise QAValueError('The --types, --modules parameters parse the data so you can index it and visualize it. ' '-t, --tests get specific tests information.', - qadocs_logger.error) + qadocs_logger.error) + def validate_parameters(parameters, parser): """Validate the parameters that qa-docs receives. @@ -127,7 +138,6 @@ def validate_parameters(parameters, parser): # If qa-docs runs without any parameter or just `-d` option, it raises an error and prints the help message. if len(sys.argv) < 2 or (len(sys.argv) < 3 and parameters.debug_level): - qadocs_logger.error('qa-docs has been run without any parameter.') parser.print_help() exit(1) @@ -136,12 +146,13 @@ def validate_parameters(parameters, parser): # Check if the directory where the tests are located exist if parameters.tests_path: if not os.path.exists(parameters.tests_path): - raise QAValueError(f"{parameters.tests_path} does not exist. Tests directory not found.", qadocs_logger.error) + raise QAValueError(f"{parameters.tests_path} does not exist. Tests directory not found.", + qadocs_logger.error) # Check that test_input name exists if parameters.test_names: doc_check = DocGenerator(Config(SCHEMA_PATH, parameters.tests_path, test_names=parameters.test_names)) - + for test_name in parameters.test_names: if doc_check.locate_test(test_name) is None: raise QAValueError(f"{test_name} not found.", qadocs_logger.error) @@ -154,28 +165,25 @@ def validate_parameters(parameters, parser): except Exception as index_exception: raise QAValueError(f"Index exception: {index_exception}", qadocs_logger.error) - # Check that the output path has permissions to write the file(s) - if parameters.output_path and not os.access(parameters.output_path, os.W_OK): - raise QAValueError(f"You cannot write within this directory {parameters.output_path}, you need write permission.", - qadocs_logger.error) - # Check that modules selection is done within a test type if parameters.test_modules and len(parameters.test_types) != 1: - raise QAValueError('The --modules option work when is only parsing a single test type. Use --types with just one' - ' type if you want to parse some modules within a test type.', + raise QAValueError('The --modules option work when is only parsing a single test type. Use --types with just ' + 'one type if you want to parse some modules within a test type.', qadocs_logger.error) - + qadocs_logger.debug('Input parameters validation completed') + def install_searchui_deps(): """Install SearchUI dependencies if needed""" + os.chdir(SEARCH_UI_PATH) if not os.path.exists(os.path.join(SEARCH_UI_PATH, 'node_modules')): qadocs_logger.info('Installing SearchUI dependencies') os.system("npm install") + def run_searchui(index): """Run SearchUI installing its dependencies if necessary""" - os.chdir(SEARCH_UI_PATH) install_searchui_deps() qadocs_logger.debug('Running SearchUI') os.system(f"ELASTICSEARCH_HOST=http://localhost:9200 INDEX={index} npm start") @@ -202,17 +210,17 @@ def main(): qadocs_logger.debug('Running sanity check') sanity.run() - elif args.test_exist: - doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) - - for test_name in args.test_exist: - if doc_check.locate_test(test_name) is not None: - print(f"{test_name} exists") - # Parse tests else: docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + if args.test_exist: + doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) + + for test_name in args.test_exist: + if doc_check.locate_test(test_name) is not None: + print(f"{test_name} exists") + # Parse a list of tests if args.test_names: qadocs_logger.info(f"Parsing the following test(s) {args.test_names}") @@ -230,7 +238,8 @@ def main(): # Parse a list of test modules if args.test_modules: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types, args.test_modules)) + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types, + args.test_modules)) else: docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) @@ -246,9 +255,9 @@ def main(): index_data = IndexData(args.index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() - # Launch SearchUI with the + # Launch SearchUI with index_name as input elif args.app_index_name: - # When SearchUI index is not hardcoded, it will be use args.app_index_name + # When SearchUI index is not hardcoded, it will be use args.app_index_name run_searchui(args.app_index_name) # Index the previous parsed tests into Elasticsearch and then launch SearchUI @@ -256,7 +265,7 @@ def main(): qadocs_logger.debug(f"Indexing {args.launching_index_name}") index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) index_data.run() - # When SearchUI index is not hardcoded, it will be use args.launching_index_name + # When SearchUI index is not hardcoded, it will be use args.launching_index_name run_searchui(args.launching_index_name) if __name__ == '__main__': From 94c0c1a5841f9d4db75ceab82e5311ccbfa244a4 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 8 Oct 2021 11:48:39 +0200 Subject: [PATCH 090/181] style: Fix some style issues. #1864 --- .../wazuh_testing/qa_docs/lib/code_parser.py | 20 +++++++++---------- .../wazuh_testing/qa_docs/lib/config.py | 4 ++-- .../wazuh_testing/qa_docs/lib/index_data.py | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 9d5a3a3558..c9995f0269 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -71,7 +71,7 @@ def remove_ignored_fields(self, doc): def check_predefined_values(self, doc, doc_type, path): """Check if the documentation block follows the predefined values. - + It iterates through the predefined values and checks if the documentation fields contain correct values. If the field does not exist or does not contain a predefined value, it would log it. @@ -95,17 +95,17 @@ def check_predefined_values(self, doc, doc_type, path): for value in doc_field: if value not in self.conf.predefined_values[field]: CodeParser.LOGGER.warning(f"{field} field in {path} {doc_type} documentation block " - f"has an invalid value: {value}. " - f"Follow the predefined values: {self.conf.predefined_values[field]}. " - "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" - "Documenting-tests-using-the-qadocs-schema#pre-defined-values.") + f"has an invalid value: {value}. Follow the predefined values: " + f"{self.conf.predefined_values[field]}. " + "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" + " Documenting-tests-using-the-qadocs-schema#pre-defined-values.") else: if doc_field not in self.conf.predefined_values[field] and doc_field is not None: CodeParser.LOGGER.warning(f"{field} field in {path} {doc_type} documentation block " - f"has an invalid value: {doc_type}. " - f"Follow the predefined values: {self.conf.predefined_values[field]} " - "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" - "Documenting-tests-using-the-qadocs-schema#pre-defined-values.") + f"has an invalid value: {doc_type}. " + f"Follow the predefined values: {self.conf.predefined_values[field]} " + "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" + " Documenting-tests-using-the-qadocs-schema#pre-defined-values.") def parse_comment(self, function, doc_type, path): """Parse one self-contained documentation block. @@ -132,7 +132,7 @@ def parse_comment(self, function, doc_type, path): except Exception as inst: if hasattr(function, 'name'): CodeParser.LOGGER.warning(f"Failed to parse test documentation in {function.name} " - "from module {self.scan_file}. Error: {inst}") + "from module {self.scan_file}. Error: {inst}") # raise QAValueError(f"Failed to parse test documentation in {function.name} " # "from module {self.scan_file}. Error: {inst}", CodeParser.LOGGER.error) else: diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 756e639e60..8136e07142 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -75,7 +75,7 @@ def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_ self.__read_schema_file(schema_path) self.__read_output_fields() self.__read_test_cases_field() - self.__set_documentation_path(output_path.replace('\\','/')) + self.__set_documentation_path(output_path.replace('\\', '/')) self.__read_predefined_values() if test_names is not None: @@ -107,7 +107,7 @@ def __get_test_types(self): def __get_include_paths(self): """Get all the modules to include within all the specified types. - + The paths to be included are generated using this info. """ dir_regex = re.compile("test_.") diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 8f01d1bc1d..1bc9025b17 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -42,7 +42,7 @@ def __init__(self, index, config): def test_connection(self): """Verify with an HTTP request that an OK response is received from ElasticSearch. - + Returns: boolean: A boolean with True if the request response is OK. """ @@ -55,7 +55,7 @@ def test_connection(self): def get_files(self): """Find all the files inside the documentation path that matches with the JSON regex. - + Returns: doc_files (list): A list with all the files inside the path. """ From 70ea6cb800c9ab68d884c9a8b4a7b4764bd65170 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 11 Oct 2021 11:50:28 +0200 Subject: [PATCH 091/181] refac: Change the `qa-docs` docker deployment. #1864 Now it has an entrypoint and parameters check. --- .../wazuh_testing/qa_docs/deploy_qa_docs.sh | 31 ++++++++++++++----- .../qa_docs/dockerfiles/entrypoint.sh | 30 ++++++++++++++++++ ...ocs_base.Dockerfile => qa_docs.Dockerfile} | 22 +++++-------- .../dockerfiles/qa_docs_tool.Dockerfile | 21 ------------- 4 files changed, 61 insertions(+), 43 deletions(-) create mode 100755 deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh rename deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/{qa_docs_base.Dockerfile => qa_docs.Dockerfile} (64%) delete mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh index e329b897f7..249fdec993 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh @@ -1,12 +1,27 @@ -#!/bin/sh -branch_name=$1 +#!/bin/bash -if [ "$#" -ne 1 ]; +if (($# < 1 || $# > 3)) then - echo "The branch where the tests to parse are located is missing:\n\n$0 BRANCH" >&2 - exit 1 + printf "Expected call:\n\n$0 (TYPE) (MODULES)\n\nTest type and modules are optionals.\n"; + exit 1; fi -docker build -t qa-docs_base:0.2 -f dockerfiles/qa_docs_base.Dockerfile dockerfiles/ -docker build -t qa-docs/$branch_name:0.2 --build-arg BRANCH=$branch_name -f dockerfiles/qa_docs_tool.Dockerfile dockerfiles/ -docker run qa-docs/$branch_name:0.2 +branch_name=$1; +test_type=$2; +test_modules=${@:3}; + +docker build -t qa-docs:0.2 -f dockerfiles/qa_docs.Dockerfile dockerfiles/ + +printf "Using $branch_name branch as test(s) input.\n"; +if (($# == 1)) +then + printf "Parsing the whole tests directory.\n"; + docker run qa-docs:0.2 $branch_name +elif (($# == 2)) +then + printf "Parsing $test_type test type.\n"; + docker run qa-docs:0.2 $branch_name $test_type +else + printf "Parsing $test_modules modules from $test_type.\n"; + docker run qa-docs:0.2 $branch_name $test_type $test_modules +fi diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh new file mode 100755 index 0000000000..fe0abe7194 --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +BRANCH="$1"; +TYPE="$2"; +MODULES="${@:3}"; + +# Clone tests to be parsed as qa-docs input +mkdir tests && cd tests +git clone https://github.com/wazuh/wazuh-qa --depth=1 -b ${BRANCH} &> /dev/null + +# Clone qa-docs +cd ~ && git clone https://github.com/wazuh/wazuh-qa + +cd wazuh-qa/ +git checkout 1864-qa-docs-fixes + +# Install python dependencies not installed from +python3 -m pip install -r requirements.txt &> /dev/null + +# Install Wazuh QA framework +cd deps/wazuh_testing &> /dev/null +python3 setup.py install &> /dev/null + +# Install search-ui deps + +# Start services +service elasticsearch start && service wazuh-manager start + +# Run qa-docs tool +/usr/local/bin/qa-docs -I /tests/wazuh-qa/tests --types ${TYPE} --modules ${MODULES} -il qa-docs \ No newline at end of file diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs.Dockerfile similarity index 64% rename from deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile rename to deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs.Dockerfile index 27a46b63d0..c5727a9bdc 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_base.Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs.Dockerfile @@ -1,5 +1,7 @@ FROM ubuntu:focal + ENV DEBIAN_FRONTEND=noninteractive +ENV RUNNING_ON_DOCKER_CONTAINER=true # install packages RUN apt-get update && \ @@ -25,19 +27,11 @@ RUN curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH | apt-key add - && \ apt-get update && \ apt-get install wazuh-manager -# cloning and installing qa reqs -WORKDIR /home - -RUN git clone https://github.com/wazuh/wazuh-qa.git - -WORKDIR /home/wazuh-qa/deps/wazuh_testing/ - -RUN git checkout 1864-qa-docs-fixes && \ - pip install -r ../../requirements.txt && \ - pip install -r wazuh_testing/qa_docs/requirements.txt && \ - python3 setup.py install +ADD https://raw.githubusercontent.com/wazuh/wazuh-qa/master/requirements.txt /tmp/requirements.txt +RUN python3 -m pip install --upgrade pip && python3 -m pip install -r /tmp/requirements.txt --ignore-installed -# install npm deps -WORKDIR /home/wazuh-qa/deps/wazuh_testing/build/lib/wazuh_testing/qa_docs/search_ui +# copy entrypoint and grant permission +COPY ./entrypoint.sh /usr/bin/entrypoint.sh +RUN chmod 755 /usr/bin/entrypoint.sh -RUN npm install +ENTRYPOINT [ "/usr/bin/entrypoint.sh" ] \ No newline at end of file diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile deleted file mode 100644 index cdd371b5bb..0000000000 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs_tool.Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM qa-docs_base:0.2 - -RUN mkdir tests - -WORKDIR /home/tests - -# cloning parsed tests -RUN git clone https://github.com/wazuh/wazuh-qa.git - -WORKDIR /home/tests/wazuh-qa - -ARG BRANCH - -RUN git checkout ${BRANCH} - -WORKDIR /home/wazuh-qa/deps/wazuh_testing - -# start services, parse some tests and launch the api -CMD service elasticsearch start && \ - service wazuh-manager start && \ - qa-docs -I /home/tests/wazuh-qa/tests --types integration -il qa-docs From ce139818c3101e4d407dc4e41c3999a25d182346 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Mon, 11 Oct 2021 15:45:09 +0200 Subject: [PATCH 092/181] Update qa-ctl help menu #2008 --- .../wazuh_testing/scripts/qa_ctl.py | 28 +++---------------- 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index aa0dadcb5c..79fb811b49 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -183,32 +183,12 @@ def get_script_parameters(): """ description = \ ''' - qa-ctl current version = 0.1 + Current version: v0.2 - qa-ctl is a tool to launch local QA tests without having to worry about the environment and its provisioning. + Description: qa-ctl is a tool for launching tests locally, automating the deployment, provisioning and testing + phase. - It has two modes: - - - Automatic mode: A test name is specified, and it automatically builds the configuration file to perform - the complete deployment, provisioning and testing process. - - Run this mode with "qa-ctl -r " - - - Manual mode: A configuration file is specified and the indicated processes are carried out with the - parameters set in that file. - - Run this mode with "qa-ctl -c " - - Tip: You can first run qa-ctl in automatic mode with the persistent environment (--persistent parameter) and - then use the same configuration file for the next run in manual mode, skipping the desired phases. Useful - when you want to relaunch tests in the same environment.. - - For example: - > qa-ctl -r test_general_settings_enabled --persistent - ...... - INFO - Configuration file saved in /tmp/qa_ctl/config_1633608335.685262.yaml - ...... - > qa-ctl -c /tmp/qa_ctl/config_1633608335.685262.yaml --skip-deployment --skip-provisioning + You can find more information in https://github.com/wazuh/wazuh-qa/wiki/QACTL-tool ''' parser = argparse.ArgumentParser(description=textwrap.dedent(description), From e3f113896fc0b0fe3e1833eb963af6c197ee6b0d Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Mon, 11 Oct 2021 15:45:53 +0200 Subject: [PATCH 093/181] Fix typo in `qa-ctl` vagrantfile template #2008 --- .../wazuh_testing/qa_ctl/deployment/vagrantfile_template.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile_template.txt b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile_template.txt index c690cdf7d0..2d9394e4b4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile_template.txt +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile_template.txt @@ -16,7 +16,7 @@ Vagrant.configure("2") do |config| if Vagrant.has_plugin?("vagrant-vbguest") config.vbguest.auto_update = false end - + config.vm.box_url = "#{vm_parameters['box_url']}" node.vm.network :private_network, ip: "#{vm_parameters['private_ip']}" node.vm.hostname = "#{vm_name}" @@ -25,7 +25,7 @@ Vagrant.configure("2") do |config| vb.cpus = "#{vm_parameters['cpus']}" vb.name = "#{vm_name}" vb.customize ["setextradata", :id, "VBoxInternal/Devices/VMMDev/0/Config/GetHostTimeDisabled", 1] - vb.customize ["modifyvm", "#{vm_name}", "--groups", "/qac-tl"] + vb.customize ["modifyvm", "#{vm_name}", "--groups", "/qa-ctl"] end end end From ce26685daec1269e19d2334be76af269040ff407 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 13 Oct 2021 09:08:12 +0200 Subject: [PATCH 094/181] refac: Refactorize docker deployment. #1864 Add entrypoint to Dockerfile. --- .../wazuh_testing/qa_docs/deploy_qa_docs.sh | 4 ++-- .../dockerfiles/{qa_docs.Dockerfile => Dockerfile} | 0 .../qa_docs/dockerfiles/entrypoint.sh | 14 ++++++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) rename deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/{qa_docs.Dockerfile => Dockerfile} (100%) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh index 249fdec993..70c877bc87 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh @@ -1,6 +1,6 @@ #!/bin/bash -if (($# < 1 || $# > 3)) +if (($# < 1)) then printf "Expected call:\n\n$0 (TYPE) (MODULES)\n\nTest type and modules are optionals.\n"; exit 1; @@ -10,7 +10,7 @@ branch_name=$1; test_type=$2; test_modules=${@:3}; -docker build -t qa-docs:0.2 -f dockerfiles/qa_docs.Dockerfile dockerfiles/ +docker build -t qa-docs:0.2 dockerfiles/ printf "Using $branch_name branch as test(s) input.\n"; if (($# == 1)) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs.Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile similarity index 100% rename from deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/qa_docs.Dockerfile rename to deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/Dockerfile diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh index fe0abe7194..37774a8e66 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh @@ -5,7 +5,7 @@ TYPE="$2"; MODULES="${@:3}"; # Clone tests to be parsed as qa-docs input -mkdir tests && cd tests +mkdir ~/tests && cd ~/tests git clone https://github.com/wazuh/wazuh-qa --depth=1 -b ${BRANCH} &> /dev/null # Clone qa-docs @@ -22,9 +22,19 @@ cd deps/wazuh_testing &> /dev/null python3 setup.py install &> /dev/null # Install search-ui deps +cd /usr/local/lib/python3.8/dist-packages/wazuh_testing-4.3.0-py3.8.egg/wazuh_testing/qa_docs/search_ui +npm install # Start services service elasticsearch start && service wazuh-manager start # Run qa-docs tool -/usr/local/bin/qa-docs -I /tests/wazuh-qa/tests --types ${TYPE} --modules ${MODULES} -il qa-docs \ No newline at end of file +if (($# == 1)) +then + /usr/local/bin/qa-docs -I ~/tests/wazuh-qa/tests -il qa-docs +elif (($# == 2)) +then + /usr/local/bin/qa-docs -I ~/tests/wazuh-qa/tests --types ${TYPE} -il qa-docs +else + /usr/local/bin/qa-docs -I ~/tests/wazuh-qa/tests --types ${TYPE} --modules ${MODULES} -il qa-docs +fi From ef1d2cf217d706ee13fbe1ca7c382ca4d02c38c9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 13 Oct 2021 09:13:07 +0200 Subject: [PATCH 095/181] refac: Modularize `qa-docs` script. #1864 --- .../wazuh_testing/scripts/qa_docs.py | 123 ++++++++++-------- 1 file changed, 66 insertions(+), 57 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 31e77df7f3..b36f17dc6b 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -189,6 +189,68 @@ def run_searchui(index): os.system(f"ELASTICSEARCH_HOST=http://localhost:9200 INDEX={index} npm start") +def parse_data(args): + """Parse the tests and collect the data.""" + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + + if args.test_exist: + doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) + + for test_name in args.test_exist: + if doc_check.locate_test(test_name) is not None: + print(f"{test_name} exists") + + # Parse a list of tests + elif args.test_names: + qadocs_logger.info(f"Parsing the following test(s) {args.test_names}") + + # When output path is specified by user, a json is generated within that path + if args.output_path: + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, args.output_path, test_names=args.test_names)) + # When no output is specified, it is printed + else: + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_names)) + + # Parse a list of test types + elif args.test_types: + qadocs_logger.info(f"Parsing the following test(s) type(s): {args.test_types}") + + # Parse a list of test modules + if args.test_modules: + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types, + args.test_modules)) + else: + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) + + # Parse the whole path + else: + qadocs_logger.info(f"Parsing all tests located in {args.tests_path}") + + if not args.test_exist: + qadocs_logger.info('Running QADOCS') + docs.run() + + +def index_and_visualize_data(args): + """Index the data previously parsed and visualize it.""" + # Index the previous parsed tests into Elasticsearch + if args.index_name: + index_data = IndexData(args.index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + index_data.run() + + # Launch SearchUI with index_name as input + elif args.app_index_name: + # When SearchUI index is not hardcoded, it will be use args.app_index_name + run_searchui(args.app_index_name) + + # Index the previous parsed tests into Elasticsearch and then launch SearchUI + elif args.launching_index_name: + qadocs_logger.debug(f"Indexing {args.launching_index_name}") + index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + index_data.run() + # When SearchUI index is not hardcoded, it will be use args.launching_index_name + run_searchui(args.launching_index_name) + def main(): args, parser = get_parameters() @@ -210,63 +272,10 @@ def main(): qadocs_logger.debug('Running sanity check') sanity.run() - # Parse tests - else: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) - - if args.test_exist: - doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) - - for test_name in args.test_exist: - if doc_check.locate_test(test_name) is not None: - print(f"{test_name} exists") - - # Parse a list of tests - if args.test_names: - qadocs_logger.info(f"Parsing the following test(s) {args.test_names}") - - # When output path is specified by user, a json is generated within that path - if args.output_path: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, args.output_path, test_names=args.test_names)) - # When no output is specified, it is printed - else: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_names)) - - # Parse a list of test types - elif args.test_types: - qadocs_logger.info(f"Parsing the following test(s) type(s): {args.test_types}") - - # Parse a list of test modules - if args.test_modules: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types, - args.test_modules)) - else: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) - - # Parse the whole path - else: - qadocs_logger.info(f"Parsing all tests located in {args.tests_path}") - - qadocs_logger.info('Running QADOCS') - docs.run() - - # Index the previous parsed tests into Elasticsearch - if args.index_name: - index_data = IndexData(args.index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) - index_data.run() - - # Launch SearchUI with index_name as input - elif args.app_index_name: - # When SearchUI index is not hardcoded, it will be use args.app_index_name - run_searchui(args.app_index_name) - - # Index the previous parsed tests into Elasticsearch and then launch SearchUI - elif args.launching_index_name: - qadocs_logger.debug(f"Indexing {args.launching_index_name}") - index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) - index_data.run() - # When SearchUI index is not hardcoded, it will be use args.launching_index_name - run_searchui(args.launching_index_name) + # Parse tests, index the data and visualize it + else: + parse_data(args) + index_and_visualize_data(args) if __name__ == '__main__': main() From cd0e361d1b672fd930f38587aabfdfbb4c9a7fd6 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 13 Oct 2021 09:14:35 +0200 Subject: [PATCH 096/181] fix: Fix windows servers `os_version` values. --- deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml index 52a23947b4..58b59c25be 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml @@ -111,8 +111,8 @@ predefined_values: - Windows 8 - Windows 10 - Windows Server 2003 - - Windows server 2012 - - Windows server 2016 + - Windows Server 2012 + - Windows Server 2016 components: - agent - manager From 994a4e0280008514500257b81670572aadaf7c86 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Wed, 13 Oct 2021 10:32:00 +0200 Subject: [PATCH 097/181] add: add no-validation flag to the qa-ctl docker run for Windows #2011 --- .../wazuh_testing/qa_ctl/provisioning/local_actions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py index 0a8324fb8b..9ecd101c29 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py @@ -84,7 +84,8 @@ def qa_ctl_docker_run(config_file, qa_branch, debug_level, topic): topic (str): Reason for running the qa-ctl docker. """ debug_args = '' if debug_level == 0 else ('-d' if debug_level == 1 else '-dd') - docker_args = f"{qa_branch} {config_file} --no-validation-logging {debug_args}" + docker_args = f"{qa_branch} {config_file} --no-validation {debug_args}" + docker_image_name = 'wazuh/qa-ctl' docker_image_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'deployment', 'dockerfiles', 'qa_ctl') @@ -93,4 +94,5 @@ def qa_ctl_docker_run(config_file, qa_branch, debug_level, topic): run_local_command_with_output(f"cd {docker_image_path} && docker build -q -t {docker_image_name} .") LOGGER.info(f"Running the Linux container for {topic}") - run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'qa_ctl')}:/qa_ctl {docker_image_name} {docker_args}") + run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'qa_ctl')}:/qa_ctl {docker_image_name} " + f"{docker_args}") From d2b9e4e192ab1778278db5928e3893701ae4de9c Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 13 Oct 2021 10:36:14 +0200 Subject: [PATCH 098/181] add: Updated qa-ctl temporary folder name --- .../qa_ctl/configuration/config_generator.py | 20 +++++++++---------- .../deployment/dockerfiles/qa_ctl/Dockerfile | 2 +- .../dockerfiles/qa_ctl/entrypoint.sh | 2 +- .../provisioning/ansible/ansible_inventory.py | 2 +- .../provisioning/ansible/ansible_playbook.py | 2 +- .../provisioning/ansible/ansible_runner.py | 2 +- .../qa_ctl/provisioning/local_actions.py | 2 +- .../provisioning/qa_framework/qa_framework.py | 2 +- .../qa_ctl/provisioning/qa_provisioning.py | 2 +- .../wazuh_deployment/wazuh_deployment.py | 2 +- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 4 ++-- .../qa_ctl/run_tests/qa_test_runner.py | 8 ++++---- .../qa_ctl/run_tests/test_launcher.py | 4 ++-- .../wazuh_testing/scripts/qa_ctl.py | 8 ++++---- 14 files changed, 31 insertions(+), 31 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index b367d32a89..ce4661491e 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -66,11 +66,11 @@ class QACTLConfigGenerator: } } - def __init__(self, tests, wazuh_version, qa_branch='master', qa_files_path=join(gettempdir(), 'qa_ctl', 'wazuh-qa')): + def __init__(self, tests, wazuh_version, qa_branch='master', qa_files_path=join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa')): self.tests = tests self.wazuh_version = get_last_wazuh_version() if wazuh_version is None else wazuh_version - self.qactl_used_ips_file = join(gettempdir(), 'qa_ctl', 'qactl_used_ips.txt') - self.config_file_path = join(gettempdir(), 'qa_ctl', f"config_{get_current_timestamp()}.yaml") + self.qactl_used_ips_file = join(gettempdir(), 'wazuh_qa_ctl', 'qactl_used_ips.txt') + self.config_file_path = join(gettempdir(), 'wazuh_qa_ctl', f"config_{get_current_timestamp()}.yaml") self.config = {} self.hosts = [] self.qa_branch = qa_branch @@ -85,8 +85,8 @@ def __get_test_info(self, test_name): Returns: dict : return the info of the named test in dict format. """ - qa_docs_command = f"qa-docs -T {test_name} -o {join(gettempdir(), 'qa_ctl')} -I {join(self.qa_files_path, 'tests')}" - test_data_file_path = f"{join(gettempdir(), 'qa_ctl', test_name)}.json" + qa_docs_command = f"qa-docs -T {test_name} -o {join(gettempdir(), 'wazuh_qa_ctl')} -I {join(self.qa_files_path, 'tests')}" + test_data_file_path = f"{join(gettempdir(), 'wazuh_qa_ctl', test_name)}.json" run_local_command_with_output(qa_docs_command) @@ -232,7 +232,7 @@ def __add_instance(self, os_version, test_name, test_target, os_platform, vm_cpu instance_ip = self.__get_host_IP() instance = { 'enabled': True, - 'vagrantfile_path': join(gettempdir(), 'qa_ctl'), + 'vagrantfile_path': join(gettempdir(), 'wazuh_qa_ctl'), 'vagrant_box': QACTLConfigGenerator.BOX_MAPPING[os_version], 'vm_memory': vm_memory, 'vm_cpu': vm_cpu, @@ -335,7 +335,7 @@ def __process_provision_data(self): # QA framework self.config['provision']['hosts'][instance]['qa_framework'] = { 'wazuh_qa_branch': self.qa_branch, - 'qa_workdir': join(self.LINUX_TMP, 'qa_ctl') + 'qa_workdir': join(self.LINUX_TMP, 'wazuh_qa_ctl') } def __process_test_data(self, tests_info): @@ -355,9 +355,9 @@ def __process_test_data(self, tests_info): self.config['tests'][instance]['test'] = { 'type': 'pytest', 'path': { - 'test_files_path': f"{self.LINUX_TMP}/qa_ctl/wazuh-qa/{test['path']}", - 'run_tests_dir_path': f"{self.LINUX_TMP}/qa_ctl/wazuh-qa/test/integration", - 'test_results_path': f"{gettempdir()}/qa_ctl/{test['test_name']}_{get_current_timestamp()}/" + 'test_files_path': f"{self.LINUX_TMP}/wazuh_qa_ctl/wazuh-qa/{test['path']}", + 'run_tests_dir_path': f"{self.LINUX_TMP}/wazuh_qa_ctl/wazuh-qa/test/integration", + 'test_results_path': f"{gettempdir()}/wazuh_qa_ctl/{test['test_name']}_{get_current_timestamp()}/" } } test_host_number += 1 diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/Dockerfile index 7ce12933aa..e42dcd37a9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/Dockerfile @@ -15,7 +15,7 @@ RUN apt-get -q update && \ ADD https://raw.githubusercontent.com/wazuh/wazuh-qa/master/requirements.txt /tmp/requirements.txt RUN python3 -m pip install --upgrade pip && python3 -m pip install -r /tmp/requirements.txt --ignore-installed -RUN mkdir /qa_ctl +RUN mkdir /wazuh_qa_ctl COPY ./entrypoint.sh /usr/bin/entrypoint.sh RUN chmod 755 /usr/bin/entrypoint.sh diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/entrypoint.sh b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/entrypoint.sh index bab4e21241..02f07e1412 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/entrypoint.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/entrypoint.sh @@ -15,4 +15,4 @@ cd wazuh-qa/deps/wazuh_testing &> /dev/null python3 setup.py install &> /dev/null # Run qa-ctl tool -/usr/local/bin/qa-ctl -c /qa_ctl/${CONFIG_FILE_PATH} ${EXTRA_ARGS} +/usr/local/bin/qa-ctl -c /wazuh_qa_ctl/${CONFIG_FILE_PATH} ${EXTRA_ARGS} diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py index 2856a76814..085c8d686f 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py @@ -25,7 +25,7 @@ def __init__(self, ansible_instances, inventory_file_path=None, ansible_groups=N self.ansible_instances = ansible_instances self.inventory_file_path = inventory_file_path if inventory_file_path else \ - f"{gettempdir()}/qa_ctl/{get_current_timestamp()}.yaml" + f"{gettempdir()}/wazuh_qa_ctl/{get_current_timestamp()}.yaml" self.ansible_groups = ansible_groups self.data = {} self.__setup_data__() diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_playbook.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_playbook.py index ab2d3d95c9..2adc5b4617 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_playbook.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_playbook.py @@ -43,7 +43,7 @@ def __init__(self, name='generic_playbook', tasks_list=None, playbook_file_path= self.become = become self.playbook_vars = playbook_vars self.playbook_file_path = playbook_file_path if playbook_file_path else \ - f"{gettempdir()}/qa_ctl/{get_current_timestamp()}.yaml" + f"{gettempdir()}/wazuh_qa_ctl/{get_current_timestamp()}.yaml" if generate_file: self.write_playbook_to_file() diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py index c787d491bd..752b202c86 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py @@ -31,7 +31,7 @@ class AnsibleRunner: """ LOGGER = Logging.get_logger(QACTL_LOGGER) - def __init__(self, ansible_inventory_path, ansible_playbook_path, private_data_dir=join(gettempdir(), 'qa_ctl'), output=False): + def __init__(self, ansible_inventory_path, ansible_playbook_path, private_data_dir=join(gettempdir(), 'wazuh_qa_ctl'), output=False): self.ansible_inventory_path = ansible_inventory_path self.ansible_playbook_path = ansible_playbook_path self.private_data_dir = private_data_dir diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py index 0a8324fb8b..43874d29a2 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py @@ -93,4 +93,4 @@ def qa_ctl_docker_run(config_file, qa_branch, debug_level, topic): run_local_command_with_output(f"cd {docker_image_path} && docker build -q -t {docker_image_name} .") LOGGER.info(f"Running the Linux container for {topic}") - run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'qa_ctl')}:/qa_ctl {docker_image_name} {docker_args}") + run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'wazuh_qa_ctl')}:/wazuh_qa_ctl {docker_image_name} {docker_args}") diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py index 84e20f9a72..51f93d75e0 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py @@ -24,7 +24,7 @@ class QAFramework(): """ LOGGER = Logging.get_logger(QACTL_LOGGER) - def __init__(self, ansible_output=False, workdir=join(gettempdir(), 'qa_ctl'), qa_branch='master', + def __init__(self, ansible_output=False, workdir=join(gettempdir(), 'wazuh_qa_ctl'), qa_branch='master', qa_repository='https://github.com/wazuh/wazuh-qa.git'): self.qa_repository = qa_repository self.qa_branch = qa_branch diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index 7c32e334c9..ad8fcb2ecd 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -213,7 +213,7 @@ def run(self): # If Windows, then run a Linux docker container to run provisioning stage with qa-ctl provision if sys.platform == 'win32': tmp_config_file_name = f"config_{get_current_timestamp()}.yaml" - tmp_config_file = os.path.join(gettempdir(), 'qa_ctl', tmp_config_file_name) + tmp_config_file = os.path.join(gettempdir(), 'wazuh_qa_ctl', tmp_config_file_name) # Write a custom configuration file with only provision section file.write_yaml_file(tmp_config_file, {'provision': self.provision_info}) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py index e821a4d7da..66db4f85fe 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py @@ -75,7 +75,7 @@ def install(self, install_type): tasks_list.append(AnsibleTask({ 'name': 'Executing "install.sh" script to build and install Wazuh', - 'shell': f"./install.sh > {gettempdir()}/qa_ctl/wazuh_install_log.txt", + 'shell': f"./install.sh > {gettempdir()}/wazuh_qa_ctl/wazuh_install_log.txt", 'args': {'chdir': f'{self.installation_files_path}'}, 'when': 'ansible_system == "Linux"'})) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index eac271fa3d..3b063502f8 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -68,7 +68,7 @@ def __init__(self, tests_result_path, tests_path, tests_run_dir, qa_ctl_configur self.log_level = log_level self.markers = markers self.hosts = hosts - self.tests_result_path = os.path.join(gettempdir(), 'qa_ctl') if tests_result_path is None else tests_result_path + self.tests_result_path = os.path.join(gettempdir(), 'wazuh_qa_ctl') if tests_result_path is None else tests_result_path if not os.path.exists(self.tests_result_path): os.makedirs(self.tests_result_path) @@ -88,7 +88,7 @@ def run(self, ansible_inventory_path): assets_zip = f"assets_{date_time}.zip" html_report_file_name = f"test_report_{date_time}.html" plain_report_file_name = f"test_report_{date_time}.txt" - playbook_file_path = os.path.join(gettempdir(), 'qa_ctl', f"{get_current_timestamp()}.yaml") + playbook_file_path = os.path.join(gettempdir(), 'wazuh_qa_ctl', f"{get_current_timestamp()}.yaml") reports_directory = os.path.join(self.tests_run_dir, reports_folder) plain_report_file_path = os.path.join(reports_directory, plain_report_file_name) html_report_file_path = os.path.join(reports_directory, html_report_file_name) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py index 3538e5b558..1145d4838f 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py @@ -140,16 +140,16 @@ def run(self): # If Windows, then run a Linux docker container to run testing stage with qa-ctl testing if sys.platform == 'win32': tmp_config_file_name = f"config_{get_current_timestamp()}.yaml" - tmp_config_file = os.path.join(gettempdir(), 'qa_ctl', tmp_config_file_name) + tmp_config_file = os.path.join(gettempdir(), 'wazuh_qa_ctl', tmp_config_file_name) # Save original directory where to store the results in Windows host original_result_paths = [ self.test_parameters[host_key]['test']['path']['test_results_path'] \ for host_key, _ in self.test_parameters.items()] # Change the destination directory, as the results will initially be stored in the shared volume between - # the Windows host and the docker container (Windows tmp as /qa_ctl). + # the Windows host and the docker container (Windows tmp as /wazuh_qa_ctl). test_results_folder = f"test_results_{get_current_timestamp()}" - temp_test_results_files_path = f"/qa_ctl/{test_results_folder}" + temp_test_results_files_path = f"/wazuh_qa_ctl/{test_results_folder}" index = 0 for host_key, _ in self.test_parameters.items(): @@ -166,7 +166,7 @@ def run(self): # Move all test results to their original paths specified in Windows qa-ctl configuration index = 0 for _, host_data in self.test_parameters.items(): - source_directory = os.path.join(gettempdir(), 'qa_ctl', f"{test_results_folder}_{index}") + source_directory = os.path.join(gettempdir(), 'wazuh_qa_ctl', f"{test_results_folder}_{index}") file.move_everything_from_one_directory_to_another(source_directory, original_result_paths[index]) file.delete_path_recursively(source_directory) QATestRunner.LOGGER.info(f"The results of {host_data['test']['path']['test_files_path']} tests " diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py index e2dac91dd9..8ca43f166f 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py @@ -32,7 +32,7 @@ class TestLauncher: def __init__(self, tests, ansible_inventory_path, qa_ctl_configuration, qa_framework_path=None): self.qa_framework_path = qa_framework_path if qa_framework_path is not None else \ - os.path.join(gettempdir(), 'qa_ctl', 'wazuh-qa') + os.path.join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa') self.ansible_inventory_path = ansible_inventory_path self.qa_ctl_configuration = qa_ctl_configuration self.tests = tests @@ -45,7 +45,7 @@ def __set_local_internal_options(self, hosts): wazuh installation path """ local_internal_options = '\n'.join(self.DEBUG_OPTIONS) - playbook_file_path = os.path.join(gettempdir(), 'qa_ctl' f"{get_current_timestamp()}.yaml") + playbook_file_path = os.path.join(gettempdir(), 'wazuh_qa_ctl' f"{get_current_timestamp()}.yaml") local_internal_path = '/var/ossec/etc/local_internal_options.conf' diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 79fb811b49..602e221e93 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -27,7 +27,7 @@ DEPLOY_KEY = 'deployment' PROVISION_KEY = 'provision' TEST_KEY = 'tests' -WAZUH_QA_FILES = os.path.join(gettempdir(), 'qa_ctl', 'wazuh-qa') +WAZUH_QA_FILES = os.path.join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa') RUNNING_ON_DOCKER_CONTAINER = True if 'RUNNING_ON_DOCKER_CONTAINER' in os.environ else False qactl_logger = Logging(QACTL_LOGGER) @@ -114,12 +114,12 @@ def set_environment(parameters): Args: (argparse.Namespace): Object with the user parameters. """ - # Create the qa_ctl temporary folder - recursive_directory_creation(os.path.join(gettempdir(), 'qa_ctl')) + # Create the wazuh_qa_ctl temporary folder + recursive_directory_creation(os.path.join(gettempdir(), 'wazuh_qa_ctl')) if parameters.run_test: # Download wazuh-qa repository locally to run qa-docs tool and get the tests info - local_actions.download_local_wazuh_qa_repository(branch=parameters.qa_branch, path=os.path.join(gettempdir(), 'qa_ctl')) + local_actions.download_local_wazuh_qa_repository(branch=parameters.qa_branch, path=os.path.join(gettempdir(), 'wazuh_qa_ctl')) def validate_parameters(parameters): From bbc40d3481c5a6b94cab1cf3c2464b8d77b71916 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 13 Oct 2021 11:03:05 +0200 Subject: [PATCH 099/181] add: Add ES RAM limit to entrypoint. #1864 1GB RAM limit. --- .../wazuh_testing/qa_docs/dockerfiles/entrypoint.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh index 37774a8e66..de12b96742 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh @@ -22,9 +22,13 @@ cd deps/wazuh_testing &> /dev/null python3 setup.py install &> /dev/null # Install search-ui deps -cd /usr/local/lib/python3.8/dist-packages/wazuh_testing-4.3.0-py3.8.egg/wazuh_testing/qa_docs/search_ui +cd /usr/local/lib/python3.8/dist-packages/wazuh_testing-*/wazuh_testing/qa_docs/search_ui npm install +# Limit ES RAM +echo "-Xms1g" >> /etc/elasticsearch/jvm.options +echo "-Xmx1g" >> /etc/elasticsearch/jvm.options + # Start services service elasticsearch start && service wazuh-manager start From 16194a716d560c8e5b5ea4d0d1dea2e17a780035 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 13 Oct 2021 11:04:05 +0200 Subject: [PATCH 100/181] doc: Add ES RAM configuration to README. #1864 --- .../wazuh_testing/qa_docs/README.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 5095feeb83..56796afc65 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -151,6 +151,29 @@ systemctl start elasticsearch.service ``` choco install elasticsearch ``` + + +##### ES RAM issue + +- Linux + +If you do not want to have ES consuming 70% of your available RAM, run this: +``` +echo "-Xms1g" >> /etc/elasticsearch/jvm.options +echo "-Xmx1g" >> /etc/elasticsearch/jvm.options +``` + +- Windows + +Add the followings line to `config/jvm.options`: + +``` +-Xms1g +-Xmx1g +``` + +`-XmsAg` defines the max allocation of RAM to ES JVM Heap, where A is the amount of GBs you want. + ##### For more options check the official website: https://www.elastic.co/es/downloads/elasticsearch From dfca4e82096968bd5d4133fb87cc70600ea4e964 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 13 Oct 2021 14:07:16 +0200 Subject: [PATCH 101/181] fix: fixed temporary qa-ctl folder error on windows --- .../wazuh_testing/qa_ctl/provisioning/local_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py index 3da95593c3..c205226865 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py @@ -94,5 +94,5 @@ def qa_ctl_docker_run(config_file, qa_branch, debug_level, topic): run_local_command_with_output(f"cd {docker_image_path} && docker build -q -t {docker_image_name} .") LOGGER.info(f"Running the Linux container for {topic}") - run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'wazuh_qa_ctl')}:/qa_ctl {docker_image_name} " + run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'wazuh_qa_ctl')}:/wazuh_qa_ctl {docker_image_name} " f"{docker_args}") From 0099edbf5a6002e33eb21a8ea0a9bed9f5388bc9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 13 Oct 2021 14:07:17 +0200 Subject: [PATCH 102/181] doc: Update `qa-docs` diagram. #1864 --- .../qa_docs/DocGenerator_diagram.png | Bin 82830 -> 196436 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/DocGenerator_diagram.png b/deps/wazuh_testing/wazuh_testing/qa_docs/DocGenerator_diagram.png index 35cb0c6de1473647ccfe38927354c072edee65fc..8b6c42fb5f1884344162e197fddf552fc5351b33 100644 GIT binary patch literal 196436 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%0A?Ydu=Y@R=SyrTcD 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

a9!~&&R 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

_)5q0aVt5*QmBhclwT~_#`bo*Dr}m><=);lQx6* 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%0DWvzHP74RQ z3IIXSr2Ivpra=3m+(k+(Un<)_AV-ppL`3A~cpm6_YWu!LEG_7R3W%iYGe+;pTlNTl z-sD$zmf2qqQzJh)+BIG4-?JXTd4dC?78gVoq~AVkqj0c$0PV)CI=R{WYOQKYO~4l^ zyq>`8P7+Um%}hxso9hR`2X-roE?=C#lF^eH#oTjbE`Iv44&i=BIgOP2`A@7{=zUBE5pE`8{0kH>%TU>h}zTa6*&TNexTjY!Q;=bo6afE!iyD~BKP-!+i=XlIi6Qi z`SYwUy`t*la57zo3~|(7eh}s1vtV-m(25ZINn#Nq!Qx0sJ6b8htm%#xAtDXdy%QbQR0< zEjj*dh`LN8sorXsjZ&WA^g znr-Vip(O6b1`itNvyf`~H2A)~NH~f|6|ke zs^t6sW7A+CLm9+y7!7X^va-?)(yO2QLbP2%OV~c_IiYC~oW*

a9!~&&R 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

_)5q0aVt5*QmBhclwT~_#`bo*Dr}m><=);lQx6* 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 Q>ZVlp07W1pZ2*`A35o(BS9b#;1xG7XJKqdrRq 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 kpb=5T{Ga?^w_X_HbR Date: Thu, 14 Oct 2021 12:10:26 +0200 Subject: [PATCH 113/181] doc: Update diagram within README. --- deps/wazuh_testing/wazuh_testing/qa_docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 99a5fccb6a..7804f129fa 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -67,7 +67,7 @@ Together with the Indexing functionality, the tool can locally lunch SearchUI to documentation content into the App UI. ### Diagram -![DocGenerator](DocGenerator_diagram.png) +![DocGenerator](qa_docs_diagram.png) ## Content ├── wazuh-testing From da0103e192be3d5c16b0b6f85579e82ace469a5e Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 14 Oct 2021 15:24:07 +0200 Subject: [PATCH 114/181] Fix: Fix qa-ctl configuration paths #2016 Now the paths are generated correctly, regardless of the system where they are launched both locally and remotely. --- .../qa_ctl/configuration/config_generator.py | 36 +++++++++++++++---- .../qa_ctl/provisioning/qa_provisioning.py | 2 +- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 93134705d7..41b1b76098 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -65,7 +65,8 @@ class QACTLConfigGenerator: } } - def __init__(self, tests, wazuh_version, qa_branch='master', qa_files_path=join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa')): + def __init__(self, tests, wazuh_version, qa_branch='master', + qa_files_path=join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa')): self.tests = tests self.wazuh_version = wazuh_version self.qactl_used_ips_file = join(gettempdir(), 'wazuh_qa_ctl', 'qactl_used_ips.txt') @@ -105,6 +106,19 @@ def __get_test_info(self, test_name): return info + def __join_path(self, path, system): + """Create the path using the separator indicated for the operating system. Used for remote hosts configuration. + + Parameters: + path (list(str)): Path list (one item for level). + system (str): host system. + + Returns: + str: Joined path. + """ + return '\\'.join(path) if system == 'windows' else '/'.join(path) + + def __get_all_tests_info(self): """Get the info of the documentation of all the test that are going to be run. @@ -318,11 +332,12 @@ def __process_provision_data(self): target = 'manager' if 'manager' in self.config['deployment'][instance]['provider']['vagrant']['label'] \ else 'agent' s3_package_url = self.__get_package_url(instance) + installation_files_path = QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'] self.config['provision']['hosts'][instance]['wazuh_deployment'] = { 'type': 'package', 'target': target, 's3_package_url': s3_package_url, - 'installation_files_path': QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'], + 'installation_files_path': installation_files_path, 'health_check': True } if target == 'agent': @@ -332,9 +347,10 @@ def __process_provision_data(self): self.config['deployment'][f"host_{manager_host_number}"]['provider']['vagrant']['vm_ip'] # QA framework + system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] self.config['provision']['hosts'][instance]['qa_framework'] = { 'wazuh_qa_branch': self.qa_branch, - 'qa_workdir': join(self.LINUX_TMP, 'wazuh_qa_ctl') + 'qa_workdir': self.__join_path([installation_files_path, 'wazuh_qa_ctl'], system) } def __process_test_data(self, tests_info): @@ -348,15 +364,23 @@ def __process_test_data(self, tests_info): for test in tests_info: instance = f"host_{test_host_number}" + vm_box = self.config['deployment'][instance]['provider']['vagrant']['vagrant_box'] + installation_files_path = QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'] + system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] + self.config['tests'][instance] = {'host_info': {}, 'test': {}} self.config['tests'][instance]['host_info'] = \ dict(self.config['provision']['hosts'][instance]['host_info']) + self.config['tests'][instance]['test'] = { 'type': 'pytest', 'path': { - 'test_files_path': f"{self.LINUX_TMP}/wazuh_qa_ctl/wazuh-qa/{test['path']}", - 'run_tests_dir_path': f"{self.LINUX_TMP}/wazuh_qa_ctl/wazuh-qa/test/integration", - 'test_results_path': f"{gettempdir()}/wazuh_qa_ctl/{test['test_name']}_{get_current_timestamp()}/" + 'test_files_path': self.__join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', + test['path']], system), + 'run_tests_dir_path': self.__join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', + 'tests', 'integration'], system), + 'test_results_path': join(gettempdir(), 'wazuh_qa_ctl', + f"{test['test_name']}_{get_current_timestamp()}") } } test_host_number += 1 diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index ad8fcb2ecd..73b3eacb0a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -186,7 +186,7 @@ def __process_config_data(self, host_provision_info): QAProvisioning.LOGGER.info(f"Provisioning the {current_host} host with the Wazuh QA framework using " f"{wazuh_qa_branch} branch.") - qa_instance = QAFramework(qa_branch=wazuh_qa_branch, + qa_instance = QAFramework(qa_branch=wazuh_qa_branch, workdir=qa_framework_info['qa_workdir'], ansible_output=self.qa_ctl_configuration.ansible_output) qa_instance.download_qa_repository(inventory_file_path=self.inventory_file_path, hosts=current_host) qa_instance.install_dependencies(inventory_file_path=self.inventory_file_path, hosts=current_host) From ed35e8beb863bb8e39b1d6b41fa447fec6276485 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 15 Oct 2021 08:19:19 +0200 Subject: [PATCH 115/181] fix: Fix version. --- deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md | 9 +-------- deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json | 2 +- .../wazuh_testing/qa_docs/deploy_qa_docs.sh | 8 ++++---- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md b/deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md index d75bed4ee6..900ddad08e 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/CHANGELOG.md @@ -2,8 +2,6 @@ ## [v0.1] -First tool implementation. - ### Added - Included dependencies installation details. ([#1693](https://github.com/wazuh/wazuh-qa/pull/1693)) - Added DocGenerator documentation. ([#1686](https://github.com/wazuh/wazuh-qa/pull/1686)) @@ -14,12 +12,7 @@ First tool implementation. - Added Config to DocGenerator. ([#1619](https://github.com/wazuh/wazuh-qa/pull/1619)) - Implemented a sanity check module for DocGenerator. ([#1649])(https://github.com/wazuh/wazuh-qa/pull/1649) - Added DocGenerator code to master branch. ([#1762](https://github.com/wazuh/wazuh-qa/pull/1762)) - -## [v0.2] - -Tool added to wazuh-testing framework. Also, added new features and behaviours. - -### Added + - Tool added to wazuh-testing framework. ([#1854](https://github.com/wazuh/wazuh-qa/pull/1854)) - Added single test parse. ([#1854](https://github.com/wazuh/wazuh-qa/pull/1854)) - Integrate qa-docs into wazuh-qa framework. ([#1854](https://github.com/wazuh/wazuh-qa/pull/1854)) - Added custom qa-docs logger. ([#1896])(https://github.com/wazuh/wazuh-qa/pull/1896) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json b/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json index aeeb7caeaf..5d026e9edc 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/VERSION.json @@ -1,4 +1,4 @@ { - "version": "0.2", + "version": "0.1", "revision": 1 } diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh index 70c877bc87..1c151deecc 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/deploy_qa_docs.sh @@ -10,18 +10,18 @@ branch_name=$1; test_type=$2; test_modules=${@:3}; -docker build -t qa-docs:0.2 dockerfiles/ +docker build -t qa-docs:0.1 dockerfiles/ printf "Using $branch_name branch as test(s) input.\n"; if (($# == 1)) then printf "Parsing the whole tests directory.\n"; - docker run qa-docs:0.2 $branch_name + docker run qa-docs:0.1 $branch_name elif (($# == 2)) then printf "Parsing $test_type test type.\n"; - docker run qa-docs:0.2 $branch_name $test_type + docker run qa-docs:0.1 $branch_name $test_type else printf "Parsing $test_modules modules from $test_type.\n"; - docker run qa-docs:0.2 $branch_name $test_type $test_modules + docker run qa-docs:0.1 $branch_name $test_type $test_modules fi From 0e09f2ab4d2e39af0f54a237473cf00163987440 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 15 Oct 2021 10:32:55 +0200 Subject: [PATCH 116/181] refac: Move join_path function to file module #2018 --- .../qa_ctl/configuration/config_generator.py | 23 ++++--------------- .../wazuh_testing/wazuh_testing/tools/file.py | 13 +++++++++++ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 41b1b76098..5c6f0748e1 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -106,19 +106,6 @@ def __get_test_info(self, test_name): return info - def __join_path(self, path, system): - """Create the path using the separator indicated for the operating system. Used for remote hosts configuration. - - Parameters: - path (list(str)): Path list (one item for level). - system (str): host system. - - Returns: - str: Joined path. - """ - return '\\'.join(path) if system == 'windows' else '/'.join(path) - - def __get_all_tests_info(self): """Get the info of the documentation of all the test that are going to be run. @@ -350,7 +337,7 @@ def __process_provision_data(self): system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] self.config['provision']['hosts'][instance]['qa_framework'] = { 'wazuh_qa_branch': self.qa_branch, - 'qa_workdir': self.__join_path([installation_files_path, 'wazuh_qa_ctl'], system) + 'qa_workdir': file.join_path([installation_files_path, 'wazuh_qa_ctl'], system) } def __process_test_data(self, tests_info): @@ -375,10 +362,10 @@ def __process_test_data(self, tests_info): self.config['tests'][instance]['test'] = { 'type': 'pytest', 'path': { - 'test_files_path': self.__join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', - test['path']], system), - 'run_tests_dir_path': self.__join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', - 'tests', 'integration'], system), + 'test_files_path': file.join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', + test['path']], system), + 'run_tests_dir_path': file.join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', + 'tests', 'integration'], system), 'test_results_path': join(gettempdir(), 'wazuh_qa_ctl', f"{test['test_name']}_{get_current_timestamp()}") } diff --git a/deps/wazuh_testing/wazuh_testing/tools/file.py b/deps/wazuh_testing/wazuh_testing/tools/file.py index 894a7a4ff7..35e4bc7993 100644 --- a/deps/wazuh_testing/wazuh_testing/tools/file.py +++ b/deps/wazuh_testing/wazuh_testing/tools/file.py @@ -331,3 +331,16 @@ def move_everything_from_one_directory_to_another(source_directory, destination_ for file_name in file_names: shutil.move(os.path.join(source_directory, file_name), destination_directory) + + +def join_path(path, system): + """Create the path using the separator indicated for the operating system. Used for remote hosts configuration. + + Parameters: + path (list(str)): Path list (one item for level). + system (str): host system. + + Returns: + str: Joined path. + """ + return '\\'.join(path) if system == 'windows' else '/'.join(path) From 8d5166dca26db6a5acb52ce79ef62a4be2a3575b Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 15 Oct 2021 13:43:13 +0200 Subject: [PATCH 117/181] refac: Replace git usage for direct downloads in qa-ctl #2018 --- .../deployment/dockerfiles/qa_ctl/Dockerfile | 2 +- .../dockerfiles/qa_ctl/entrypoint.sh | 4 +-- .../qa_ctl/provisioning/local_actions.py | 27 +++++++++++++------ .../provisioning/qa_framework/qa_framework.py | 20 +++++++++----- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/Dockerfile b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/Dockerfile index e42dcd37a9..d54f75f88e 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/Dockerfile +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/Dockerfile @@ -5,7 +5,7 @@ ENV RUNNING_ON_DOCKER_CONTAINER=true RUN apt-get -q update && \ apt-get install -y \ - git \ + curl \ python \ python3 \ sshpass \ diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/entrypoint.sh b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/entrypoint.sh index 02f07e1412..fa3b59a5d6 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/entrypoint.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/dockerfiles/qa_ctl/entrypoint.sh @@ -4,8 +4,8 @@ BRANCH="$1" CONFIG_FILE_PATH="$2" EXTRA_ARGS="${@:3}" -# Clone custom wazuh-qa repository branch -git clone https://github.com/wazuh/wazuh-qa --depth=1 -b ${BRANCH} &> /dev/null +# Download the custom branch of wazuh-qa repository +curl -Ls https://github.com/wazuh/wazuh-qa/archive/${BRANCH}.tar.gz | tar zx &> /dev/null && mv wazuh-* wazuh-qa # Install python dependencies not installed from python3 -m pip install -r wazuh-qa/requirements.txt &> /dev/null diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py index c205226865..ee9a7dc089 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py @@ -4,8 +4,11 @@ from tempfile import gettempdir from wazuh_testing.qa_ctl import QACTL_LOGGER +from wazuh_testing.tools.github_api_requests import WAZUH_QA_REPO +from wazuh_testing.tools.github_checks import branch_exists from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.exceptions import QAValueError +from wazuh_testing.tools.file import delete_path_recursively LOGGER = Logging.get_logger(QACTL_LOGGER) @@ -61,17 +64,25 @@ def download_local_wazuh_qa_repository(branch, path): path (string): Local path where save the repository files. """ wazuh_qa_path = os.path.join(path, 'wazuh-qa') - mute_output = '&> /dev/null' if sys.platform != 'win32' else '>nul 2>&1' + command = '' + + if not branch_exists(branch, repository=WAZUH_QA_REPO): + raise QAValueError(f"{branch} branch does not exist in Wazuh QA repository.", LOGGER.error, QACTL_LOGGER) + + # Delete previous files if exist + delete_path_recursively(wazuh_qa_path) - if os.path.exists(wazuh_qa_path): - LOGGER.info(f"Pulling remote repository changes in {wazuh_qa_path} local repository") - run_local_command_with_output(f"cd {wazuh_qa_path} && git pull {mute_output} && " - f"git checkout {branch} {mute_output}") + if sys.platform == 'win32': + command = f"cd {path} && curl -OL https://github.com/wazuh/wazuh-qa/archive/{branch}.tar.gz {mute_output} && " \ + f"tar -xzf {branch}.tar.gz {mute_output} && move wazuh-qa-{branch} wazuh-qa {mute_output} && " \ + f"del {branch}.tar.gz {mute_output}" else: - LOGGER.info(f"Downloading wazuh-qa repository in {wazuh_qa_path}") - run_local_command_with_output(f"cd {path} && git clone https://github.com/wazuh/wazuh-qa {mute_output} && " - f"cd {wazuh_qa_path} && git checkout {branch} {mute_output}") + command = f"cd {path} && curl -Ls https://github.com/wazuh/wazuh-qa/archive/{branch}.tar.gz | tar zx " \ + f"{mute_output} && mv wazuh-* wazuh-qa {mute_output} && rm -rf *tar.gz {mute_output}" + + LOGGER.debug(f"Downloading {branch} files of wazuh-qa repository in {wazuh_qa_path}") + run_local_command_with_output(command) def qa_ctl_docker_run(config_file, qa_branch, debug_level, topic): diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py index 51f93d75e0..f8b0c13e4b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py @@ -5,6 +5,7 @@ from wazuh_testing.qa_ctl.provisioning.ansible.ansible_runner import AnsibleRunner from wazuh_testing.qa_ctl import QACTL_LOGGER from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.file import join_path class QAFramework(): @@ -28,8 +29,9 @@ def __init__(self, ansible_output=False, workdir=join(gettempdir(), 'wazuh_qa_ct qa_repository='https://github.com/wazuh/wazuh-qa.git'): self.qa_repository = qa_repository self.qa_branch = qa_branch - self.workdir = f"{workdir}/wazuh-qa" + self.workdir = workdir self.ansible_output = ansible_output + self.system_path = 'windows' if '\\' in self.workdir else 'unix' def install_dependencies(self, inventory_file_path, hosts='all'): """Install all the necessary dependencies to allow the execution of the tests. @@ -39,7 +41,7 @@ def install_dependencies(self, inventory_file_path, hosts='all'): """ dependencies_task = AnsibleTask({'name': 'Install python dependencies', 'shell': 'python3 -m pip install -r requirements.txt', - 'args': {'chdir': self.workdir}, + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa'], self.system_path)}, 'become': True}) ansible_tasks = [dependencies_task] playbook_parameters = {'hosts': hosts, 'tasks_list': ansible_tasks} @@ -57,7 +59,8 @@ def install_framework(self, inventory_file_path, hosts='all'): """ install_framework_task = AnsibleTask({'name': 'Install wazuh-qa framework', 'shell': 'python3 setup.py install', - 'args': {'chdir': f"{self.workdir}/deps/wazuh_testing"}}) + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa', 'deps', + 'wazuh_testing'], self.system_path)}}) ansible_tasks = [install_framework_task] playbook_parameters = {'hosts': hosts, 'tasks_list': ansible_tasks, 'become': True} QAFramework.LOGGER.debug(f"Installing wazuh-qa framework in {hosts} hosts.") @@ -75,11 +78,14 @@ def download_qa_repository(self, inventory_file_path, hosts='all'): create_path_task = AnsibleTask({'name': f"Create {self.workdir} path", 'file': {'path': self.workdir, 'state': 'directory', 'mode': '0755'}}) - download_qa_repo_task = AnsibleTask({'name': f"Download {self.qa_repository} QA repository", - 'git': {'repo': self.qa_repository, 'dest': self.workdir, - 'version': self.qa_branch}}) + download_qa_repo_task = AnsibleTask({'name': f"Download {self.qa_branch} branch of wazuh-qa repository", + 'shell': f"cd {self.workdir} && " + + 'curl -Ls https://github.com/wazuh/wazuh-qa/archive/' + + f"{self.qa_branch}.tar.gz | tar zx && mv wazuh-* wazuh-qa", + 'when': 'ansible_system != "Windows"'}) + ansible_tasks = [create_path_task, download_qa_repo_task] - playbook_parameters = {'hosts': hosts, 'tasks_list': ansible_tasks} + playbook_parameters = {'hosts': hosts, 'gather_facts': True, 'tasks_list': ansible_tasks} QAFramework.LOGGER.debug(f"Downloading qa-repository in {hosts} hosts") AnsibleRunner.run_ephemeral_tasks(inventory_file_path, playbook_parameters, output=self.ansible_output) From b025b55b93ef089988aa2c7a0c5f19f0bf5c40f2 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 18 Oct 2021 09:42:31 +0200 Subject: [PATCH 118/181] doc: Update README.MD. Update wiki links and fix some errors. --- .../wazuh_testing/qa_docs/README.md | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 7804f129fa..1e7a980af4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -63,7 +63,7 @@ The JSON files generated by `qa-docs` are intended to be indexed into elasticsea So, DocGenerator treats each JSON file as a document that will be added to the ElasticSearch index. ### Local launch -Together with the Indexing functionality, the tool can locally lunch SearchUI to visualize the +Together with the Indexing functionality, the tool can locally launch SearchUI to visualize the documentation content into the App UI. ### Diagram @@ -100,23 +100,11 @@ documentation content into the App UI. ## Schema The schema file of the tool is located at **qa-docs/schema.yaml**. -The schema fields are specified in the qa-docs documenting test [wiki](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks). +The schema fields are specified in the qa-docs documenting test [wiki](https://github.com/wazuh/wazuh-qa/wiki/QADOCS-tool-How-to-document-a-test). ## Installation -To install `qa-docs` you have to install the wazuh-qa framework by running: - -``` -python3 setup.py install -``` - -This `setup.py` is located in `wazuh-qa/deps/wazuh_testing/setup.py` - -## Usage - -You can parse the tests and run the API with just one command, e.g. `qa-docs -I /path-to-tests/ --type integration --modules test_active_response -il active-response-index` - -For a detailed usage visit the `qa-docs documentation generation` [wiki](https://github.com/wazuh/wazuh-qa/wiki/Documentation-generation-with-qadocs-tool) +A [wiki entry](https://github.com/wazuh/wazuh-qa/wiki/QADOCS-tool-installation-guide) is available with the installation details. ### Dependencies @@ -161,8 +149,8 @@ choco install elasticsearch - Linux ``` -echo "-Xms256m" >> /etc/elasticsearch/jvm.options -echo "-Xmx256m" >> /etc/elasticsearch/jvm.options +echo "-Xms1g" >> /etc/elasticsearch/jvm.options +echo "-Xmx1g" >> /etc/elasticsearch/jvm.options ``` - Windows @@ -170,8 +158,8 @@ echo "-Xmx256m" >> /etc/elasticsearch/jvm.options Add the followings line to `config/jvm.options`: ``` --Xms256m --Xmx256m +-Xms1g +-Xmx1g ``` `-XmsAm` defines the max allocation of RAM to ES JVM Heap, where A is the amount of MBs you want. You can also @@ -196,6 +184,12 @@ sudo apt-get install npm Windows installer: https://nodejs.org/en/download/ and follow the setup wizard steps. +## Usage + +You can parse the tests and run the API with just one command, e.g. `qa-docs -I /path-to-tests/ --type integration --modules test_active_response -il active-response-index` + +Also, you can visit the `qa-docs` tool [use guide](https://github.com/wazuh/wazuh-qa/wiki/QADOCS-tool-use-guide) + ### Parsing #### Complete run @@ -227,7 +221,7 @@ This option is not compatible with API-related options, because the output is pr With this option the tool prints if test(s) do(es) exist. -### Sanity Check +#### Sanity Check qa-docs -I /path-to-tests-to-parse/ -s Using `-s`, the tool will run a sanity check of the content in the output folder. @@ -238,17 +232,17 @@ the **tests path**. Also, it will validate that the output files have every Mandatory field and check that the documentation parsed has no wrong values following the `qa-docs` [predefined values](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#pre-defined-values). -### Debug +#### Debug qa-docs -I /path-to-tests-to-parse/ -d Using `-d`, the tool runs in DEBUG mode, logging extra information in the log file(created within the `qa-docs` build directory) or console output. -### Version +#### Version qa-docs -v Using `-v`, the tool will print its current version. -### Index output data +#### Index output data qa-docs -i Using `-i` option, the tool indexes the content of each file output previously generated as a document into **ElasticSearch**. The name of the index @@ -276,12 +270,12 @@ For detailed API information, you can visit https://www.elastic.co/guide/en/elas https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/index.html. -### Local api launch using index +#### Local api launch using index qa-docs -l Using `-l` option, the tool launches the application with a previously generated index. The name of the index must be provided as a parameter. -### Index output data and launch the api +#### Index output data and launch the api qa-docs -il Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the API. The name of the index must be provided as a parameter. e.g: `qa-docs -I /wazuh-qa/tests/ -il qa-tests`. A previous run must be performed, e.g.`qa-docs -I /wazuh-qa/tests/` so the output data is previously generated. From eb3f644f2c7c8f65bacf1c2dba5be47b29c88b95 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Mon, 18 Oct 2021 16:16:16 +0200 Subject: [PATCH 119/181] add: Add --os parameter to qa-ctl #2019 Now it is possible to launch tests for different operating systems simultaneously --- .../qa_ctl/configuration/config_generator.py | 171 +++++++++++++----- .../wazuh_testing/scripts/qa_ctl.py | 15 +- 2 files changed, 133 insertions(+), 53 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 5c6f0748e1..c0a598ac60 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -22,6 +22,7 @@ class QACTLConfigGenerator: Args: tests (list): list with all the test that the user desires to run. wazuh_version (string): version of the wazuh packages that the user desires to install. + systems (list(str)): Systems with which the tests will be launched This parameter is set to None by default. In case that version parameter is not given, the wazuh package version will be taken from the documetation test information @@ -44,6 +45,17 @@ class QACTLConfigGenerator: 'CentOS 8': 'qactl/centos_8' } + SYSTEMS = { + 'centos': { + 'os_version': 'CentOS 8', + 'os_platform': 'linux' + }, + 'ubuntu': { + 'os_version': 'Ubuntu Focal', + 'os_platform': 'linux' + } + } + BOX_INFO = { 'qactl/ubuntu_20_04': { 'connection_method': 'ssh', @@ -66,9 +78,10 @@ class QACTLConfigGenerator: } def __init__(self, tests, wazuh_version, qa_branch='master', - qa_files_path=join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa')): + qa_files_path=join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa'), systems=None): self.tests = tests self.wazuh_version = wazuh_version + self.systems = systems self.qactl_used_ips_file = join(gettempdir(), 'wazuh_qa_ctl', 'qactl_used_ips.txt') self.config_file_path = join(gettempdir(), 'wazuh_qa_ctl', f"config_{get_current_timestamp()}.yaml") self.config = {} @@ -264,43 +277,73 @@ def __get_package_url(self, instance): return package_url + def __add_deployment_config_block(self, test_name, os_version, components, os_platform): + """Add a configuration block to deploy a test environment in qa-ctl. + + Args: + test_name (string): Test name. + os_version (string): Host vendor to deploy (e.g: CentOS 8). + components (string): Test target (manager or agent). + os_platform (string): host system (e.g: linux). + """ + # Process deployment data + host_number = len(self.config['deployment'].keys()) + 1 + vm_name = f"{test_name}_{get_current_timestamp()}" + self.config['deployment'][f"host_{host_number}"] = { + 'provider': { + 'vagrant': self.__add_instance(os_version, vm_name, components, os_platform) + } + } + # Add manager if the target is an agent + if components == 'agent': + host_number += 1 + self.config['deployment'][f"host_{host_number}"] = { + 'provider': { + 'vagrant': self.__add_instance(os_version, vm_name, 'manager', 'linux') + } + } + def __process_deployment_data(self, tests_info): """Generate the data for the deployment module with the information of the tests given as parameter. Args: test_info(dict object): dict object containing information of all the tests that are going to be run. + + Raises: + QAValueError: If the test system or specified systems are not valid. """ self.config['deployment'] = {} - for test in tests_info: - if self.__validate_test_info(test): - # Choose items from the available list. To be improved in future versions - if 'Ubuntu Focal' in test['os_version']: - test['os_version'] = 'Ubuntu Focal' - else: - test['os_version'] = 'CentOS 8' - - test['components'] = 'manager' if 'manager' in test['components'] else 'agent' - test['os_platform'] = 'linux' - - # Process deployment data - host_number = len(self.config['deployment'].keys()) + 1 - vm_name = f"{test['test_name']}_{get_current_timestamp()}" - self.config['deployment'][f"host_{host_number}"] = { - 'provider': { - 'vagrant': self.__add_instance(test['os_version'], vm_name, test['components'], + # If not system parameter was specified, then one is automatically selected + if not self.systems: + for test in tests_info: + if self.__validate_test_info(test): + if 'Ubuntu Focal' in test['os_version']: + test['os_version'] = 'Ubuntu Focal' + elif 'CentOS 8' in test['os_version']: + test['os_version'] = 'CentOS 8' + else: + raise QAValueError(f"No valid system was found for {test['name']} test", + QACTLConfigGenerator.LOGGER.error, QACTL_LOGGER) + + test['components'] = 'manager' if 'manager' in test['components'] else 'agent' + test['os_platform'] = 'linux' + + self.__add_deployment_config_block(test['test_name'], test['os_version'], test['components'], test['os_platform']) - } - } - # Add manager if the target is an agent - if test['components'] == 'agent': - host_number += 1 - self.config['deployment'][f"host_{host_number}"] = { - 'provider': { - 'vagrant': self.__add_instance(test['os_version'], vm_name, 'manager', - test['os_platform']) - } - } + # If system parameter is specified and have values + elif isinstance(self.systems, list) and len(self.systems) > 0: + for system in self.systems: + for test in tests_info: + if self.__validate_test_info(test): + version = self.SYSTEMS[system]['os_version'] + component = 'manager' if 'manager' in test['components'] else 'agent' + platform = self.SYSTEMS[system]['os_platform'] + + self.__add_deployment_config_block(test['test_name'], version, component, platform) + else: + raise QAValueError('Unable to process systems in the automatically generated configuration', + QACTLConfigGenerator.LOGGER.error, QACTL_LOGGER) def __process_provision_data(self): """Generate the data for the provision module using the fields from the already generated deployment module.""" @@ -340,13 +383,37 @@ def __process_provision_data(self): 'qa_workdir': file.join_path([installation_files_path, 'wazuh_qa_ctl'], system) } - def __process_test_data(self, tests_info): - """Generate the data for the test module with the information of the tests given as parameter. + def __add_testing_config_block(self, instance, installation_files_path, system, test_path, test_name): + """Add a configuration block to launch a test in qa-ctl. + + Args: + instance (str): block instance name (host_x). + installation_files_path (str): Path where locate wazuh qa-ctl files. + system (str): System where launch the test. + test_path (str): Path where are located the test files. + test_name (str): Test name. + """ + self.config['tests'][instance] = {'host_info': {}, 'test': {}} + self.config['tests'][instance]['host_info'] = \ + dict(self.config['provision']['hosts'][instance]['host_info']) + + self.config['tests'][instance]['test'] = { + 'type': 'pytest', + 'path': { + 'test_files_path': file.join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', + test_path], system), + 'run_tests_dir_path': file.join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', + 'tests', 'integration'], system), + 'test_results_path': join(gettempdir(), 'wazuh_qa_ctl', f"{test_name}_{get_current_timestamp()}") + } + } + + def __set_testing_config(self, tests_info): + """Add all blocks corresponding to the testing configuration for qa-ctl Args: test_info(dict object): dict object containing information of all the tests that are going to be run. """ - self.config['tests'] = {} test_host_number = len(self.config['tests'].keys()) + 1 for test in tests_info: @@ -355,27 +422,37 @@ def __process_test_data(self, tests_info): installation_files_path = QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'] system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] - self.config['tests'][instance] = {'host_info': {}, 'test': {}} - self.config['tests'][instance]['host_info'] = \ - dict(self.config['provision']['hosts'][instance]['host_info']) - - self.config['tests'][instance]['test'] = { - 'type': 'pytest', - 'path': { - 'test_files_path': file.join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', - test['path']], system), - 'run_tests_dir_path': file.join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', - 'tests', 'integration'], system), - 'test_results_path': join(gettempdir(), 'wazuh_qa_ctl', - f"{test['test_name']}_{get_current_timestamp()}") - } - } + self.__add_testing_config_block(instance, installation_files_path, system, test['path'], + test['test_name']) test_host_number += 1 # If it is an agent test then we skip the next manager instance since no test will be launched in that # instance if test['components'] == 'agent': test_host_number += 1 + def __process_test_data(self, tests_info): + """Generate the data for the test module with the information of the tests given as parameter. + + Args: + test_info(dict object): dict object containing information of all the tests that are going to be run. + + Raises: + QAValueError: If the specified systems are not valid. + """ + self.config['tests'] = {} + + if not self.systems: + for _ in tests_info: + self.__set_testing_config(tests_info) + + elif isinstance(self.systems, list) and len(self.systems) > 0: + for _ in self.systems: + for _ in tests_info: + self.__set_testing_config(tests_info) + else: + raise QAValueError('Unable to process systems in the automatically generated configuration', + QACTLConfigGenerator.LOGGER.error, QACTL_LOGGER) + def __process_test_info(self, tests_info): """Process all the info of the desired tests that are going to be run in order to generate the data configuration for the YAML config file. diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 07449e3c05..53ee002518 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -208,7 +208,7 @@ def get_script_parameters(): parser.add_argument('--config', '-c', type=str, action='store', required=False, dest='config', help='Path to the configuration file.') - parser.add_argument('-p', '--persistent', action='store_true', + parser.add_argument('--persistent', '-p', action='store_true', help='Persistent instance mode. Do not destroy the instances once the process has finished.') parser.add_argument('--dry-run', action='store_true', @@ -221,16 +221,19 @@ def get_script_parameters(): parser.add_argument('--version', '-v', type=str, action='store', required=False, dest='version', help='Wazuh installation and tests version.') - parser.add_argument('-d', '--debug', action='count', default=0, help='Run in debug mode. You can increase the debug' + parser.add_argument('--debug', '-d', action='count', default=0, help='Run in debug mode. You can increase the debug' ' level with more [-d+]') parser.add_argument('--no-validation-logging', action='store_true', help='Disable initial logging of parameter ' 'validations.') parser.add_argument('--no-validation', action='store_true', help='Disable the script parameters validation.') + parser.add_argument('--os', '-o', type=str, action='store', required=False, nargs='+', dest='operating_systems', + choices=['centos', 'ubuntu'], help='System/s where the tests will be launched.') + parser.add_argument('--qa-branch', type=str, action='store', required=False, dest='qa_branch', - help='Set a custom wazuh-qa branch to use in the run and provisioning. This ' - 'has higher priority than the specified in the configuration file.') + help='Set a custom wazuh-qa branch to use in the run and provisioning. This ' + 'has higher priority than the specified in the configuration file.') parser.add_argument('--skip-deployment', action='store_true', help='Flag to skip the deployment phase. Set it only if -c or --config was specified.') @@ -264,7 +267,7 @@ def main(): if arguments.run_test: qactl_logger.debug('Generating configuration file') config_generator = QACTLConfigGenerator(arguments.run_test, arguments.version, arguments.qa_branch, - WAZUH_QA_FILES) + WAZUH_QA_FILES, arguments.operating_systems) config_generator.run() launched['config_generator'] = True configuration_file = config_generator.config_file_path @@ -327,7 +330,7 @@ def main(): if arguments.run_test and launched['config_generator']: config_generator.destroy() else: - if not RUNNING_ON_DOCKER_CONTAINER: + if not RUNNING_ON_DOCKER_CONTAINER and arguments.run_test: qactl_logger.info(f"Configuration file saved in {config_generator.config_file_path}") if __name__ == '__main__': From 790926dbe5da45db16840d353fe36ef2fd165b60 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 15 Oct 2021 08:50:54 +0200 Subject: [PATCH 120/181] add: Add error logging to few behaviors in the `qa-docs` tool. #2017 - Empty test - Wrong test name - Fields not following the predefined values --- .../wazuh_testing/qa_docs/doc_generator.py | 13 +++--- .../wazuh_testing/qa_docs/lib/code_parser.py | 43 ++++++++++--------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index a8dcfff637..a9104333a9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -188,8 +188,8 @@ def create_group(self, path, group_id): DocGenerator.LOGGER.debug(f"New group file '{doc_path}' was created with ID:{self.__id_counter}") return self.__id_counter else: - DocGenerator.LOGGER.warning(f"Content for {path} is empty, ignoring it") - return None + DocGenerator.LOGGER.error(f"Content for {path} is empty, ignoring it") + raise QAValueError(f"Content for {path} is empty, ignoring it", DocGenerator.LOGGER.error) def create_test(self, path, group_id, test_name=None): """Parse the content of a test file and dumps the content into a file. @@ -233,8 +233,8 @@ def create_test(self, path, group_id, test_name=None): DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' was created with ID:{self.__id_counter}") return self.__id_counter else: - DocGenerator.LOGGER.warning(f"Content for {path} is empty, ignoring it") - return None + DocGenerator.LOGGER.error(f"Content for {path} is empty, ignoring it") + raise QAValueError(f"Content for {path} is empty, ignoring it", DocGenerator.LOGGER.error) def parse_folder(self, path, group_id): """Search in a specific folder to parse possible group files and each test file. @@ -244,8 +244,8 @@ def parse_folder(self, path, group_id): group_id (str): A string with the id of the group where the new elements belong. """ if not os.path.exists(path): - DocGenerator.LOGGER.warning(f"Include path '{path}' doesn´t exist") - return + DocGenerator.LOGGER.error(f"Include path '{path}' doesn´t exist") + raise QAValueError(f"Include path '{path}' doesn´t exist", DocGenerator.LOGGER.error) if not self.is_valid_folder(path): return @@ -274,6 +274,7 @@ def parse_test_list(self): self.create_test(self.test_path, 0, test_name) else: DocGenerator.LOGGER.error(f"'{self.conf.test_name}' could not be found") + raise QAValueError(f"'{self.conf.test_name}' could not be found", DocGenerator.LOGGER.error) def locate_test(self, test_name): """Get the test path when a test is specified by the user. diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index c9995f0269..2f67d6dbe3 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -11,6 +11,7 @@ from wazuh_testing.qa_docs.lib.utils import remove_inexistent from wazuh_testing.qa_docs import QADOCS_LOGGER from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.exceptions import QAValueError INTERNAL_FIELDS = ['id', 'group_id', 'name'] STOP_FIELDS = ['tests', 'test_cases'] @@ -94,18 +95,21 @@ def check_predefined_values(self, doc, doc_type, path): if isinstance(doc_field, list): for value in doc_field: if value not in self.conf.predefined_values[field]: - CodeParser.LOGGER.warning(f"{field} field in {path} {doc_type} documentation block " - f"has an invalid value: {value}. Follow the predefined values: " - f"{self.conf.predefined_values[field]}. " - "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" - " Documenting-tests-using-the-qadocs-schema#pre-defined-values.") + error = f"{field} field in {path} {doc_type} documentation block has an invalid value: {value}." + f"Follow the predefined values: {self.conf.predefined_values[field]}. " + "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" + " Documenting-tests-using-the-qadocs-schema#pre-defined-values." + CodeParser.LOGGER.error(error) + raise QAValueError(error, CodeParser.LOGGER.error) else: if doc_field not in self.conf.predefined_values[field] and doc_field is not None: - CodeParser.LOGGER.warning(f"{field} field in {path} {doc_type} documentation block " - f"has an invalid value: {doc_type}. " - f"Follow the predefined values: {self.conf.predefined_values[field]} " - "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" - " Documenting-tests-using-the-qadocs-schema#pre-defined-values.") + error = f"{field} field in {path} {doc_type} documentation block " + f"has an invalid value: {doc_type}. " + f"Follow the predefined values: {self.conf.predefined_values[field]} " + "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" + " Documenting-tests-using-the-qadocs-schema#pre-defined-values." + CodeParser.LOGGER.error(error) + raise QAValueError(error, CodeParser.LOGGER.error) def parse_comment(self, function, doc_type, path): """Parse one self-contained documentation block. @@ -120,8 +124,8 @@ def parse_comment(self, function, doc_type, path): """ docstring = ast.get_docstring(function) if not docstring: - CodeParser.LOGGER.warning(f"Documentation block not found in {path}") - # raise QAValueError(f"Documentation block not found in {path}", CodeParser.LOGGER.error) + CodeParser.LOGGER.error(f"Documentation block not found in {path}") + raise QAValueError(f"Documentation block not found in {path}", CodeParser.LOGGER.error) try: doc = yaml.safe_load(docstring) @@ -131,15 +135,14 @@ def parse_comment(self, function, doc_type, path): except Exception as inst: if hasattr(function, 'name'): - CodeParser.LOGGER.warning(f"Failed to parse test documentation in {function.name} " - "from module {self.scan_file}. Error: {inst}") - # raise QAValueError(f"Failed to parse test documentation in {function.name} " - # "from module {self.scan_file}. Error: {inst}", CodeParser.LOGGER.error) + CodeParser.LOGGER.error(f"Failed to parse test documentation in {function.name} " + "from module {self.scan_file}. Error: {inst}") + raise QAValueError(f"Failed to parse test documentation in {function.name} " + "from module {self.scan_file}. Error: {inst}", CodeParser.LOGGER.error) else: - CodeParser.LOGGER.warning(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}") - # raise QAValueError(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}" - # , CodeParser.LOGGER.error) - return None + CodeParser.LOGGER.error(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}") + raise QAValueError(f"Failed to parse module documentation in {self.scan_file}. Error: {inst}", + CodeParser.LOGGER.error) CodeParser.LOGGER.debug(f"Checking that the documentation block within {path} follow the predefined values.") self.check_predefined_values(doc, doc_type, path) From effc407fab51f3bca8d7431936d8166937d9d73b Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 15 Oct 2021 09:06:04 +0200 Subject: [PATCH 121/181] fix: Fix raise message for predefined values. #2017 --- .../wazuh_testing/qa_docs/lib/code_parser.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 2f67d6dbe3..67a00a5c4a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -95,21 +95,22 @@ def check_predefined_values(self, doc, doc_type, path): if isinstance(doc_field, list): for value in doc_field: if value not in self.conf.predefined_values[field]: - error = f"{field} field in {path} {doc_type} documentation block has an invalid value: {value}." - f"Follow the predefined values: {self.conf.predefined_values[field]}. " - "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" - " Documenting-tests-using-the-qadocs-schema#pre-defined-values." - CodeParser.LOGGER.error(error) - raise QAValueError(error, CodeParser.LOGGER.error) + CodeParser.LOGGER.error(f"{field} field in {path} {doc_type} documentation block has " + f"an invalid value: {value}." + f"Follow the predefined values: {self.conf.predefined_values[field]}. " + "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" + "Documenting-tests-using-the-qadocs-schema#pre-defined-values.") + raise QAValueError(f"{field} field in {path} {doc_type} documentation block has " + f"an invalid value: {value}.", CodeParser.LOGGER.error) else: if doc_field not in self.conf.predefined_values[field] and doc_field is not None: - error = f"{field} field in {path} {doc_type} documentation block " - f"has an invalid value: {doc_type}. " - f"Follow the predefined values: {self.conf.predefined_values[field]} " - "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" - " Documenting-tests-using-the-qadocs-schema#pre-defined-values." - CodeParser.LOGGER.error(error) - raise QAValueError(error, CodeParser.LOGGER.error) + CodeParser.LOGGER.error(f"{field} field in {path} {doc_type} documentation block " + f"has an invalid value: {doc_field}. " + f"Follow the predefined values: {self.conf.predefined_values[field]} " + "If you want more info, visit https://github.com/wazuh/wazuh-qa/wiki/" + "Documenting-tests-using-the-qadocs-schema#pre-defined-values.") + raise QAValueError(f"{field} field in {path} {doc_type} documentation block " + f"has an invalid value: {doc_field}.", CodeParser.LOGGER.error) def parse_comment(self, function, doc_type, path): """Parse one self-contained documentation block. From fc06820e6b9b57afa97890b63c0c2fb4580a9b06 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 15 Oct 2021 12:17:32 +0200 Subject: [PATCH 122/181] refac: Fix how `qa-ctl` uses `qa-docs`. #2017 - Change `qa-docs` options in `qa_ctl.py` - Refac `-e` option: now raises an error if documentation block is missing. - Refac `-t` option: now if no output path is passed, it generates the files in the `/output` directory as the default behavior does. --- .../qa_ctl/configuration/config_generator.py | 2 +- .../wazuh_testing/qa_docs/doc_generator.py | 25 +++++++++++-------- .../wazuh_testing/scripts/qa_ctl.py | 3 ++- .../wazuh_testing/scripts/qa_docs.py | 11 ++++---- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index b367d32a89..ad9d987388 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -85,7 +85,7 @@ def __get_test_info(self, test_name): Returns: dict : return the info of the named test in dict format. """ - qa_docs_command = f"qa-docs -T {test_name} -o {join(gettempdir(), 'qa_ctl')} -I {join(self.qa_files_path, 'tests')}" + qa_docs_command = f"qa-docs -t {test_name} -o {join(gettempdir(), 'qa_ctl')} -I {join(self.qa_files_path, 'tests')}" test_data_file_path = f"{join(gettempdir(), 'qa_ctl', test_name)}.json" run_local_command_with_output(qa_docs_command) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index a9104333a9..74f3016b1b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -218,20 +218,18 @@ def create_test(self, path, group_id, test_name=None): if self.conf.mode == Mode.DEFAULT: doc_path = self.get_test_doc_path(path) + self.dump_output(test, doc_path) + DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' " + "was created with ID:{self.__id_counter}") + return self.__id_counter + elif self.conf.mode == Mode.PARSE_TESTS: - doc_path = self.conf.documentation_path - - # If the user does not specify an output dir - if not doc_path: - self.print_test_info(test) - return - # If the user specifies an output dir - else: + if self.conf.documentation_path: + doc_path = self.conf.documentation_path doc_path = os.path.join(doc_path, test_name) - self.dump_output(test, doc_path) - DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' was created with ID:{self.__id_counter}") - return self.__id_counter + self.dump_output(test, doc_path) + DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' was created.") else: DocGenerator.LOGGER.error(f"Content for {path} is empty, ignoring it") raise QAValueError(f"Content for {path} is empty, ignoring it", DocGenerator.LOGGER.error) @@ -293,6 +291,11 @@ def locate_test(self, test_name): print(f"{test_name} does not exist") return None + def check_documentation_block(self): + self.parse_test_list() + + print("Documentation block ok") + def print_test_info(self, test): """Print the test info to standard output. diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index aa0dadcb5c..ac4f095e3b 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -169,7 +169,8 @@ def validate_parameters(parameters): if parameters.run_test: for test in parameters.run_test: tests_path = os.path.join(WAZUH_QA_FILES, 'tests') - if 'test exists' not in local_actions.run_local_command_with_output(f"qa-docs -e {test} -I {tests_path}"): + if 'Documentation block ok' not in \ + local_actions.run_local_command_with_output(f"qa-docs -e {test} -I {tests_path}"): raise QAValueError(f"{test} does not exist in {tests_path}", qactl_logger.error, QACTL_LOGGER) qactl_logger.info('Input parameters validation has passed successfully') diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 41c2a3abc8..d1aaeb6e06 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -192,11 +192,9 @@ def run_searchui(index): def parse_data(args): """Parse the tests and collect the data.""" if args.test_exist: - doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) + doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, '', test_names=args.test_exist)) - for test_name in args.test_exist: - if doc_check.locate_test(test_name) is not None: - print(f"{test_name} exists") + doc_check.check_documentation_block() # Parse a list of tests elif args.test_names: @@ -207,7 +205,7 @@ def parse_data(args): docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, args.output_path, test_names=args.test_names)) # When no output is specified, it is printed else: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_names)) + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, test_names=args.test_names)) # Parse a list of test types elif args.test_types: @@ -216,7 +214,7 @@ def parse_data(args): # Parse a list of test modules if args.test_modules: docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types, - args.test_modules)) + args.test_modules)) else: docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, args.test_types)) @@ -252,6 +250,7 @@ def index_and_visualize_data(args): # When SearchUI index is not hardcoded, it will be use args.launching_index_name run_searchui(args.launching_index_name) + def main(): args, parser = get_parameters() From 0b1e9e978bf80d1332fc3e1a6029e2d276f2e8fa Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 18 Oct 2021 10:57:11 +0200 Subject: [PATCH 123/181] fix: Fix test name reference. #2017 --- deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 74f3016b1b..d1c89214c1 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -271,8 +271,8 @@ def parse_test_list(self): if self.test_path: self.create_test(self.test_path, 0, test_name) else: - DocGenerator.LOGGER.error(f"'{self.conf.test_name}' could not be found") - raise QAValueError(f"'{self.conf.test_name}' could not be found", DocGenerator.LOGGER.error) + DocGenerator.LOGGER.error(f"'{test_name}' could not be found") + raise QAValueError(f"'{test_name}' could not be found", DocGenerator.LOGGER.error) def locate_test(self, test_name): """Get the test path when a test is specified by the user. From 8cbb04e4d2a46cb2f33cb9dde90c21c32da96078 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 18 Oct 2021 11:28:55 +0200 Subject: [PATCH 124/181] fix: Revert `-e` behavior. #2017 Now the `-e` only checks if a list of tests exist(again). --- .../wazuh_testing/wazuh_testing/qa_docs/doc_generator.py | 9 ++++++--- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index d1c89214c1..03cab95a68 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -291,10 +291,13 @@ def locate_test(self, test_name): print(f"{test_name} does not exist") return None - def check_documentation_block(self): - self.parse_test_list() + def do_exist(self): + for test_name in self.conf.test_names: + if not self.locate_test(test_name): + DocGenerator.LOGGER.error(f"'{test_name}' could not be found") + raise QAValueError(f"'{test_name}' could not be found", DocGenerator.LOGGER.error) - print("Documentation block ok") + print("Tests exist") def print_test_info(self, test): """Print the test info to standard output. diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index d1aaeb6e06..7d3817e1da 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -194,7 +194,7 @@ def parse_data(args): if args.test_exist: doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, '', test_names=args.test_exist)) - doc_check.check_documentation_block() + doc_check.do_exist() # Parse a list of tests elif args.test_names: From 5d47b55c9fd4f11e9a05bebef4da3fc5adb35c6c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 18 Oct 2021 12:46:40 +0200 Subject: [PATCH 125/181] add: Add two errors checking. #2017 - Mandatory fields missing - Fields not listed in `schema.yaml` --- .../wazuh_testing/qa_docs/lib/code_parser.py | 25 +++++++++++++++++-- .../wazuh_testing/qa_docs/schema.yaml | 1 - 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 67a00a5c4a..a8acc6c049 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -70,6 +70,24 @@ def remove_ignored_fields(self, doc): for test in doc['tests']: remove_inexistent(test, allowed_fields, STOP_FIELDS) + def check_fields(self, doc, doc_type, path): + if doc_type == 'module': + expected_fields = self.conf.module_fields + elif doc_type == 'test': + expected_fields = self.conf.test_fields + + # check that mandatory fields are documented + for field in expected_fields.mandatory: + if not field in doc.keys(): + CodeParser.LOGGER.error(f"{field} mandatory field is missing.") + raise QAValueError(f"{field} mandatory field is missing.", CodeParser.LOGGER.error) + + # check that only schema fields are documented + for field in doc.keys(): + if not field in expected_fields.mandatory and not field in expected_fields.optional and field != 'name': + CodeParser.LOGGER.error(f"{field} is not specified in qa-docs schema.") + raise QAValueError(f"{field} is not specified in qa-docs schema.", CodeParser.LOGGER.error) + def check_predefined_values(self, doc, doc_type, path): """Check if the documentation block follows the predefined values. @@ -147,6 +165,7 @@ def parse_comment(self, function, doc_type, path): CodeParser.LOGGER.debug(f"Checking that the documentation block within {path} follow the predefined values.") self.check_predefined_values(doc, doc_type, path) + self.check_fields(doc, doc_type, path) return doc @@ -192,10 +211,12 @@ def parse_test(self, path, id, group_id): functions_doc.append(function_doc) if not functions_doc: - CodeParser.LOGGER.warning(f"Module '{module_doc['name']}' doesn´t contain any test function") + CodeParser.LOGGER.error(f"Module '{module_doc['name']}' doesn´t contain any test function") + raise QAValueError(f"Module '{module_doc['name']}' doesn´t contain any test function", + CodeParser.LOGGER.error) else: module_doc['tests'] = functions_doc - + self.remove_ignored_fields(module_doc) return module_doc diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml index 58b59c25be..581ae2544a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml @@ -8,7 +8,6 @@ output_fields: - modules - daemons - components - - path - os_platform - os_version optional: From 5717053ae924281a13d8e3b001bd7c556df486d5 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 18 Oct 2021 13:07:32 +0200 Subject: [PATCH 126/181] add: Add documented tests. #2017 Tests that have documentation block so `qa-ctl` can use them. --- .../test_execd/test_execd_firewall_drop.py | 6 +-- .../test_execd/test_execd_restart.py | 4 +- .../test_config/test_cache/test_cache.py | 10 ++-- .../test_config/test_cors/test_cors.py | 17 +++---- .../test_general_settings_enabled.py | 46 ++++--------------- 5 files changed, 26 insertions(+), 57 deletions(-) diff --git a/tests/integration/test_active_response/test_execd/test_execd_firewall_drop.py b/tests/integration/test_active_response/test_execd/test_execd_firewall_drop.py index 463b6f1b49..7634b0bc43 100644 --- a/tests/integration/test_active_response/test_execd/test_execd_firewall_drop.py +++ b/tests/integration/test_active_response/test_execd/test_execd_firewall_drop.py @@ -20,8 +20,6 @@ components: - agent -path: tests/integration/test_active_response/test_execd/test_execd_restart.py - daemons: - wazuh-analysisd - wazuh-authd @@ -153,7 +151,7 @@ def start_agent(request, get_configuration): @pytest.fixture(scope="function") def remove_ip_from_iptables(request, get_configuration): - """Remove the test IP from iptables if it exist. + """Remove the testing IP address from `iptables` if it exists. Args: get_configuration (fixture): Get configurations from the module. @@ -246,7 +244,7 @@ def test_execd_firewall_drop(set_debug_mode, get_configuration, test_version, co is sent to it. This response includes an IP address that must be added and removed from iptables, the Linux firewall. - wazuh_min_version: 4.2 + wazuh_min_version: 4.2.0 parameters: - set_debug_mode: diff --git a/tests/integration/test_active_response/test_execd/test_execd_restart.py b/tests/integration/test_active_response/test_execd/test_execd_restart.py index 33be8ed851..03a46e7469 100644 --- a/tests/integration/test_active_response/test_execd/test_execd_restart.py +++ b/tests/integration/test_active_response/test_execd/test_execd_restart.py @@ -20,8 +20,6 @@ components: - agent -path: tests/integration/test_active_response/test_execd/test_execd_restart.py - daemons: - wazuh-analysisd - wazuh-authd @@ -201,7 +199,7 @@ def test_execd_restart(set_debug_mode, get_configuration, test_version, This response includes the order to restart the Wazuh agent, which must restart after receiving this response. - wazuh_min_version: 4.2 + wazuh_min_version: 4.2.0 parameters: - set_debug_mode: diff --git a/tests/integration/test_api/test_config/test_cache/test_cache.py b/tests/integration/test_api/test_config/test_cache/test_cache.py index e9a36cfae5..f078b46dfe 100644 --- a/tests/integration/test_api/test_config/test_cache/test_cache.py +++ b/tests/integration/test_api/test_config/test_cache/test_cache.py @@ -7,8 +7,10 @@ type: integration -brief: These tests will check if the cache feature of the API handled - by the `wazuh-apid` daemon is working properly. +brief: These tests will check if the cache feature of the API handled by the `wazuh-apid` daemon + is working properly. The Wazuh API is an open source `RESTful` API that allows for interaction + with the Wazuh manager from a web browser, command line tool like `cURL` or any script + or program that can make web requests. tier: 0 @@ -18,8 +20,6 @@ components: - manager -path: tests/integration/test_api/test_config/test_cache/test_cache.py - daemons: - wazuh-apid - wazuh-analysisd @@ -114,7 +114,7 @@ def test_cache(tags_to_apply, get_configuration, configure_api_environment, rest a period established in the configuration, even though a new file has been created during the process. - wazuh_min_version: 4.2 + wazuh_min_version: 4.2.0 parameters: - tags_to_apply: diff --git a/tests/integration/test_api/test_config/test_cors/test_cors.py b/tests/integration/test_api/test_config/test_cors/test_cors.py index 3b77f8a0cb..292a7ca437 100644 --- a/tests/integration/test_api/test_config/test_cors/test_cors.py +++ b/tests/integration/test_api/test_config/test_cors/test_cors.py @@ -7,8 +7,11 @@ type: integration -brief: These tests will check if the CORS (Cross-origin resource sharing) feature - of the API handled by the `wazuh-apid` daemon is working properly. +brief: + These tests will check if the `CORS` (Cross-origin resource sharing) feature of the API handled + by the `wazuh-apid` daemon is working properly. The Wazuh API is an open source `RESTful` API + that allows for interaction with the Wazuh manager from a web browser, command line tool + like `cURL` or any script or program that can make web requests. tier: 0 @@ -18,8 +21,6 @@ components: - manager -path: tests/integration/test_api/test_config/test_cors/test_cors.py - daemons: - wazuh-apid - wazuh-analysisd @@ -92,12 +93,12 @@ def get_configuration(request): def test_cors(origin, tags_to_apply, get_configuration, configure_api_environment, restart_api, wait_for_start, get_api_details): ''' - description: Check if expected headers are returned when CORS is enabled. - When CORS is enabled, special headers must be returned in case the - request origin matches the one established in the CORS configuration + description: Check if expected headers are returned when `CORS` is enabled. + When `CORS` is enabled, special headers must be returned in case the + request origin matches the one established in the `CORS` configuration of the API. - wazuh_min_version: 4.2 + wazuh_min_version: 4.2.0 parameters: - origin: diff --git a/tests/integration/test_vulnerability_detector/test_general_settings/test_general_settings_enabled.py b/tests/integration/test_vulnerability_detector/test_general_settings/test_general_settings_enabled.py index 32a18e539d..130b99ba6a 100644 --- a/tests/integration/test_vulnerability_detector/test_general_settings/test_general_settings_enabled.py +++ b/tests/integration/test_vulnerability_detector/test_general_settings/test_general_settings_enabled.py @@ -1,32 +1,20 @@ ''' copyright: Copyright (C) 2015-2021, Wazuh Inc. - Created by Wazuh, Inc. . - This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - type: integration - -brief: These tests will check if the `enabled` option of the vulnerability detector module +brief: These tests will check if the 'enabled' option of the vulnerability detector module is working correctly. This option is located in its corresponding section of - the `ossec.conf` file and allows enabling or disabling this module. - + the 'ossec.conf' file and allows enabling or disabling this module. tier: 0 - modules: - vulnerability_detector - components: - manager - -path: tests/integration/test_vulnerability_detector/test_general_settings/test_general_settings_enabled.py - daemons: - wazuh-modulesd - os_platform: - linux - os_version: - Arch Linux - Amazon Linux 2 @@ -45,16 +33,13 @@ - Red Hat 8 - Red Hat 7 - Red Hat 6 - references: - https://documentation.wazuh.com/current/user-manual/capabilities/vulnerability-detection/index.html - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/vuln-detector.html#enabled - tags: - settings ''' import os - import pytest from wazuh_testing.tools import LOG_FILE_PATH from wazuh_testing.tools.configuration import load_wazuh_configurations, check_apply_test @@ -65,27 +50,19 @@ # Marks pytestmark = pytest.mark.tier(level=0) - # variables test_data_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data') configurations_path = os.path.join(test_data_path, 'wazuh_enabled.yaml') - wazuh_log_monitor = FileMonitor(LOG_FILE_PATH) - parameters = [{'ENABLED': 'yes', 'TAG': 'enabled'}, {'ENABLED': 'no', 'TAG': 'disabled'}] metadata = [{'enabled': 'yes'}, {'enabled': 'no'}] - # Configuration data configurations = load_wazuh_configurations(configurations_path, __name__, params=parameters, metadata=metadata) - - # fixtures @pytest.fixture(scope='module', params=configurations) def get_configuration(request): """Get configurations from the module.""" return request.param - - @pytest.mark.parametrize('tags_to_apply, custom_callback, custom_error_message', [ ({'enabled'}, callback_detect_vulnerability_detector_enabled, 'Vulnerability detector is disabled'), ({'disabled'}, callback_detect_vulnerability_detector_disabled, 'Vulnerability detector is enabled') @@ -93,12 +70,10 @@ def get_configuration(request): def test_enabled(tags_to_apply, custom_callback, custom_error_message, get_configuration, configure_environment, restart_modulesd): ''' - description: Check if the `enabled ` option is working correctly. To do this, - it checks the `ossec.log` file for the message indicating that the + description: Check if the 'enabled' option is working correctly. To do this, + it checks the 'ossec.log' file for the message indicating that the vulnerability detector is enabled or disabled. - - wazuh_min_version: 4.2 - + wazuh_min_version: 4.2.0 parameters: - configure_environment: type: fixture @@ -108,21 +83,18 @@ def test_enabled(tags_to_apply, custom_callback, custom_error_message, get_confi brief: Get configurations from the module. - restart_modulesd: type: callable - brief: Restart the `wazuh-modulesd` daemon. + brief: Restart the 'wazuh-modulesd' daemon. - tags_to_apply: type: string brief: Tags used for use cases. - custom_callback_vulnerability: type: string brief: Custom callback for the use case. - assertions: - - Verify that when the `enabled` option is set to `yes`, the vulnerability detector module is running. - - Verify that when the `enabled` option is set to `no`, the vulnerability detector module is stopped. - + - Verify that when the 'enabled' option is set to 'yes', the vulnerability detector module is running. + - Verify that when the 'enabled' option is set to 'no', the vulnerability detector module is stopped. input_description: Two use cases are found in the test module and include - parameters for `enabled` option (`yes` and `no`). - + parameters for 'enabled' option ('yes' and 'no'). expected_output: - r'(.*)wazuh-modulesd:vulnerability-detector(.*)' - r'DEBUG: Module disabled. Exiting...' From f46ba89f2e322237b0aaad9f1ac3bec48ec088e0 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 18 Oct 2021 13:10:27 +0200 Subject: [PATCH 127/181] rm: Remove useless warning. --- .../wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index a8acc6c049..6e38e8f479 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -78,13 +78,13 @@ def check_fields(self, doc, doc_type, path): # check that mandatory fields are documented for field in expected_fields.mandatory: - if not field in doc.keys(): + if field not in doc.keys(): CodeParser.LOGGER.error(f"{field} mandatory field is missing.") raise QAValueError(f"{field} mandatory field is missing.", CodeParser.LOGGER.error) # check that only schema fields are documented for field in doc.keys(): - if not field in expected_fields.mandatory and not field in expected_fields.optional and field != 'name': + if field not in expected_fields.mandatory and field not in expected_fields.optional and field != 'name': CodeParser.LOGGER.error(f"{field} is not specified in qa-docs schema.") raise QAValueError(f"{field} is not specified in qa-docs schema.", CodeParser.LOGGER.error) @@ -106,7 +106,6 @@ def check_predefined_values(self, doc, doc_type, path): try: doc_field = doc[field] except KeyError: - CodeParser.LOGGER.warning(f"{field} field missing in {path} {doc_type}") doc_field = None # If the field is a list, iterate thru predefined values @@ -216,7 +215,7 @@ def parse_test(self, path, id, group_id): CodeParser.LOGGER.error) else: module_doc['tests'] = functions_doc - + self.remove_ignored_fields(module_doc) return module_doc From fb0ee7c7d5bf6e536b0348fcba395b06b09b61ce Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 18 Oct 2021 14:14:40 +0200 Subject: [PATCH 128/181] refac: Refac `-e` option print format. --- .../wazuh_testing/qa_docs/doc_generator.py | 12 +++++------- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 4 ++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 03cab95a68..224d802bef 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -288,16 +288,14 @@ def locate_test(self, test_name): if filename == complete_test_name: return os.path.join(root, complete_test_name) - print(f"{test_name} does not exist") return None - def do_exist(self): + def do_exist(self, path): for test_name in self.conf.test_names: - if not self.locate_test(test_name): - DocGenerator.LOGGER.error(f"'{test_name}' could not be found") - raise QAValueError(f"'{test_name}' could not be found", DocGenerator.LOGGER.error) - - print("Tests exist") + if self.locate_test(test_name): + print(f'{test_name} does exist in {path}') + else: + print(f'{test_name} does not exist in {path}') def print_test_info(self, test): """Print the test info to standard output. diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 7d3817e1da..751ea7670c 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -155,7 +155,7 @@ def validate_parameters(parameters, parser): for test_name in parameters.test_names: if doc_check.locate_test(test_name) is None: - raise QAValueError(f"{test_name} not found.", qadocs_logger.error) + raise QAValueError(f"{test_name} has not been not found in {parameters.tests_path}.", qadocs_logger.error) # Check that the index exists if parameters.app_index_name: @@ -194,7 +194,7 @@ def parse_data(args): if args.test_exist: doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, '', test_names=args.test_exist)) - doc_check.do_exist() + doc_check.do_exist(args.tests_path) # Parse a list of tests elif args.test_names: From 42f76f5d1c6aafa4f5ebab6e94e7eab97e7c8954 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 19 Oct 2021 11:32:08 +0200 Subject: [PATCH 129/181] fix: Fix some requested changes. - Rename a method that is not self-descriptive - Add doc to that method - Revert `qa-ctl` script --- .../wazuh_testing/qa_docs/doc_generator.py | 16 +++++++++++----- .../wazuh_testing/scripts/qa_ctl.py | 3 +-- .../wazuh_testing/scripts/qa_docs.py | 9 ++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index d1c89214c1..f83b57dc4a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -288,13 +288,19 @@ def locate_test(self, test_name): if filename == complete_test_name: return os.path.join(root, complete_test_name) - print(f"{test_name} does not exist") return None - def check_documentation_block(self): - self.parse_test_list() - - print("Documentation block ok") + def check_test_exists(self, path): + """Check that a test exists within the tests path input. + + Args: + path (str): A string with the tests path. + """ + for test_name in self.conf.test_names: + if self.locate_test(test_name): + print(f'{test_name} exists in {path}') + else: + print(f'{test_name} does not exist in {path}') def print_test_info(self, test): """Print the test info to standard output. diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index ac4f095e3b..aa0dadcb5c 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -169,8 +169,7 @@ def validate_parameters(parameters): if parameters.run_test: for test in parameters.run_test: tests_path = os.path.join(WAZUH_QA_FILES, 'tests') - if 'Documentation block ok' not in \ - local_actions.run_local_command_with_output(f"qa-docs -e {test} -I {tests_path}"): + if 'test exists' not in local_actions.run_local_command_with_output(f"qa-docs -e {test} -I {tests_path}"): raise QAValueError(f"{test} does not exist in {tests_path}", qactl_logger.error, QACTL_LOGGER) qactl_logger.info('Input parameters validation has passed successfully') diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index d1aaeb6e06..47a84fe41e 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -155,7 +155,7 @@ def validate_parameters(parameters, parser): for test_name in parameters.test_names: if doc_check.locate_test(test_name) is None: - raise QAValueError(f"{test_name} not found.", qadocs_logger.error) + raise QAValueError(f"{test_name} has not been not found in {parameters.tests_path}.", qadocs_logger.error) # Check that the index exists if parameters.app_index_name: @@ -194,7 +194,7 @@ def parse_data(args): if args.test_exist: doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, '', test_names=args.test_exist)) - doc_check.check_documentation_block() + doc_check.check_test_exists(args.tests_path) # Parse a list of tests elif args.test_names: @@ -260,11 +260,6 @@ def main(): if args.debug_level: set_qadocs_logger_level('DEBUG') - if args.test_exist: - doc_check = DocGenerator(Config(CONFIG_PATH, args.test_dir, '', args.test_exist)) - if doc_check.locate_test() is not None: - print("test exists") - if args.version: with open(VERSION_PATH, 'r') as version_file: version_data = version_file.read() From a2a6ba1e9f6df6ac3921d16b5d5b3e6e6aa02a6c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 19 Oct 2021 11:39:22 +0200 Subject: [PATCH 130/181] doc: Add method documentation. --- .../wazuh_testing/wazuh_testing/qa_docs/doc_generator.py | 7 ------- .../wazuh_testing/qa_docs/lib/code_parser.py | 9 +++++++++ deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 4 ---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index 16d770c432..f83b57dc4a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -290,7 +290,6 @@ def locate_test(self, test_name): return None -<<<<<<< HEAD def check_test_exists(self, path): """Check that a test exists within the tests path input. @@ -300,12 +299,6 @@ def check_test_exists(self, path): for test_name in self.conf.test_names: if self.locate_test(test_name): print(f'{test_name} exists in {path}') -======= - def do_exist(self, path): - for test_name in self.conf.test_names: - if self.locate_test(test_name): - print(f'{test_name} does exist in {path}') ->>>>>>> fb0ee7c7d5bf6e536b0348fcba395b06b09b61ce else: print(f'{test_name} does not exist in {path}') diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py index 6e38e8f479..647466a54d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/code_parser.py @@ -71,6 +71,15 @@ def remove_ignored_fields(self, doc): remove_inexistent(test, allowed_fields, STOP_FIELDS) def check_fields(self, doc, doc_type, path): + """Check if the fields that a documentation block has, are valids. + + You can check them in the `schema.yaml` file. + + Args: + doc (dict): A dict with the documentation block parsed. + doc_type (str): A string that specifies which type of documentation block is. + path (str): A string with the file path. + """ if doc_type == 'module': expected_fields = self.conf.module_fields elif doc_type == 'test': diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 92586ab7b3..47a84fe41e 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -194,11 +194,7 @@ def parse_data(args): if args.test_exist: doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, '', test_names=args.test_exist)) -<<<<<<< HEAD doc_check.check_test_exists(args.tests_path) -======= - doc_check.do_exist(args.tests_path) ->>>>>>> fb0ee7c7d5bf6e536b0348fcba395b06b09b61ce # Parse a list of tests elif args.test_names: From 96fe1e6292968e00aec43eb2f79df68361e2881b Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Tue, 19 Oct 2021 16:47:07 +0200 Subject: [PATCH 131/181] add: Add dynamic wazuh local internal configuration for qa-ctl #2009 --- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 11 ++- .../qa_ctl/run_tests/qa_test_runner.py | 6 +- .../wazuh_testing/qa_ctl/run_tests/test.py | 10 +- .../qa_ctl/run_tests/test_launcher.py | 97 ++++++++++++++++--- 4 files changed, 106 insertions(+), 18 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index 3b063502f8..81151fba79 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -32,6 +32,9 @@ class Pytest(Test): log_level(str, None): Log level to be set markers(list(str), []): Set of markers to be added to the test execution command hosts(list(), ['all']): List of hosts aliases where the tests will be runned + modules (list(str)): List of wazuh modules to which the test belongs. + components (list(str)): List of wazuh targets to which the test belongs (manager, agent). + system (str): System where the test will be launched. Attributes: tests_result_path(str): Path to the directory where the reports will be stored in the local machine @@ -56,7 +59,8 @@ class Pytest(Test): def __init__(self, tests_result_path, tests_path, tests_run_dir, qa_ctl_configuration, tiers=[], stop_after_first_failure=False, keyword_expression=None, traceback='auto', dry_run=False, - custom_args=[], verbose_level=False, log_level=None, markers=[], hosts=['all']): + custom_args=[], verbose_level=False, log_level=None, markers=[], hosts=['all'], modules=None, + components=None, system='linux'): self.qa_ctl_configuration = qa_ctl_configuration self.tiers = tiers self.stop_after_first_failure = stop_after_first_failure @@ -68,12 +72,13 @@ def __init__(self, tests_result_path, tests_path, tests_run_dir, qa_ctl_configur self.log_level = log_level self.markers = markers self.hosts = hosts - self.tests_result_path = os.path.join(gettempdir(), 'wazuh_qa_ctl') if tests_result_path is None else tests_result_path + self.tests_result_path = os.path.join(gettempdir(), 'wazuh_qa_ctl') if tests_result_path is None \ + else tests_result_path if not os.path.exists(self.tests_result_path): os.makedirs(self.tests_result_path) - super().__init__(tests_path, tests_run_dir, tests_result_path) + super().__init__(tests_path, tests_run_dir, tests_result_path, modules, components, system) def run(self, ansible_inventory_path): """Executes the current test with the specified options defined in attributes and bring back the reports diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py index 1145d4838f..3166682035 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py @@ -24,7 +24,7 @@ class QATestRunner(): inventory_file_path (string): Path of the inventory file generated. test_launchers (list(TestLauncher)): Test launchers objects (one for each host). qa_ctl_configuration (QACTLConfiguration): QACTL configuration. - test_parameters (dict): a dictionary containing all the required data to build the tests + test_parameters (dict): a dictionary containing all the required data to build the tests """ LOGGER = Logging.get_logger(QACTL_LOGGER) @@ -115,6 +115,10 @@ def __build_test(self, test_params, host=['all']): test_dict['tests_result_path'] = paths['test_results_path'] test_dict['tests_run_dir'] = paths['run_tests_dir_path'] + test_dict['components'] = test_params['components'] if 'components' in test_params else None + test_dict['modules'] = test_params['modules'] if 'modules' in test_params else None + test_dict['system'] = test_params['system'] if 'system' in test_params else None + if 'parameters' in test_params: parameters = test_params['parameters'] if parameters is not None: diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test.py index 2443742d43..1a09fdbc1c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test.py @@ -8,6 +8,10 @@ class Test(ABC): tests_path (str): Path to the set of tests to be executed tests_run_dir (str): Path to the directory from where the tests are going to be executed tests_result_path(str): Path to the directory where the reports will be stored in the local machine + result (TestResult): Result of the test. It is set when the test has been finished. + modules (list(str)): List of wazuh modules to which the test belongs. + components (list(str)): List of wazuh targets to which the test belongs (manager, agent). + system (str): System where the test will be launched. Args: tests_path (str): Path to the set of tests to be executed @@ -15,11 +19,13 @@ class Test(ABC): tests_result_path(str): Path to the directory where the reports will be stored in the local machine """ - def __init__(self, tests_path, tests_run_dir, tests_result_path): + def __init__(self, tests_path, tests_run_dir, tests_result_path, modules=None, components=None, system='linux'): self.tests_path = tests_path self.tests_run_dir = tests_run_dir self.tests_result_path = tests_result_path - self.result = None + self.modules = modules + self.components = components + self.system = system @abstractmethod def run(self): diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py index 8ca43f166f..a72e3a7337 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py @@ -26,9 +26,60 @@ class TestLauncher: """ LOGGER = Logging.get_logger(QACTL_LOGGER) - DEBUG_OPTIONS = ["syscheck.debug=2", "agent.debug=2", "monitord.rotate_log=0", "analysisd.debug=2", - "wazuh_modules.debug=2", "wazuh_database.interval=1", "wazuh_db.commit_time=2", - "wazuh_db.commit_time_max=3", "remoted.debug=2"] + ALL_DEBUG_OPTIONS = ["syscheck.debug=2", "agent.debug=2", "monitord.rotate_log=0", "analysisd.debug=2", + "wazuh_modules.debug=2", "wazuh_database.interval=1", "wazuh_db.commit_time=2", + "wazuh_db.commit_time_max=3", "remoted.debug=2"] + DEBUG_OPTIONS = { + 'active_response': { + 'manager': ['monitord.rotate_log=0'], + 'agent': { + 'generic': ['monitord.rotate_log=0'], + 'windows': ['monitord.rotate_log=0'] + } + }, + 'agentd': { + 'agent': { + 'generic': ['agent.debug=2', 'execd.debug=2', 'monitord.rotate_log=0'], + 'windows': ['agent.debug=2', 'execd.debug=2', 'monitord.rotate_log=0'] + } + }, + 'analysisd': { + 'manager': ['analysisd.debug=2', 'monitord.rotate_log=0'] + }, + 'api': { + 'manager': ['monitord.rotate_log=0'] + }, + 'fim': { + 'manager': ['syscheck.debug=2', 'analysisd.debug=2', 'monitord.rotate_log=0'], + 'agent': { + 'generic': ['syscheck.debug=2', 'agent.debug=2', 'monitord.rotate_log=0'], + 'windows': ['syscheck.debug=2', 'agent.debug=2', 'monitord.rotate_log=0'] + } + }, + 'gcloud': { + 'manager': ['analysisd.debug=2', 'wazuh_modules.debug=2', 'monitord.rotate_log=0'] + }, + 'logtest': { + 'manager': ['analysisd.debug=2'] + }, + 'remoted': { + 'manager': ['remoted.debug=2', 'wazuh_database.interval=1', 'wazuh_db.commit_time=2', + 'wazuh_db.commit_time_max=3', 'monitord.rotate_log=0'] + }, + 'vulnerability_detector': { + 'manager': ['wazuh_modules.debug=2', 'monitord.rotate_log=0'] + }, + 'wazuh_db': { + 'manager': ['wazuh_modules.debug=2', 'monitord.rotate_log=0'] + }, + 'wpk': { + 'manager': ['wazuh_modules.debug=2'], + 'agent': { + 'generic': ['wazuh_modules.debug=2'], + 'windows': ['windows.debug=2'] + } + } + } def __init__(self, tests, ansible_inventory_path, qa_ctl_configuration, qa_framework_path=None): self.qa_framework_path = qa_framework_path if qa_framework_path is not None else \ @@ -37,27 +88,49 @@ def __init__(self, tests, ansible_inventory_path, qa_ctl_configuration, qa_frame self.qa_ctl_configuration = qa_ctl_configuration self.tests = tests - def __set_local_internal_options(self, hosts): + def __set_local_internal_options(self, hosts, modules, components, system): """Private method that set the local internal options in the hosts passed by parameter Args: hosts (list(str)): list of hosts aliases to index the dict attribute wazuh_dir_paths and extract the - wazuh installation path + wazuh installation path. + modules (list(str)): List of wazuh modules to which the test belongs. + components (list(str)): List of wazuh targets to which the test belongs (manager, agent). + system (str): System where the test will be launched. """ - local_internal_options = '\n'.join(self.DEBUG_OPTIONS) + local_internal_options_content = [] + + if isinstance(modules, list) and len(modules) > 0 and isinstance(components, list) and len(components) > 0 \ + and system: + for module in modules: + for component in components: + if component == 'agent': + system = 'windows' if system == 'windows' else 'generic' + local_internal_options_content.extend(self.DEBUG_OPTIONS[module][component][system]) + else: + local_internal_options_content.extend(self.DEBUG_OPTIONS[module][component]) + + # Delete duplicated items + local_internal_options_content = list(set(local_internal_options_content)) + else: + local_internal_options_content = self.ALL_DEBUG_OPTIONS + playbook_file_path = os.path.join(gettempdir(), 'wazuh_qa_ctl' f"{get_current_timestamp()}.yaml") + local_internal_options_path = '/var/ossec/etc/local_internal_options.conf' - local_internal_path = '/var/ossec/etc/local_internal_options.conf' + clean_local_internal_configuration = {'copy': {'dest': local_internal_options_path, 'content': ''}} - set_local_internal_opts = {'lineinfile': {'path': local_internal_path, - 'line': local_internal_options}} + set_local_internal_configuration = {'lineinfile': {'path': local_internal_options_path, + 'line': "{{ item }}"}, + 'with_items': local_internal_options_content} - ansible_tasks = [AnsibleTask(set_local_internal_opts)] + ansible_tasks = [AnsibleTask(clean_local_internal_configuration), AnsibleTask(set_local_internal_configuration)] playbook_parameters = {'become': True, 'tasks_list': ansible_tasks, 'playbook_file_path': playbook_file_path, 'hosts': hosts} - TestLauncher.LOGGER.debug(f"Setting local_internal_options configuration in {hosts} hosts") + TestLauncher.LOGGER.debug(f"Setting local_internal_options configuration in {hosts} hosts with " + f"{set_local_internal_configuration}") AnsibleRunner.run_ephemeral_tasks(self.ansible_inventory_path, playbook_parameters, raise_on_error=False, output=self.qa_ctl_configuration.ansible_output) @@ -74,5 +147,5 @@ def add(self, test): def run(self): """Function to iterate over a list of tests and run them one by one.""" for test in self.tests: - self.__set_local_internal_options(test.hosts) + self.__set_local_internal_options(test.hosts, test.modules, test.components, test.system) test.run(self.ansible_inventory_path) From eab41c211fd7fd53e5394b848ffb9b25f84863c9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 20 Oct 2021 09:40:27 +0200 Subject: [PATCH 132/181] fix: Fix `schema.yaml` adding path as optional. #1864 Add schema as optional because it was not being dumped to json as it is not mandatory anymore. It is optional because it is autogenerated. --- deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml index 581ae2544a..95a441be9b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml @@ -11,6 +11,7 @@ output_fields: - os_platform - os_version optional: + - path - references - pytest_args - tags From 13bf7c16892f38780085b2775f7d5053a946724d Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 20 Oct 2021 11:55:35 +0200 Subject: [PATCH 133/181] add: Add `--no-logging` parameter to `qa-docs` tool. #1864 --- .../wazuh_testing/scripts/qa_docs.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 47a84fe41e..a79a2cad2f 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -37,6 +37,14 @@ def set_qadocs_logger_level(logging_level): else: qadocs_logger.set_level(logging_level) +def set_parameters(args): + # Set the qa-docs logger level + if args.debug_level: + set_qadocs_logger_level('DEBUG') + + # Deactivate the qa-docs logger if necessary. + if args.no_logging: + set_qadocs_logger_level(None) def get_parameters(): """Capture the script parameters @@ -53,6 +61,9 @@ def get_parameters(): parser.add_argument('-s', '--sanity-check', action='store_true', dest='sanity', help="Run a sanity check.") + parser.add_argument('--no-logging', action='store_true', dest='no_logging', + help="Do not perform logging when running the tool.") + parser.add_argument('-v', '--version', action='store_true', dest="version", help="Print qa-docs version.") @@ -127,6 +138,10 @@ def check_incompatible_parameters(parameters): '-t, --tests get specific tests information.', qadocs_logger.error) + if parameters.no_logging and parameters.debug_level: + raise QAValueError('You cannot specify debug level and no-logging at the same time.', + qadocs_logger.error) + def validate_parameters(parameters, parser): """Validate the parameters that qa-docs receives. @@ -254,12 +269,9 @@ def index_and_visualize_data(args): def main(): args, parser = get_parameters() + set_parameters(args) validate_parameters(args, parser) - # Set the qa-docs logger level - if args.debug_level: - set_qadocs_logger_level('DEBUG') - if args.version: with open(VERSION_PATH, 'r') as version_file: version_data = version_file.read() From 145df6bcafff9a492f4aa9e26e59759ce1dc3c9f Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Wed, 20 Oct 2021 12:08:58 +0200 Subject: [PATCH 134/181] add: Update qa-docs usage for qa-ctl #2080 Necessary to adapt to the new changes made in qa-docs --- .../wazuh_testing/qa_ctl/configuration/config_generator.py | 5 +++-- deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index da5af92173..b4aaeecb97 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -98,7 +98,8 @@ def __get_test_info(self, test_name): Returns: dict : return the info of the named test in dict format. """ - qa_docs_command = f"qa-docs -t {test_name} -o {join(gettempdir(), 'wazuh_qa_ctl')} -I {join(self.qa_files_path, 'tests')}" + qa_docs_command = f"qa-docs -t {test_name} -o {join(gettempdir(), 'wazuh_qa_ctl')} -I " \ + f"{join(self.qa_files_path, 'tests')} --no-logging" test_data_file_path = f"{join(gettempdir(), 'wazuh_qa_ctl', test_name)}.json" run_local_command_with_output(qa_docs_command) @@ -171,7 +172,7 @@ def _check_validate(check, test_info, allowed_values): _check_validate(check, test_info, allowed_values) # Validate version requirements - if parse(str(test_info['wazuh_min_version'])) > parse(str(self.wazuh_version)): + if parse(str(test_info['tests'][0]['wazuh_min_version'])) > parse(str(self.wazuh_version)): error_message = f"The minimal version of wazuh to launch the {test_info['test_name']} is " \ f"{test_info['wazuh_min_version']} and you are using {self.wazuh_version}" raise QAValueError(error_message, QACTLConfigGenerator.LOGGER.error, QACTL_LOGGER) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 53ee002518..6cab6c38ce 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -180,7 +180,8 @@ def validate_parameters(parameters): if parameters.run_test: for test in parameters.run_test: tests_path = os.path.join(WAZUH_QA_FILES, 'tests') - if 'test exists' not in local_actions.run_local_command_with_output(f"qa-docs -e {test} -I {tests_path}"): + if f"{test} exists" not in local_actions.run_local_command_with_output(f"qa-docs -e {test} -I {tests_path} " + ' --no-logging'): raise QAValueError(f"{test} does not exist in {tests_path}", qactl_logger.error, QACTL_LOGGER) qactl_logger.info('Input parameters validation has passed successfully') From e3ee00ed626a71a190868513fb2a9928464d2ba2 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 21 Oct 2021 11:01:55 +0200 Subject: [PATCH 135/181] add: Add new testing block info in qa-ctl config generator #2009 --- .../qa_ctl/configuration/config_generator.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index b4aaeecb97..4fae36dacf 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -319,19 +319,20 @@ def __process_deployment_data(self, tests_info): if not self.systems: for test in tests_info: if self.__validate_test_info(test): + os_version = '' if 'Ubuntu Focal' in test['os_version']: - test['os_version'] = 'Ubuntu Focal' + os_version = 'Ubuntu Focal' elif 'CentOS 8' in test['os_version']: - test['os_version'] = 'CentOS 8' + os_version = 'CentOS 8' else: raise QAValueError(f"No valid system was found for {test['name']} test", QACTLConfigGenerator.LOGGER.error, QACTL_LOGGER) - test['components'] = 'manager' if 'manager' in test['components'] else 'agent' - test['os_platform'] = 'linux' + components = 'manager' if 'manager' in test['components'] else 'agent' + os_platform = 'linux' + + self.__add_deployment_config_block(test['test_name'], os_version, components, os_platform) - self.__add_deployment_config_block(test['test_name'], test['os_version'], test['components'], - test['os_platform']) # If system parameter is specified and have values elif isinstance(self.systems, list) and len(self.systems) > 0: for system in self.systems: @@ -384,7 +385,8 @@ def __process_provision_data(self): 'qa_workdir': file.join_path([installation_files_path, 'wazuh_qa_ctl'], system) } - def __add_testing_config_block(self, instance, installation_files_path, system, test_path, test_name): + def __add_testing_config_block(self, instance, installation_files_path, system, test_path, test_name, module, + component): """Add a configuration block to launch a test in qa-ctl. Args: @@ -393,6 +395,8 @@ def __add_testing_config_block(self, instance, installation_files_path, system, system (str): System where launch the test. test_path (str): Path where are located the test files. test_name (str): Test name. + module (list(str)): List of modules. + component (list(str)) List of components (manager, agent). """ self.config['tests'][instance] = {'host_info': {}, 'test': {}} self.config['tests'][instance]['host_info'] = \ @@ -406,7 +410,10 @@ def __add_testing_config_block(self, instance, installation_files_path, system, 'run_tests_dir_path': file.join_path([installation_files_path, 'wazuh_qa_ctl', 'wazuh-qa', 'tests', 'integration'], system), 'test_results_path': join(gettempdir(), 'wazuh_qa_ctl', f"{test_name}_{get_current_timestamp()}") - } + }, + 'components': component, + 'modules': module, + 'system': system } def __set_testing_config(self, tests_info): @@ -423,8 +430,12 @@ def __set_testing_config(self, tests_info): installation_files_path = QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'] system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] + system = 'linux' if 'linux' in test['os_platform'] else test['os_platform'][0] + module = [test['modules'][0]] + component = ['manager'] if 'manager' in test['components'] else [test['components'][0]] + self.__add_testing_config_block(instance, installation_files_path, system, test['path'], - test['test_name']) + test['test_name'], module, component) test_host_number += 1 # If it is an agent test then we skip the next manager instance since no test will be launched in that # instance From bab73113bf18832c38d7a28b024e04fa208d0e7e Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 21 Oct 2021 15:32:46 +0200 Subject: [PATCH 136/181] refac: Convert the components parameter type #2009 From list to string --- .../qa_ctl/configuration/config_generator.py | 22 +++++++++---------- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 6 ++--- .../qa_ctl/run_tests/qa_test_runner.py | 2 +- .../wazuh_testing/qa_ctl/run_tests/test.py | 6 ++--- .../qa_ctl/run_tests/test_launcher.py | 22 +++++++++---------- 5 files changed, 28 insertions(+), 30 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 4fae36dacf..cd0a1ca7e4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -385,7 +385,7 @@ def __process_provision_data(self): 'qa_workdir': file.join_path([installation_files_path, 'wazuh_qa_ctl'], system) } - def __add_testing_config_block(self, instance, installation_files_path, system, test_path, test_name, module, + def __add_testing_config_block(self, instance, installation_files_path, system, test_path, test_name, modules, component): """Add a configuration block to launch a test in qa-ctl. @@ -395,8 +395,8 @@ def __add_testing_config_block(self, instance, installation_files_path, system, system (str): System where launch the test. test_path (str): Path where are located the test files. test_name (str): Test name. - module (list(str)): List of modules. - component (list(str)) List of components (manager, agent). + modules (list(str)): List of modules. + component (str): Test component (manager, agent). """ self.config['tests'][instance] = {'host_info': {}, 'test': {}} self.config['tests'][instance]['host_info'] = \ @@ -411,9 +411,9 @@ def __add_testing_config_block(self, instance, installation_files_path, system, 'tests', 'integration'], system), 'test_results_path': join(gettempdir(), 'wazuh_qa_ctl', f"{test_name}_{get_current_timestamp()}") }, - 'components': component, - 'modules': module, - 'system': system + 'system': system, + 'component': component, + 'modules': modules } def __set_testing_config(self, tests_info): @@ -430,16 +430,16 @@ def __set_testing_config(self, tests_info): installation_files_path = QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'] system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] - system = 'linux' if 'linux' in test['os_platform'] else test['os_platform'][0] - module = [test['modules'][0]] - component = ['manager'] if 'manager' in test['components'] else [test['components'][0]] + system = 'linux' if system == 'deb' or system == 'rpm' else system + modules = test['modules'] + component = 'manager' if 'manager' in test['components'] else test['components'][0] self.__add_testing_config_block(instance, installation_files_path, system, test['path'], - test['test_name'], module, component) + test['test_name'], modules, component) test_host_number += 1 # If it is an agent test then we skip the next manager instance since no test will be launched in that # instance - if test['components'] == 'agent': + if component == 'agent': test_host_number += 1 def __process_test_data(self, tests_info): diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index 81151fba79..a0a5828ed9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -33,7 +33,7 @@ class Pytest(Test): markers(list(str), []): Set of markers to be added to the test execution command hosts(list(), ['all']): List of hosts aliases where the tests will be runned modules (list(str)): List of wazuh modules to which the test belongs. - components (list(str)): List of wazuh targets to which the test belongs (manager, agent). + component (str): Test target (manager, agent). system (str): System where the test will be launched. Attributes: @@ -60,7 +60,7 @@ class Pytest(Test): def __init__(self, tests_result_path, tests_path, tests_run_dir, qa_ctl_configuration, tiers=[], stop_after_first_failure=False, keyword_expression=None, traceback='auto', dry_run=False, custom_args=[], verbose_level=False, log_level=None, markers=[], hosts=['all'], modules=None, - components=None, system='linux'): + component=None, system='linux'): self.qa_ctl_configuration = qa_ctl_configuration self.tiers = tiers self.stop_after_first_failure = stop_after_first_failure @@ -78,7 +78,7 @@ def __init__(self, tests_result_path, tests_path, tests_run_dir, qa_ctl_configur if not os.path.exists(self.tests_result_path): os.makedirs(self.tests_result_path) - super().__init__(tests_path, tests_run_dir, tests_result_path, modules, components, system) + super().__init__(tests_path, tests_run_dir, tests_result_path, modules, component, system) def run(self, ansible_inventory_path): """Executes the current test with the specified options defined in attributes and bring back the reports diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py index 3166682035..7fcf2db1b5 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py @@ -115,7 +115,7 @@ def __build_test(self, test_params, host=['all']): test_dict['tests_result_path'] = paths['test_results_path'] test_dict['tests_run_dir'] = paths['run_tests_dir_path'] - test_dict['components'] = test_params['components'] if 'components' in test_params else None + test_dict['component'] = test_params['component'] if 'component' in test_params else None test_dict['modules'] = test_params['modules'] if 'modules' in test_params else None test_dict['system'] = test_params['system'] if 'system' in test_params else None diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test.py index 1a09fdbc1c..38e04909d9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test.py @@ -10,7 +10,7 @@ class Test(ABC): tests_result_path(str): Path to the directory where the reports will be stored in the local machine result (TestResult): Result of the test. It is set when the test has been finished. modules (list(str)): List of wazuh modules to which the test belongs. - components (list(str)): List of wazuh targets to which the test belongs (manager, agent). + component (str): Test target (manager, agent). system (str): System where the test will be launched. Args: @@ -19,12 +19,12 @@ class Test(ABC): tests_result_path(str): Path to the directory where the reports will be stored in the local machine """ - def __init__(self, tests_path, tests_run_dir, tests_result_path, modules=None, components=None, system='linux'): + def __init__(self, tests_path, tests_run_dir, tests_result_path, modules=None, component=None, system='linux'): self.tests_path = tests_path self.tests_run_dir = tests_run_dir self.tests_result_path = tests_result_path self.modules = modules - self.components = components + self.component = component self.system = system @abstractmethod diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py index a72e3a7337..52063dd05b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py @@ -88,27 +88,25 @@ def __init__(self, tests, ansible_inventory_path, qa_ctl_configuration, qa_frame self.qa_ctl_configuration = qa_ctl_configuration self.tests = tests - def __set_local_internal_options(self, hosts, modules, components, system): + def __set_local_internal_options(self, hosts, modules, component, system): """Private method that set the local internal options in the hosts passed by parameter Args: hosts (list(str)): list of hosts aliases to index the dict attribute wazuh_dir_paths and extract the wazuh installation path. modules (list(str)): List of wazuh modules to which the test belongs. - components (list(str)): List of wazuh targets to which the test belongs (manager, agent). + component (str): Test wazuh target (manager, agent). system (str): System where the test will be launched. """ local_internal_options_content = [] - if isinstance(modules, list) and len(modules) > 0 and isinstance(components, list) and len(components) > 0 \ - and system: + if isinstance(modules, list) and len(modules) > 0 and component and system: for module in modules: - for component in components: - if component == 'agent': - system = 'windows' if system == 'windows' else 'generic' - local_internal_options_content.extend(self.DEBUG_OPTIONS[module][component][system]) - else: - local_internal_options_content.extend(self.DEBUG_OPTIONS[module][component]) + if component == 'agent': + system = 'windows' if system == 'windows' else 'generic' + local_internal_options_content.extend(self.DEBUG_OPTIONS[module][component][system]) + else: + local_internal_options_content.extend(self.DEBUG_OPTIONS[module][component]) # Delete duplicated items local_internal_options_content = list(set(local_internal_options_content)) @@ -130,7 +128,7 @@ def __set_local_internal_options(self, hosts, modules, components, system): playbook_file_path, 'hosts': hosts} TestLauncher.LOGGER.debug(f"Setting local_internal_options configuration in {hosts} hosts with " - f"{set_local_internal_configuration}") + f"{local_internal_options_content}") AnsibleRunner.run_ephemeral_tasks(self.ansible_inventory_path, playbook_parameters, raise_on_error=False, output=self.qa_ctl_configuration.ansible_output) @@ -147,5 +145,5 @@ def add(self, test): def run(self): """Function to iterate over a list of tests and run them one by one.""" for test in self.tests: - self.__set_local_internal_options(test.hosts, test.modules, test.components, test.system) + self.__set_local_internal_options(test.hosts, test.modules, test.component, test.system) test.run(self.ansible_inventory_path) From 49ba88cf4ed56a33212854f168b16c52f0b426dd Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 22 Oct 2021 10:29:13 +0200 Subject: [PATCH 137/181] doc: Add test_registry_restric test documentation #2020 Done for testing Windows configurations --- .../test_registry_restrict.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/integration/test_fim/test_registry/test_registry_restrict/test_registry_restrict.py b/tests/integration/test_fim/test_registry/test_registry_restrict/test_registry_restrict.py index 64130a9ba6..739c6bb53b 100644 --- a/tests/integration/test_fim/test_registry/test_registry_restrict/test_registry_restrict.py +++ b/tests/integration/test_fim/test_registry/test_registry_restrict/test_registry_restrict.py @@ -1,6 +1,48 @@ # Copyright (C) 2015-2021, Wazuh Inc. # Created by Wazuh, Inc. . # This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + Created by Wazuh, Inc. . + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 +type: integration +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when + these files are modified. Specifically, these tests will verify that FIM generates events + only for registry entry operations in monitored keys that do not match the 'restrict_key' + or the 'restrict_value' attributes. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured + files for changes to the checksums, permissions, and ownership. +tier: 1 +modules: + - fim +components: + - agent +daemons: + - wazuh-syscheckd +os_platform: + - windows +os_version: + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html#windows-registry +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. +tags: + - fim_registry_restrict +''' import os import pytest From f9db744413cb540ff629d391b31930aa378c0bf9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 22 Oct 2021 10:42:00 +0200 Subject: [PATCH 138/181] add: Add new fim tags to `qa-docs` `schema.yaml`. --- .../wazuh_testing/qa_docs/schema.yaml | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml index 95a441be9b..da67279a01 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml @@ -25,7 +25,7 @@ output_fields: - expected_output optional: - inputs - - tags + - tags test_cases_field: test_cases @@ -204,6 +204,36 @@ predefined_values: - fim_env_variables - fim_file_limit - fim_follow_symbolic_link + - fim_ignore + - fim_inotify + - fim_invalid + - fim_max_eps + - fim_max_files_per_second + - fim_moving_files + - fim_multiple_dirs + - fim_nodiff + - fim_prefilter_cmd + - fim_report_changes + - fim_process_priority + - fim_recursion_level + - fim_restrict + - fim_scan + - fim_skip + - fim_stats_integrity_sync + - fim_tags + - fim_timezone_changes + - fim_wildcards_complex + - fim_windows_audit_interval + - fim_registry_ambiguous_confs + - fim_registry_basic_usage + - fim_registry_checks + - fim_registry_ignore + - fim_registry_nodiff + - fim_registry_file_limit + - fim_registry_multiple_registries + - fim_registry_recursion_level + - fim_registry_restrict + - fim_synchronization - gcloud - github - integrity From a144630c6b20fbecdec6e6601ff5e85c0f5c4c01 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 22 Oct 2021 11:50:08 +0200 Subject: [PATCH 139/181] doc: update test_registry_restrict documentation --- .../wazuh_testing/qa_docs/schema.yaml | 2 + .../test_registry_restrict.py | 177 ++++++++++++++---- 2 files changed, 138 insertions(+), 41 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml index da67279a01..d5e7cfcc80 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/schema.yaml @@ -113,6 +113,7 @@ predefined_values: - Windows Server 2003 - Windows Server 2012 - Windows Server 2016 + - Windows Server 2019 components: - agent - manager @@ -256,6 +257,7 @@ predefined_values: - rootcheck - rules - scan + - scheduled - settings - simulator - ssl diff --git a/tests/integration/test_fim/test_registry/test_registry_restrict/test_registry_restrict.py b/tests/integration/test_fim/test_registry/test_registry_restrict/test_registry_restrict.py index 739c6bb53b..3f06a537a7 100644 --- a/tests/integration/test_fim/test_registry/test_registry_restrict/test_registry_restrict.py +++ b/tests/integration/test_fim/test_registry/test_registry_restrict/test_registry_restrict.py @@ -1,37 +1,47 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 ''' copyright: Copyright (C) 2015-2021, Wazuh Inc. + Created by Wazuh, Inc. . + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + type: integration + brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these files are modified. Specifically, these tests will verify that FIM generates events only for registry entry operations in monitored keys that do not match the 'restrict_key' or the 'restrict_value' attributes. The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files for changes to the checksums, permissions, and ownership. + tier: 1 + modules: - fim + components: - agent + daemons: - wazuh-syscheckd + os_platform: - windows + os_version: - Windows 10 - Windows 8 - Windows 7 + - Windows Server 2019 - Windows Server 2016 - Windows Server 2012 - Windows Server 2003 - Windows XP + references: - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html#windows-registry + pytest_args: - fim_mode: realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. @@ -40,6 +50,7 @@ 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. 1: Only level 1 tests are performed, they check functionalities of medium complexity. 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + tags: - fim_registry_restrict ''' @@ -104,24 +115,66 @@ def get_configuration(request): def test_restrict_value(key, subkey, arch, value_name, triggers_event, tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check the only registry values detected are those matching the restrict regex - - Parameters - ---------- - key : str - Root key (HKEY_*) - subkey : str - path of the registry. - arch : str - Architecture of the registry. - value_name : str - Name of the value that will be created - triggers_event : bool - True if an event must be generated, False otherwise. - tags_to_apply : set - Run test if match with a configuration identifier, skip otherwise. - """ + ''' + description: Check if the 'wazuh-syscheckd' daemon detects or ignores events in monitored registry entries + depending on the value set in the 'restrict_value' attribute. This attribute limit checks to + values that match the entered string or regex and its name. For this purpose, the test will + monitor a key, create testing values inside it, and make operations on that values. Finally, + the test will verify that FIM 'added' and 'modified' events are generated only for the testing + values that are not restricted. + + wazuh_min_version: 4.2.0 + + parameters: + - key: + type: str + brief: Path of the registry root key (HKEY_* constants). + - subkey: + type: str + brief: The registry key being monitored by syscheck. + - arch: + type: str + brief: Architecture of the registry. + - value_name: + type: str + brief: Name of the testing value that will be created. + - triggers_event: + type: bool + brief: True if an event must be generated, False otherwise. + - tags_to_apply: + type: set + brief: Run test if matches with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events are only generated for operations in monitored values + that do not match the 'restrict_value' attribute. + - Verify that FIM 'ignoring' events are generated for monitored values that are restricted. + + input_description: A test case (value_restrict) is contained in external YAML file + (wazuh_restrict_conf.yaml) which includes configuration settings + for the 'wazuh-syscheckd' daemon. That is combined with the testing + registry keys to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('added', 'modified' events) + - r'.*Ignoring entry .* due to restriction .*' + + tags: + - scheduled + - time_travel + ''' check_apply_test(tags_to_apply, get_configuration['tags']) # This shouldn't create an alert because the key is already created key_h = create_registry(registry_parser[key], subkey, arch) @@ -188,24 +241,66 @@ def test_restrict_value(key, subkey, arch, value_name, triggers_event, tags_to_a def test_restrict_key(key, subkey, test_subkey, arch, triggers_event, tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check the only registry keys detected are those matching the restrict regex - - Parameters - ---------- - key : str - Root key (HKEY_*) - subkey : str - Path of the registry. - test_subkey : str - Name of the key that will be used for the test - arch : str - Architecture of the registry. - triggers_event : bool - True if an event must be generated, False otherwise. - tags_to_apply : set - Run test if match with a configuration identifier, skip otherwise. - """ + ''' + description: Check if the 'wazuh-syscheckd' daemon detects or ignores events in monitored registry entries + depending on the value set in the 'restrict_key' attribute. This attribute limit checks to + keys that match the entered string or regex and its name. For this purpose, the test will + monitor a key, create testing subkeys inside it, and make operations on those subkeys. Finally, + the test will verify that FIM 'added' and 'deleted' events are generated only for the testing + subkeys that are not restricted. + + wazuh_min_version: 4.2.0 + + parameters: + - key: + type: str + brief: Path of the registry root key (HKEY_* constants). + - subkey: + type: str + brief: The registry key being monitored by syscheck. + - test_subkey: + type: str + brief: Name of the key that will be used for the test + - arch: + type: str + brief: Architecture of the registry. + - triggers_event: + type: bool + brief: True if an event must be generated, False otherwise. + - tags_to_apply: + type: set + brief: Run test if matches with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events are only generated for operations in monitored keys + that do not match the 'restrict_key' attribute. + - Verify that FIM 'ignoring' events are generated for monitored keys that are restricted. + + input_description: A test case (key_restrict) is contained in external YAML file + (wazuh_restrict_conf.yaml) which includes configuration settings + for the 'wazuh-syscheckd' daemon. That is combined with the testing + registry keys to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('added', 'deleted' events) + - r'.*Ignoring entry .* due to restriction .*' + + tags: + - scheduled + - time_travel + ''' check_apply_test(tags_to_apply, get_configuration['tags']) test_key = os.path.join(subkey, test_subkey) create_registry(registry_parser[key], test_key, arch) @@ -215,7 +310,7 @@ def test_restrict_key(key, subkey, test_subkey, arch, triggers_event, tags_to_ap if triggers_event: event = wazuh_log_monitor.start(timeout=global_parameters.default_timeout, - callback=callback_detect_event).result() + callback=callback_detect_event, accum_results=1).result() assert event['data']['type'] == 'added', 'Event type not equal' assert event['data']['path'] == os.path.join(key, test_key), 'Event path not equal' assert event['data']['arch'] == '[x32]' if arch == KEY_WOW64_32KEY else '[x64]', 'Arch not equal' @@ -235,7 +330,7 @@ def test_restrict_key(key, subkey, test_subkey, arch, triggers_event, tags_to_ap if triggers_event: event = wazuh_log_monitor.start(timeout=global_parameters.default_timeout, - callback=callback_detect_event).result() + callback=callback_detect_event, accum_results=1).result() assert event['data']['type'] == 'deleted', 'key event not equal' assert event['data']['path'] == os.path.join(key, test_key), 'Key event wrong path' From dbc9700d614dc346e576220cc2a06a81e71f2a53 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 22 Oct 2021 13:40:36 +0200 Subject: [PATCH 140/181] add: Add automatic configuration generation for Windows tests #2020 --- .../qa_ctl/configuration/config_generator.py | 57 ++++++++++++++++--- .../qa_ctl/deployment/vagrantfile.py | 3 +- .../wazuh_testing/scripts/qa_ctl.py | 4 +- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index cd0a1ca7e4..d945887701 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -39,10 +39,12 @@ class QACTLConfigGenerator: LOGGER = Logging.get_logger(QACTL_LOGGER) LINUX_TMP = '/tmp' + WINDOWS_TMP = 'C:\\Users\\vagrant\\AppData\\Local\\Temp' BOX_MAPPING = { 'Ubuntu Focal': 'qactl/ubuntu_20_04', - 'CentOS 8': 'qactl/centos_8' + 'CentOS 8': 'qactl/centos_8', + 'Windows Server 2019': 'qactl/windows_2019' } SYSTEMS = { @@ -53,6 +55,25 @@ class QACTLConfigGenerator: 'ubuntu': { 'os_version': 'Ubuntu Focal', 'os_platform': 'linux' + }, + 'windows': { + 'os_version': 'Windows Server 2019', + 'os_platform': 'windows' + } + } + + DEFAULT_BOX_RESOURCES = { + 'qactl/ubuntu_20_04': { + 'cpu': 1, + 'memory': 1024 + }, + 'qactl/centos_8': { + 'cpu': 1, + 'memory': 1024 + }, + 'qactl/windows_2019': { + 'cpu': 2, + 'memory': 2048 } } @@ -74,6 +95,15 @@ class QACTLConfigGenerator: 'ansible_python_interpreter': '/usr/bin/python3', 'system': 'rpm', 'installation_files_path': LINUX_TMP + }, + 'qactl/windows_2019': { + 'ansible_connection': 'winrm', + 'ansible_user': 'vagrant', + 'ansible_password': 'vagrant', + 'ansible_port': 5985, + 'ansible_winrm_server_cert_validation': 'ignore', + 'system': 'windows', + 'installation_files_path': WINDOWS_TMP } } @@ -163,7 +193,7 @@ def _check_validate(check, test_info, allowed_values): return True allowed_info = { - 'os_platform': ['linux'], + 'os_platform': ['linux', 'windows'], 'os_version': list(QACTLConfigGenerator.BOX_MAPPING.keys()) } @@ -227,7 +257,7 @@ def __delete_ip_entry(self, host_ip): file.write_file(self.qactl_used_ips_file, data) - def __add_instance(self, os_version, test_name, test_target, os_platform, vm_cpu=1, vm_memory=1024): + def __add_instance(self, os_version, test_name, test_target, os_platform): """Add a new provider instance for the deployment module. T Args: @@ -243,6 +273,14 @@ def __add_instance(self, os_version, test_name, test_target, os_platform, vm_cpu dict object: dict containing all the field required for generating a new vagrant box in the deployment module. """ + vm_cpu=1 + vm_memory=1024 + + if os_version in self.BOX_MAPPING: + box = self.BOX_MAPPING[os_version] + vm_cpu = self.DEFAULT_BOX_RESOURCES[box]['cpu'] + vm_memory = self.DEFAULT_BOX_RESOURCES[box]['memory'] + instance_ip = self.__get_host_IP() instance = { 'enabled': True, @@ -298,9 +336,10 @@ def __add_deployment_config_block(self, test_name, os_version, components, os_pl # Add manager if the target is an agent if components == 'agent': host_number += 1 + #manager_vm_name = f"manager_{test_name}_{get_current_timestamp()}" self.config['deployment'][f"host_{host_number}"] = { 'provider': { - 'vagrant': self.__add_instance(os_version, vm_name, 'manager', 'linux') + 'vagrant': self.__add_instance('CentOS 8', vm_name, 'manager', 'linux') } } @@ -324,12 +363,14 @@ def __process_deployment_data(self, tests_info): os_version = 'Ubuntu Focal' elif 'CentOS 8' in test['os_version']: os_version = 'CentOS 8' + elif 'Windows Server 2019' in test['os_version']: + os_version = 'Windows Server 2019' else: raise QAValueError(f"No valid system was found for {test['name']} test", QACTLConfigGenerator.LOGGER.error, QACTL_LOGGER) components = 'manager' if 'manager' in test['components'] else 'agent' - os_platform = 'linux' + os_platform = 'windows' if 'Windows' in os_version else 'linux' self.__add_deployment_config_block(test['test_name'], os_version, components, os_platform) @@ -433,8 +474,10 @@ def __set_testing_config(self, tests_info): system = 'linux' if system == 'deb' or system == 'rpm' else system modules = test['modules'] component = 'manager' if 'manager' in test['components'] else test['components'][0] + # Convert test path string to the corresponding according to the system + test_path = file.join_path(test['path'].split('/'), system) - self.__add_testing_config_block(instance, installation_files_path, system, test['path'], + self.__add_testing_config_block(instance, installation_files_path, system, test_path, test['test_name'], modules, component) test_host_number += 1 # If it is an agent test then we skip the next manager instance since no test will be launched in that @@ -456,7 +499,7 @@ def __process_test_data(self, tests_info): if not self.systems: for _ in tests_info: self.__set_testing_config(tests_info) - + # If we want to launch the test in one or multiple systems specified in qa-ctl parameters elif isinstance(self.systems, list) and len(self.systems) > 0: for _ in self.systems: for _ in tests_info: diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py index 33624f7637..f9dd70c23c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py @@ -64,7 +64,8 @@ def __get_box_url(self): """ box_mapping = { 'qactl/ubuntu_20_04': 'https://s3.amazonaws.com/ci.wazuh.com/qa/boxes/QACTL_ubuntu_20_04.box', - 'qactl/centos_8': 'https://s3.amazonaws.com/ci.wazuh.com/qa/boxes/QACTL_centos_8.box' + 'qactl/centos_8': 'https://s3.amazonaws.com/ci.wazuh.com/qa/boxes/QACTL_centos_8.box', + 'qactl/windows_2019': 'https://s3.amazonaws.com/ci.wazuh.com/qa/boxes/QACTL_windows_server_2019.box' } try: diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 6cab6c38ce..5bea23ce65 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -184,6 +184,8 @@ def validate_parameters(parameters): ' --no-logging'): raise QAValueError(f"{test} does not exist in {tests_path}", qactl_logger.error, QACTL_LOGGER) + ## --> Add os validation for each test + qactl_logger.info('Input parameters validation has passed successfully') @@ -230,7 +232,7 @@ def get_script_parameters(): parser.add_argument('--no-validation', action='store_true', help='Disable the script parameters validation.') parser.add_argument('--os', '-o', type=str, action='store', required=False, nargs='+', dest='operating_systems', - choices=['centos', 'ubuntu'], help='System/s where the tests will be launched.') + choices=['centos', 'ubuntu', 'windows'], help='System/s where the tests will be launched.') parser.add_argument('--qa-branch', type=str, action='store', required=False, dest='qa_branch', help='Set a custom wazuh-qa branch to use in the run and provisioning. This ' From c933603425ef70edcf05ddba7f0dc96727c0f71d Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 22 Oct 2021 17:27:00 +0200 Subject: [PATCH 141/181] add: Add new ansible instance classes #2020 This is necessary because the attributes of the initial AnsibleInstance class change depending on the OS --- .../provisioning/ansible/ansible_instance.py | 51 ++------------- .../ansible/unix_ansible_instance.py | 62 +++++++++++++++++++ .../ansible/windows_ansible_instance.py | 49 +++++++++++++++ 3 files changed, 116 insertions(+), 46 deletions(-) create mode 100644 deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py create mode 100644 deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py index 007402b6e0..ba2e403fc6 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py @@ -1,59 +1,18 @@ import yaml import json +from abc import ABC - -class AnsibleInstance(): +class AnsibleInstance(ABC): """Represent the necessary attributes of an instance to be specified in an ansible inventory. Args: host (str): Ip or hostname. - connection_user (str): Host connection user - connection_user_password (str): Host connection user password - ssh_private_key_file_path (str): Path where is located the private key to authenticate the user. - host_vars (dict): Set of custom variables to add to that host. - connection_method (str): Connection method: smart, ssh or paramiko. - connection_port (int): Remote ssh connection port. - ansible_python_interpreter (str): Python interpreter path in the remote host. + ansible_user (str): Host connection user Attributes: host (str): Ip or hostname. - connection_user (str): Host connection user - connection_user_password (str): Host connection user password - ssh_private_key_file_path (str): Path where is located the private key to authenticate the user. - host_vars (dict): Set of custom variables to add to that host. - connection_method (str): Connection method: smart, ssh or paramiko. - connection_port (int): Remote ssh connection port. - ansible_python_interpreter (str): Python interpreter path in the remote host. + ansible_user (str): Host connection user """ - def __init__(self, host, connection_user, connection_user_password=None, ssh_private_key_file_path=None, - host_vars=None, connection_method='ssh', connection_port=22, - ansible_python_interpreter='/usr/bin/python'): + def __init__(self, host, host_vars=None): self.host = host self.host_vars = host_vars - self.connection_method = connection_method - self.connection_port = connection_port - self.connection_user = connection_user - self.connection_user_password = connection_user_password - self.ssh_private_key_file_path = ssh_private_key_file_path - self.ansible_python_interpreter = ansible_python_interpreter - - def __str__(self): - """Define how the class object is to be displayed.""" - data = {'host_information': {'host': self.host, 'connection_method': self.connection_method, - 'connection_port': self.connection_port, 'connection_user': self.connection_user, - 'password': self.connection_user_password, - 'connection_user_password': self.connection_user_password, - 'ssh_private_key_file_path': self.ssh_private_key_file_path, - 'ansible_python_interpreter': self.ansible_python_interpreter - } - } - - return yaml.dump(data, allow_unicode=True, sort_keys=False) - - def __repr__(self): - """Representation of the object of the class in string format""" - return json.dumps({'host': self.host, 'host_vars': self.host_vars, 'connection_method': self.connection_method, - 'connection_port': self.connection_port, 'connection_user': self.connection_user, - 'connection_user_password': self.connection_user_password, - 'ssh_private_key_file_path': self.ssh_private_key_file_path, - 'ansible_python_interpreter': self.ansible_python_interpreter}) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py new file mode 100644 index 0000000000..9dd32155cd --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py @@ -0,0 +1,62 @@ +import yaml +import json + +from wazuh_testing.qa_ctl.provisioning.ansible.ansible_instance import AnsibleInstance + +class UnixAnsibleInstance(AnsibleInstance): + """Represent the necessary attributes of an instance to be specified in an ansible inventory. + + Args: + host (str): Ip or hostname. + connection_user (str): Host connection user + connection_user_password (str): Host connection user password + ssh_private_key_file_path (str): Path where is located the private key to authenticate the user. + host_vars (dict): Set of custom variables to add to that host. + connection_method (str): Connection method: smart, ssh or paramiko. + connection_port (int): Remote ssh connection port. + ansible_python_interpreter (str): Python interpreter path in the remote host. + + Attributes: + connection_user (str): Host connection user + connection_user_password (str): Host connection user password + ssh_private_key_file_path (str): Path where is located the private key to authenticate the user. + connection_method (str): Connection method: smart, ssh or paramiko. + connection_port (int): Remote ssh connection port. + ansible_python_interpreter (str): Python interpreter path in the remote host. + """ + def __init__(self, host, connection_user, connection_user_password=None, ssh_private_key_file_path=None, + host_vars=None, connection_method='ssh', connection_port=22, + ansible_python_interpreter='/usr/bin/python'): + self.connection_method = connection_method + self.connection_port = connection_port + self.connection_user = connection_user + self.connection_user_password = connection_user_password + self.ssh_private_key_file_path = ssh_private_key_file_path + self.ansible_python_interpreter = ansible_python_interpreter + + super().__init__(host=host, host_vars=host_vars) + + def __str__(self): + """Define how the class object is to be displayed.""" + data = { + 'host_information': { + 'host': self.host, 'connection_method': self.connection_method, + 'connection_port': self.connection_port, 'connection_user': self.connection_user, + 'password': self.connection_user_password, + 'connection_user_password': self.connection_user_password, + 'ssh_private_key_file_path': self.ssh_private_key_file_path, + 'ansible_python_interpreter': self.ansible_python_interpreter + } + } + + return yaml.dump(data, allow_unicode=True, sort_keys=False) + + def __repr__(self): + """Representation of the object of the class in string format""" + return json.dumps({ + 'host': self.host, 'host_vars': self.host_vars, 'connection_method': self.connection_method, + 'connection_port': self.connection_port, 'connection_user': self.connection_user, + 'connection_user_password': self.connection_user_password, + 'ssh_private_key_file_path': self.ssh_private_key_file_path, + 'ansible_python_interpreter': self.ansible_python_interpreter + }) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py new file mode 100644 index 0000000000..237ad25c4f --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py @@ -0,0 +1,49 @@ +import yaml +import json + +from wazuh_testing.qa_ctl.provisioning.ansible.ansible_instance import AnsibleInstance + +class WindowsAnsibleInstance(AnsibleInstance): + """Represent the necessary attributes of an instance to be specified in an ansible inventory. + + Args: + host (str): Ip or hostname. + ansible_user (str): Host connection user + ansible_connection (str): Host connection user password + host_vars (dict): Set of custom variables to add to that host. + ansible_connection (str): Connection method. + ansible_port (int): Remote connection port. + + Attributes: + ansible_connection (str): Host connection user password + host_vars (dict): Set of custom variables to add to that host. + ansible_connection (str): Connection method. + ansible_port (int): Remote connection port. + """ + def __init__(self, host, ansible_user='vagrant', ansible_password='vagrant', host_vars=None, + ansible_connection='winrm', ansible_port=5985): + self.ansible_connection = ansible_connection + self.ansible_port = ansible_port + self.ansible_user = ansible_user + self.ansible_password = ansible_password + + super().__init__(host=host, host_vars=host_vars) + + def __str__(self): + """Define how the class object is to be displayed.""" + data = { + 'host_information': { + 'host': self.host, 'ansible_connection': self.ansible_connection, 'ansible_port': self.ansible_port, + 'ansible_user': self.ansible_user, 'ansible_password': self.ansible_password + } + } + + return yaml.dump(data, allow_unicode=True, sort_keys=False) + + def __repr__(self): + """Representation of the object of the class in string format""" + return json.dumps({ + 'host': self.host, 'host_vars': self.host_vars, 'ansible_connection': self.ansible_connection, + 'ansible_port': self.ansible_port, 'ansible_user': self.ansible_user, + 'ansible_password': self.ansible_password + }) From 460a299a11d6f08bddd2ab2fd382c0d5aab81d9e Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 22 Oct 2021 17:28:00 +0200 Subject: [PATCH 142/181] refac: Update AnsibleInstance usage for the new subclasses #2020 --- .../qa_ctl/provisioning/ansible/ansible.py | 40 ------------------- .../qa_ctl/provisioning/qa_provisioning.py | 29 ++++++++++---- .../qa_ctl/run_tests/qa_test_runner.py | 30 ++++++++++---- 3 files changed, 45 insertions(+), 54 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible.py index 2dde6955f2..22f4dd6802 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible.py @@ -1,8 +1,5 @@ from wazuh_testing.qa_ctl.provisioning.ansible.ansible_task import AnsibleTask from wazuh_testing.qa_ctl.provisioning.ansible.ansible_runner import AnsibleRunner -from wazuh_testing.qa_ctl.provisioning.ansible.ansible_instance import AnsibleInstance -from wazuh_testing.qa_ctl.provisioning.ansible.ansible_inventory import AnsibleInventory -from wazuh_testing.tools.exceptions import AnsibleException def _ansible_runner(inventory_file_path, playbook_parameters, ansible_output=False, log_ansible_error=True): @@ -147,40 +144,3 @@ def launch_remote_commands(inventory_file_path, hosts, commands, become=False, a return _ansible_runner(inventory_file_path, {'tasks_list': tasks_list, 'hosts': hosts, 'gather_facts': True, 'become': become}, ansible_output) - - -def check_windows_ansible_credentials(user, password, log_ansible_error=False): - """Check if the windows ansible credentials are correct. - - This method must be run in a Windows WSL. - - Args: - user (str): Windows user. - password (str): Windows user password. - log_ansible_error (boolean): True for logging the error exception message if any. - - Returns: - boolean: True if credentials are correct, False otherwise. - """ - inventory = AnsibleInventory([AnsibleInstance('127.0.0.1', connection_user=user, - connection_user_password=password, - connection_method='winrm', - connection_port='5986') - ]) - inventory_file_path = inventory.inventory_file_path - tasks_list = [ - AnsibleTask({ - 'debug': { - 'msg': 'Hello world' - } - }), - - ] - - try: - _ansible_runner(inventory_file_path, {'tasks_list': tasks_list, 'hosts': '127.0.0.1', 'gather_facts': True, - 'become': False}, ansible_output=False, - log_ansible_error=log_ansible_error) - return True - except AnsibleException: - return False diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index 73b3eacb0a..4d3ac8df91 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -4,7 +4,8 @@ from time import sleep -from wazuh_testing.qa_ctl.provisioning.ansible.ansible_instance import AnsibleInstance +from wazuh_testing.qa_ctl.provisioning.ansible.unix_ansible_instance import UnixAnsibleInstance +from wazuh_testing.qa_ctl.provisioning.ansible.windows_ansible_instance import WindowsAnsibleInstance from wazuh_testing.qa_ctl.provisioning.ansible.ansible_inventory import AnsibleInventory from wazuh_testing.qa_ctl.provisioning.wazuh_deployment.wazuh_local_package import WazuhLocalPackage from wazuh_testing.qa_ctl.provisioning.wazuh_deployment.wazuh_s3_package import WazuhS3Package @@ -64,12 +65,26 @@ def __read_ansible_instance(self, host_info): extra_vars = None if 'host_vars' not in host_info else host_info['host_vars'] private_key_path = None if 'local_private_key_file_path' not in host_info \ else host_info['local_private_key_file_path'] - instance = AnsibleInstance(host=host_info['host'], host_vars=extra_vars, - connection_method=host_info['connection_method'], - connection_port=host_info['connection_port'], connection_user=host_info['user'], - connection_user_password=host_info['password'], - ssh_private_key_file_path=private_key_path, - ansible_python_interpreter=host_info['ansible_python_interpreter']) + + if host_info['system'] == 'windows': + instance = WindowsAnsibleInstance( + host=host_info['host'], host_vars=extra_vars, + ansible_user=host_info['ansible_user'], + ansible_password=host_info['ansible_password'], + ansible_connection=host_info['ansible_connection'], + ansible_port=host_info['ansible_port'] + ) + else: + instance = UnixAnsibleInstance( + host=host_info['host'], host_vars=extra_vars, + connection_method=host_info['connection_method'], + connection_port=host_info['connection_port'], + connection_user=host_info['user'], + connection_user_password=host_info['password'], + ssh_private_key_file_path=private_key_path, + ansible_python_interpreter=host_info['ansible_python_interpreter'] + ) + return instance def __process_inventory_data(self): diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py index 7fcf2db1b5..34c60cab84 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py @@ -2,7 +2,8 @@ import sys from tempfile import gettempdir -from wazuh_testing.qa_ctl.provisioning.ansible.ansible_instance import AnsibleInstance +from wazuh_testing.qa_ctl.provisioning.ansible.unix_ansible_instance import UnixAnsibleInstance +from wazuh_testing.qa_ctl.provisioning.ansible.windows_ansible_instance import WindowsAnsibleInstance from wazuh_testing.qa_ctl.provisioning.ansible.ansible_inventory import AnsibleInventory from wazuh_testing.qa_ctl.run_tests.test_launcher import TestLauncher from wazuh_testing.qa_ctl.run_tests.pytest import Pytest @@ -49,12 +50,27 @@ def __read_ansible_instance(self, host_info): extra_vars = None if 'host_vars' not in host_info else host_info['host_vars'] private_key_path = None if 'local_private_key_file_path' not in host_info \ else host_info['local_private_key_file_path'] - instance = AnsibleInstance(host=host_info['host'], host_vars=extra_vars, - connection_method=host_info['connection_method'], - connection_port=host_info['connection_port'], connection_user=host_info['user'], - connection_user_password=host_info['password'], - ssh_private_key_file_path=private_key_path, - ansible_python_interpreter=host_info['ansible_python_interpreter']) + + + if host_info['system'] == 'windows': + instance = WindowsAnsibleInstance( + host=host_info['host'], host_vars=extra_vars, + ansible_user=host_info['ansible_user'], + ansible_password=host_info['ansible_password'], + ansible_connection=host_info['ansible_connection'], + ansible_port=host_info['ansible_port'] + ) + else: + instance = UnixAnsibleInstance( + host=host_info['host'], host_vars=extra_vars, + connection_method=host_info['connection_method'], + connection_port=host_info['connection_port'], + connection_user=host_info['user'], + connection_user_password=host_info['password'], + ssh_private_key_file_path=private_key_path, + ansible_python_interpreter=host_info['ansible_python_interpreter'] + ) + return instance def __process_inventory_data(self, instances_info): From 78bd6452f185b126368ea96f5ec037a313aedcac Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Mon, 25 Oct 2021 12:01:17 +0200 Subject: [PATCH 143/181] refac: Refactor the ansible inventory generation #2020 Done for adding Windows connection --- .../qa_ctl/configuration/config_generator.py | 20 +++---- .../provisioning/ansible/ansible_instance.py | 22 +++++++- .../provisioning/ansible/ansible_inventory.py | 25 ++------- .../ansible/unix_ansible_instance.py | 56 ++++++++----------- .../ansible/windows_ansible_instance.py | 34 +++++------ .../qa_ctl/provisioning/qa_provisioning.py | 35 +++++++----- 6 files changed, 92 insertions(+), 100 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index d945887701..d5ee49154d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -79,19 +79,19 @@ class QACTLConfigGenerator: BOX_INFO = { 'qactl/ubuntu_20_04': { - 'connection_method': 'ssh', - 'user': 'vagrant', - 'password': 'vagrant', - 'connection_port': 22, + 'ansible_connection': 'ssh', + 'ansible_user': 'vagrant', + 'ansible_password': 'vagrant', + 'ansible_port': 22, 'ansible_python_interpreter': '/usr/bin/python3', 'system': 'deb', 'installation_files_path': LINUX_TMP }, 'qactl/centos_8': { - 'connection_method': 'ssh', - 'user': 'vagrant', - 'password': 'vagrant', - 'connection_port': 22, + 'ansible_connection': 'ssh', + 'ansible_user': 'vagrant', + 'ansible_password': 'vagrant', + 'ansible_port': 22, 'ansible_python_interpreter': '/usr/bin/python3', 'system': 'rpm', 'installation_files_path': LINUX_TMP @@ -103,6 +103,7 @@ class QACTLConfigGenerator: 'ansible_port': 5985, 'ansible_winrm_server_cert_validation': 'ignore', 'system': 'windows', + 'ansible_python_interpreter': 'C:\\Users\\vagrant\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', 'installation_files_path': WINDOWS_TMP } } @@ -327,7 +328,7 @@ def __add_deployment_config_block(self, test_name, os_version, components, os_pl """ # Process deployment data host_number = len(self.config['deployment'].keys()) + 1 - vm_name = f"{test_name}_{get_current_timestamp()}" + vm_name = f"{test_name}_{get_current_timestamp()}".replace('.', '_') self.config['deployment'][f"host_{host_number}"] = { 'provider': { 'vagrant': self.__add_instance(os_version, vm_name, components, os_platform) @@ -336,7 +337,6 @@ def __add_deployment_config_block(self, test_name, os_version, components, os_pl # Add manager if the target is an agent if components == 'agent': host_number += 1 - #manager_vm_name = f"manager_{test_name}_{get_current_timestamp()}" self.config['deployment'][f"host_{host_number}"] = { 'provider': { 'vagrant': self.__add_instance('CentOS 8', vm_name, 'manager', 'linux') diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py index ba2e403fc6..05eb04ac11 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py @@ -7,12 +7,28 @@ class AnsibleInstance(ABC): Args: host (str): Ip or hostname. - ansible_user (str): Host connection user + ansible_connection (str): Connection method. + ansible_port (int): Remote connection port. + ansible_user (str): Host connection user. + ansible_password (str): Host connection user password. + ansible_python_interpreter (str): Python interpreter path in the remote host. + host_vars (dict): Set of custom variables to add to that host. Attributes: host (str): Ip or hostname. - ansible_user (str): Host connection user + ansible_connection (str): Connection method. + ansible_port (int): Remote connection port. + ansible_user (str): Host connection user. + ansible_password (str): Host connection user password. + ansible_python_interpreter (str): Python interpreter path in the remote host. + host_vars (dict): Set of custom variables to add to that host. """ - def __init__(self, host, host_vars=None): + def __init__(self, host, ansible_connection, ansible_port, ansible_user, ansible_password, + ansible_python_interpreter=None, host_vars=None): self.host = host + self.ansible_connection = ansible_connection + self.ansible_port = ansible_port + self.ansible_user = ansible_user + self.ansible_password = ansible_password + self.ansible_python_interpreter = ansible_python_interpreter self.host_vars = host_vars diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py index 085c8d686f..18e076f0ae 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py @@ -38,24 +38,8 @@ def __setup_data__(self): hosts = {} for instance in self.ansible_instances: - host_info = { - 'ansible_host': instance.host, - 'ansible_user': instance.connection_user, - 'ansible_password': instance.connection_user_password, - 'ansible_connection': instance.connection_method, - 'ansible_port': instance.connection_port, - 'ansible_ssh_private_key_file': instance.ssh_private_key_file_path, - - 'vars': instance.host_vars, - 'ansible_ssh_common_args': "-o UserKnownHostsFile=/dev/null" - - } - - if instance.connection_method == 'winrm': - host_info.update({ - 'ansible_winrm_transport': 'basic', - 'ansible_winrm_server_cert_validation': 'ignore' - }) + # Create dict from instance attributes + host_info = vars(instance) # Remove ansible vars with None value host_info = {key: value for key, value in host_info.items() if value is not None} @@ -88,6 +72,5 @@ def write_inventory_to_file(self): def delete_playbook_file(self): """Delete all created playbook files""" - # if os.path.exists(self.inventory_file_path): - # os.remove(self.inventory_file_path) - pass + if os.path.exists(self.inventory_file_path): + os.remove(self.inventory_file_path) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py index 9dd32155cd..9270f12a11 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py @@ -8,44 +8,34 @@ class UnixAnsibleInstance(AnsibleInstance): Args: host (str): Ip or hostname. - connection_user (str): Host connection user - connection_user_password (str): Host connection user password - ssh_private_key_file_path (str): Path where is located the private key to authenticate the user. + ansible_connection (str): Connection method. + ansible_port (int): Remote connection port. + ansible_user (str): Host connection user + ansible_password (str): Host connection user password host_vars (dict): Set of custom variables to add to that host. - connection_method (str): Connection method: smart, ssh or paramiko. - connection_port (int): Remote ssh connection port. ansible_python_interpreter (str): Python interpreter path in the remote host. + ansible_ssh_private_key_file (str): Path where is located the private key to authenticate the user. Attributes: - connection_user (str): Host connection user - connection_user_password (str): Host connection user password - ssh_private_key_file_path (str): Path where is located the private key to authenticate the user. - connection_method (str): Connection method: smart, ssh or paramiko. - connection_port (int): Remote ssh connection port. - ansible_python_interpreter (str): Python interpreter path in the remote host. + ansible_ssh_private_key_file (str): Path where is located the private key to authenticate the user. """ - def __init__(self, host, connection_user, connection_user_password=None, ssh_private_key_file_path=None, - host_vars=None, connection_method='ssh', connection_port=22, - ansible_python_interpreter='/usr/bin/python'): - self.connection_method = connection_method - self.connection_port = connection_port - self.connection_user = connection_user - self.connection_user_password = connection_user_password - self.ssh_private_key_file_path = ssh_private_key_file_path - self.ansible_python_interpreter = ansible_python_interpreter - - super().__init__(host=host, host_vars=host_vars) + def __init__(self, host, ansible_connection='ssh', ansible_port=22, ansible_user='vagrant', + ansible_password='vagrant', host_vars=None, ansible_python_interpreter='/usr/bin/python', + ansible_ssh_private_key_file=None): + self.ansible_ssh_private_key_file = ansible_ssh_private_key_file + + super().__init__(host, ansible_connection, ansible_port, ansible_user, ansible_password, + ansible_python_interpreter, host_vars) def __str__(self): """Define how the class object is to be displayed.""" data = { 'host_information': { - 'host': self.host, 'connection_method': self.connection_method, - 'connection_port': self.connection_port, 'connection_user': self.connection_user, - 'password': self.connection_user_password, - 'connection_user_password': self.connection_user_password, - 'ssh_private_key_file_path': self.ssh_private_key_file_path, - 'ansible_python_interpreter': self.ansible_python_interpreter + 'host': self.host, 'ansible_connection': self.ansible_connection, + 'ansible_port': self.ansible_port, 'ansible_user': self.ansible_user, + 'ansible_password': self.ansible_password, 'host_vars': self.host_vars, + 'ansible_python_interpreter': self.ansible_python_interpreter, + 'ansible_ssh_private_key_file': self.ansible_ssh_private_key_file } } @@ -54,9 +44,9 @@ def __str__(self): def __repr__(self): """Representation of the object of the class in string format""" return json.dumps({ - 'host': self.host, 'host_vars': self.host_vars, 'connection_method': self.connection_method, - 'connection_port': self.connection_port, 'connection_user': self.connection_user, - 'connection_user_password': self.connection_user_password, - 'ssh_private_key_file_path': self.ssh_private_key_file_path, - 'ansible_python_interpreter': self.ansible_python_interpreter + 'host': self.host, 'ansible_connection': self.ansible_connection, + 'ansible_port': self.ansible_port, 'ansible_user': self.ansible_user, + 'ansible_password': self.ansible_password, 'host_vars': self.host_vars, + 'ansible_python_interpreter': self.ansible_python_interpreter, + 'ansible_ssh_private_key_file': self.ansible_ssh_private_key_file }) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py index 237ad25c4f..711a05cfdf 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py @@ -8,33 +8,26 @@ class WindowsAnsibleInstance(AnsibleInstance): Args: host (str): Ip or hostname. - ansible_user (str): Host connection user - ansible_connection (str): Host connection user password - host_vars (dict): Set of custom variables to add to that host. ansible_connection (str): Connection method. ansible_port (int): Remote connection port. - - Attributes: - ansible_connection (str): Host connection user password + ansible_user (str): Host connection user + ansible_password (str): Host connection user password + ansible_python_interpreter (str): Python interpreter path in the remote host. host_vars (dict): Set of custom variables to add to that host. - ansible_connection (str): Connection method. - ansible_port (int): Remote connection port. """ - def __init__(self, host, ansible_user='vagrant', ansible_password='vagrant', host_vars=None, - ansible_connection='winrm', ansible_port=5985): - self.ansible_connection = ansible_connection - self.ansible_port = ansible_port - self.ansible_user = ansible_user - self.ansible_password = ansible_password - - super().__init__(host=host, host_vars=host_vars) + def __init__(self, host, ansible_connection='winrm', ansible_port=5985, ansible_user='vagrant', + ansible_password='vagrant', host_vars=None, ansible_python_interpreter=None): + super().__init__(host, ansible_connection, ansible_port, ansible_user, ansible_password, + ansible_python_interpreter, host_vars) def __str__(self): """Define how the class object is to be displayed.""" data = { 'host_information': { - 'host': self.host, 'ansible_connection': self.ansible_connection, 'ansible_port': self.ansible_port, - 'ansible_user': self.ansible_user, 'ansible_password': self.ansible_password + 'host': self.host, 'ansible_connection': self.ansible_connection, + 'ansible_port': self.ansible_port, 'ansible_user': self.ansible_user, + 'ansible_password': self.ansible_password, + 'ansible_python_interpreter': self.ansible_python_interpreter, 'host_vars': self.host_vars } } @@ -43,7 +36,8 @@ def __str__(self): def __repr__(self): """Representation of the object of the class in string format""" return json.dumps({ - 'host': self.host, 'host_vars': self.host_vars, 'ansible_connection': self.ansible_connection, + 'host': self.host, 'ansible_connection': self.ansible_connection, 'ansible_port': self.ansible_port, 'ansible_user': self.ansible_user, - 'ansible_password': self.ansible_password + 'ansible_password': self.ansible_password, 'ansible_python_interpreter': self.ansible_python_interpreter, + 'host_vars': self.host_vars }) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index 4d3ac8df91..e32ae9cf0e 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -68,20 +68,23 @@ def __read_ansible_instance(self, host_info): if host_info['system'] == 'windows': instance = WindowsAnsibleInstance( - host=host_info['host'], host_vars=extra_vars, + host=host_info['host'], + ansible_connection=host_info['ansible_connection'], + ansible_port=host_info['ansible_port'], ansible_user=host_info['ansible_user'], ansible_password=host_info['ansible_password'], - ansible_connection=host_info['ansible_connection'], - ansible_port=host_info['ansible_port'] + ansible_python_interpreter=host_info['ansible_python_interpreter'], + host_vars=extra_vars ) else: instance = UnixAnsibleInstance( - host=host_info['host'], host_vars=extra_vars, - connection_method=host_info['connection_method'], - connection_port=host_info['connection_port'], - connection_user=host_info['user'], - connection_user_password=host_info['password'], - ssh_private_key_file_path=private_key_path, + host=host_info['host'], + ansible_connection=host_info['ansible_connection'], + ansible_port=host_info['ansible_port'], + ansible_user=host_info['ansible_user'], + ansible_password=host_info['ansible_password'], + host_vars=extra_vars, + ansible_ssh_private_key_file=private_key_path, ansible_python_interpreter=host_info['ansible_python_interpreter'] ) @@ -214,11 +217,17 @@ def __check_hosts_connection(self, hosts='all'): hosts (str): Hosts to check. """ QAProvisioning.LOGGER.info('Checking hosts SSH connection') - wait_for_connection = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable', - 'wait_for_connection': {'delay': 5, 'timeout': 60}}) + wait_for_connection_unix = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable', + 'wait_for_connection': {'delay': 5, 'timeout': 60}, + 'when': 'ansible_system != "Windows"'}) - playbook_parameters = {'hosts': hosts, 'tasks_list': [wait_for_connection]} + wait_for_connection_windows = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable', + 'win_wait_for': {'delay': 5, 'timeout': 60}, + 'when': 'ansible_system == "Windows"'}) + playbook_parameters = {'hosts': hosts, 'gather_facts': True, 'tasks_list': [wait_for_connection_unix, + wait_for_connection_windows]} + print(file.read_file(self.inventory_file_path)) AnsibleRunner.run_ephemeral_tasks(self.inventory_file_path, playbook_parameters, output=self.qa_ctl_configuration.ansible_output) QAProvisioning.LOGGER.info('Hosts connection OK. The instances are accessible via ssh') @@ -251,7 +260,7 @@ def run(self): for runner_thread in provision_threads: runner_thread.join() - QAProvisioning.LOGGER.info(f"The instances have been provisioned sucessfully") + QAProvisioning.LOGGER.info('The instances have been provisioned sucessfully') def destroy(self): """Destroy all the temporary files created by an instance of this object""" From de1bb2f6585fc65394e713672e98a6735f656856 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Tue, 26 Oct 2021 12:55:02 +0200 Subject: [PATCH 144/181] add: Add Windows provisioning support in `qa-ctl` #2020 --- .../qa_ctl/configuration/config_generator.py | 7 +- .../provisioning/qa_framework/qa_framework.py | 87 ++++++++++++++----- .../qa_ctl/provisioning/qa_provisioning.py | 54 ++++++------ .../wazuh_deployment/agent_deployment.py | 27 +++--- .../wazuh_deployment/manager_deployment.py | 2 +- .../wazuh_deployment/wazuh_deployment.py | 45 ++++++---- .../wazuh_deployment/wazuh_installation.py | 16 +++- .../wazuh_deployment/wazuh_s3_package.py | 30 +++++-- requirements.txt | 1 + 9 files changed, 178 insertions(+), 91 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index d5ee49154d..2940ec1fb7 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -406,12 +406,16 @@ def __process_provision_data(self): else 'agent' s3_package_url = self.__get_package_url(instance) installation_files_path = QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'] + system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] + wazuh_install_path = 'C:\\Program Files (x86)\\ossec-agent' if system == 'windows' else '/var/ossec' + self.config['provision']['hosts'][instance]['wazuh_deployment'] = { 'type': 'package', 'target': target, 's3_package_url': s3_package_url, 'installation_files_path': installation_files_path, - 'health_check': True + 'health_check': True, + 'wazuh_install_path': wazuh_install_path } if target == 'agent': # Add manager IP to the agent. The manager's host will always be the one after the agent's host. @@ -420,7 +424,6 @@ def __process_provision_data(self): self.config['deployment'][f"host_{manager_host_number}"]['provider']['vagrant']['vm_ip'] # QA framework - system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] self.config['provision']['hosts'][instance]['qa_framework'] = { 'wazuh_qa_branch': self.qa_branch, 'qa_workdir': file.join_path([installation_files_path, 'wazuh_qa_ctl'], system) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py index f8b0c13e4b..4ce83df2b0 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py @@ -39,12 +39,24 @@ def install_dependencies(self, inventory_file_path, hosts='all'): Args: inventory_file_path (str): Path were save the ansible inventory. """ - dependencies_task = AnsibleTask({'name': 'Install python dependencies', - 'shell': 'python3 -m pip install -r requirements.txt', - 'args': {'chdir': join_path([self.workdir, 'wazuh-qa'], self.system_path)}, - 'become': True}) - ansible_tasks = [dependencies_task] - playbook_parameters = {'hosts': hosts, 'tasks_list': ansible_tasks} + dependencies_unix_task = AnsibleTask({'name': 'Install python dependencies (Unix)', + 'shell': 'python3 -m pip install -r requirements.txt', + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa'], + self.system_path)}, + 'become': True, + 'when': 'ansible_system != "Win32NT"'}) + + dependencies_windows_task = AnsibleTask({'name': 'Install python dependencies (Windows)', + 'win_shell': 'python -m pip install -r requirements.txt', + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa'], + self.system_path)}, + 'become': True, + 'become_method': 'runas', + 'become_user': 'vagrant', + 'when': 'ansible_system == "Win32NT"'}) + + ansible_tasks = [dependencies_unix_task, dependencies_windows_task] + playbook_parameters = {'hosts': hosts, 'gather_facts': True, 'tasks_list': ansible_tasks} QAFramework.LOGGER.debug(f"Installing python dependencies in {hosts} hosts") AnsibleRunner.run_ephemeral_tasks(inventory_file_path, playbook_parameters, output=self.ansible_output) @@ -57,12 +69,24 @@ def install_framework(self, inventory_file_path, hosts='all'): Args: inventory_file_path (str): Path were save the ansible inventory. """ - install_framework_task = AnsibleTask({'name': 'Install wazuh-qa framework', - 'shell': 'python3 setup.py install', - 'args': {'chdir': join_path([self.workdir, 'wazuh-qa', 'deps', - 'wazuh_testing'], self.system_path)}}) - ansible_tasks = [install_framework_task] - playbook_parameters = {'hosts': hosts, 'tasks_list': ansible_tasks, 'become': True} + install_framework_unix_task = AnsibleTask({'name': 'Install wazuh-qa framework (Unix)', + 'shell': 'python3 setup.py install', + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa', 'deps', + 'wazuh_testing'], self.system_path)}, + 'become': True, + 'when': 'ansible_system != "Win32NT"'}) + + install_framework_windows_task = AnsibleTask({'name': 'Install wazuh-qa framework (Windows)', + 'win_shell': 'python setup.py install', + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa', 'deps', + 'wazuh_testing'], self.system_path)}, + 'become': True, + 'become_method': 'runas', + 'become_user': 'vagrant', + 'when': 'ansible_system == "Win32NT"'}) + + ansible_tasks = [install_framework_unix_task, install_framework_windows_task] + playbook_parameters = {'hosts': hosts, 'tasks_list': ansible_tasks, 'gather_facts': True, 'become': False} QAFramework.LOGGER.debug(f"Installing wazuh-qa framework in {hosts} hosts.") AnsibleRunner.run_ephemeral_tasks(inventory_file_path, playbook_parameters, output=self.ansible_output) @@ -75,16 +99,35 @@ def download_qa_repository(self, inventory_file_path, hosts='all'): Args: inventory_file_path (str): Path were save the ansible inventory. """ - create_path_task = AnsibleTask({'name': f"Create {self.workdir} path", - 'file': {'path': self.workdir, 'state': 'directory', 'mode': '0755'}}) - - download_qa_repo_task = AnsibleTask({'name': f"Download {self.qa_branch} branch of wazuh-qa repository", - 'shell': f"cd {self.workdir} && " + - 'curl -Ls https://github.com/wazuh/wazuh-qa/archive/' + - f"{self.qa_branch}.tar.gz | tar zx && mv wazuh-* wazuh-qa", - 'when': 'ansible_system != "Windows"'}) - - ansible_tasks = [create_path_task, download_qa_repo_task] + create_path_unix_task = AnsibleTask({'name': f"Create {self.workdir} path (Unix)", + 'file': {'path': self.workdir, 'state': 'directory', 'mode': '0755'}, + 'when': 'ansible_system != "Win32NT"'}) + + create_path_windows_task = AnsibleTask({'name': f"Create {self.workdir} path (Windows)", + 'win_file': {'path': self.workdir, 'state': 'directory'}, + 'when': 'ansible_system == "Win32NT"'}) + + download_qa_repo_unix_task = AnsibleTask({'name': f"Download {self.qa_branch} branch of wazuh-qa repository " \ + '(Unix)', + 'shell': f"cd {self.workdir} && " \ + 'curl -Ls https://github.com/wazuh/wazuh-qa/archive/' \ + f"{self.qa_branch}.tar.gz | tar zx && mv wazuh-* wazuh-qa", + 'when': 'ansible_system != "Win32NT"'}) + + download_qa_repo_windows_task = AnsibleTask({ + 'name': f"Download {self.qa_branch} branch of wazuh-qa repository (Windows)", + 'win_shell': "powershell.exe {{ item }}", + 'with_items': [ + f"curl.exe -L https://github.com/wazuh/wazuh-qa/archive/{self.qa_branch}.tar.gz -o " \ + f"{self.workdir}\\{self.qa_branch}.tar.gz", + f"tar -xzf {self.workdir}\\{self.qa_branch}.tar.gz -C {self.workdir}", + f"move {self.workdir}\\wazuh-qa-{self.qa_branch} {self.workdir}\\wazuh-qa", + f"rm {self.workdir}\\{self.qa_branch}.tar.gz" + ], + 'when': 'ansible_system == "Win32NT"'}) + + ansible_tasks = [create_path_unix_task, create_path_windows_task, download_qa_repo_unix_task, + download_qa_repo_windows_task] playbook_parameters = {'hosts': hosts, 'gather_facts': True, 'tasks_list': ansible_tasks} QAFramework.LOGGER.debug(f"Downloading qa-repository in {hosts} hosts") diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index e32ae9cf0e..319c48bf8b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -126,34 +126,27 @@ def __process_config_data(self, host_provision_info): install_type = None if 'type' not in deploy_info else deploy_info['type'] installation_files_path = None if 'installation_files_path' not in deploy_info \ else deploy_info['installation_files_path'] - wazuh_install_path = None if 'wazuh_install_path' not in deploy_info \ - else deploy_info['wazuh_install_path'] + wazuh_install_path = '/var/ossec' if 'wazuh_install_path' not in deploy_info else \ + deploy_info['wazuh_install_path'] wazuh_branch = 'master' if 'wazuh_branch' not in deploy_info else deploy_info['wazuh_branch'] - s3_package_url = None if 's3_package_url' not in deploy_info \ - else deploy_info['s3_package_url'] - system = None if 'version' not in deploy_info \ - else deploy_info['system'] - version = None if 'version' not in deploy_info \ - else deploy_info['version'] - repository = None if 'repository' not in deploy_info \ - else deploy_info['repository'] - revision = None if 'revision' not in deploy_info \ - else deploy_info['revision'] - local_package_path = None if 'local_package_path' not in deploy_info \ - else deploy_info['local_package_path'] + s3_package_url = None if 's3_package_url' not in deploy_info else deploy_info['s3_package_url'] + system = None if 'version' not in deploy_info else deploy_info['system'] + version = None if 'version' not in deploy_info else deploy_info['version'] + repository = None if 'repository' not in deploy_info else deploy_info['repository'] + revision = None if 'revision' not in deploy_info else deploy_info['revision'] + local_package_path = None if 'local_package_path' not in deploy_info else deploy_info['local_package_path'] manager_ip = None if 'manager_ip' not in deploy_info else deploy_info['manager_ip'] installation_files_parameters = {'wazuh_target': install_target} if installation_files_path: installation_files_parameters['installation_files_path'] = installation_files_path - if wazuh_install_path: - installation_files_parameters['wazuh_install_path'] = wazuh_install_path installation_files_parameters['qa_ctl_configuration'] = self.qa_ctl_configuration if install_type == 'sources': installation_files_parameters['wazuh_branch'] = wazuh_branch + installation_files_parameters['wazuh_install_path'] = wazuh_install_path installation_instance = WazuhSources(**installation_files_parameters) if install_type == 'package': @@ -164,27 +157,31 @@ def __process_config_data(self, host_provision_info): installation_files_parameters['repository'] = repository installation_instance = WazuhS3Package(**installation_files_parameters) remote_files_path = installation_instance.download_installation_files(self.inventory_file_path, - hosts=current_host) + hosts=current_host) elif s3_package_url is None and local_package_path is not None: installation_files_parameters['local_package_path'] = local_package_path installation_instance = WazuhLocalPackage(**installation_files_parameters) remote_files_path = installation_instance.download_installation_files(self.inventory_file_path, - hosts=current_host) + hosts=current_host) else: installation_files_parameters['s3_package_url'] = s3_package_url installation_instance = WazuhS3Package(**installation_files_parameters) remote_files_path = installation_instance.download_installation_files(self.inventory_file_path, - hosts=current_host) + hosts=current_host) if install_target == 'agent': deployment_instance = AgentDeployment(remote_files_path, - inventory_file_path=self.inventory_file_path, - install_mode=install_type, hosts=current_host, - server_ip=manager_ip, - qa_ctl_configuration=self.qa_ctl_configuration) + inventory_file_path=self.inventory_file_path, + install_mode=install_type, + hosts=current_host, + server_ip=manager_ip, + install_dir_path=wazuh_install_path, + qa_ctl_configuration=self.qa_ctl_configuration) if install_target == 'manager': deployment_instance = ManagerDeployment(remote_files_path, inventory_file_path=self.inventory_file_path, - install_mode=install_type, hosts=current_host, + install_mode=install_type, + hosts=current_host, + install_dir_path=wazuh_install_path, qa_ctl_configuration=self.qa_ctl_configuration) deployment_instance.install() @@ -217,17 +214,16 @@ def __check_hosts_connection(self, hosts='all'): hosts (str): Hosts to check. """ QAProvisioning.LOGGER.info('Checking hosts SSH connection') - wait_for_connection_unix = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable', + wait_for_connection_unix = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable (Unix)', 'wait_for_connection': {'delay': 5, 'timeout': 60}, - 'when': 'ansible_system != "Windows"'}) + 'when': 'ansible_system != "Win32NT"'}) - wait_for_connection_windows = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable', + wait_for_connection_windows = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable (Windows)', 'win_wait_for': {'delay': 5, 'timeout': 60}, - 'when': 'ansible_system == "Windows"'}) + 'when': 'ansible_system == "Win32NT"'}) playbook_parameters = {'hosts': hosts, 'gather_facts': True, 'tasks_list': [wait_for_connection_unix, wait_for_connection_windows]} - print(file.read_file(self.inventory_file_path)) AnsibleRunner.run_ephemeral_tasks(self.inventory_file_path, playbook_parameters, output=self.qa_ctl_configuration.ansible_output) QAProvisioning.LOGGER.info('Hosts connection OK. The instances are accessible via ssh') diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py index c6c68bd1bb..054cc3ad16 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py @@ -69,21 +69,25 @@ def register_agent(self): """ tasks_list = [] - tasks_list.append(AnsibleTask({'name': 'Configuring server ip to autoenrollment agent', + tasks_list.append(AnsibleTask({'name': 'Configuring server ip to autoenrollment Unix agent', 'lineinfile': {'path': f'{self.install_dir_path}/etc/ossec.conf', 'regexp': '

(.*)
', 'line': f'
{self.server_ip}
', 'backrefs': 'yes'}, - 'when': 'ansible_system != "Windows"'})) + 'become': True, + 'when': 'ansible_system != "Win32NT"'})) - tasks_list.append(AnsibleTask({'name': 'Configuring server ip to autoenrollment agent', - 'lineinfile': {'path': f'{self.install_dir_path}\\ossec.conf', - 'regexp': '
(.*)
', - 'line': f'
{self.server_ip}
', - 'backrefs': 'yes'}, - 'when': 'ansible_system == "Windows"'}))\ + tasks_list.append(AnsibleTask({'name': 'Configuring server ip to autoenrollment Windows agent', + 'win_lineinfile': {'path': f'{self.install_dir_path}\\ossec.conf', + 'regexp': '
(.*)
', + 'line': f'
{self.server_ip}
', + 'backrefs': 'yes'}, + 'become': True, + 'become_method': 'runas', + 'become_user': 'vagrant', + 'when': 'ansible_system == "Win32NT"'}))\ - playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': True} + playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} self.stop_service() @@ -105,12 +109,13 @@ def health_check(self): tasks_list = [] tasks_list.append(AnsibleTask({'name': 'Extract service status', 'command': f"{self.install_dir_path}/bin/wazuh-control status", - 'when': 'ansible_system != "Windows"', + 'when': 'ansible_system != "Win32NT"', 'register': 'status', + 'become': True, 'failed_when': ['"wazuh-agentd" not in status.stdout', '"wazuh-execd" not in status.stdout']})) - playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': True} + playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} return AnsibleRunner.run_ephemeral_tasks(self.inventory_file_path, playbook_parameters, output=self.qa_ctl_configuration.ansible_output) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py index 1a527471e7..811bc37f80 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py @@ -72,7 +72,7 @@ def health_check(self): tasks_list = [] tasks_list.append(AnsibleTask({'name': 'Extract service status', 'command': f'{self.install_dir_path}/bin/wazuh-control status', - 'when': 'ansible_system != "Windows"', + 'when': 'ansible_system != "Win32NT"', 'register': 'status', 'failed_when': ['"wazuh-analysisd is running" not in status.stdout or' + '"wazuh-db is running" not in status.stdout or' + diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py index 66db4f85fe..944f492b15 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py @@ -71,22 +71,26 @@ def install(self, install_type): 'server_ip': f'{self.server_ip}', 'ca_store': f'{self.installation_files_path}/wpk_root.pem', 'make_cert': 'y' if install_type == 'server' else 'n'}, + 'become': True, 'when': 'ansible_system == "Linux"'})) tasks_list.append(AnsibleTask({ 'name': 'Executing "install.sh" script to build and install Wazuh', 'shell': f"./install.sh > {gettempdir()}/wazuh_qa_ctl/wazuh_install_log.txt", 'args': {'chdir': f'{self.installation_files_path}'}, + 'become': True, 'when': 'ansible_system == "Linux"'})) elif self.install_mode == 'package': tasks_list.append(AnsibleTask({'name': 'Install Wazuh Agent from .deb packages', 'apt': {'deb': f'{self.installation_files_path}'}, + 'become': True, 'when': 'ansible_os_family|lower == "debian"'})) tasks_list.append(AnsibleTask({'name': 'Install Wazuh Agent from .rpm packages | yum', 'yum': {'name': f'{self.installation_files_path}', 'disable_gpg_check': 'yes'}, + 'become': True, 'when': ['ansible_os_family|lower == "redhat"', 'not (ansible_distribution|lower == "centos" and ' + 'ansible_distribution_major_version >= "8")', @@ -96,6 +100,7 @@ def install(self, install_type): tasks_list.append(AnsibleTask({'name': 'Install Wazuh Agent from .rpm packages | dnf', 'dnf': {'name': f'{self.installation_files_path}', 'disable_gpg_check': 'yes'}, + 'become': True, 'when': ['ansible_os_family|lower == "redhat"', '(ansible_distribution|lower == "centos" and ' + 'ansible_distribution_major_version >= "8") or' + @@ -104,14 +109,18 @@ def install(self, install_type): tasks_list.append(AnsibleTask({'name': 'Install Wazuh Agent from Windows packages', 'win_package': {'path': f'{self.installation_files_path}'}, - 'when': 'ansible_system == "Windows"'})) + 'become': True, + 'become_method': 'runas', + 'become_user': 'vagrant', + 'when': 'ansible_system == "Win32NT"'})) tasks_list.append(AnsibleTask({'name': 'Install macOS wazuh package', 'shell': 'installer -pkg wazuh-* -target /', 'args': {'chdir': f'{self.installation_files_path}'}, + 'become': True, 'when': 'ansible_system == "Darwin"'})) - playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': True} + playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} tasks_result = AnsibleRunner.run_ephemeral_tasks(self.inventory_file_path, playbook_parameters, output=self.qa_ctl_configuration.ansible_output) @@ -141,16 +150,18 @@ def __control_service(self, command, install_type): tasks_list.append(AnsibleTask({'name': f'Wazuh agent {command} service from wazuh-control', 'become': True, 'command': f'{self.install_dir_path}/bin/wazuh-control {command}', - 'when': 'ansible_system == "Darwin" or ansible_system == "SunOS" or ' + - 'output_command.failed == true'})) + 'when': 'ansible_system == "Darwin" or ansible_system == "SunOS"'})) tasks_list.append(AnsibleTask({'name': f'Wazuh agent {command} service from Windows', 'win_shell': 'Get-Service -Name WazuhSvc -ErrorAction SilentlyContinue |' + f' {command.capitalize()}-Service -ErrorAction SilentlyContinue', 'args': {'executable': 'powershell.exe'}, - 'when': 'ansible_system == "Windows"'})) + 'become': True, + 'become_method': 'runas', + 'become_user': 'vagrant', + 'when': 'ansible_system == "Win32NT"'})) - playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': True} + playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} tasks_result = AnsibleRunner.run_ephemeral_tasks(self.inventory_file_path, playbook_parameters, output=self.qa_ctl_configuration.ansible_output) @@ -194,23 +205,27 @@ def health_check(self): """ WazuhDeployment.LOGGER.debug(f"Starting wazuh deployment healthcheck in {self.hosts} hosts") tasks_list = [] - tasks_list.append(AnsibleTask({'name': 'Read ossec.log searching errors', + tasks_list.append(AnsibleTask({'name': 'Read ossec.log searching errors (Unix)', 'lineinfile': {'path': f'{self.install_dir_path}/logs/ossec.log', 'line': 'ERROR|CRITICAL'}, - 'when': 'ansible_system != "Windows"', 'register': 'exists', 'check_mode': 'yes', - 'failed_when': 'exists is not changed'})) + 'become': True, + 'failed_when': 'exists is not changed', + 'when': 'ansible_system != "Win32NT"'})) - tasks_list.append(AnsibleTask({'name': 'Read ossec.log searching errors', - 'lineinfile': {'path': f'{self.install_dir_path}/ossec.log', - 'line': 'ERROR'}, - 'when': 'ansible_system == "Windows"', + tasks_list.append(AnsibleTask({'name': 'Read ossec.log searching errors (Windows)', + 'win_lineinfile': {'path': f'{self.install_dir_path}\\ossec.log', + 'line': 'ERROR|CRITICAL'}, 'register': 'exists', 'check_mode': 'yes', - 'failed_when': 'exists is not changed'})) + 'become': True, + 'become_method': 'runas', + 'become_user': 'vagrant', + 'failed_when': 'exists is not changed', + 'when': 'ansible_system == "Win32NT"'})) - playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': True} + playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} tasks_result = AnsibleRunner.run_ephemeral_tasks(self.inventory_file_path, playbook_parameters, output=self.qa_ctl_configuration.ansible_output) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_installation.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_installation.py index 3cd080bba8..8490addea9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_installation.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_installation.py @@ -32,11 +32,19 @@ def download_installation_files(self, inventory_file_path, ansible_tasks, hosts= ansible_tasks (ansible object): ansible instance with already provided tasks to run hosts (string): Parameter set to `all` by default """ - create_path_task = AnsibleTask({'name': f"Create {self.installation_files_path} path", - 'file': {'path': self.installation_files_path, 'state': 'directory'}}) + create_path_task_unix = AnsibleTask({'name': f"Create {self.installation_files_path} path (Unix)", + 'file': {'path': self.installation_files_path, 'state': 'directory'}, + 'when': 'ansible_system != "Win32NT"'}) + + create_path_task_windows = AnsibleTask({'name': f"Create {self.installation_files_path} path (Windows)", + 'win_file': {'path': self.installation_files_path, + 'state': 'directory'}, + 'when': 'ansible_system == "Win32NT"'}) + # Add path creation task at the beggining of the playbook - ansible_tasks.insert(0, create_path_task) - playbook_parameters = {'hosts': hosts, 'tasks_list': ansible_tasks} + ansible_tasks.insert(0, create_path_task_unix) + ansible_tasks.insert(1, create_path_task_windows) + playbook_parameters = {'hosts': hosts, 'gather_facts': True, 'tasks_list': ansible_tasks} AnsibleRunner.run_ephemeral_tasks(inventory_file_path, playbook_parameters, output=self.qa_ctl_configuration.ansible_output) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py index f01e1dce01..12c37f0815 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py @@ -7,6 +7,7 @@ from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.exceptions import QAValueError from wazuh_testing.tools.s3_package import get_s3_package_url +from wazuh_testing.tools.file import join_path class WazuhS3Package(WazuhPackage): @@ -105,13 +106,28 @@ def download_installation_files(self, inventory_file_path, hosts='all'): package_name = Path(self.s3_package_url).name WazuhS3Package.LOGGER.debug(f"Downloading Wazuh S3 package from {self.s3_package_url} in {hosts} hosts") - download_s3_package = AnsibleTask({'name': 'Download S3 package', - 'get_url': {'url': self.s3_package_url, - 'dest': self.installation_files_path}, - 'register': 'download_state', 'retries': 6, 'delay': 10, - 'until': 'download_state is success'}) + download_unix_s3_package = AnsibleTask({'name': 'Download S3 package (Unix)', + 'get_url': {'url': self.s3_package_url, + 'dest': self.installation_files_path}, + 'register': 'download_state', 'retries': 6, 'delay': 10, + 'until': 'download_state is success', + 'when': 'ansible_system != "Win32NT"'}) + + download_windows_s3_package = AnsibleTask({'name': 'Download S3 package (Windows)', + 'win_get_url': {'url': self.s3_package_url, + 'dest': self.installation_files_path}, + 'register': 'download_state', 'retries': 6, 'delay': 10, + 'until': 'download_state is success', + 'when': 'ansible_system == "Win32NT"'}) + WazuhS3Package.LOGGER.debug(f"Wazuh S3 package was successfully downloaded in {hosts} hosts") - super().download_installation_files(inventory_file_path, [download_s3_package], hosts) + super().download_installation_files(inventory_file_path, [download_unix_s3_package, + download_windows_s3_package], hosts) + + package_system = 'windows' if '.msi' in package_name else 'generic' + path_list = self.installation_files_path.split('\\') if package_system == 'windows' else \ + self.installation_files_path.split('/') + path_list.append(package_name) - return os.path.join(self.installation_files_path, package_name) + return join_path(path_list, package_system) diff --git a/requirements.txt b/requirements.txt index cef338d739..9e47bad108 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,3 +40,4 @@ elasticsearch>=7.14.1 ; platform_system == "Linux" or platform_system=='Windows' safety==1.10.3 bandit==1.7.0 git-repo==1.10.3 +pywinrm>=0.4.2 ; platform_system == "Linux" or platform_system=='Windows' From 659bf3f8c63a667db990a05a241196af06304c99 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Tue, 26 Oct 2021 15:58:02 +0200 Subject: [PATCH 145/181] add: Parameterize ansible_admin_user in the `qa-ctl` tool #2020 --- .../qa_ctl/configuration/config_generator.py | 2 ++ .../qa_ctl/provisioning/qa_framework/qa_framework.py | 9 ++++++--- .../qa_ctl/provisioning/qa_provisioning.py | 9 +++++++-- .../wazuh_deployment/agent_deployment.py | 4 +++- .../wazuh_deployment/manager_deployment.py | 2 ++ .../wazuh_deployment/wazuh_deployment.py | 12 ++++++++---- 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 2940ec1fb7..20390c5d90 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -103,6 +103,7 @@ class QACTLConfigGenerator: 'ansible_port': 5985, 'ansible_winrm_server_cert_validation': 'ignore', 'system': 'windows', + 'ansible_admin_user': 'vagrant', 'ansible_python_interpreter': 'C:\\Users\\vagrant\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', 'installation_files_path': WINDOWS_TMP } @@ -417,6 +418,7 @@ def __process_provision_data(self): 'health_check': True, 'wazuh_install_path': wazuh_install_path } + if target == 'agent': # Add manager IP to the agent. The manager's host will always be the one after the agent's host. manager_host_number = int(instance.replace('host_', '')) + 1 diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py index 4ce83df2b0..222008a0da 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py @@ -16,22 +16,25 @@ class QAFramework(): qa_repository (str): Url to the QA repository. qa_branch (str): QA branch of the qa repository. ansible_output (boolean): True if show ansible tasks output False otherwise. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) Attributes: workdir (str): Directory where the qa repository files are stored qa_repository (str): Url to the QA repository. qa_branch (str): QA branch of the qa repository. ansible_output (boolean): True if show ansible tasks output False otherwise. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) """ LOGGER = Logging.get_logger(QACTL_LOGGER) def __init__(self, ansible_output=False, workdir=join(gettempdir(), 'wazuh_qa_ctl'), qa_branch='master', - qa_repository='https://github.com/wazuh/wazuh-qa.git'): + qa_repository='https://github.com/wazuh/wazuh-qa.git', ansible_admin_user='vagrant'): self.qa_repository = qa_repository self.qa_branch = qa_branch self.workdir = workdir self.ansible_output = ansible_output self.system_path = 'windows' if '\\' in self.workdir else 'unix' + self.ansible_admin_user = ansible_admin_user def install_dependencies(self, inventory_file_path, hosts='all'): """Install all the necessary dependencies to allow the execution of the tests. @@ -52,7 +55,7 @@ def install_dependencies(self, inventory_file_path, hosts='all'): self.system_path)}, 'become': True, 'become_method': 'runas', - 'become_user': 'vagrant', + 'become_user': self.ansible_admin_user, 'when': 'ansible_system == "Win32NT"'}) ansible_tasks = [dependencies_unix_task, dependencies_windows_task] @@ -82,7 +85,7 @@ def install_framework(self, inventory_file_path, hosts='all'): 'wazuh_testing'], self.system_path)}, 'become': True, 'become_method': 'runas', - 'become_user': 'vagrant', + 'become_user': self.ansible_admin_user, 'when': 'ansible_system == "Win32NT"'}) ansible_tasks = [install_framework_unix_task, install_framework_windows_task] diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index 319c48bf8b..f84761a320 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -136,6 +136,8 @@ def __process_config_data(self, host_provision_info): revision = None if 'revision' not in deploy_info else deploy_info['revision'] local_package_path = None if 'local_package_path' not in deploy_info else deploy_info['local_package_path'] manager_ip = None if 'manager_ip' not in deploy_info else deploy_info['manager_ip'] + ansible_admin_user = 'vagrant' if 'ansible_admin_user' not in host_provision_info['host_info'] else \ + host_provision_info['host_info']['ansible_admin_user'] installation_files_parameters = {'wazuh_target': install_target} @@ -168,6 +170,7 @@ def __process_config_data(self, host_provision_info): installation_instance = WazuhS3Package(**installation_files_parameters) remote_files_path = installation_instance.download_installation_files(self.inventory_file_path, hosts=current_host) + if install_target == 'agent': deployment_instance = AgentDeployment(remote_files_path, inventory_file_path=self.inventory_file_path, @@ -175,14 +178,16 @@ def __process_config_data(self, host_provision_info): hosts=current_host, server_ip=manager_ip, install_dir_path=wazuh_install_path, - qa_ctl_configuration=self.qa_ctl_configuration) + qa_ctl_configuration=self.qa_ctl_configuration, + ansible_admin_user=ansible_admin_user) if install_target == 'manager': deployment_instance = ManagerDeployment(remote_files_path, inventory_file_path=self.inventory_file_path, install_mode=install_type, hosts=current_host, install_dir_path=wazuh_install_path, - qa_ctl_configuration=self.qa_ctl_configuration) + qa_ctl_configuration=self.qa_ctl_configuration, + ansible_admin_user=ansible_admin_user) deployment_instance.install() if health_check: diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py index 054cc3ad16..29b31008f1 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py @@ -16,6 +16,7 @@ class AgentDeployment(WazuhDeployment): hosts (string): Group of hosts to be deployed. server_ip (string): Manager IP to connect. qa_ctl_configuration (QACTLConfiguration): QACTL configuration. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) Attributes: installation_files (string): Path where is located the Wazuh instalation files. @@ -26,6 +27,7 @@ class AgentDeployment(WazuhDeployment): hosts (string): Group of hosts to be deployed. server_ip (string): Manager IP to connect. qa_ctl_configuration (QACTLConfiguration): QACTL configuration. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) """ def install(self): @@ -84,7 +86,7 @@ def register_agent(self): 'backrefs': 'yes'}, 'become': True, 'become_method': 'runas', - 'become_user': 'vagrant', + 'become_user': self.ansible_admin_user, 'when': 'ansible_system == "Win32NT"'}))\ playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py index 811bc37f80..339fd05257 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py @@ -16,6 +16,7 @@ class ManagerDeployment(WazuhDeployment): hosts (string): Group of hosts to be deployed. server_ip (string): Manager IP to connect. qa_ctl_configuration (QACTLConfiguration): QACTL configuration. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) Attributes: installation_files (string): Path where is located the Wazuh instalation files. @@ -26,6 +27,7 @@ class ManagerDeployment(WazuhDeployment): hosts (string): Group of hosts to be deployed. server_ip (string): Manager IP to connect. qa_ctl_configuration (QACTLConfiguration): QACTL configuration. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) """ def install(self): diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py index 944f492b15..2a62efec00 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py @@ -23,6 +23,7 @@ class WazuhDeployment(ABC): hosts (string): Group of hosts to be deployed. server_ip (string): Manager IP to let agent get autoenrollment. qa_ctl_configuration (QACTLConfiguration): QACTL configuration. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) Attributes: installation_files_path (string): Path where is located the Wazuh instalation files. @@ -33,11 +34,13 @@ class WazuhDeployment(ABC): hosts (string): Group of hosts to be deployed. server_ip (string): Manager IP to let agent get autoenrollment. qa_ctl_configuration (QACTLConfiguration): QACTL configuration. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) """ LOGGER = Logging.get_logger(QACTL_LOGGER) def __init__(self, installation_files_path, inventory_file_path, qa_ctl_configuration, configuration=None, - install_mode='package', install_dir_path='/var/ossec', hosts='all', server_ip=None): + install_mode='package', install_dir_path='/var/ossec', hosts='all', server_ip=None, + ansible_admin_user='vagrant'): self.installation_files_path = installation_files_path self.configuration = configuration @@ -47,6 +50,7 @@ def __init__(self, installation_files_path, inventory_file_path, qa_ctl_configur self.hosts = hosts self.server_ip = server_ip self.qa_ctl_configuration = qa_ctl_configuration + self.ansible_admin_user = ansible_admin_user @abstractmethod def install(self, install_type): @@ -111,7 +115,7 @@ def install(self, install_type): 'win_package': {'path': f'{self.installation_files_path}'}, 'become': True, 'become_method': 'runas', - 'become_user': 'vagrant', + 'become_user': self.ansible_admin_user, 'when': 'ansible_system == "Win32NT"'})) tasks_list.append(AnsibleTask({'name': 'Install macOS wazuh package', @@ -158,7 +162,7 @@ def __control_service(self, command, install_type): 'args': {'executable': 'powershell.exe'}, 'become': True, 'become_method': 'runas', - 'become_user': 'vagrant', + 'become_user': self.ansible_admin_user, 'when': 'ansible_system == "Win32NT"'})) playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} @@ -221,7 +225,7 @@ def health_check(self): 'check_mode': 'yes', 'become': True, 'become_method': 'runas', - 'become_user': 'vagrant', + 'become_user': self.ansible_admin_user, 'failed_when': 'exists is not changed', 'when': 'ansible_system == "Win32NT"'})) From e67bd6231450a57c349e9f0bf97fa086ce5a9917 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Wed, 27 Oct 2021 15:31:10 +0200 Subject: [PATCH 146/181] add: Add Windows testing support for the qa-ctl tool #2020 --- .../qa_ctl/configuration/config_generator.py | 8 +- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 130 +++++++++++------- .../qa_ctl/run_tests/qa_test_runner.py | 33 +++-- .../qa_ctl/run_tests/test_launcher.py | 65 ++++++--- .../wazuh_testing/wazuh_testing/tools/file.py | 16 ++- 5 files changed, 173 insertions(+), 79 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 20390c5d90..69d2f1d786 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -40,6 +40,8 @@ class QACTLConfigGenerator: LOGGER = Logging.get_logger(QACTL_LOGGER) LINUX_TMP = '/tmp' WINDOWS_TMP = 'C:\\Users\\vagrant\\AppData\\Local\\Temp' + WINDOWS_DEFAULT_WAZUH_INSTALL_PATH = 'C:\\Program Files (x86)\\ossec-agent' + LINUX_DEFAULT_WAZUH_INSTALL_PATH = '/var/ossec' BOX_MAPPING = { 'Ubuntu Focal': 'qactl/ubuntu_20_04', @@ -408,7 +410,8 @@ def __process_provision_data(self): s3_package_url = self.__get_package_url(instance) installation_files_path = QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'] system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] - wazuh_install_path = 'C:\\Program Files (x86)\\ossec-agent' if system == 'windows' else '/var/ossec' + wazuh_install_path = self.WINDOWS_DEFAULT_WAZUH_INSTALL_PATH if system == 'windows' else \ + self.LINUX_DEFAULT_WAZUH_INSTALL_PATH self.config['provision']['hosts'][instance]['wazuh_deployment'] = { 'type': 'package', @@ -447,6 +450,8 @@ def __add_testing_config_block(self, instance, installation_files_path, system, self.config['tests'][instance] = {'host_info': {}, 'test': {}} self.config['tests'][instance]['host_info'] = \ dict(self.config['provision']['hosts'][instance]['host_info']) + wazuh_install_path = self.WINDOWS_DEFAULT_WAZUH_INSTALL_PATH if system == 'windows' else \ + self.LINUX_DEFAULT_WAZUH_INSTALL_PATH self.config['tests'][instance]['test'] = { 'type': 'pytest', @@ -457,6 +462,7 @@ def __add_testing_config_block(self, instance, installation_files_path, system, 'tests', 'integration'], system), 'test_results_path': join(gettempdir(), 'wazuh_qa_ctl', f"{test_name}_{get_current_timestamp()}") }, + 'wazuh_install_path': wazuh_install_path, 'system': system, 'component': component, 'modules': modules diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index a0a5828ed9..5dd82df24d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -9,6 +9,7 @@ from wazuh_testing.tools.time import get_current_timestamp from wazuh_testing.qa_ctl import QACTL_LOGGER from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools.file import join_path class Pytest(Test): @@ -35,6 +36,7 @@ class Pytest(Test): modules (list(str)): List of wazuh modules to which the test belongs. component (str): Test target (manager, agent). system (str): System where the test will be launched. + wazuh_install_path (str): Wazuh installation directory p.e /var/ossec. Attributes: tests_result_path(str): Path to the directory where the reports will be stored in the local machine @@ -53,14 +55,16 @@ class Pytest(Test): log_level(str, None): Log level to be set markers(list(str), None): Set of markers to be added to the test execution command hosts(list(), ['all']): List of hosts aliases where the tests will be runned + wazuh_install_path (str): Wazuh installation directory p.e /var/ossec. """ - RUN_PYTEST = 'python3 -m pytest ' + RUN_PYTEST_UNIX = 'python3 -m pytest ' + RUN_PYTEST_WINDOWS = 'python -m pytest ' LOGGER = Logging.get_logger(QACTL_LOGGER) def __init__(self, tests_result_path, tests_path, tests_run_dir, qa_ctl_configuration, tiers=[], stop_after_first_failure=False, keyword_expression=None, traceback='auto', dry_run=False, custom_args=[], verbose_level=False, log_level=None, markers=[], hosts=['all'], modules=None, - component=None, system='linux'): + component=None, system='linux', wazuh_install_path=None, ansible_admin_user=None): self.qa_ctl_configuration = qa_ctl_configuration self.tiers = tiers self.stop_after_first_failure = stop_after_first_failure @@ -72,6 +76,8 @@ def __init__(self, tests_result_path, tests_path, tests_run_dir, qa_ctl_configur self.log_level = log_level self.markers = markers self.hosts = hosts + self.wazuh_install_path = wazuh_install_path + self.ansible_admin_user = ansible_admin_user self.tests_result_path = os.path.join(gettempdir(), 'wazuh_qa_ctl') if tests_result_path is None \ else tests_result_path @@ -94,15 +100,13 @@ def run(self, ansible_inventory_path): html_report_file_name = f"test_report_{date_time}.html" plain_report_file_name = f"test_report_{date_time}.txt" playbook_file_path = os.path.join(gettempdir(), 'wazuh_qa_ctl', f"{get_current_timestamp()}.yaml") - reports_directory = os.path.join(self.tests_run_dir, reports_folder) + reports_directory = join_path([self.tests_run_dir, reports_folder], self.system) plain_report_file_path = os.path.join(reports_directory, plain_report_file_name) html_report_file_path = os.path.join(reports_directory, html_report_file_name) - assets_dest_directory = os.path.join(reports_directory, assets_folder) assets_src_directory = os.path.join(reports_directory, assets_folder) zip_src_path = os.path.join(reports_directory, assets_zip) - zip_dest_path = os.path.join(self.tests_result_path, assets_zip) - pytest_command = self.RUN_PYTEST + pytest_command = self.RUN_PYTEST_WINDOWS if self.system == 'windows' else self.RUN_PYTEST_UNIX if self.keyword_expression: pytest_command += os.path.join(self.tests_path, self.keyword_expression) + " " @@ -126,64 +130,96 @@ def run(self, ansible_inventory_path): if self.markers: pytest_command += f"-m {' '.join(self.markers)} " - pytest_command += f"--html='{reports_directory}/{html_report_file_name}'" - - create_path_task = {'name': f"Create {reports_directory} path", - 'file': {'path': reports_directory, 'state': 'directory', 'mode': '0755'}} - - execute_test_task = {'name': f"Launch pytest in {self.tests_run_dir}", - 'shell': pytest_command, 'vars': - {'chdir': self.tests_run_dir}, - 'register': 'test_output', - 'ignore_errors': 'yes'} - - create_plain_report = {'name': f"Create plain report file in {plain_report_file_path}", - 'copy': {'dest': plain_report_file_path, - 'content': "{{test_output.stdout}}"}} - - fetch_plain_report = {'name': f"Move {plain_report_file_name} from " - f"{plain_report_file_path} to {self.tests_result_path}", + pytest_command += f"--html='{os.path.join(reports_directory, html_report_file_name)}'" + + create_path_task_unix = {'name': f"Create {reports_directory} path (Unix)", + 'file': {'path': reports_directory, 'state': 'directory', 'mode': '0755'}, + 'become':True, + 'when': 'ansible_system != "Win32NT"'} + + + create_path_task_unix_windows = {'name': f"Create {reports_directory} path (Windows)", + 'win_file': {'path': reports_directory, 'state': 'directory'}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"'} + + execute_test_task_unix = {'name': f"Launch pytest in {self.tests_run_dir} (Unix)", + 'shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, + 'register': 'test_output', + 'ignore_errors': 'yes', + 'become':True, + 'when': 'ansible_system != "Win32NT"'} + + execute_test_task_windows = {'name': f"Launch pytest in {self.tests_run_dir} (Windows)", + 'win_shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, + 'register': 'test_output', + 'ignore_errors': 'yes', + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"'} + + create_plain_report_unix = {'name': f"Create plain report file in {plain_report_file_path} (Unix)", + 'copy': {'dest': plain_report_file_path, 'content': "{{test_output.stdout}}"}, + 'become': True, + 'when': 'ansible_system != "Win32NT"'} + + create_plain_report_windows = {'name': f"Create plain report file in {plain_report_file_path} (Windows)", + 'win_copy': {'dest': plain_report_file_path, + 'content': "{{test_output.stdout}}"}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"'} + + # Fetch works in Unix and windows + fetch_plain_report = {'name': f"Move {plain_report_file_name} from " \ + f"{plain_report_file_path} to {self.tests_result_path}", 'fetch': {'src': plain_report_file_path, 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}} + # Fetch works in Unix and windows fetch_html_report = {'name': f"Move {html_report_file_name} from {html_report_file_path}" f" to {self.tests_result_path}", 'fetch': {'src': html_report_file_path, 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}, 'ignore_errors': 'yes'} - create_assets_directory = {'name': f"Create {assets_dest_directory} directory", - 'local_action': {'module': 'ansible.builtin.file', - 'path': assets_dest_directory, - 'state': 'directory'}, - 'become': False} - - compress_assets_folder = {'name': "Compress assets folder", - 'community.general.archive': {'path': assets_src_directory, - 'dest': zip_src_path, - 'format': 'zip'}, - 'ignore_errors': 'yes'} + compress_assets_folder_unix = {'name': "Compress assets folder (Unix)", + 'community.general.archive': {'path': assets_src_directory, 'dest': zip_src_path, + 'format': 'zip'}, + 'ignore_errors': 'yes', + 'become': True, + 'when': 'ansible_system != "Win32NT"'} + + compress_assets_folder_windows = {'name': "Compress assets folder (Windows)", + 'win_shell': f"powershell.exe Compress-Archive {assets_src_directory} " \ + f"{zip_src_path}", + 'ignore_errors': 'yes', + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"'} fetch_compressed_assets = {'name': f"Copy compressed assets from {zip_src_path} to {self.tests_result_path}", 'fetch': {'src': zip_src_path, 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}, 'ignore_errors': 'yes'} - uncompress_assets = {'name': f"Uncompress {assets_zip} in {assets_dest_directory}", - 'local_action': {'module': 'unarchive', - 'src': zip_dest_path, - 'dest': assets_dest_directory}, - 'become': False, - 'ignore_errors': 'yes'} - ansible_tasks = [AnsibleTask(create_path_task), AnsibleTask(execute_test_task), - AnsibleTask(create_plain_report), AnsibleTask(fetch_plain_report), - AnsibleTask(fetch_html_report), AnsibleTask(create_assets_directory), - AnsibleTask(compress_assets_folder), AnsibleTask(fetch_compressed_assets), - AnsibleTask(uncompress_assets)] + ansible_tasks = [AnsibleTask(create_path_task_unix), AnsibleTask(create_path_task_unix_windows), + AnsibleTask(execute_test_task_unix), AnsibleTask(execute_test_task_windows), + AnsibleTask(create_plain_report_unix), AnsibleTask(create_plain_report_windows), + AnsibleTask(fetch_plain_report), AnsibleTask(fetch_html_report), + AnsibleTask(compress_assets_folder_unix), + AnsibleTask(compress_assets_folder_windows), + AnsibleTask(fetch_compressed_assets)] + + playbook_parameters = {'become': False, 'tasks_list': ansible_tasks, 'playbook_file_path': + playbook_file_path, "hosts": self.hosts, 'gather_facts': True} - playbook_parameters = {'become': True, 'tasks_list': ansible_tasks, 'playbook_file_path': - playbook_file_path, "hosts": self.hosts} Pytest.LOGGER.info(f"Running {self.tests_path} test on {self.hosts} hosts") Pytest.LOGGER.debug(f"Running {pytest_command} on {self.hosts} hosts") diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py index 34c60cab84..0aa8378e83 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py @@ -51,24 +51,26 @@ def __read_ansible_instance(self, host_info): private_key_path = None if 'local_private_key_file_path' not in host_info \ else host_info['local_private_key_file_path'] - if host_info['system'] == 'windows': instance = WindowsAnsibleInstance( - host=host_info['host'], host_vars=extra_vars, + host=host_info['host'], + ansible_connection=host_info['ansible_connection'], + ansible_port=host_info['ansible_port'], ansible_user=host_info['ansible_user'], ansible_password=host_info['ansible_password'], - ansible_connection=host_info['ansible_connection'], - ansible_port=host_info['ansible_port'] + ansible_python_interpreter=host_info['ansible_python_interpreter'], + host_vars=extra_vars ) else: instance = UnixAnsibleInstance( - host=host_info['host'], host_vars=extra_vars, - connection_method=host_info['connection_method'], - connection_port=host_info['connection_port'], - connection_user=host_info['user'], - connection_user_password=host_info['password'], - ssh_private_key_file_path=private_key_path, - ansible_python_interpreter=host_info['ansible_python_interpreter'] + host=host_info['host'], + ansible_connection=host_info['ansible_connection'], + ansible_port=host_info['ansible_port'], + ansible_user=host_info['ansible_user'], + ansible_password=host_info['ansible_password'], + ansible_ssh_private_key_file=private_key_path, + ansible_python_interpreter=host_info['ansible_python_interpreter'], + host_vars=extra_vars ) return instance @@ -105,12 +107,14 @@ def __process_test_data(self, instances_info): test_launcher = TestLauncher([], self.inventory_file_path, self.qa_ctl_configuration) for module_key, module_value in host_value.items(): hosts = host_value['host_info']['host'] + ansible_admin_user = host_value['host_info']['ansible_admin_user'] if 'ansible_admin_user' \ + in host_value['host_info'] else None if module_key == 'test': - test_launcher.add(self.__build_test(module_value, hosts)) + test_launcher.add(self.__build_test(module_value, hosts, ansible_admin_user)) self.test_launchers.append(test_launcher) QATestRunner.LOGGER.debug('Testing data from hosts info was processed successfully') - def __build_test(self, test_params, host=['all']): + def __build_test(self, test_params, host=['all'], ansible_admin_user=None): """Private method in charge of reading all the required fields to build one test of type Pytest Args: @@ -134,6 +138,9 @@ def __build_test(self, test_params, host=['all']): test_dict['component'] = test_params['component'] if 'component' in test_params else None test_dict['modules'] = test_params['modules'] if 'modules' in test_params else None test_dict['system'] = test_params['system'] if 'system' in test_params else None + test_dict['wazuh_install_path'] = test_params['wazuh_install_path'] if 'wazuh_install_path' in test_params \ + else None + test_dict['ansible_admin_user'] = ansible_admin_user if 'parameters' in test_params: parameters = test_params['parameters'] diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py index 52063dd05b..e3154180d2 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py @@ -23,7 +23,6 @@ class TestLauncher: ansible_inventory_path (str): path to the ansible inventory file qa_ctl_configuration (QACTLConfiguration): QACTL configuration. qa_framework_path (str, None): remote directory path where the qa repository will be download to - """ LOGGER = Logging.get_logger(QACTL_LOGGER) ALL_DEBUG_OPTIONS = ["syscheck.debug=2", "agent.debug=2", "monitord.rotate_log=0", "analysisd.debug=2", @@ -88,7 +87,7 @@ def __init__(self, tests, ansible_inventory_path, qa_ctl_configuration, qa_frame self.qa_ctl_configuration = qa_ctl_configuration self.tests = tests - def __set_local_internal_options(self, hosts, modules, component, system): + def __set_local_internal_options(self, hosts, modules, component, system, wazuh_install_path, ansible_admin_user): """Private method that set the local internal options in the hosts passed by parameter Args: @@ -97,13 +96,15 @@ def __set_local_internal_options(self, hosts, modules, component, system): modules (list(str)): List of wazuh modules to which the test belongs. component (str): Test wazuh target (manager, agent). system (str): System where the test will be launched. + wazuh_install_path (str): Wazuh installation directory p.e /var/ossec. + ansible_admin_user (str): User to launch the ansible task with admin privileges (ansible_become_user) """ local_internal_options_content = [] + system = 'windows' if system == 'windows' else 'generic' - if isinstance(modules, list) and len(modules) > 0 and component and system: + if isinstance(modules, list) and len(modules) > 0 and component: for module in modules: if component == 'agent': - system = 'windows' if system == 'windows' else 'generic' local_internal_options_content.extend(self.DEBUG_OPTIONS[module][component][system]) else: local_internal_options_content.extend(self.DEBUG_OPTIONS[module][component]) @@ -114,22 +115,51 @@ def __set_local_internal_options(self, hosts, modules, component, system): local_internal_options_content = self.ALL_DEBUG_OPTIONS playbook_file_path = os.path.join(gettempdir(), 'wazuh_qa_ctl' f"{get_current_timestamp()}.yaml") - local_internal_options_path = '/var/ossec/etc/local_internal_options.conf' - - clean_local_internal_configuration = {'copy': {'dest': local_internal_options_path, 'content': ''}} - - set_local_internal_configuration = {'lineinfile': {'path': local_internal_options_path, - 'line': "{{ item }}"}, - 'with_items': local_internal_options_content} - ansible_tasks = [AnsibleTask(clean_local_internal_configuration), AnsibleTask(set_local_internal_configuration)] - - playbook_parameters = {'become': True, 'tasks_list': ansible_tasks, 'playbook_file_path': - playbook_file_path, 'hosts': hosts} + if system == 'windows': + local_internal_options_path = f"{wazuh_install_path}\\local_internal_options.conf" + else: + local_internal_options_path = f"{wazuh_install_path}/etc/local_internal_options.conf" + + clean_local_internal_configuration_unix = {'name': 'Clean local internal configuration (Unix)', + 'copy': {'dest': local_internal_options_path, 'content': ''}, + 'become': True, + 'when': 'ansible_system != "Win32NT"'} + + clean_local_internal_configuration_windows = {'name': 'Clean local internal configuration (Windows)', + 'win_copy': {'dest': local_internal_options_path, 'content': ''}, + 'become': True, + 'become_method': 'runas', + 'become_user': ansible_admin_user, + 'when': 'ansible_system == "Win32NT"'} + + set_local_internal_configuration_unix = {'name': 'Set custom local internal configuration (Unix)', + 'lineinfile': {'path': local_internal_options_path, + 'line': "{{ item }}"}, + 'with_items': local_internal_options_content, + 'become': True, + 'when': 'ansible_system != "Win32NT"'} + + set_local_internal_configuration_windows = {'name': 'Set custom local internal configuration (Windows)', + 'win_lineinfile': {'path': local_internal_options_path, + 'line': "{{ item }}"}, + 'with_items': local_internal_options_content, + 'become': True, + 'become_method': 'runas', + 'become_user': ansible_admin_user, + 'when': 'ansible_system == "Win32NT"'} + + ansible_tasks = [AnsibleTask(clean_local_internal_configuration_unix), + AnsibleTask(clean_local_internal_configuration_windows), + AnsibleTask(set_local_internal_configuration_unix), + AnsibleTask(set_local_internal_configuration_windows) + ] + + playbook_parameters = {'become': False, 'gather_facts': True, 'tasks_list': ansible_tasks, + 'playbook_file_path': playbook_file_path, 'hosts': hosts} TestLauncher.LOGGER.debug(f"Setting local_internal_options configuration in {hosts} hosts with " f"{local_internal_options_content}") - AnsibleRunner.run_ephemeral_tasks(self.ansible_inventory_path, playbook_parameters, raise_on_error=False, output=self.qa_ctl_configuration.ansible_output) @@ -145,5 +175,6 @@ def add(self, test): def run(self): """Function to iterate over a list of tests and run them one by one.""" for test in self.tests: - self.__set_local_internal_options(test.hosts, test.modules, test.component, test.system) + self.__set_local_internal_options(test.hosts, test.modules, test.component, test.system, + test.wazuh_install_path, test.ansible_admin_user) test.run(self.ansible_inventory_path) diff --git a/deps/wazuh_testing/wazuh_testing/tools/file.py b/deps/wazuh_testing/wazuh_testing/tools/file.py index b15ff7f6b9..c452822669 100644 --- a/deps/wazuh_testing/wazuh_testing/tools/file.py +++ b/deps/wazuh_testing/wazuh_testing/tools/file.py @@ -350,6 +350,10 @@ def move_everything_from_one_directory_to_another(source_directory, destination_ def join_path(path, system): """Create the path using the separator indicated for the operating system. Used for remote hosts configuration. + Path can be defined by the following formats + path = ['tmp', 'user', 'test'] + path = ['/tmp/user', test] + Parameters: path (list(str)): Path list (one item for level). system (str): host system. @@ -357,7 +361,17 @@ def join_path(path, system): Returns: str: Joined path. """ - return '\\'.join(path) if system == 'windows' else '/'.join(path) + result_path = [] + + for item in path: + if system == 'windows' and '\\' in item: + result_path.extend([path_item for path_item in item.split('\\')]) + elif '/' in item: + result_path.extend([path_item for path_item in item.split('/')]) + else: + result_path.append(item) + + return '\\'.join(result_path) if system == 'windows' else '/'.join(result_path) def count_file_lines(filepath): From d2c7c196025f10460b072a92f7b67a828b306241 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Wed, 27 Oct 2021 16:02:49 +0200 Subject: [PATCH 147/181] refac: Update playbooks indentation code #2020 --- .../provisioning/qa_framework/qa_framework.py | 103 +++++----- .../qa_ctl/provisioning/qa_provisioning.py | 18 +- .../wazuh_deployment/agent_deployment.py | 54 ++--- .../wazuh_deployment/manager_deployment.py | 16 +- .../wazuh_deployment/wazuh_deployment.py | 189 ++++++++++-------- .../wazuh_deployment/wazuh_installation.py | 19 +- .../wazuh_deployment/wazuh_local_package.py | 9 +- .../wazuh_deployment/wazuh_s3_package.py | 28 +-- .../wazuh_deployment/wazuh_sources.py | 9 +- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 187 +++++++++-------- .../qa_ctl/run_tests/test_launcher.py | 75 ++++--- 11 files changed, 389 insertions(+), 318 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py index 222008a0da..61b19ef2b0 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py @@ -42,21 +42,25 @@ def install_dependencies(self, inventory_file_path, hosts='all'): Args: inventory_file_path (str): Path were save the ansible inventory. """ - dependencies_unix_task = AnsibleTask({'name': 'Install python dependencies (Unix)', - 'shell': 'python3 -m pip install -r requirements.txt', - 'args': {'chdir': join_path([self.workdir, 'wazuh-qa'], - self.system_path)}, - 'become': True, - 'when': 'ansible_system != "Win32NT"'}) - - dependencies_windows_task = AnsibleTask({'name': 'Install python dependencies (Windows)', - 'win_shell': 'python -m pip install -r requirements.txt', - 'args': {'chdir': join_path([self.workdir, 'wazuh-qa'], - self.system_path)}, - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'}) + dependencies_unix_task = AnsibleTask({ + 'name': 'Install python dependencies (Unix)', + 'shell': 'python3 -m pip install -r requirements.txt', + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa'], + self.system_path)}, + 'become': True, + 'when': 'ansible_system != "Win32NT"' + }) + + dependencies_windows_task = AnsibleTask({ + 'name': 'Install python dependencies (Windows)', + 'win_shell': 'python -m pip install -r requirements.txt', + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa'], + self.system_path)}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + }) ansible_tasks = [dependencies_unix_task, dependencies_windows_task] playbook_parameters = {'hosts': hosts, 'gather_facts': True, 'tasks_list': ansible_tasks} @@ -72,21 +76,25 @@ def install_framework(self, inventory_file_path, hosts='all'): Args: inventory_file_path (str): Path were save the ansible inventory. """ - install_framework_unix_task = AnsibleTask({'name': 'Install wazuh-qa framework (Unix)', - 'shell': 'python3 setup.py install', - 'args': {'chdir': join_path([self.workdir, 'wazuh-qa', 'deps', - 'wazuh_testing'], self.system_path)}, - 'become': True, - 'when': 'ansible_system != "Win32NT"'}) - - install_framework_windows_task = AnsibleTask({'name': 'Install wazuh-qa framework (Windows)', - 'win_shell': 'python setup.py install', - 'args': {'chdir': join_path([self.workdir, 'wazuh-qa', 'deps', - 'wazuh_testing'], self.system_path)}, - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'}) + install_framework_unix_task = AnsibleTask({ + 'name': 'Install wazuh-qa framework (Unix)', + 'shell': 'python3 setup.py install', + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa', 'deps', + 'wazuh_testing'], self.system_path)}, + 'become': True, + 'when': 'ansible_system != "Win32NT"' + }) + + install_framework_windows_task = AnsibleTask({ + 'name': 'Install wazuh-qa framework (Windows)', + 'win_shell': 'python setup.py install', + 'args': {'chdir': join_path([self.workdir, 'wazuh-qa', 'deps', + 'wazuh_testing'], self.system_path)}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + }) ansible_tasks = [install_framework_unix_task, install_framework_windows_task] playbook_parameters = {'hosts': hosts, 'tasks_list': ansible_tasks, 'gather_facts': True, 'become': False} @@ -102,20 +110,24 @@ def download_qa_repository(self, inventory_file_path, hosts='all'): Args: inventory_file_path (str): Path were save the ansible inventory. """ - create_path_unix_task = AnsibleTask({'name': f"Create {self.workdir} path (Unix)", - 'file': {'path': self.workdir, 'state': 'directory', 'mode': '0755'}, - 'when': 'ansible_system != "Win32NT"'}) - - create_path_windows_task = AnsibleTask({'name': f"Create {self.workdir} path (Windows)", - 'win_file': {'path': self.workdir, 'state': 'directory'}, - 'when': 'ansible_system == "Win32NT"'}) - - download_qa_repo_unix_task = AnsibleTask({'name': f"Download {self.qa_branch} branch of wazuh-qa repository " \ - '(Unix)', - 'shell': f"cd {self.workdir} && " \ - 'curl -Ls https://github.com/wazuh/wazuh-qa/archive/' \ - f"{self.qa_branch}.tar.gz | tar zx && mv wazuh-* wazuh-qa", - 'when': 'ansible_system != "Win32NT"'}) + create_path_unix_task = AnsibleTask({ + 'name': f"Create {self.workdir} path (Unix)", + 'file': {'path': self.workdir, 'state': 'directory', 'mode': '0755'}, + 'when': 'ansible_system != "Win32NT"' + }) + + create_path_windows_task = AnsibleTask({ + 'name': f"Create {self.workdir} path (Windows)", + 'win_file': {'path': self.workdir, 'state': 'directory'}, + 'when': 'ansible_system == "Win32NT"' + }) + + download_qa_repo_unix_task = AnsibleTask({ + 'name': f"Download {self.qa_branch} branch of wazuh-qa repository (Unix)", + 'shell': f"cd {self.workdir} && curl -Ls https://github.com/wazuh/wazuh-qa/archive/" \ + f"{self.qa_branch}.tar.gz | tar zx && mv wazuh-* wazuh-qa", + 'when': 'ansible_system != "Win32NT"' + }) download_qa_repo_windows_task = AnsibleTask({ 'name': f"Download {self.qa_branch} branch of wazuh-qa repository (Windows)", @@ -127,7 +139,8 @@ def download_qa_repository(self, inventory_file_path, hosts='all'): f"move {self.workdir}\\wazuh-qa-{self.qa_branch} {self.workdir}\\wazuh-qa", f"rm {self.workdir}\\{self.qa_branch}.tar.gz" ], - 'when': 'ansible_system == "Win32NT"'}) + 'when': 'ansible_system == "Win32NT"' + }) ansible_tasks = [create_path_unix_task, create_path_windows_task, download_qa_repo_unix_task, download_qa_repo_windows_task] diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index f84761a320..95bf8d542f 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -219,13 +219,17 @@ def __check_hosts_connection(self, hosts='all'): hosts (str): Hosts to check. """ QAProvisioning.LOGGER.info('Checking hosts SSH connection') - wait_for_connection_unix = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable (Unix)', - 'wait_for_connection': {'delay': 5, 'timeout': 60}, - 'when': 'ansible_system != "Win32NT"'}) - - wait_for_connection_windows = AnsibleTask({'name': 'Waiting for SSH hosts connection are reachable (Windows)', - 'win_wait_for': {'delay': 5, 'timeout': 60}, - 'when': 'ansible_system == "Win32NT"'}) + wait_for_connection_unix = AnsibleTask({ + 'name': 'Waiting for SSH hosts connection are reachable (Unix)', + 'wait_for_connection': {'delay': 5, 'timeout': 60}, + 'when': 'ansible_system != "Win32NT"' + }) + + wait_for_connection_windows = AnsibleTask({ + 'name': 'Waiting for SSH hosts connection are reachable (Windows)', + 'win_wait_for': {'delay': 5, 'timeout': 60}, + 'when': 'ansible_system == "Win32NT"' + }) playbook_parameters = {'hosts': hosts, 'gather_facts': True, 'tasks_list': [wait_for_connection_unix, wait_for_connection_windows]} diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py index 29b31008f1..17b61ab9bc 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py @@ -71,23 +71,27 @@ def register_agent(self): """ tasks_list = [] - tasks_list.append(AnsibleTask({'name': 'Configuring server ip to autoenrollment Unix agent', - 'lineinfile': {'path': f'{self.install_dir_path}/etc/ossec.conf', - 'regexp': '
(.*)
', - 'line': f'
{self.server_ip}
', - 'backrefs': 'yes'}, - 'become': True, - 'when': 'ansible_system != "Win32NT"'})) - - tasks_list.append(AnsibleTask({'name': 'Configuring server ip to autoenrollment Windows agent', - 'win_lineinfile': {'path': f'{self.install_dir_path}\\ossec.conf', - 'regexp': '
(.*)
', - 'line': f'
{self.server_ip}
', - 'backrefs': 'yes'}, - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'}))\ + tasks_list.append(AnsibleTask({ + 'name': 'Configuring server ip to autoenrollment Unix agent', + 'lineinfile': {'path': f'{self.install_dir_path}/etc/ossec.conf', + 'regexp': '
(.*)
', + 'line': f'
{self.server_ip}
', + 'backrefs': 'yes'}, + 'become': True, + 'when': 'ansible_system != "Win32NT"' + })) + + tasks_list.append(AnsibleTask({ + 'name': 'Configuring server ip to autoenrollment Windows agent', + 'win_lineinfile': {'path': f'{self.install_dir_path}\\ossec.conf', + 'regexp': '
(.*)
', + 'line': f'
{self.server_ip}
', + 'backrefs': 'yes'}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + })) playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} @@ -109,13 +113,15 @@ def health_check(self): super().health_check() tasks_list = [] - tasks_list.append(AnsibleTask({'name': 'Extract service status', - 'command': f"{self.install_dir_path}/bin/wazuh-control status", - 'when': 'ansible_system != "Win32NT"', - 'register': 'status', - 'become': True, - 'failed_when': ['"wazuh-agentd" not in status.stdout', - '"wazuh-execd" not in status.stdout']})) + tasks_list.append(AnsibleTask({ + 'name': 'Extract service status', + 'command': f"{self.install_dir_path}/bin/wazuh-control status", + 'when': 'ansible_system != "Win32NT"', + 'register': 'status', + 'become': True, + 'failed_when': ['"wazuh-agentd" not in status.stdout', + '"wazuh-execd" not in status.stdout'] + })) playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py index 339fd05257..cb019790a3 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/manager_deployment.py @@ -72,13 +72,15 @@ def health_check(self): super().health_check() tasks_list = [] - tasks_list.append(AnsibleTask({'name': 'Extract service status', - 'command': f'{self.install_dir_path}/bin/wazuh-control status', - 'when': 'ansible_system != "Win32NT"', - 'register': 'status', - 'failed_when': ['"wazuh-analysisd is running" not in status.stdout or' + - '"wazuh-db is running" not in status.stdout or' + - '"wazuh-authd is running" not in status.stdout']})) + tasks_list.append(AnsibleTask({ + 'name': 'Extract service status', + 'command': f'{self.install_dir_path}/bin/wazuh-control status', + 'when': 'ansible_system != "Win32NT"', + 'register': 'status', + 'failed_when': ['"wazuh-analysisd is running" not in status.stdout or' + + '"wazuh-db is running" not in status.stdout or' + + '"wazuh-authd is running" not in status.stdout'] + })) playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': True} diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py index 2a62efec00..72dc55ba2d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py @@ -67,62 +67,70 @@ def install(self, install_type): 'name': 'Render the "preloaded-vars.conf" file', 'template': {'src': os.path.join(parent_path, 'templates', 'preloaded_vars.conf.j2'), 'dest': f'{self.installation_files_path}/etc/preloaded-vars.conf', - 'owner': 'root', - 'group': 'root', - 'mode': '0644'}, + 'owner': 'root', 'group': 'root', 'mode': '0644'}, 'vars': {'install_type': install_type, 'install_dir_path': f'{self.install_dir_path}', 'server_ip': f'{self.server_ip}', 'ca_store': f'{self.installation_files_path}/wpk_root.pem', 'make_cert': 'y' if install_type == 'server' else 'n'}, 'become': True, - 'when': 'ansible_system == "Linux"'})) + 'when': 'ansible_system == "Linux"' + })) tasks_list.append(AnsibleTask({ 'name': 'Executing "install.sh" script to build and install Wazuh', 'shell': f"./install.sh > {gettempdir()}/wazuh_qa_ctl/wazuh_install_log.txt", 'args': {'chdir': f'{self.installation_files_path}'}, 'become': True, - 'when': 'ansible_system == "Linux"'})) + 'when': 'ansible_system == "Linux"' + })) elif self.install_mode == 'package': - tasks_list.append(AnsibleTask({'name': 'Install Wazuh Agent from .deb packages', - 'apt': {'deb': f'{self.installation_files_path}'}, - 'become': True, - 'when': 'ansible_os_family|lower == "debian"'})) - - tasks_list.append(AnsibleTask({'name': 'Install Wazuh Agent from .rpm packages | yum', - 'yum': {'name': f'{self.installation_files_path}', - 'disable_gpg_check': 'yes'}, - 'become': True, - 'when': ['ansible_os_family|lower == "redhat"', - 'not (ansible_distribution|lower == "centos" and ' + - 'ansible_distribution_major_version >= "8")', - 'not (ansible_distribution|lower == "redhat" and ' + - 'ansible_distribution_major_version >= "8")']})) - - tasks_list.append(AnsibleTask({'name': 'Install Wazuh Agent from .rpm packages | dnf', - 'dnf': {'name': f'{self.installation_files_path}', - 'disable_gpg_check': 'yes'}, - 'become': True, - 'when': ['ansible_os_family|lower == "redhat"', - '(ansible_distribution|lower == "centos" and ' + - 'ansible_distribution_major_version >= "8") or' + - '(ansible_distribution|lower == "redhat" and ' + - 'ansible_distribution_major_version >= "8")']})) - - tasks_list.append(AnsibleTask({'name': 'Install Wazuh Agent from Windows packages', - 'win_package': {'path': f'{self.installation_files_path}'}, - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'})) - - tasks_list.append(AnsibleTask({'name': 'Install macOS wazuh package', - 'shell': 'installer -pkg wazuh-* -target /', - 'args': {'chdir': f'{self.installation_files_path}'}, - 'become': True, - 'when': 'ansible_system == "Darwin"'})) + tasks_list.append(AnsibleTask({ + 'name': 'Install Wazuh Agent from .deb packages', + 'apt': {'deb': f'{self.installation_files_path}'}, + 'become': True, + 'when': 'ansible_os_family|lower == "debian"' + })) + + tasks_list.append(AnsibleTask({ + 'name': 'Install Wazuh Agent from .rpm packages | yum', + 'yum': {'name': f'{self.installation_files_path}', 'disable_gpg_check': 'yes'}, + 'become': True, + 'when': ['ansible_os_family|lower == "redhat"', + 'not (ansible_distribution|lower == "centos" and ' + + 'ansible_distribution_major_version >= "8")', + 'not (ansible_distribution|lower == "redhat" and ' + + 'ansible_distribution_major_version >= "8")'] + })) + + tasks_list.append(AnsibleTask({ + 'name': 'Install Wazuh Agent from .rpm packages | dnf', + 'dnf': {'name': f'{self.installation_files_path}', 'disable_gpg_check': 'yes'}, + 'become': True, + 'when': ['ansible_os_family|lower == "redhat"', + '(ansible_distribution|lower == "centos" and ' + + 'ansible_distribution_major_version >= "8") or' + + '(ansible_distribution|lower == "redhat" and ' + + 'ansible_distribution_major_version >= "8")'] + })) + + tasks_list.append(AnsibleTask({ + 'name': 'Install Wazuh Agent from Windows packages', + 'win_package': {'path': f'{self.installation_files_path}'}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + })) + + tasks_list.append(AnsibleTask({ + 'name': 'Install macOS wazuh package', + 'shell': 'installer -pkg wazuh-* -target /', + 'args': {'chdir': f'{self.installation_files_path}'}, + 'become': True, + 'when': 'ansible_system == "Darwin"' + })) playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} @@ -143,27 +151,32 @@ def __control_service(self, command, install_type): service_name = install_type if install_type == 'agent' else 'manager' service_command = f'{command}ed' if command != 'stop' else 'stopped' - tasks_list.append(AnsibleTask({'name': f'Wazuh manager {command} service from systemd', - 'become': True, - 'systemd': {'name': f'wazuh-{service_name}', - 'state': f'{service_command}'}, - 'register': 'output_command', - 'ignore_errors': 'true', - 'when': 'ansible_system == "Linux"'})) - - tasks_list.append(AnsibleTask({'name': f'Wazuh agent {command} service from wazuh-control', - 'become': True, - 'command': f'{self.install_dir_path}/bin/wazuh-control {command}', - 'when': 'ansible_system == "Darwin" or ansible_system == "SunOS"'})) - - tasks_list.append(AnsibleTask({'name': f'Wazuh agent {command} service from Windows', - 'win_shell': 'Get-Service -Name WazuhSvc -ErrorAction SilentlyContinue |' + - f' {command.capitalize()}-Service -ErrorAction SilentlyContinue', - 'args': {'executable': 'powershell.exe'}, - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'})) + tasks_list.append(AnsibleTask({ + 'name': f'Wazuh manager {command} service from systemd', + 'become': True, + 'systemd': {'name': f'wazuh-{service_name}', 'state': f'{service_command}'}, + 'register': 'output_command', + 'ignore_errors': 'true', + 'when': 'ansible_system == "Linux"' + })) + + tasks_list.append(AnsibleTask({ + 'name': f'Wazuh agent {command} service from wazuh-control', + 'become': True, + 'command': f'{self.install_dir_path}/bin/wazuh-control {command}', + 'when': 'ansible_system == "Darwin" or ansible_system == "SunOS"' + })) + + tasks_list.append(AnsibleTask({ + 'name': f'Wazuh agent {command} service from Windows', + 'win_shell': 'Get-Service -Name WazuhSvc -ErrorAction SilentlyContinue |' + + f' {command.capitalize()}-Service -ErrorAction SilentlyContinue', + 'args': {'executable': 'powershell.exe'}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + })) playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} @@ -209,25 +222,27 @@ def health_check(self): """ WazuhDeployment.LOGGER.debug(f"Starting wazuh deployment healthcheck in {self.hosts} hosts") tasks_list = [] - tasks_list.append(AnsibleTask({'name': 'Read ossec.log searching errors (Unix)', - 'lineinfile': {'path': f'{self.install_dir_path}/logs/ossec.log', - 'line': 'ERROR|CRITICAL'}, - 'register': 'exists', - 'check_mode': 'yes', - 'become': True, - 'failed_when': 'exists is not changed', - 'when': 'ansible_system != "Win32NT"'})) - - tasks_list.append(AnsibleTask({'name': 'Read ossec.log searching errors (Windows)', - 'win_lineinfile': {'path': f'{self.install_dir_path}\\ossec.log', - 'line': 'ERROR|CRITICAL'}, - 'register': 'exists', - 'check_mode': 'yes', - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'failed_when': 'exists is not changed', - 'when': 'ansible_system == "Win32NT"'})) + tasks_list.append(AnsibleTask({ + 'name': 'Read ossec.log searching errors (Unix)', + 'lineinfile': {'path': f'{self.install_dir_path}/logs/ossec.log', 'line': 'ERROR|CRITICAL'}, + 'register': 'exists', + 'check_mode': 'yes', + 'become': True, + 'failed_when': 'exists is not changed', + 'when': 'ansible_system != "Win32NT"' + })) + + tasks_list.append(AnsibleTask({ + 'name': 'Read ossec.log searching errors (Windows)', + 'win_lineinfile': {'path': f'{self.install_dir_path}\\ossec.log', 'line': 'ERROR|CRITICAL'}, + 'register': 'exists', + 'check_mode': 'yes', + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'failed_when': 'exists is not changed', + 'when': 'ansible_system == "Win32NT"' + })) playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': False} @@ -244,10 +259,12 @@ def wazuh_is_already_installed(self): bool: True if wazuh is already installed, False if not """ tasks_list = [] - tasks_list.append(AnsibleTask({'name': 'Check Wazuh directory exist', - 'stat': {'path': f'{self.install_dir_path}'}, - 'register': 'dir_exist', - 'failed_when': 'dir_exist.stat.exists and dir_exist.stat.isdir'})) + tasks_list.append(AnsibleTask({ + 'name': 'Check Wazuh directory exist', + 'stat': {'path': f'{self.install_dir_path}'}, + 'register': 'dir_exist', + 'failed_when': 'dir_exist.stat.exists and dir_exist.stat.isdir' + })) playbook_parameters = {'tasks_list': tasks_list, 'hosts': self.hosts, 'gather_facts': True, 'become': True} diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_installation.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_installation.py index 8490addea9..ab1c6e2cf8 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_installation.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_installation.py @@ -32,14 +32,17 @@ def download_installation_files(self, inventory_file_path, ansible_tasks, hosts= ansible_tasks (ansible object): ansible instance with already provided tasks to run hosts (string): Parameter set to `all` by default """ - create_path_task_unix = AnsibleTask({'name': f"Create {self.installation_files_path} path (Unix)", - 'file': {'path': self.installation_files_path, 'state': 'directory'}, - 'when': 'ansible_system != "Win32NT"'}) - - create_path_task_windows = AnsibleTask({'name': f"Create {self.installation_files_path} path (Windows)", - 'win_file': {'path': self.installation_files_path, - 'state': 'directory'}, - 'when': 'ansible_system == "Win32NT"'}) + create_path_task_unix = AnsibleTask({ + 'name': f"Create {self.installation_files_path} path (Unix)", + 'file': {'path': self.installation_files_path, 'state': 'directory'}, + 'when': 'ansible_system != "Win32NT"' + }) + + create_path_task_windows = AnsibleTask({ + 'name': f"Create {self.installation_files_path} path (Windows)", + 'win_file': {'path': self.installation_files_path, 'state': 'directory'}, + 'when': 'ansible_system == "Win32NT"' + }) # Add path creation task at the beggining of the playbook ansible_tasks.insert(0, create_path_task_unix) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_local_package.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_local_package.py index 3f8dd29725..b0896e460a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_local_package.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_local_package.py @@ -48,10 +48,11 @@ def download_installation_files(self, inventory_file_path, hosts='all'): WazuhLocalPackage.LOGGER.debug(f"Copying local package {self.local_package_path} to " f"{self.installation_files_path} in {hosts} hosts") - copy_ansible_task = AnsibleTask({'name': f"Copy {self.local_package_path} package to \ - {self.installation_files_path}", - 'copy': {'src': self.local_package_path, - 'dest': self.installation_files_path}}) + copy_ansible_task = AnsibleTask({ + 'name': f"Copy {self.local_package_path} package to {self.installation_files_path}", + 'copy': {'src': self.local_package_path, 'dest': self.installation_files_path} + }) + WazuhLocalPackage.LOGGER.debug(f"{self.local_package_path} has been successfully copied in {hosts} hosts") super().download_installation_files(inventory_file_path, [copy_ansible_task], hosts) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py index 12c37f0815..5e472c24c7 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py @@ -106,19 +106,21 @@ def download_installation_files(self, inventory_file_path, hosts='all'): package_name = Path(self.s3_package_url).name WazuhS3Package.LOGGER.debug(f"Downloading Wazuh S3 package from {self.s3_package_url} in {hosts} hosts") - download_unix_s3_package = AnsibleTask({'name': 'Download S3 package (Unix)', - 'get_url': {'url': self.s3_package_url, - 'dest': self.installation_files_path}, - 'register': 'download_state', 'retries': 6, 'delay': 10, - 'until': 'download_state is success', - 'when': 'ansible_system != "Win32NT"'}) - - download_windows_s3_package = AnsibleTask({'name': 'Download S3 package (Windows)', - 'win_get_url': {'url': self.s3_package_url, - 'dest': self.installation_files_path}, - 'register': 'download_state', 'retries': 6, 'delay': 10, - 'until': 'download_state is success', - 'when': 'ansible_system == "Win32NT"'}) + download_unix_s3_package = AnsibleTask({ + 'name': 'Download S3 package (Unix)', + 'get_url': {'url': self.s3_package_url, 'dest': self.installation_files_path}, + 'register': 'download_state', 'retries': 6, 'delay': 10, + 'until': 'download_state is success', + 'when': 'ansible_system != "Win32NT"' + }) + + download_windows_s3_package = AnsibleTask({ + 'name': 'Download S3 package (Windows)', + 'win_get_url': {'url': self.s3_package_url, 'dest': self.installation_files_path}, + 'register': 'download_state', 'retries': 6, 'delay': 10, + 'until': 'download_state is success', + 'when': 'ansible_system == "Win32NT"' + }) WazuhS3Package.LOGGER.debug(f"Wazuh S3 package was successfully downloaded in {hosts} hosts") diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_sources.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_sources.py index fa1d76316f..4799597e32 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_sources.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_sources.py @@ -47,10 +47,11 @@ def download_installation_files(self, inventory_file_path, hosts='all'): """ WazuhSources.LOGGER.debug(f"Downloading Wazuh sources from {self.wazuh_branch} branch in {hosts} hosts") - download_wazuh_sources_task = AnsibleTask({'name': f"Download Wazuh branch in {self.installation_files_path}", - 'shell': f"cd {self.installation_files_path} && " + - 'curl -Ls https://github.com/wazuh/wazuh/archive/' + - f"{self.wazuh_branch}.tar.gz | tar zx && mv wazuh-*/* ."}) + download_wazuh_sources_task = AnsibleTask({ + 'name': f"Download Wazuh branch in {self.installation_files_path}", + 'shell': f"cd {self.installation_files_path} && curl -Ls https://github.com/wazuh/wazuh/archive/" \ + f"{self.wazuh_branch}.tar.gz | tar zx && mv wazuh-*/* ." + }) WazuhSources.LOGGER.debug(f"Wazuh sources from {self.wazuh_branch} branch were successfully downloaded in " f"{hosts} hosts") super().download_installation_files(inventory_file_path, [download_wazuh_sources_task], hosts) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index 5dd82df24d..85085a1f0d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -132,93 +132,106 @@ def run(self, ansible_inventory_path): pytest_command += f"--html='{os.path.join(reports_directory, html_report_file_name)}'" - create_path_task_unix = {'name': f"Create {reports_directory} path (Unix)", - 'file': {'path': reports_directory, 'state': 'directory', 'mode': '0755'}, - 'become':True, - 'when': 'ansible_system != "Win32NT"'} - - - create_path_task_unix_windows = {'name': f"Create {reports_directory} path (Windows)", - 'win_file': {'path': reports_directory, 'state': 'directory'}, - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'} - - execute_test_task_unix = {'name': f"Launch pytest in {self.tests_run_dir} (Unix)", - 'shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, - 'register': 'test_output', - 'ignore_errors': 'yes', - 'become':True, - 'when': 'ansible_system != "Win32NT"'} - - execute_test_task_windows = {'name': f"Launch pytest in {self.tests_run_dir} (Windows)", - 'win_shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, - 'register': 'test_output', - 'ignore_errors': 'yes', - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'} - - create_plain_report_unix = {'name': f"Create plain report file in {plain_report_file_path} (Unix)", - 'copy': {'dest': plain_report_file_path, 'content': "{{test_output.stdout}}"}, - 'become': True, - 'when': 'ansible_system != "Win32NT"'} - - create_plain_report_windows = {'name': f"Create plain report file in {plain_report_file_path} (Windows)", - 'win_copy': {'dest': plain_report_file_path, - 'content': "{{test_output.stdout}}"}, - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'} - - # Fetch works in Unix and windows - fetch_plain_report = {'name': f"Move {plain_report_file_name} from " \ - f"{plain_report_file_path} to {self.tests_result_path}", - 'fetch': {'src': plain_report_file_path, - 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}} - - # Fetch works in Unix and windows - fetch_html_report = {'name': f"Move {html_report_file_name} from {html_report_file_path}" - f" to {self.tests_result_path}", - 'fetch': {'src': html_report_file_path, - 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}, - 'ignore_errors': 'yes'} - - compress_assets_folder_unix = {'name': "Compress assets folder (Unix)", - 'community.general.archive': {'path': assets_src_directory, 'dest': zip_src_path, - 'format': 'zip'}, - 'ignore_errors': 'yes', - 'become': True, - 'when': 'ansible_system != "Win32NT"'} - - compress_assets_folder_windows = {'name': "Compress assets folder (Windows)", - 'win_shell': f"powershell.exe Compress-Archive {assets_src_directory} " \ - f"{zip_src_path}", - 'ignore_errors': 'yes', - 'become': True, - 'become_method': 'runas', - 'become_user': self.ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'} - - fetch_compressed_assets = {'name': f"Copy compressed assets from {zip_src_path} to {self.tests_result_path}", - 'fetch': {'src': zip_src_path, - 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}, - 'ignore_errors': 'yes'} - - - ansible_tasks = [AnsibleTask(create_path_task_unix), AnsibleTask(create_path_task_unix_windows), - AnsibleTask(execute_test_task_unix), AnsibleTask(execute_test_task_windows), - AnsibleTask(create_plain_report_unix), AnsibleTask(create_plain_report_windows), - AnsibleTask(fetch_plain_report), AnsibleTask(fetch_html_report), - AnsibleTask(compress_assets_folder_unix), - AnsibleTask(compress_assets_folder_windows), - AnsibleTask(fetch_compressed_assets)] - - playbook_parameters = {'become': False, 'tasks_list': ansible_tasks, 'playbook_file_path': - playbook_file_path, "hosts": self.hosts, 'gather_facts': True} + create_path_task_unix = { + 'name': f"Create {reports_directory} path (Unix)", + 'file': {'path': reports_directory, 'state': 'directory', 'mode': '0755'}, + 'become':True, + 'when': 'ansible_system != "Win32NT"' + } + + create_path_task_windows = { + 'name': f"Create {reports_directory} path (Windows)", + 'win_file': {'path': reports_directory, 'state': 'directory'}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + } + + run_test_task_unix = { + 'name': f"Launch pytest in {self.tests_run_dir} (Unix)", + 'shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, + 'register': 'test_output', + 'ignore_errors': 'yes', + 'become':True, + 'when': 'ansible_system != "Win32NT"' + } + + run_test_task_windows = { + 'name': f"Launch pytest in {self.tests_run_dir} (Windows)", + 'win_shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, + 'register': 'test_output', + 'ignore_errors': 'yes', + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + } + + create_plain_report_unix = { + 'name': f"Create plain report file in {plain_report_file_path} (Unix)", + 'copy': {'dest': plain_report_file_path, 'content': "{{test_output.stdout}}"}, + 'become': True, + 'when': 'ansible_system != "Win32NT"' + } + + create_plain_report_windows = { + 'name': f"Create plain report file in {plain_report_file_path} (Windows)", + 'win_copy': {'dest': plain_report_file_path, 'content': "{{test_output.stdout}}"}, + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + } + + fetch_plain_report = { + 'name': f"Move {plain_report_file_name} from {plain_report_file_path} to {self.tests_result_path}", + 'fetch': {'src': plain_report_file_path, 'dest': f"{self.tests_result_path}/", 'flat': 'yes'} + } + + fetch_html_report = { + 'name': f"Move {html_report_file_name} from {html_report_file_path} to {self.tests_result_path}", + 'fetch': {'src': html_report_file_path, 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}, + 'ignore_errors': 'yes' + } + + compress_assets_folder_unix = { + 'name': "Compress assets folder (Unix)", + 'community.general.archive': {'path': assets_src_directory, 'dest': zip_src_path, 'format': 'zip'}, + 'ignore_errors': 'yes', + 'become': True, + 'when': 'ansible_system != "Win32NT"' + } + + compress_assets_folder_windows = { + 'name': "Compress assets folder (Windows)", + 'win_shell': f"powershell.exe Compress-Archive {assets_src_directory} {zip_src_path}", + 'ignore_errors': 'yes', + 'become': True, + 'become_method': 'runas', + 'become_user': self.ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + } + + fetch_compressed_assets = { + 'name': f"Copy compressed assets from {zip_src_path} to {self.tests_result_path}", + 'fetch': {'src': zip_src_path, + 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}, + 'ignore_errors': 'yes' + } + + ansible_tasks = [ + AnsibleTask(create_path_task_unix), AnsibleTask(create_path_task_windows), + AnsibleTask(run_test_task_unix), AnsibleTask(run_test_task_windows), + AnsibleTask(create_plain_report_unix), AnsibleTask(create_plain_report_windows), + AnsibleTask(fetch_plain_report), AnsibleTask(fetch_html_report), AnsibleTask(compress_assets_folder_unix), + AnsibleTask(compress_assets_folder_windows),AnsibleTask(fetch_compressed_assets) + ] + + playbook_parameters = { + 'become': False, 'tasks_list': ansible_tasks, 'playbook_file_path': playbook_file_path, "hosts": self.hosts, + 'gather_facts': True + } Pytest.LOGGER.info(f"Running {self.tests_path} test on {self.hosts} hosts") Pytest.LOGGER.debug(f"Running {pytest_command} on {self.hosts} hosts") diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py index e3154180d2..97cffb7b86 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py @@ -121,39 +121,48 @@ def __set_local_internal_options(self, hosts, modules, component, system, wazuh_ else: local_internal_options_path = f"{wazuh_install_path}/etc/local_internal_options.conf" - clean_local_internal_configuration_unix = {'name': 'Clean local internal configuration (Unix)', - 'copy': {'dest': local_internal_options_path, 'content': ''}, - 'become': True, - 'when': 'ansible_system != "Win32NT"'} - - clean_local_internal_configuration_windows = {'name': 'Clean local internal configuration (Windows)', - 'win_copy': {'dest': local_internal_options_path, 'content': ''}, - 'become': True, - 'become_method': 'runas', - 'become_user': ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'} - - set_local_internal_configuration_unix = {'name': 'Set custom local internal configuration (Unix)', - 'lineinfile': {'path': local_internal_options_path, - 'line': "{{ item }}"}, - 'with_items': local_internal_options_content, - 'become': True, - 'when': 'ansible_system != "Win32NT"'} - - set_local_internal_configuration_windows = {'name': 'Set custom local internal configuration (Windows)', - 'win_lineinfile': {'path': local_internal_options_path, - 'line': "{{ item }}"}, - 'with_items': local_internal_options_content, - 'become': True, - 'become_method': 'runas', - 'become_user': ansible_admin_user, - 'when': 'ansible_system == "Win32NT"'} - - ansible_tasks = [AnsibleTask(clean_local_internal_configuration_unix), - AnsibleTask(clean_local_internal_configuration_windows), - AnsibleTask(set_local_internal_configuration_unix), - AnsibleTask(set_local_internal_configuration_windows) - ] + clean_local_internal_configuration_unix = { + 'name': 'Clean local internal configuration (Unix)', + 'copy': {'dest': local_internal_options_path, 'content': ''}, + 'become': True, + 'when': 'ansible_system != "Win32NT"' + } + + clean_local_internal_configuration_windows = { + 'name': 'Clean local internal configuration (Windows)', + 'win_copy': {'dest': local_internal_options_path, 'content': ''}, + 'become': True, + 'become_method': 'runas', + 'become_user': ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + } + + set_local_internal_configuration_unix = { + 'name': 'Set custom local internal configuration (Unix)', + 'lineinfile': {'path': local_internal_options_path, + 'line': "{{ item }}"}, + 'with_items': local_internal_options_content, + 'become': True, + 'when': 'ansible_system != "Win32NT"' + } + + set_local_internal_configuration_windows = { + 'name': 'Set custom local internal configuration (Windows)', + 'win_lineinfile': {'path': local_internal_options_path, + 'line': "{{ item }}"}, + 'with_items': local_internal_options_content, + 'become': True, + 'become_method': 'runas', + 'become_user': ansible_admin_user, + 'when': 'ansible_system == "Win32NT"' + } + + ansible_tasks = [ + AnsibleTask(clean_local_internal_configuration_unix), + AnsibleTask(clean_local_internal_configuration_windows), + AnsibleTask(set_local_internal_configuration_unix), + AnsibleTask(set_local_internal_configuration_windows) + ] playbook_parameters = {'become': False, 'gather_facts': True, 'tasks_list': ansible_tasks, 'playbook_file_path': playbook_file_path, 'hosts': hosts} From 3fdbeb84120426130b79017236da48a1cde1a8f6 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Wed, 27 Oct 2021 18:09:43 +0200 Subject: [PATCH 148/181] fix: Fix pytest tests results fetching #2020 --- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 18 ++++++++++++------ .../qa_ctl/run_tests/test_launcher.py | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index 85085a1f0d..9c3cf971ce 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -151,7 +151,7 @@ def run(self, ansible_inventory_path): run_test_task_unix = { 'name': f"Launch pytest in {self.tests_run_dir} (Unix)", 'shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, - 'register': 'test_output', + 'register': 'test_output_unix', 'ignore_errors': 'yes', 'become':True, 'when': 'ansible_system != "Win32NT"' @@ -160,7 +160,7 @@ def run(self, ansible_inventory_path): run_test_task_windows = { 'name': f"Launch pytest in {self.tests_run_dir} (Windows)", 'win_shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, - 'register': 'test_output', + 'register': 'test_output_windows', 'ignore_errors': 'yes', 'become': True, 'become_method': 'runas', @@ -170,14 +170,14 @@ def run(self, ansible_inventory_path): create_plain_report_unix = { 'name': f"Create plain report file in {plain_report_file_path} (Unix)", - 'copy': {'dest': plain_report_file_path, 'content': "{{test_output.stdout}}"}, + 'copy': {'dest': plain_report_file_path, 'content': "{{test_output_unix.stdout}}"}, 'become': True, 'when': 'ansible_system != "Win32NT"' } create_plain_report_windows = { 'name': f"Create plain report file in {plain_report_file_path} (Windows)", - 'win_copy': {'dest': plain_report_file_path, 'content': "{{test_output.stdout}}"}, + 'win_copy': {'dest': plain_report_file_path, 'content': "{{test_output_windows.stdout}}"}, 'become': True, 'become_method': 'runas', 'become_user': self.ansible_admin_user, @@ -245,6 +245,12 @@ def run(self, ansible_inventory_path): # Print test result in stdout if self.qa_ctl_configuration.logging_enable: - Pytest.LOGGER.info(self.result) + if os.path.exists(self.result.plain_report_file_path): + Pytest.LOGGER.info(self.result) + else: + Pytest.LOGGER.error(f"Test results could not be saved in {self.result.plain_report_file_path} file") else: - print(self.result) + if os.path.exists(self.result.plain_report_file_path): + print(self.result) + else: + print(f"Test results could not be saved in {self.result.plain_report_file_path} file") diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py index 97cffb7b86..e5b3117d3a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py @@ -150,7 +150,7 @@ def __set_local_internal_options(self, hosts, modules, component, system, wazuh_ 'name': 'Set custom local internal configuration (Windows)', 'win_lineinfile': {'path': local_internal_options_path, 'line': "{{ item }}"}, - 'with_items': local_internal_options_content, + 'with_items': local_internal_options_content.copy(), 'become': True, 'become_method': 'runas', 'become_user': ansible_admin_user, From a5dfe02d51a5a20e9a913bedb2ce61fb8f1fac1b Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 28 Oct 2021 11:44:06 +0200 Subject: [PATCH 149/181] add: Add qa_ctl_launcher_branch config parameter #2143 --- .../qa_ctl/configuration/config_generator.py | 15 +++++++++++++-- .../qa_ctl/configuration/qa_ctl_configuration.py | 6 +++++- .../qa_ctl/provisioning/qa_provisioning.py | 2 +- .../qa_ctl/run_tests/qa_test_runner.py | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 69d2f1d786..554c675570 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -1,3 +1,4 @@ +import sys from os.path import join, exists from tempfile import gettempdir @@ -362,9 +363,9 @@ def __process_deployment_data(self, tests_info): for test in tests_info: if self.__validate_test_info(test): os_version = '' - if 'Ubuntu Focal' in test['os_version']: + if 'CentOS 8' in test['os_version']: os_version = 'Ubuntu Focal' - elif 'CentOS 8' in test['os_version']: + elif 'Ubuntu Focal' in test['os_version']: os_version = 'CentOS 8' elif 'Windows Server 2019' in test['os_version']: os_version = 'Windows Server 2019' @@ -530,10 +531,20 @@ def __process_test_info(self, tests_info): self.__process_provision_data() self.__process_test_data(tests_info) + def __proces_config_info(self): + """Write the config section info in the qa-ctl configuration file""" + # It is only necessary to specify the qa_ctl_launcher_branch when using qa-ctl on Windows, as this branch will + # be used to launch qa-ctl in the docker container used for provisioning and testing. + if sys.platform == 'win32': + self.config['config'] = {} + self.config['config']['qa_ctl_launcher_branch'] = self.qa_branch + + def run(self): """Run an instance with the parameters created. This generates the YAML configuration file automatically.""" info = self.__get_all_tests_info() self.__process_test_info(info) + self.__proces_config_info() file.write_yaml_file(self.config_file_path, self.config) def destroy(self): diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/qa_ctl_configuration.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/qa_ctl_configuration.py index f801fb6075..607843a5e1 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/qa_ctl_configuration.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/qa_ctl_configuration.py @@ -16,6 +16,7 @@ class QACTLConfiguration: logging_level (string): Defines the logging level for the outputs. Four options are available: DEBUG, INFO, WARNING, ERROR, CRITICAL. logging_file (string): This field defines a path for a file where the outputs will be logged as well + qa_ctl_launcher_branch (str): QA branch to launch the qa-ctl tool in the docker container (for Windows native) """ def __init__(self, configuration_data, script_parameters): @@ -25,6 +26,7 @@ def __init__(self, configuration_data, script_parameters): self.logging_enable = True self.logging_level = 'INFO' self.logging_file = None + self.qa_ctl_launcher_branch = None self.script_parameters = script_parameters self.debug_level = script_parameters.debug @@ -53,9 +55,11 @@ def __read_configuration_data(self): self.logging_level = self.configuration_data['config']['logging']['level'] if 'file' in self.configuration_data['config']['logging']: self.logging_file = self.configuration_data['config']['logging']['file'] + if 'qa_ctl_launcher_branch' in self.configuration_data['config']: + self.qa_ctl_launcher_branch = self.configuration_data['config']['qa_ctl_launcher_branch'] def __str__(self): """Define how the class object is to be displayed.""" return f"vagrant_output: {self.vagrant_output}\nansible_output: {self.ansible_output}\n" \ f"logging_enable: {self.logging_enable}\nloggin_level: {self.logging_level}\n"\ - f"logging_file: {self.logging_file}\n" + f"logging_file: {self.logging_file}\nqa_ctl_launcher_branch:{self.qa_ctl_launcher_branch}\n" diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index 95bf8d542f..677ec77065 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -248,7 +248,7 @@ def run(self): file.write_yaml_file(tmp_config_file, {'provision': self.provision_info}) try: - qa_ctl_docker_run(tmp_config_file_name, self.qa_ctl_configuration.script_parameters.qa_branch, + qa_ctl_docker_run(tmp_config_file_name, self.qa_ctl_configuration.qa_ctl_launcher_branch, self.qa_ctl_configuration.debug_level, topic='provisioning the instances') finally: file.remove_file(tmp_config_file) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py index 0aa8378e83..4b1cd91788 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py @@ -188,7 +188,7 @@ def run(self): file.write_yaml_file(tmp_config_file, {'tests': self.test_parameters}) try: - qa_ctl_docker_run(tmp_config_file_name, self.qa_ctl_configuration.script_parameters.qa_branch, + qa_ctl_docker_run(tmp_config_file_name, self.qa_ctl_configuration.qa_ctl_launcher_branch, self.qa_ctl_configuration.debug_level, topic='launching the tests') # Move all test results to their original paths specified in Windows qa-ctl configuration index = 0 From 1e193315fa070d004f6c1074874613364340a64c Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 28 Oct 2021 12:50:51 +0200 Subject: [PATCH 150/181] add: Add qa_ctl_launcher_branch config parameter validation #2143 --- .../wazuh_testing/scripts/qa_ctl.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 5bea23ce65..7e2a7a456f 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -4,6 +4,7 @@ import json import argparse import os +import sys import yaml import textwrap @@ -29,6 +30,9 @@ TEST_KEY = 'tests' WAZUH_QA_FILES = os.path.join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa') RUNNING_ON_DOCKER_CONTAINER = True if 'RUNNING_ON_DOCKER_CONTAINER' in os.environ else False +AUTOMATIC_MODE = 'manual_mode' +MANUAL_MODE = 'automatic_mode' + qactl_logger = Logging(QACTL_LOGGER) _data_path = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'data') @@ -54,11 +58,12 @@ def read_configuration_data(configuration_file_path): return configuration_data -def validate_configuration_data(configuration_data): +def validate_configuration_data(configuration_data, qa_ctl_mode): """Validate the configuration data schema. Args: configuration_data (dict): Configuration data info. + qa_ctl_mode (str): qa-ctl run mode (AUTOMATIC_MODE or MANUAL_MODE) """ qactl_logger.debug('Validating configuration schema') data_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'data') @@ -67,8 +72,21 @@ def validate_configuration_data(configuration_data): with open(os.path.join(_data_path, schema_file), 'r') as config_data: schema = json.load(config_data) + # Validate schema constraints validate(instance=configuration_data, schema=schema) + # Check that qa_ctl_launcher_branch parameter has been specified for Windows manual mode + if sys.platform == 'win32' and qa_ctl_mode == MANUAL_MODE and ('config' not in configuration_data or \ + 'qa_ctl_launcher_branch' not in configuration_data['config']): + raise QAValueError('qa_ctl_launcher_branch was not found in the configuration file. It is required if you ' \ + 'are running qa-ctl in a Windows host', qactl_logger.error, QACTL_LOGGER) + # Check that qa_ctl_launcher_branch exists + elif not github_checks.branch_exists(configuration_data['config']['qa_ctl_launcher_branch'], + repository=WAZUH_QA_REPO): + raise QAValueError(f"{configuration_data['config']['qa_ctl_launcher_branch']} branch specified as " + 'qa_ctl_launcher_branch does not exist in Wazuh QA repository.', qactl_logger.error, + QACTL_LOGGER) + qactl_logger.debug('Schema validation has passed successfully') @@ -266,8 +284,10 @@ def main(): if not arguments.no_validation: validate_parameters(arguments) + qa_ctl_mode = AUTOMATIC_MODE if arguments.run_test else MANUAL_MODE + # Generate or get the qactl configuration file - if arguments.run_test: + if AUTOMATIC_MODE: qactl_logger.debug('Generating configuration file') config_generator = QACTLConfigGenerator(arguments.run_test, arguments.version, arguments.qa_branch, WAZUH_QA_FILES, arguments.operating_systems) @@ -292,7 +312,7 @@ def main(): configuration_data = read_configuration_data(configuration_file) # Validate configuration schema - validate_configuration_data(configuration_data) + validate_configuration_data(configuration_data, qa_ctl_mode) # Set QACTL configuration qactl_configuration = QACTLConfiguration(configuration_data, arguments) From 291539687a04a9d02f3d53c3c82b457e1ee8b910 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 28 Oct 2021 16:16:42 +0200 Subject: [PATCH 151/181] fix: add qa-ctl path fixes #2143 --- .../qa_ctl/configuration/config_generator.py | 6 ++++- .../wazuh_testing/scripts/qa_ctl.py | 25 ++++++++++--------- .../wazuh_testing/wazuh_testing/tools/file.py | 2 +- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 554c675570..4d52bccc47 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -1,4 +1,5 @@ import sys +import re from os.path import join, exists from tempfile import gettempdir @@ -486,8 +487,11 @@ def __set_testing_config(self, tests_info): system = 'linux' if system == 'deb' or system == 'rpm' else system modules = test['modules'] component = 'manager' if 'manager' in test['components'] else test['components'][0] + + # Cut out the full path, and convert it to relative path (tests/integration....) + test_path = re.sub(r".*wazuh-qa.*(tests.*)", r"\1", test['path']) # Convert test path string to the corresponding according to the system - test_path = file.join_path(test['path'].split('/'), system) + test_path = file.join_path([test_path], system) self.__add_testing_config_block(instance, installation_files_path, system, test_path, test['test_name'], modules, component) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 7e2a7a456f..aac4088e1d 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -75,17 +75,18 @@ def validate_configuration_data(configuration_data, qa_ctl_mode): # Validate schema constraints validate(instance=configuration_data, schema=schema) - # Check that qa_ctl_launcher_branch parameter has been specified for Windows manual mode - if sys.platform == 'win32' and qa_ctl_mode == MANUAL_MODE and ('config' not in configuration_data or \ - 'qa_ctl_launcher_branch' not in configuration_data['config']): - raise QAValueError('qa_ctl_launcher_branch was not found in the configuration file. It is required if you ' \ - 'are running qa-ctl in a Windows host', qactl_logger.error, QACTL_LOGGER) - # Check that qa_ctl_launcher_branch exists - elif not github_checks.branch_exists(configuration_data['config']['qa_ctl_launcher_branch'], - repository=WAZUH_QA_REPO): - raise QAValueError(f"{configuration_data['config']['qa_ctl_launcher_branch']} branch specified as " - 'qa_ctl_launcher_branch does not exist in Wazuh QA repository.', qactl_logger.error, - QACTL_LOGGER) + # Check that qa_ctl_launcher_branch parameter has been specified and its valid for Windows manual mode + if sys.platform == 'win32' and qa_ctl_mode == MANUAL_MODE: + if 'config' not in configuration_data or 'qa_ctl_launcher_branch' not in configuration_data['config']: + raise QAValueError('qa_ctl_launcher_branch was not found in the configuration file. It is required if ' \ + 'you are running qa-ctl in a Windows host', qactl_logger.error, QACTL_LOGGER) + + # Check that qa_ctl_launcher_branch exists + if not github_checks.branch_exists(configuration_data['config']['qa_ctl_launcher_branch'], + repository=WAZUH_QA_REPO): + raise QAValueError(f"{configuration_data['config']['qa_ctl_launcher_branch']} branch specified as " + 'qa_ctl_launcher_branch does not exist in Wazuh QA repository.', qactl_logger.error, + QACTL_LOGGER) qactl_logger.debug('Schema validation has passed successfully') @@ -287,7 +288,7 @@ def main(): qa_ctl_mode = AUTOMATIC_MODE if arguments.run_test else MANUAL_MODE # Generate or get the qactl configuration file - if AUTOMATIC_MODE: + if qa_ctl_mode == AUTOMATIC_MODE: qactl_logger.debug('Generating configuration file') config_generator = QACTLConfigGenerator(arguments.run_test, arguments.version, arguments.qa_branch, WAZUH_QA_FILES, arguments.operating_systems) diff --git a/deps/wazuh_testing/wazuh_testing/tools/file.py b/deps/wazuh_testing/wazuh_testing/tools/file.py index c452822669..0f4c254aaa 100644 --- a/deps/wazuh_testing/wazuh_testing/tools/file.py +++ b/deps/wazuh_testing/wazuh_testing/tools/file.py @@ -364,7 +364,7 @@ def join_path(path, system): result_path = [] for item in path: - if system == 'windows' and '\\' in item: + if '\\' in item: result_path.extend([path_item for path_item in item.split('\\')]) elif '/' in item: result_path.extend([path_item for path_item in item.split('/')]) From 1df50b0f8b8a7181e911702d3832ca39f252f82f Mon Sep 17 00:00:00 2001 From: mdengra Date: Mon, 25 Oct 2021 14:05:42 +0200 Subject: [PATCH 152/181] doc: Add test_basic_usage of test_fim/test_files documentation in QA Docs style The current scheme of the issue #1694 has been used. PEP-8 fixes. Related: #1796 --- .../test_basic_usage_baseline_generation.py | 119 +++++++++++-- .../test_basic_usage_changes.py | 146 ++++++++++++++-- ...est_basic_usage_create_after_delete_dir.py | 136 ++++++++++++--- .../test_basic_usage_create_rt_wd.py | 161 +++++++++++++++--- .../test_basic_usage_create_scheduled.py | 161 +++++++++++++++--- .../test_basic_usage_db_inode_check.py | 132 +++++++++++--- .../test_basic_usage_delete_folder.py | 152 ++++++++++++++--- .../test_basic_usage_dir_with_commas.py | 122 ++++++++++++- .../test_basic_usage_disabled.py | 118 +++++++++++-- ...st_basic_usage_entries_match_path_count.py | 129 ++++++++++++-- .../test_basic_usage_move_dir.py | 151 +++++++++++++--- .../test_basic_usage_move_file.py | 156 ++++++++++++++--- .../test_basic_usage_new_dirs.py | 130 ++++++++++++-- .../test_basic_usage_no_dir.py | 123 +++++++++++-- .../test_basic_usage_quick_changes.py | 130 ++++++++++++-- .../test_basic_usage_rename.py | 138 +++++++++++++-- .../test_basic_usage_starting_agent.py | 123 ++++++++++++- .../test_basic_usage_wildcards.py | 146 +++++++++++++--- .../test_basic_usage_wildcards_runtime.py | 151 +++++++++++++--- 19 files changed, 2309 insertions(+), 315 deletions(-) diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_baseline_generation.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_baseline_generation.py index 77e9a09bdd..960fe2e25e 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_baseline_generation.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_baseline_generation.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if the modifications made on files during + the initial scan ('baseline') generate events when the scan is finished. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os from time import time @@ -62,14 +132,39 @@ def extra_configuration_before_yield(): def test_wait_until_baseline(get_configuration, configure_environment, restart_syscheckd): - """ - Check if events are appearing after the baseline - The message 'File integrity monitoring scan ended' informs about the end of the first scan, - which generates the baseline - - It creates a file, checks if the baseline has generated before the file addition event, and then - if this event has generated. - """ + ''' + description: Check if FIM events are appearing after the 'baseline'. The log message + 'File integrity monitoring scan ended' informs about the end of the first scan, + which generates the 'baseline'. For this purpose, the test creates a test file + while the initial scan is being performed. When the baseline has been generated + it checks if the FIM addition event has been triggered. + + wazuh_min_version: 4.2.0 + + parameters: + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + + assertions: + - Verify that a FIM addition event was generated during the initial scan. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' + + tags: + - realtime + ''' check_apply_test({'ossec_conf'}, get_configuration['tags']) # Create a file during initial scan to check if the event is logged after the 'scan ended' message diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_changes.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_changes.py index 0fa1067312..e70f0b82e3 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_changes.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_changes.py @@ -1,7 +1,82 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when + these files are modified. In particular, these tests will check if common operations + ('add', 'modify', and 'delete') on monitored directories are correctly detected. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured + files for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + - macos + - solaris + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + - macOS Catalina + - Solaris 10 + - Solaris 11 + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import sys @@ -68,16 +143,59 @@ def get_configuration(request): def test_regular_file_changes(folder, name, encoding, checkers, tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if syscheckd detects regular file changes (add, modify, delete) - - Parameters - ---------- - folder : str - Directory where the files will be created. - checkers : dict - Syscheck checkers (check_all). - """ + ''' + description: Check if the 'wazuh-syscheckd' daemon detects regular file changes (add, modify, delete). + For this purpose, the test uses different character encodings in the names of the testing + directories and files and performs operations on them. Finally, it verifies that + the FIM events have been generated properly. + + wazuh_min_version: 4.2.0 + + parameters: + - folder: + type: str + brief: Path to the monitored testing directory. + - name: + type: str + brief: Name used for the testing files. + - encoding: + type: str + brief: Character encoding used for the directory and testing files. + - checkers: + type: dict + brief: Syscheck checkers (check_all). + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that all FIM events are generated for the operations performed, + and these contain all 'check_' fields specified in the configuration. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' (Initial scan when restarting Wazuh) + - Multiple FIM events logs of the monitored directories. + + tags: + - scheduled + - time_travel + ''' check_apply_test(tags_to_apply, get_configuration['tags']) mult = 1 if sys.platform == 'win32' else 2 diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_after_delete_dir.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_after_delete_dir.py index 3c961e45c7..428402e650 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_after_delete_dir.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_after_delete_dir.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. In particular, these tests will check if FIM events are still generated when + a monitored directory is deleted and created again. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import shutil import sys @@ -50,22 +120,48 @@ def get_configuration(request): ]) def test_create_after_delete(tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check that a monitored directory keeps reporting events after deleting and creating it again. It tests - that under Windows systems the directory watcher is refreshed after directory re-creation 1 second after. - - This test performs the following steps: - - Monitor a directory that exist. - - Create some files inside. Check that it does produce events in ossec.log. - - Delete the directory and wait for a second. - - Create the directory again and wait for a second. - - Check that creating files within the directory do generate events again. - - Parameters - ---------- - tags_to_apply : set - Run test if matches with a configuration identifier, skip otherwise - """ + ''' + description: Check if a monitored directory keeps reporting FIM events after deleting and creating it again. + Under Windows systems, it verifies that the directory watcher is refreshed (checks the SACLs) + after directory re-creation one second after. For this purpose, the test creates the testing + directory to be monitored, checks that FIM events are generated, and then deletes it. + Finally, it creates the directory again and verifies that the events are still generated correctly. + + wazuh_min_version: 4.2.0 + + parameters: + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events are still generated when a monitored directory is deleted and created again. + + input_description: A test case (ossec_conf) is contained in external YAML file + (wazuh_conf.yaml or wazuh_conf_win32.yaml) which includes configuration + settings for the 'wazuh-syscheckd' daemon and, it is combined with + the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' (Initial scan when restarting Wazuh) + - Multiple FIM events logs of the monitored directories. + + tags: + - realtime + - who-data + ''' check_apply_test(tags_to_apply, get_configuration['tags']) # Create the monitored directory with files and check that events are not raised diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_rt_wd.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_rt_wd.py index 7b2250a920..cf80af6b1c 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_rt_wd.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_rt_wd.py @@ -1,7 +1,82 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. In particular, these tests will verify that only regular files are monitored + using the 'realtime' and 'whodata' monitoring modes. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + - macos + - solaris + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + - macOS Catalina + - Solaris 10 + - Solaris 11 + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import sys @@ -79,24 +154,66 @@ def get_configuration(request): def test_create_file_realtime_whodata(folder, name, filetype, content, checkers, tags_to_apply, encoding, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if a special or regular file creation is detected by syscheck using realtime and whodata monitoring - - Regular files must be monitored. Special files must not. - - Parameters - ---------- - folder : str - Name of the monitored folder. - name : str - Name of the file. - filetype : str - Type of the file. - content : str - Content of the file. - checkers : set - Checks that will compared to the ones from the event. - """ + ''' + description: Check if a special or regular file creation is detected by the 'wazuh-syscheckd' daemon using + the 'realtime' and 'whodata' monitoring modes. Regular files must be monitored, special files + must not. For this purpose, the test creates the testing directories and files using different + character encodings in their names. Finally, it verifies that only the regular testing + files have generated FIM events. + + wazuh_min_version: 4.2.0 + + parameters: + - folder: + type: str + brief: Path to the monitored testing directory. + - name: + type: str + brief: Name used for the testing file. + - filetype: + type: str + brief: Type of the testing file. + - content: + type: str + brief: Content of the testing file. + - checkers: + type: dict + brief: Checks that will compared to the ones from the event. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - encoding: + type: str + brief: Character encoding used for the directory and testing files. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events are only generated for the regular testing files, + and these contain all 'check_' fields specified in the configuration. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' (Initial scan when restarting Wazuh) + - Multiple FIM events logs of the monitored directories. + + tags: + - realtime + - who-data + ''' check_apply_test(tags_to_apply, get_configuration['tags']) # Create files diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_scheduled.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_scheduled.py index 1eb166fa28..4624799a32 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_scheduled.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_create_scheduled.py @@ -1,7 +1,82 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. In particular, these tests will verify that only regular files are monitored + using the 'scheduled' monitoring mode. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + - macos + - solaris + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + - macOS Catalina + - Solaris 10 + - Solaris 11 + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import sys @@ -77,24 +152,66 @@ def get_configuration(request): ]) def test_create_file_scheduled(folder, name, filetype, content, checkers, tags_to_apply, encoding, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if a special or regular file creation is detected by syscheck using scheduled monitoring - - Regular files must be monitored. Special files must not. - - Parameters - ---------- - folder : str - Name of the monitored folder. - name : str - Name of the file. - filetype : str - Type of the file. - content : str - Content of the file. - checkers : set - Checks that will compared to the ones from the event. - """ + ''' + description: Check if a special or regular file creation is detected by the 'wazuh-syscheckd' daemon using + the 'scheduled' monitoring mode. Regular files must be monitored, special files must not. + For this purpose, the test creates the testing directories and files using different + character encodings in their names, and then it changes the system time until the next + scheduled scan. Finally, it verifies that only the regular testing files have generated FIM events. + + wazuh_min_version: 4.2.0 + + parameters: + - folder: + type: str + brief: Path to the monitored testing directory. + - name: + type: str + brief: Name used for the testing file. + - filetype: + type: str + brief: Type of the testing file. + - content: + type: str + brief: Content of the testing file. + - checkers: + type: dict + brief: Checks that will compared to the ones from the event. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - encoding: + type: str + brief: Character encoding used for the directory and testing files. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events are only generated for the regular testing files, + and these contain all 'check_' fields specified in the configuration. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' (Initial scan when restarting Wazuh) + - Multiple FIM events logs of the monitored directories. + + tags: + - scheduled + - time_travel + ''' check_apply_test(tags_to_apply, get_configuration['tags']) # Create files diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_db_inode_check.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_db_inode_check.py index d66f1cca43..b2601073be 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_db_inode_check.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_db_inode_check.py @@ -1,7 +1,69 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when + these files are modified. Specifically, these tests will check for false positives due + to possible inconsistencies with 'inodes' in the FIM database. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + - https://en.wikipedia.org/wiki/Inode + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import shutil @@ -73,23 +135,51 @@ def wait_for_fim_start_function(get_configuration, request): @pytest.mark.parametrize('test_cases', [0, 1, 2]) def test_db_inode_check(test_cases, get_configuration, configure_environment, restart_syscheck_function, wait_for_fim_start_function): - """Test to check for false positives due to possible inconsistencies with inodes in the database. - Cases: - - With check_mtime="no" and check_inode="no", no modification events should appear. - - With check_mtime="yes" and check_inode="yes", modification events should have: - "changed_attributes":["mtime","inode"] - - Args: - test_added (boolean): variable to set whether the test will add one more or one less file. - get_configuration (fixture): Function to access the configuration in use. - configure_environment (fixture): Fixture to prepare the environment to pass the test - restart_syscheck_function (fixture): Restart syscheck and truncate the log file with function scope. - wait_for_fim_start_function (fixture): Wait until the log 'scan end' appear, with function scope. - - Raises: - AttributeError: If an wrong or unexpected modified event appear - """ - + ''' + description: Check for false positives due to possible inconsistencies with inodes in the FIM database. + For example, with 'check_mtime=no' and 'check_inode=no', no modification events should appear, + and using 'check_mtime=yes' and 'check_inode=yes', since the 'mtime' and 'inode' attributes + are modified, modification events should appear. + For this purpose, the test will monitor a folder using the 'scheduled' monitoring mode, + create ten files with some content and wait for the scan. Then, remove the files and + create them again (adding one more at the beginning or deleting it) with different inodes. + Finally, the test changes the system time until the next scheduled scan and check + if there are any unexpected events in the log. + + wazuh_min_version: 4.2.0 + + parameters: + - test_cases: + type: int + brief: Test case number. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that the FIM database does not become inconsistent due to the change of inodes, + whether or not 'check_mtime' and 'check_inode' are enabled. + + input_description: Two test cases defined in this module, and the configuration settings for + the 'wazuh-syscheckd' daemon (tag ossec_conf) which are contained in external + YAML file (wazuh_conf_check_inodes.yaml). + + expected_output: + - r'.*Sending FIM event: (.+)$' + + tags: + - scheduled + - time_travel + ''' check_apply_test({'ossec_conf'}, get_configuration['tags']) aux_file_list = file_list.copy() diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_delete_folder.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_delete_folder.py index 080902cf0b..0cbe060e24 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_delete_folder.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_delete_folder.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if when a monitored folder is deleted, + the files inside it generate FIM events of the type 'deleted'. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import shutil from collections import Counter @@ -53,23 +123,59 @@ def get_configuration(request): def test_delete_folder(folder, file_list, filetype, tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if syscheckd detects 'deleted' events from the files contained - in a folder that is being deleted. - - If we are monitoring /testdir and we have r1, r2, r3 withing /testdir, if we delete /testdir, - we must see 3 events of the type 'deleted'. One for each one of the regular files. - - Parameters - ---------- - folder : str - Directory where the files will be created. - file_list : list - Names of the files. - filetype : str - Type of the files that will be created. - """ - + ''' + description: Check if the 'wazuh-syscheckd' daemon detects 'deleted' events from the files contained + in a folder that is being deleted. For example, the folder '/testdir' is monitored, and + the files 'r1', 'r2' and 'r3' are inside '/testdir'. If '/testdir' is deleted, three + events of type 'deleted' must be generated, one for each of the regular files. + For this purpose, the test will monitor a folder using the 'scheduled' monitoring mode, + create the testing files inside it, and change the system time until the next + scheduled scan. Then, remove the monitored folder, and finally, the test + verifies that the 'deleted' events have been generated. + + wazuh_min_version: 4.2.0 + + parameters: + - folder: + type: str + brief: Path to the monitored testing directory. + - file_list: + type: list + brief: Used names for the testing files. + - filetype: + type: str + brief: Type of the testing file. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that when a monitored folder is deleted, the files inside it + generate FIM events of the type 'deleted'. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' + + tags: + - scheduled + - time_travel + ''' check_apply_test(tags_to_apply, get_configuration['tags']) scheduled = get_configuration['metadata']['fim_mode'] == 'scheduled' mode = get_configuration['metadata']['fim_mode'] @@ -80,8 +186,8 @@ def test_delete_folder(folder, file_list, filetype, tags_to_apply, check_time_travel(scheduled, monitor=wazuh_log_monitor) events = wazuh_log_monitor.start(timeout=global_parameters.default_timeout, callback=callback_detect_event, - accum_results=len(file_list), error_message='Did not receive expected ' - '"Sending FIM event: ..." event').result() + accum_results=len(file_list), + error_message='Did not receive expected "Sending FIM event: ..." event').result() for ev in events: validate_event(ev, mode=mode) diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_dir_with_commas.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_dir_with_commas.py index 4098964795..5dd7354a24 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_dir_with_commas.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_dir_with_commas.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when + these files are modified. Specifically, these tests will check if FIM events are generated + on a monitored folder whose name contains commas. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 2 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import pytest @@ -48,9 +118,49 @@ def get_configuration(request): ]) def test_directories_with_commas(directory, get_configuration, put_env_variables, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Test alerts are generated when monitor environment variables - """ + ''' + description: Check if the 'wazuh-syscheckd' daemon generates FIM events from monitoring folders + whose name contains commas. For this purpose, the test will monitor a testing folder + using the 'scheduled' monitoring mode, and create the testing files inside it. + Then, perform CUD (creation, update, and delete) operations and finally verify that + the FIM events are generated correctly. + + wazuh_min_version: 4.2.0 + + parameters: + - directory: + type: str + brief: Path to the monitored testing directory. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - put_env_variables: + type: fixture + brief: Create environment variables. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events are generated on a monitored folder whose name contains commas. + + input_description: A test case is contained in external YAML file (wazuh_conf.yaml) which includes + configuration settings for the 'wazuh-syscheckd' daemon and, it is combined with + the testing directories to be monitored defined in this module. + + expected_output: + - Multiple FIM events logs of the monitored directories. + + tags: + - scheduled + - time_travel + ''' check_apply_test({'ossec_conf'}, get_configuration['tags']) regular_file_cud(directory, wazuh_log_monitor, file_list=["testing_env_variables"], diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_disabled.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_disabled.py index 898ea05fd9..239ea955e5 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_disabled.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_disabled.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will verify that when the 'wazuh-syscheckd' daemon + is disabled, no FIM events are generated. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import pytest @@ -45,19 +115,43 @@ def get_configuration(request): # tests def test_disabled(get_configuration, configure_environment, restart_syscheckd): - """Check if syscheckd sends events when disabled="yes". - - Parameters - ---------- - folder : str - Path where files will be created. - """ + ''' + description: Check if the 'wazuh-syscheckd' daemon generates FIM events when it is disabled + in the main configuration file. For this purpose, the test will monitor a testing + folder and finally verifies that no FIM events have been generated. + + wazuh_min_version: 4.2.0 + + parameters: + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + + assertions: + - Verify that when the 'wazuh-syscheckd' daemon is disabled, no FIM events are generated. + + input_description: A test case is contained in external YAML file (wazuh_conf_disabled.yaml) which + includes configuration settings for the 'wazuh-syscheckd' daemon and, it is combined + with the testing directory to be monitored defined in this module. + + expected_output: + - No FIM events should be generated. + + tags: + - scheduled + ''' # Expect a timeout when checking for syscheckd initial scan with pytest.raises(TimeoutError): event = wazuh_log_monitor.start(timeout=10, callback=callback_detect_end_scan) raise AttributeError(f'Unexpected event {event}') - # Use `regular_file_cud` and don't expect any event + # Use 'regular_file_cud' and don't expect any event scheduled = get_configuration['metadata']['fim_mode'] == 'scheduled' if scheduled: with pytest.raises(TimeoutError): diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_entries_match_path_count.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_entries_match_path_count.py index 48d53d272d..b1e9d3a98e 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_entries_match_path_count.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_entries_match_path_count.py @@ -1,7 +1,83 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when + these files are modified. In particular, these tests will verify that when using 'hard' and + 'symbolic' links, the FIM events contain the number of inodes and paths to files consistent. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + - macos + - solaris + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + - macOS Catalina + - Solaris 10 + - Solaris 11 + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + - https://en.wikipedia.org/wiki/Inode + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import pytest @@ -51,12 +127,45 @@ def extra_configuration_before_yield(): def test_entries_match_path_count(get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if FIM entries match the path count - - It creates two regular files, a symlink and a hard link before the scan begins. After events are logged, - we should have 3 inode entries and a path count of 4. - """ + ''' + description: Check if FIM events contain the correct number of file paths when 'hard' + and 'symbolic' links are used. For this purpose, the test will monitor + a testing folder and create two regular files, a 'symlink' and a 'hard link' + before the scan starts. Finally, it verifies in the generated FIM event + that three inodes and four file paths are detected. + + wazuh_min_version: 4.2.0 + + parameters: + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that when using hard and symbolic links, the FIM events contain + the number of inodes and paths to files consistent. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Fim inode entries*, path count' (If the OS used is not Windows) + - r'.*Fim entries' (If the OS used is Windows) + + tags: + - scheduled + - time_travel + ''' check_apply_test({'ossec_conf'}, get_configuration['tags']) entries, path_count = wazuh_log_monitor.start(timeout=global_parameters.default_timeout, diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_move_dir.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_move_dir.py index 6a4d476dc8..fd3b622107 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_move_dir.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_move_dir.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when + these files are modified. Specifically, these tests will check if FIM events are generated + when subfolders are moved between monitored directories. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import shutil import sys @@ -70,24 +140,61 @@ def extra_configuration_after_yield(): ]) def test_move_dir(source_folder, target_folder, subdir, tags_to_apply, triggers_delete_event, triggers_add_event, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if syscheckd detects 'added' or 'deleted' events when moving a - subfolder from a folder to another one. - - Parameters - ---------- - subdir : str - Name of the subdir to be moved. - source_folder : str - Folder to move the file from. - target_folder : str - Destination folder to move the file to. - triggers_delete_event : bool - Expect a 'deleted' event in the source folder. - triggers_add_event : bool - Expect a 'added' event in the target folder. - """ - + ''' + description: Check if the 'wazuh-syscheckd' daemon detects 'added' and 'deleted' events when moving a subdirectory + from a monitored folder to another one. For this purpose, the test will move a testing subfolder + from the source directory to the target directory and change the system time until the next + scheduled scan. Finally, it verifies that the expected FIM events have been generated. + + wazuh_min_version: 4.2.0 + + parameters: + - source_folder: + type: str + brief: Path to the source directory where the subfolder to move is located. + - target_folder: + type: str + brief: Path to the destination directory where the subfolder will be moved. + - subdir: + type: str + brief: Name of the subfolder to be moved. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - triggers_delete_event: + type: bool + brief: True if it expects a 'deleted' event in the source folder. False otherwise. + - triggers_add_event: + type: bool + brief: True if it expects an 'added' event in the target folder. False otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events of type 'added' and 'deleted' are generated + when subfolders are moved between monitored directories. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('added' and 'deleted' events) + + tags: + - scheduled + - time_travel + ''' check_apply_test(tags_to_apply, get_configuration['tags']) scheduled = get_configuration['metadata']['fim_mode'] == 'scheduled' mode = get_configuration['metadata']['fim_mode'] diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_move_file.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_move_file.py index 5d1a8f3498..760e32cff3 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_move_file.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_move_file.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if FIM events are generated when files + are moved between monitored directories. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import pytest @@ -58,25 +128,65 @@ def test_move_file(file, file_content, tags_to_apply, source_folder, target_fold triggers_delete_event, triggers_add_event, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if syscheckd detects 'added' or 'deleted' events when moving a file. - - Parameters - ---------- - file : str - Name of the file to be created. - file_content : str - Content of the file to be created. - source_folder : str - Folder to move the file from. - target_folder : str - Destination folder to move the file to. - triggers_delete_event : bool - Expects a 'deleted' event in the `source_folder`. - triggers_add_event : bool - Expects a 'added' event in the `target_folder`. - """ - + ''' + description: Check if the 'wazuh-syscheckd' daemon detects 'added' and 'deleted' events when moving a file + from a monitored folder to another one. For this purpose, the test will create a testing file and + move it from the source directory to the target directory. Then, it changes the system time until + the next scheduled scan, and finally, it removes the testing file and verifies that + the expected FIM events have been generated. + + wazuh_min_version: 4.2.0 + + parameters: + - file: + type: str + brief: Name of the testing file to be created. + - file_content: + type: str + brief: Content of the testing file to be created. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - source_folder: + type: str + brief: Path to the source directory where the testing file to move is located. + - target_folder: + type: str + brief: Path to the destination directory where the testing file will be moved. + - triggers_delete_event: + type: bool + brief: True if it expects a 'deleted' event in the source folder. False otherwise. + - triggers_add_event: + type: bool + brief: True if it expects an 'added' event in the target folder. False otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events of type 'added' and 'deleted' are generated + when files are moved between monitored directories. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('added' and 'deleted' events) + + tags: + - scheduled + - time_travel + ''' check_apply_test(tags_to_apply, get_configuration['tags']) scheduled = get_configuration['metadata']['fim_mode'] == 'scheduled' mode = get_configuration['metadata']['fim_mode'] diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_new_dirs.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_new_dirs.py index f5a399213f..3351f9c5b8 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_new_dirs.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_new_dirs.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when + these files are modified. Specifically, these tests will check if FIM events are generated + after the next scheduled scan using the 'scheduled' monitoring mode. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import shutil import sys @@ -63,20 +133,44 @@ def extra_configuration_after_yield(): {'ossec_conf'} ]) def test_new_directory(tags_to_apply, get_configuration, configure_environment, restart_syscheckd): - """ - Check that a new monitored directory generates events after the next scheduled scan. - - This test performs the following steps: - - Monitor a directory that does not exist. - - Create the directory with files inside. Check that this does not produce events in ossec.log. - - Move time forward to the next scheduled scan. - - Check that now creating files within the directory do generate events. - - Parameters - ---------- - tags_to_apply : set - Run test if matches with a configuration identifier, skip otherwise - """ + ''' + description: Check if the 'wazuh-syscheckd' daemon detects 'CUD' (creation, update, and delete) events after + the next scheduled scan. For this purpose, the test will create a monitored folder and several + testing files inside it. Then, it will perform different operations over the testing files and + verify that no events are generated before the next scheduled scan. Finally, the test + will perform operations on another set of testing files and wait to the next scheduled scan for + the expected FIM events to be generated. + + wazuh_min_version: 4.2.0 + + parameters: + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + + assertions: + - Verify that FIM events are generated after the next scheduled scan using the 'scheduled' monitoring mode. + + input_description: A test case (ossec_conf) is contained in external YAML file + (wazuh_conf_new_dirs.yaml or wazuh_conf_new_dirs_win32.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('added', 'modified', and 'deleted' events) + + tags: + - scheduled + ''' check_apply_test(tags_to_apply, get_configuration['tags']) if sys.platform != 'win32': diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_no_dir.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_no_dir.py index 7a3649e436..47d1514779 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_no_dir.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_no_dir.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if the 'wazuh-syscheckd' daemon generates + a debug log when the 'directories' configuration tag is empty. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import pytest @@ -49,13 +119,44 @@ def get_configuration(request): {'ossec_conf'} ]) def test_new_directory(tags_to_apply, get_configuration, configure_environment, restart_syscheckd): - """Verify that syscheck shows a debug message when an empty directories tag is found. - - Parameters - ---------- - tags_to_apply : set - Run test if matches with a configuration identifier, skip otherwise - """ + ''' + description: Check if the 'wazuh-syscheckd' daemon shows a debug message when an empty 'directories' tag is found. + For this purpose, the test uses a configuration without specifying the directory to monitor. + It will then verify that the appropriate debug message is generated. Finally, the test will use + a valid directory and verify that the above message is not generated. + + wazuh_min_version: 4.2.0 + + parameters: + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + + assertions: + - Verify that the 'wazuh-syscheckd' daemon generates a debug log when + the 'directories' configuration tag is empty. + - Verify that the 'wazuh-syscheckd' daemon does not generate a debug log when + the 'directories' configuration tag is not empty. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'Empty directories tag found in the configuration.' + + tags: + - scheduled + ''' check_apply_test(tags_to_apply, get_configuration['tags']) # Check that the warning is displayed when there is no directory. diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_quick_changes.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_quick_changes.py index 0e58fbb217..cae10c59e9 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_quick_changes.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_quick_changes.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if FIM events of type 'added', 'modified', + and 'deleted' are generated when the related operations are performed in specific time intervals. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import time @@ -55,15 +125,49 @@ def get_configuration(request): ]) def test_regular_file_changes(sleep, tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if syscheckd detects regular file changes (add, modify, delete) with a very specific delay between every - action. - - Parameters - ---------- - sleep : float - Delay in seconds between every action. - """ + ''' + description: Check if the 'wazuh-syscheckd' regular file changes (add, modify, delete) with a very specific delay + between every operation. For this purpose, the test will perform the above operations over + a testing file and wait for the specified time between each operation. Finally, the test + will check that the expected FIM events have been generated. + + wazuh_min_version: 4.2.0 + + parameters: + - sleep: + type: float + brief: Delay in seconds between every action. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events of type 'added', 'modified', and 'deleted' are generated + when the related operations are performed in specific time intervals. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' + + tags: + - realtime + - who-data + ''' check_apply_test(tags_to_apply, get_configuration['tags']) file = 'regular' diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_rename.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_rename.py index e3d3325f91..958d51e811 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_rename.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_rename.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if FIM events of type 'added' and 'deleted' + are generated when monitored directories or files are renamed. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import shutil @@ -66,17 +136,55 @@ def clean_directories(request): def test_rename(folder, tags_to_apply, get_configuration, clean_directories, configure_environment, restart_syscheckd, wait_for_fim_start): - """ - Check if syscheckd detects events when renaming directories or files. - - If we rename a directory or file, we expect 'deleted' and 'added' events. - - Parameters - ---------- - folder : str - Directory where the files will be created. - """ - + ''' + description: Check if the 'wazuh-syscheckd' daemon detects events when renaming directories or files. + When changing directory or file names, FIM events of type 'deleted' and 'added' + should be generated. For this purpose, the test will create the directory and testing files + to be monitored and verify that they have been created correctly. It will then verify two cases, + on the one hand that the proper FIM events are generated when the testing files are renamed + in the monitored directory, and on the other hand, that these events are generated + when the monitored directory itself is renamed. + + wazuh_min_version: 4.2.0 + + parameters: + - folder: + type: str + brief: Path to the directory where the files will be created. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - clean_directories: + type: fixture + brief: Delete the contents of the testing directory. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events of type 'added' and 'deleted' are generated + when monitored directories or files are renamed. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('added' and 'deleted' events) + + tags: + - scheduled + - time_travel + ''' def expect_events(path): event = wazuh_log_monitor.start(timeout=global_parameters.default_timeout, callback=callback_detect_event, diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_starting_agent.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_starting_agent.py index 7446745599..2f6650bb0e 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_starting_agent.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_starting_agent.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if FIM events of type 'modified' and + 'deleted' are generated when files that exist before starting the Wazuh agent are modified. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import pytest @@ -65,7 +135,50 @@ def get_configuration(request): ]) def test_events_from_existing_files(filename, tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """Check if syscheck generates modified alerts for files that exists when starting the agent""" + ''' + description: Check if the 'wazuh-syscheckd' daemon detects 'modified' and 'deleted' events when modifying + files that exist before the Wazuh agent is started. For this purpose, the test will modify + the testing file, change the system time to the next scheduled scan, and verify that + the proper FIM event is generated. Finally, the test will perform + the above steps but deleting the testing file. + + wazuh_min_version: 4.2.0 + + parameters: + - filename: + type: str + brief: Name of the testing file to be modified. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait for realtime start, whodata start, or end of initial FIM scan. + + assertions: + - Verify that FIM events of type 'modified' and 'deleted' are generated + when files that exist before starting the Wazuh agent are modified. + + input_description: A test case (ossec_conf) is contained in external YAML file (wazuh_conf.yaml) + which includes configuration settings for the 'wazuh-syscheckd' daemon and, it + is combined with the testing directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('modified' and 'deleted' events) + + tags: + - scheduled + - time_travel + ''' check_apply_test(tags_to_apply, get_configuration['tags']) scheduled = get_configuration['metadata']['fim_mode'] == 'scheduled' mode = get_configuration['metadata']['fim_mode'] diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_wildcards.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_wildcards.py index 4828f1d21e..8e2f73d266 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_wildcards.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_wildcards.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if FIM monitors newly added directories + that match a wildcard used in the configuration. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import sys import pytest @@ -56,27 +126,53 @@ def get_configuration(request): @pytest.mark.parametrize('tags_to_apply', [{'ossec_conf_wildcards'}]) def test_basic_usage_wildcards(subfolder_name, file_name, tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_fim_start): - """Test the correct expansion of wildcards for monitored directories in syscheck - - The following wildcards expansions will be tried against the directory list: - - test_folder/simple? will match simple? - - test_folder/star* will match stars123 - - test_folder/*ple* will match simple1 and multiple_1 - - not_monitored_directory won't match any of the previous expressions - - For each subfolder there will be three different calls to regular_file_cud and - for every subfolder the variable triggers_event will be set properly depending on the - wildcards matching of the subfolder. - - Params: - subfolder_name (str): Name of the subfolder under root folder. - file_name (str): Name of the file that will be created under subfolder. - tags_to_apply (str): Value holding the configuration used in the test. - get_configuration (fixture): Gets the current configuration of the test. - configure_environment (fixture): Configure the environment for the execution of the test. - restart_syscheckd (fixture): Restarts syscheck. - wait_for_fim_start (fixture): Waits until the first FIM scan is completed. - """ + ''' + description: Check if the number of directories to monitor grows when using wildcards to specify them. + For this purpose, the test creates a set of directories that match the wildcard expressions + and ones that do not match the expressions set in the directories to be monitored. + Then, the test will create, modify and delete files inside a folder given as an argument. + Finally, the test will wait for events only if the folder where the changes are made + matches the expression previously set in the 'wazuh-syscheckd' daemon configuration. + + wazuh_min_version: 4.2.0 + + parameters: + - subfolder_name: + type: str + brief: Path to the subdirectory in the monitored folder. + - filename: + type: str + brief: Name of the testing file that will be created in the subfolder. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_fim_start: + type: fixture + brief: Wait until the first FIM scan is completed. + + assertions: + - Verify that FIM monitors newly added directories that match a wildcard used in the configuration. + + input_description: A test case (ossec_conf_wildcards) is contained in external YAML file + (wazuh_conf_wildcards.yaml) which includes configuration settings for + the 'wazuh-syscheckd' daemon and, it is combined with the testing + directories to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('added', 'modified' and 'deleted' events) + + tags: + - scheduled + ''' if sys.platform == 'win32': if '?' in file_name or '*' in file_name: pytest.skip("Windows can't create files with wildcards.") diff --git a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_wildcards_runtime.py b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_wildcards_runtime.py index 4a281c5df6..a8941de582 100644 --- a/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_wildcards_runtime.py +++ b/tests/integration/test_fim/test_files/test_basic_usage/test_basic_usage_wildcards_runtime.py @@ -1,7 +1,77 @@ -# Copyright (C) 2015-2021, Wazuh Inc. -# Created by Wazuh, Inc. . -# This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 - +''' +copyright: Copyright (C) 2015-2021, Wazuh Inc. + + Created by Wazuh, Inc. . + + This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 + +type: integration + +brief: File Integrity Monitoring (FIM) system watches selected files and triggering alerts when these + files are modified. Specifically, these tests will check if FIM monitors newly added directories + that match a wildcard used in the configuration. + The FIM capability is managed by the 'wazuh-syscheckd' daemon, which checks configured files + for changes to the checksums, permissions, and ownership. + +tier: 0 + +modules: + - fim + +components: + - agent + - manager + +daemons: + - wazuh-syscheckd + +os_platform: + - linux + - windows + +os_version: + - Arch Linux + - Amazon Linux 2 + - Amazon Linux 1 + - CentOS 8 + - CentOS 7 + - CentOS 6 + - Ubuntu Focal + - Ubuntu Bionic + - Ubuntu Xenial + - Ubuntu Trusty + - Debian Buster + - Debian Stretch + - Debian Jessie + - Debian Wheezy + - Red Hat 8 + - Red Hat 7 + - Red Hat 6 + - Windows 10 + - Windows 8 + - Windows 7 + - Windows Server 2019 + - Windows Server 2016 + - Windows Server 2012 + - Windows Server 2003 + - Windows XP + +references: + - https://documentation.wazuh.com/current/user-manual/capabilities/file-integrity/index.html + - https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/syscheck.html + +pytest_args: + - fim_mode: + realtime: Enable real-time monitoring on Linux (using the 'inotify' system calls) and Windows systems. + whodata: Implies real-time monitoring but adding the 'who-data' information. + - tier: + 0: Only level 0 tests are performed, they check basic functionalities and are quick to perform. + 1: Only level 1 tests are performed, they check functionalities of medium complexity. + 2: Only level 2 tests are performed, they check advanced functionalities and are slow to perform. + +tags: + - fim_basic_usage +''' import os import sys import pytest @@ -48,7 +118,7 @@ def wait_for_initial_scan(): @pytest.fixture() def create_test_folders(): - """Fixture that creates all the folders specified in the `test_subdirectories` list""" + """Fixture that creates all the folders specified in the 'test_subdirectories' list""" for dir in test_subdirectories: recursive_directory_creation(os.path.join(test_folder, dir)) @@ -74,22 +144,61 @@ def get_configuration(request): def test_basic_usage_wildcards_runtime(subfolder_name, file_name, tags_to_apply, get_configuration, configure_environment, restart_syscheckd, wait_for_initial_scan, create_test_folders, wait_for_wildcards_scan): - """Test the expansion once a given directory matches a configured expresion. - - The test monitors a given expresion and will create folders that match the configured expresion. It also creates - folders that doesn't match the expresion and check that no event is triggered if changes are made inside a folder - that doesn't match the glob expresion. - Params: - subfolder_name (str): Name of the subfolder under root folder. - file_name (str): Name of the file that will be created under subfolder. - tags_to_apply (str): Value holding the configuration used in the test. - get_configuration (fixture): Gets the current configuration of the test. - configure_environment (fixture): Configure the environment for the execution of the test. - restart_syscheckd (fixture): Restarts syscheck. - wait_for_initial_scan (fixture): Waits until the first FIM scan is completed. - create_test_folders (fixture): Creates the folders that will match (or not) the configured glob expresion. - wait_for_wildcards_scan (fixture): Waits until the end of wildcards scan event is triggered. - """ + ''' + description: Check if the number of directories to monitor grows when using wildcards to specify them. + For this purpose, the test will configure wildcards expressions and create an empty folder. + Once the FIM module has started, and the 'baseline' scan is completed, the test will create + folders that may match a configured expression, and it waits until the wildcards are expanded + again (in the next scan). Once the wildcards are reloaded, the test will create, modify and + delete files inside those folders. Finally, the test will wait for events of a folder + only if it matches a configured expression. + + wazuh_min_version: 4.2.0 + + parameters: + - subfolder_name: + type: str + brief: Path to the subdirectory in the monitored folder. + - filename: + type: str + brief: Name of the testing file that will be created in the subfolder. + - tags_to_apply: + type: set + brief: Run test if match with a configuration identifier, skip otherwise. + - get_configuration: + type: fixture + brief: Get configurations from the module. + - configure_environment: + type: fixture + brief: Configure a custom environment for testing. + - restart_syscheckd: + type: fixture + brief: Clear the 'ossec.log' file and start a new monitor. + - wait_for_initial_scan: + type: fixture + brief: Wait until the first FIM scan is completed. + - create_test_folders: + type: fixture + brief: Create the testing folders that will match (or not) the configured glob expression. + - wait_for_wildcards_scan: + type: fixture + brief: Wait until the end of wildcards scan event is triggered. + + assertions: + - Verify that FIM monitors newly added directories that match a wildcard used in the configuration. + + input_description: A test case (ossec_conf_wildcards_runtime) is contained in external YAML file + (wazuh_conf_wildcards_rt.yaml) which includes configuration settings for + the 'wazuh-syscheckd' daemon and, it is combined with the testing directories + to be monitored defined in this module. + + expected_output: + - r'.*Sending FIM event: (.+)$' ('added', 'modified' and 'deleted' events) + + tags: + - scheduled + - who-data + ''' check_apply_test(tags_to_apply, get_configuration['tags']) if sys.platform == 'win32': if '?' in file_name or '*' in file_name: From f84fa24367269f080971db628e82ca320c42d65d Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 2 Nov 2021 10:50:47 +0100 Subject: [PATCH 153/181] fix: modified pytest traceback results from qa-ctl #2140 --- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index a0a5828ed9..e641453abd 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -1,5 +1,5 @@ import os - +import re from datetime import datetime from tempfile import gettempdir from wazuh_testing.qa_ctl.run_tests.test_result import TestResult @@ -194,8 +194,28 @@ def run(self, ansible_inventory_path): plain_report_file_path=os.path.join(self.tests_result_path, plain_report_file_name), test_name=self.tests_path) + # Trim the result report for a more simple and readable output + output_result = str(self.result) + error_fail_pattern = re.compile('^=*.(ERRORS|FAILURES|short test summary info).*=$', re.M) + test_summary_pattern = re.compile('^=*.(short test summary info).*=$', re.M) + + error_case = re.search(error_fail_pattern, output_result) + if error_case is not None: + test_summary_case = re.search(test_summary_pattern, output_result) + if test_summary_case is not None: + test_result_message = test_summary_case.group(0) + result_output = output_result[output_result.index(test_result_message):] + + error_case_message = error_case.group(0) + trimmed_output = output_result[:output_result.index(error_case_message)] + trimmed_output += result_output # Print test result in stdout - if self.qa_ctl_configuration.logging_enable: - Pytest.LOGGER.info(self.result) + if self.qa_ctl_configuration.logging_enable: + Pytest.LOGGER.info(trimmed_output) + else: + print(trimmed_output) else: - print(self.result) + if self.qa_ctl_configuration.logging_enable: + Pytest.LOGGER.info(self.result) + else: + print(self.result) From ce229e8fd50c8fffd6e6b092d39ed4539527b938 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 2 Nov 2021 11:27:17 +0100 Subject: [PATCH 154/181] add: Add `--check-documentation` flag that allows `qa-docs` tool to check if test(s) are documentated following the `qa-docs` schema. #1864 --- .../wazuh_testing/qa_docs/doc_generator.py | 21 +++++++++++++++++-- .../wazuh_testing/qa_docs/lib/config.py | 5 ++++- .../wazuh_testing/scripts/qa_docs.py | 18 +++++++++++----- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index f83b57dc4a..abe84e2604 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -220,10 +220,14 @@ def create_test(self, path, group_id, test_name=None): self.dump_output(test, doc_path) DocGenerator.LOGGER.debug(f"New documentation file '{doc_path}' " - "was created with ID:{self.__id_counter}") + f"was created with ID:{self.__id_counter}") return self.__id_counter elif self.conf.mode == Mode.PARSE_TESTS: + # If qa-docs is run with --check-doc flag then the output files wont be generated + if self.conf.check_doc: + return + if self.conf.documentation_path: doc_path = self.conf.documentation_path doc_path = os.path.join(doc_path, test_name) @@ -292,7 +296,7 @@ def locate_test(self, test_name): def check_test_exists(self, path): """Check that a test exists within the tests path input. - + Args: path (str): A string with the tests path. """ @@ -302,6 +306,16 @@ def check_test_exists(self, path): else: print(f'{test_name} does not exist in {path}') + def check_documentation(self): + for test_name in self.conf.test_names: + test_path = self.locate_test(test_name) + test = self.parser.parse_test(test_path, self.__id_counter, 0) + + if test: + print(f"{test_name} is documented using qa-docs current schema") + else: + print(f"{test_name} is not documented using qa-docs current schema") + def print_test_info(self, test): """Print the test info to standard output. @@ -340,3 +354,6 @@ def run(self): elif self.conf.mode == Mode.PARSE_TESTS: self.parse_test_list() + + if not self.conf.check_doc: + DocGenerator.LOGGER.info(f"Run completed, documentation location: {self.conf.documentation_path}") diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py index 8136e07142..4dd825ea76 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/config.py @@ -39,7 +39,8 @@ class Config(): """ LOGGER = Logging.get_logger(QADOCS_LOGGER) - def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_modules=None, test_names=None): + def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_modules=None, test_names=None, + check_doc=False): """Constructor that loads the schema file and set the `qa-docs` configuration. If a test name is passed, it would be run in `single test mode`. @@ -57,6 +58,7 @@ def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_ test_types (list): A list that contains the test type(s) that the user specifies. test_modules (list): A list that contains the test module(s) that the user specifies. test_names (list): A list that contains the test name(s) that the user specifies. + check_dock (boolean): Flag to indicate if the test specified (with -t parameter) is documented. """ self.mode = Mode.DEFAULT self.project_path = test_dir @@ -71,6 +73,7 @@ def __init__(self, schema_path, test_dir, output_path='', test_types=None, test_ self.test_types = [] self.test_modules = [] self.predefined_values = {} + self.check_doc = check_doc self.__read_schema_file(schema_path) self.__read_output_fields() diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index a79a2cad2f..a57e6efb81 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -97,6 +97,9 @@ def get_parameters(): parser.add_argument('-e', '--exist', nargs='+', default=[], dest='test_exist', help="Checks if test(s) exist or not.",) + parser.add_argument('--check-documentation', action='store_true', dest='check_doc', + help="Checks if test(s) are correctly documentated according to qa-docs current schema.",) + return parser.parse_args(), parser @@ -170,7 +173,8 @@ def validate_parameters(parameters, parser): for test_name in parameters.test_names: if doc_check.locate_test(test_name) is None: - raise QAValueError(f"{test_name} has not been not found in {parameters.tests_path}.", qadocs_logger.error) + raise QAValueError(f"{test_name} has not been not found in " + f"{parameters.tests_path}.", qadocs_logger.error) # Check that the index exists if parameters.app_index_name: @@ -217,10 +221,12 @@ def parse_data(args): # When output path is specified by user, a json is generated within that path if args.output_path: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, args.output_path, test_names=args.test_names)) - # When no output is specified, it is printed + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, args.output_path, test_names=args.test_names, + check_doc=args.check_doc)) + # When no output is specified, it is generated within the default qa-docs output folder else: - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, test_names=args.test_names)) + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH, test_names=args.test_names, + check_doc=args.check_doc)) # Parse a list of test types elif args.test_types: @@ -240,9 +246,11 @@ def parse_data(args): docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) docs.run() - if args.test_types or args.test_modules or args.test_names: + if args.test_types or args.test_modules or args.test_names and not args.check_doc: qadocs_logger.info('Running QADOCS') docs.run() + elif args.test_names and args.check_doc: + docs.check_documentation() def index_and_visualize_data(args): From db45b73816a018127a9060296a12e013f4028bb3 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 2 Nov 2021 11:41:03 +0100 Subject: [PATCH 155/181] fix: Fix `--check-documentation` flag when the test is not documented. #1864 --- deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py index abe84e2604..5670e03495 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/doc_generator.py @@ -309,12 +309,14 @@ def check_test_exists(self, path): def check_documentation(self): for test_name in self.conf.test_names: test_path = self.locate_test(test_name) - test = self.parser.parse_test(test_path, self.__id_counter, 0) + try: + test = self.parser.parse_test(test_path, self.__id_counter, 0) + except Exception as qaerror: + test = None + print(f"{test_name} is not documented using qa-docs current schema") if test: print(f"{test_name} is documented using qa-docs current schema") - else: - print(f"{test_name} is not documented using qa-docs current schema") def print_test_info(self, test): """Print the test info to standard output. From 2d697c25b77907de1bb09a1c7c25f0b8fc3efa3c Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Tue, 2 Nov 2021 12:31:35 +0100 Subject: [PATCH 156/181] add: Add new qa-ctl test documentation validation #2023 --- deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index aac4088e1d..f73a1cf0dc 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -199,11 +199,17 @@ def validate_parameters(parameters): if parameters.run_test: for test in parameters.run_test: tests_path = os.path.join(WAZUH_QA_FILES, 'tests') + # Validate if the specified tests exist if f"{test} exists" not in local_actions.run_local_command_with_output(f"qa-docs -e {test} -I {tests_path} " ' --no-logging'): raise QAValueError(f"{test} does not exist in {tests_path}", qactl_logger.error, QACTL_LOGGER) - ## --> Add os validation for each test + # Validate if the selected tests are documented + test_documentation_check = local_actions.run_local_command_with_output(f"qa-docs -t {test} -I {tests_path} " + '--check-documentation --no-logging') + if f'{test} is not documented' in test_documentation_check: + raise QAValueError(f"{test} is not documented using qa-docs current schema", qactl_logger.error, + QACTL_LOGGER) qactl_logger.info('Input parameters validation has passed successfully') From 17a6d1eac5fc13c4357b165cca7a811b7250288c Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 2 Nov 2021 14:34:54 +0100 Subject: [PATCH 157/181] add: added output_trimmer function #2140 --- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index b37a324a0e..8d06053530 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -86,6 +86,36 @@ def __init__(self, tests_result_path, tests_path, tests_run_dir, qa_ctl_configur super().__init__(tests_path, tests_run_dir, tests_result_path, modules, component, system) + def __output_trimmer(self, result): + """This function trims the obtained results in order to get a more readable output information + when executing qa-ctl + + Args: + result (Test resutl object): object containing all the results obtained from the test + + Return: + output_result (string): String containing the trimmed output + """ + output_result = str(result) + error_fail_pattern = re.compile('^=*.(ERRORS|FAILURES).*=$', re.M) + test_summary_pattern = re.compile('^=*.(short test summary info).*=$', re.M) + + # Check for any error or failure message case in the test result output + error_case = re.search(error_fail_pattern, output_result) + if error_case is not None: + # Checks if there is any test summary info at the end of the result output + test_summary_case = re.search(test_summary_pattern, output_result) + test_summary_output = '' + if test_summary_case is not None: + test_result_message = test_summary_case.group(0) + test_summary_output = output_result[output_result.index(test_result_message):] + + error_case_message = error_case.group(0) + output_result = output_result[:output_result.index(error_case_message)] + output_result += test_summary_output + + return output_result + def run(self, ansible_inventory_path): """Executes the current test with the specified options defined in attributes and bring back the reports to the host machine. @@ -244,24 +274,11 @@ def run(self, ansible_inventory_path): test_name=self.tests_path) # Trim the result report for a more simple and readable output - output_result = str(self.result) - error_fail_pattern = re.compile('^=*.(ERRORS|FAILURES|short test summary info).*=$', re.M) - test_summary_pattern = re.compile('^=*.(short test summary info).*=$', re.M) - - # Checks for any error or failure message case in the test result output - error_case = re.search(error_fail_pattern, output_result) - if error_case is not None: - # Checks if there is any test summary info at the end of the result output - test_summary_case = re.search(test_summary_pattern, output_result) - test_summary_output = "" - if test_summary_case is not None: - test_result_message = test_summary_case.group(0) - test_summary_output = output_result[output_result.index(test_result_message):] - - error_case_message = error_case.group(0) - output_result = output_result[:output_result.index(error_case_message)] - output_result += test_summary_output - + if Pytest.LOGGER.level != 10: + output_result = self.__output_trimmer(self.result) + else: + output_result = str(self.result) + # Print test result in stdout if self.qa_ctl_configuration.logging_enable: if os.path.exists(self.result.plain_report_file_path): From 061075f905a94472bef9d719a0ab1eb806cf86d9 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Wed, 3 Nov 2021 16:53:54 +0100 Subject: [PATCH 158/181] add: Add os_system parameter test validation in qa-ctl #2139 --- .../wazuh_testing/scripts/qa_ctl.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index f73a1cf0dc..127f25ea80 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -20,6 +20,7 @@ from wazuh_testing.tools.exceptions import QAValueError from wazuh_testing.qa_ctl.configuration.config_generator import QACTLConfigGenerator from wazuh_testing.tools import github_checks +from wazuh_testing.tools import file from wazuh_testing.tools.github_api_requests import WAZUH_QA_REPO from wazuh_testing.qa_ctl.provisioning import local_actions from wazuh_testing.tools.file import recursive_directory_creation @@ -123,6 +124,10 @@ def set_parameters(parameters): level = 'DEBUG' if parameters.debug >= 1 else 'INFO' qactl_logger.set_level(level) + # Disable traceback if it is not run in DEBUG mode + if level != 'DEBUG': + sys.tracebacklimit = 0 + parameters.user_version = parameters.version if parameters.version else None try: @@ -162,6 +167,29 @@ def validate_parameters(parameters): QAValueError: If parameters are incompatible, or version has not a valid format, or the specified wazuh version has not been released, or wazuh QA branch does not exist (calculated from wazuh_version). """ + def _validate_tests_os(parameters): + for test in parameters.run_test: + tests_path = os.path.join(WAZUH_QA_FILES, 'tests') + test_documentation_command = f"qa-docs -I {tests_path} -t {test} -o {gettempdir()} --no-logging" + test_documentation_file_path = os.path.join(gettempdir(), f"{test}.json") + local_actions.run_local_command_with_output(test_documentation_command) + + test_data = json.loads(file.read_file(test_documentation_file_path)) + + for op_system in parameters.operating_systems: + # Check platform + platform = 'linux' if op_system == 'ubuntu' or op_system == 'centos' else op_system + if not platform in test_data['os_platform']: + raise QAValueError(f"The {test} test does not support the {op_system} system. Allowed platforms: " + f"{test_data['os_platform']} (ubuntu and centos are from linux platform)") + # Check os version + if len([os_version.lower() for os_version in test_data['os_version'] if op_system in os_version]) > 0: + raise QAValueError(f"The {test} test does not support the {op_system} system. Allowed operating " + f"system versions: {test_data['os_version']}") + # Clean the temporary files + for extension in ['.json', '.yaml']: + file.remove_file(os.path.join(gettempdir(), f"{test}{extension}")) + qactl_logger.info('Validating input parameters') # Check incompatible parameters @@ -210,6 +238,9 @@ def validate_parameters(parameters): if f'{test} is not documented' in test_documentation_check: raise QAValueError(f"{test} is not documented using qa-docs current schema", qactl_logger.error, QACTL_LOGGER) + # Validate the tests operating system compatibility if specified + if parameters.operating_systems: + _validate_tests_os(parameters) qactl_logger.info('Input parameters validation has passed successfully') From 2c70a9ad3694b52cf954ad59c97ee72d3821390b Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 5 Nov 2021 09:37:22 +0100 Subject: [PATCH 159/181] add: add new validation parameters to qa-ctl schema validator #2164 --- .../data/qactl_conf_validator_schema.json | 133 +++++++++++------- 1 file changed, 84 insertions(+), 49 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/data/qactl_conf_validator_schema.json b/deps/wazuh_testing/wazuh_testing/data/qactl_conf_validator_schema.json index 803af7d393..3e93dde811 100644 --- a/deps/wazuh_testing/wazuh_testing/data/qactl_conf_validator_schema.json +++ b/deps/wazuh_testing/wazuh_testing/data/qactl_conf_validator_schema.json @@ -9,7 +9,7 @@ "$id": "#/properties/deployment", "type": "object", "patternProperties": { - "^host[0-9]*$": { + "^host_[0-9]*$": { "type": "object", "properties": { "provider": { @@ -48,7 +48,8 @@ "type": "string" }, "vm_system": { - "type": "string" + "type": "string", + "enum": ["linux","windows"] }, "label": { "type": "string" @@ -117,7 +118,7 @@ "properties": { "hosts": { "patternProperties": { - "^host[0-9]*$": { + "^host_[0-9]*$": { "type": "object", "required": [ "host_info" @@ -126,31 +127,49 @@ "host_info": { "type": "object", "required": [ - "connection_method", - "host", - "user", - "password", - "connection_port", - "ansible_python_interpreter" + "ansible_connection", + "ansible_user", + "ansible_password", + "ansible_port", + "system", + "installation_files_path", + "ansible_python_interpreter", + "host" ], "properties": { - "connection_method": { + "ansible_connection": { "type": "string" }, "host": { "type": "string" }, - "user": { + "ansible_user": { "type": "string" }, - "password": { + "ansible_password": { "type": "string" }, - "connection_port": { + "ansible_port": { "type": "integer" }, "ansible_python_interpreter": { "type": "string" + }, + "installation_files_path": { + "type": "string" + }, + "system": { + "type": "string", + "enum": [ + "rpm", + "deb", + "windows", + "macos", + "solaris10", + "solaris11", + "rpm5", + "wpk-linux", + "wpk-windows"] } } }, @@ -159,7 +178,8 @@ "required": [ "type", "target", - "installation_files_path" + "installation_files_path", + "wazuh_install_path" ], "properties": { "type": { @@ -255,46 +275,55 @@ "tests": { "type": "object", "patternProperties": { - "^host[0-9]*$": { + "^host_[0-9]*$": { "type": "object", "required": ["host_info", "test"], "properties": { "host_info": { "type": "object", "required": [ - "connection_method", + "ansible_connection", + "ansible_user", + "ansible_port", + "installation_files_path", + "ansible_python_interpreter", "host", - "user", - "connection_port", - "ansible_python_interpreter" + "system" ], "properties": { - "connection_method": { + "ansible_connection": { "type" : "string" }, "host": { "type" : "string" }, - "user": { + "ansible_user": { "type": "string" }, - "password": { + "ansible_password": { "type": "string", "default": "empty" }, "ssh_private_key_file_path":{ "type": "string" }, - "connection_port": { + "ansible_port": { "type": "integer" }, "ansible_python_interpreter": { "type": "string" + }, + "installation_files_path": { + "type": "string" + }, + "system": { + "type": "string" } + }, "oneOf": [ { - "required": ["password"] + "required": ["ansible_password"] }, { "required": ["ssh_private_key_file_path"] @@ -303,7 +332,10 @@ }, "test": { "type": "object", - "required": ["path"], + "required": [ + "path", + "type" + ], "properties": { "hosts": { "type": "string" @@ -367,30 +399,33 @@ } } } - } - }, - "config": { - "$id": "#/properties/config", - "type": "object", - "patternProperties": { - "vagrant_output": { - "type": "boolean" - }, - "ansible_output": { - "type": "boolean" - }, - "logging":{ - "type": "object", - "properties": { - "enable": { - "type": "boolean" - }, - "level": { - "type": "string", - "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - }, - "file": { - "type": "string" + }, + "config": { + "$id": "#/properties/config", + "type": "object", + "patternProperties": { + "qa_ctl_launcher_branch": { + "type": "string" + }, + "vagrant_output": { + "type": "boolean" + }, + "ansible_output": { + "type": "boolean" + }, + "logging":{ + "type": "object", + "properties": { + "enable": { + "type": "boolean" + }, + "level": { + "type": "string", + "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + }, + "file": { + "type": "string" + } } } } From 8a6d4db807830e342645e7b982d1276a356f1621 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 5 Nov 2021 10:40:23 +0100 Subject: [PATCH 160/181] style: Fixe style errors in qa-ctl according to the PEP-8 standard. #2173 --- .../qa_ctl/configuration/config_generator.py | 9 ++++----- .../qa_ctl/deployment/vagrant_wrapper.py | 6 +++--- .../provisioning/ansible/ansible_instance.py | 1 + .../provisioning/ansible/ansible_runner.py | 5 +++-- .../ansible/unix_ansible_instance.py | 1 + .../ansible/windows_ansible_instance.py | 1 + .../qa_ctl/provisioning/local_actions.py | 4 ++-- .../provisioning/qa_framework/qa_framework.py | 4 ++-- .../qa_ctl/provisioning/qa_provisioning.py | 4 ++-- .../wazuh_deployment/agent_deployment.py | 6 +++--- .../wazuh_deployment/wazuh_deployment.py | 18 +++++++++--------- .../wazuh_deployment/wazuh_sources.py | 2 +- .../wazuh_testing/qa_ctl/run_tests/pytest.py | 19 +++++++++---------- .../qa_ctl/run_tests/qa_test_runner.py | 5 +++-- .../qa_ctl/run_tests/test_launcher.py | 6 ++---- 15 files changed, 46 insertions(+), 45 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 4d52bccc47..085d4d7d91 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -279,8 +279,8 @@ def __add_instance(self, os_version, test_name, test_target, os_platform): dict object: dict containing all the field required for generating a new vagrant box in the deployment module. """ - vm_cpu=1 - vm_memory=1024 + vm_cpu = 1 + vm_memory = 1024 if os_version in self.BOX_MAPPING: box = self.BOX_MAPPING[os_version] @@ -412,7 +412,7 @@ def __process_provision_data(self): s3_package_url = self.__get_package_url(instance) installation_files_path = QACTLConfigGenerator.BOX_INFO[vm_box]['installation_files_path'] system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] - wazuh_install_path = self.WINDOWS_DEFAULT_WAZUH_INSTALL_PATH if system == 'windows' else \ + wazuh_install_path = self.WINDOWS_DEFAULT_WAZUH_INSTALL_PATH if system == 'windows' else \ self.LINUX_DEFAULT_WAZUH_INSTALL_PATH self.config['provision']['hosts'][instance]['wazuh_deployment'] = { @@ -452,7 +452,7 @@ def __add_testing_config_block(self, instance, installation_files_path, system, self.config['tests'][instance] = {'host_info': {}, 'test': {}} self.config['tests'][instance]['host_info'] = \ dict(self.config['provision']['hosts'][instance]['host_info']) - wazuh_install_path = self.WINDOWS_DEFAULT_WAZUH_INSTALL_PATH if system == 'windows' else \ + wazuh_install_path = self.WINDOWS_DEFAULT_WAZUH_INSTALL_PATH if system == 'windows' else \ self.LINUX_DEFAULT_WAZUH_INSTALL_PATH self.config['tests'][instance]['test'] = { @@ -543,7 +543,6 @@ def __proces_config_info(self): self.config['config'] = {} self.config['config']['qa_ctl_launcher_branch'] = self.qa_branch - def run(self): """Run an instance with the parameters created. This generates the YAML configuration file automatically.""" info = self.__get_all_tests_info() diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrant_wrapper.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrant_wrapper.py index eb1be5c8ba..fcd994788b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrant_wrapper.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrant_wrapper.py @@ -5,15 +5,15 @@ import sys from shutil import rmtree -if 'RUNNING_ON_DOCKER_CONTAINER' not in os.environ: - import vagrant - import wazuh_testing.qa_ctl.deployment.vagrantfile as vfile from wazuh_testing.qa_ctl.deployment.instance import Instance from wazuh_testing.qa_ctl import QACTL_LOGGER from wazuh_testing.tools.logging import Logging from wazuh_testing.qa_ctl.provisioning.local_actions import run_local_command_with_output +if 'RUNNING_ON_DOCKER_CONTAINER' not in os.environ: + import vagrant + class VagrantWrapper(Instance): """Class to handle Vagrant operations. The class will use the Vagrantfile class to create a vagrantfile in diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py index 05eb04ac11..7a627063b9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_instance.py @@ -2,6 +2,7 @@ import json from abc import ABC + class AnsibleInstance(ABC): """Represent the necessary attributes of an instance to be specified in an ansible inventory. diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py index 752b202c86..9e2381aed3 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py @@ -4,7 +4,6 @@ from tempfile import gettempdir from os.path import join - from wazuh_testing.qa_ctl.provisioning.ansible.ansible_output import AnsibleOutput from wazuh_testing.qa_ctl.provisioning.ansible.ansible_playbook import AnsiblePlaybook from wazuh_testing.qa_ctl import QACTL_LOGGER @@ -14,6 +13,7 @@ if sys.platform != 'win32': import ansible_runner + class AnsibleRunner: """Allow to run ansible playbooks in the indicated hosts. @@ -31,7 +31,8 @@ class AnsibleRunner: """ LOGGER = Logging.get_logger(QACTL_LOGGER) - def __init__(self, ansible_inventory_path, ansible_playbook_path, private_data_dir=join(gettempdir(), 'wazuh_qa_ctl'), output=False): + def __init__(self, ansible_inventory_path, ansible_playbook_path, + private_data_dir=join(gettempdir(), 'wazuh_qa_ctl'), output=False): self.ansible_inventory_path = ansible_inventory_path self.ansible_playbook_path = ansible_playbook_path self.private_data_dir = private_data_dir diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py index 9270f12a11..531d93d686 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/unix_ansible_instance.py @@ -3,6 +3,7 @@ from wazuh_testing.qa_ctl.provisioning.ansible.ansible_instance import AnsibleInstance + class UnixAnsibleInstance(AnsibleInstance): """Represent the necessary attributes of an instance to be specified in an ansible inventory. diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py index 711a05cfdf..f5f858bbcf 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/windows_ansible_instance.py @@ -3,6 +3,7 @@ from wazuh_testing.qa_ctl.provisioning.ansible.ansible_instance import AnsibleInstance + class WindowsAnsibleInstance(AnsibleInstance): """Represent the necessary attributes of an instance to be specified in an ansible inventory. diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py index ee9a7dc089..44a8020b61 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py @@ -105,5 +105,5 @@ def qa_ctl_docker_run(config_file, qa_branch, debug_level, topic): run_local_command_with_output(f"cd {docker_image_path} && docker build -q -t {docker_image_name} .") LOGGER.info(f"Running the Linux container for {topic}") - run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'wazuh_qa_ctl')}:/wazuh_qa_ctl {docker_image_name} " - f"{docker_args}") + run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'wazuh_qa_ctl')}:/wazuh_qa_ctl " + f"{docker_image_name} {docker_args}") diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py index 61b19ef2b0..5fd9e57a7b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_framework/qa_framework.py @@ -124,7 +124,7 @@ def download_qa_repository(self, inventory_file_path, hosts='all'): download_qa_repo_unix_task = AnsibleTask({ 'name': f"Download {self.qa_branch} branch of wazuh-qa repository (Unix)", - 'shell': f"cd {self.workdir} && curl -Ls https://github.com/wazuh/wazuh-qa/archive/" \ + 'shell': f"cd {self.workdir} && curl -Ls https://github.com/wazuh/wazuh-qa/archive/" f"{self.qa_branch}.tar.gz | tar zx && mv wazuh-* wazuh-qa", 'when': 'ansible_system != "Win32NT"' }) @@ -133,7 +133,7 @@ def download_qa_repository(self, inventory_file_path, hosts='all'): 'name': f"Download {self.qa_branch} branch of wazuh-qa repository (Windows)", 'win_shell': "powershell.exe {{ item }}", 'with_items': [ - f"curl.exe -L https://github.com/wazuh/wazuh-qa/archive/{self.qa_branch}.tar.gz -o " \ + f"curl.exe -L https://github.com/wazuh/wazuh-qa/archive/{self.qa_branch}.tar.gz -o " f"{self.workdir}\\{self.qa_branch}.tar.gz", f"tar -xzf {self.workdir}\\{self.qa_branch}.tar.gz -C {self.workdir}", f"move {self.workdir}\\wazuh-qa-{self.qa_branch} {self.workdir}\\wazuh-qa", diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index 677ec77065..f075864b81 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -244,7 +244,7 @@ def run(self): tmp_config_file_name = f"config_{get_current_timestamp()}.yaml" tmp_config_file = os.path.join(gettempdir(), 'wazuh_qa_ctl', tmp_config_file_name) - # Write a custom configuration file with only provision section + # Write a custom configuration file with only provision section file.write_yaml_file(tmp_config_file, {'provision': self.provision_info}) try: @@ -256,7 +256,7 @@ def run(self): self.__check_hosts_connection() provision_threads = [ThreadExecutor(self.__process_config_data, parameters={'host_provision_info': host_value}) - for _, host_value in self.provision_info['hosts'].items()] + for _, host_value in self.provision_info['hosts'].items()] QAProvisioning.LOGGER.info(f"Provisioning {len(provision_threads)} instances") for runner_thread in provision_threads: diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py index 17b61ab9bc..67747be8a6 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/agent_deployment.py @@ -84,9 +84,9 @@ def register_agent(self): tasks_list.append(AnsibleTask({ 'name': 'Configuring server ip to autoenrollment Windows agent', 'win_lineinfile': {'path': f'{self.install_dir_path}\\ossec.conf', - 'regexp': '
(.*)
', - 'line': f'
{self.server_ip}
', - 'backrefs': 'yes'}, + 'regexp': '
(.*)
', + 'line': f'
{self.server_ip}
', + 'backrefs': 'yes'}, 'become': True, 'become_method': 'runas', 'become_user': self.ansible_admin_user, diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py index 72dc55ba2d..7a384298a9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py @@ -98,10 +98,10 @@ def install(self, install_type): 'yum': {'name': f'{self.installation_files_path}', 'disable_gpg_check': 'yes'}, 'become': True, 'when': ['ansible_os_family|lower == "redhat"', - 'not (ansible_distribution|lower == "centos" and ' + - 'ansible_distribution_major_version >= "8")', - 'not (ansible_distribution|lower == "redhat" and ' + - 'ansible_distribution_major_version >= "8")'] + 'not (ansible_distribution|lower == "centos" and ' + + 'ansible_distribution_major_version >= "8")', + 'not (ansible_distribution|lower == "redhat" and ' + + 'ansible_distribution_major_version >= "8")'] })) tasks_list.append(AnsibleTask({ @@ -109,10 +109,10 @@ def install(self, install_type): 'dnf': {'name': f'{self.installation_files_path}', 'disable_gpg_check': 'yes'}, 'become': True, 'when': ['ansible_os_family|lower == "redhat"', - '(ansible_distribution|lower == "centos" and ' + - 'ansible_distribution_major_version >= "8") or' + - '(ansible_distribution|lower == "redhat" and ' + - 'ansible_distribution_major_version >= "8")'] + '(ansible_distribution|lower == "centos" and ' + + 'ansible_distribution_major_version >= "8") or' + + '(ansible_distribution|lower == "redhat" and ' + + 'ansible_distribution_major_version >= "8")'] })) tasks_list.append(AnsibleTask({ @@ -170,7 +170,7 @@ def __control_service(self, command, install_type): tasks_list.append(AnsibleTask({ 'name': f'Wazuh agent {command} service from Windows', 'win_shell': 'Get-Service -Name WazuhSvc -ErrorAction SilentlyContinue |' + - f' {command.capitalize()}-Service -ErrorAction SilentlyContinue', + f' {command.capitalize()}-Service -ErrorAction SilentlyContinue', 'args': {'executable': 'powershell.exe'}, 'become': True, 'become_method': 'runas', diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_sources.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_sources.py index 4799597e32..7c1bbf857e 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_sources.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_sources.py @@ -49,7 +49,7 @@ def download_installation_files(self, inventory_file_path, hosts='all'): download_wazuh_sources_task = AnsibleTask({ 'name': f"Download Wazuh branch in {self.installation_files_path}", - 'shell': f"cd {self.installation_files_path} && curl -Ls https://github.com/wazuh/wazuh/archive/" \ + 'shell': f"cd {self.installation_files_path} && curl -Ls https://github.com/wazuh/wazuh/archive/" f"{self.wazuh_branch}.tar.gz | tar zx && mv wazuh-*/* ." }) WazuhSources.LOGGER.debug(f"Wazuh sources from {self.wazuh_branch} branch were successfully downloaded in " diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index 8d06053530..b5ee6245ea 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -94,10 +94,10 @@ def __output_trimmer(self, result): result (Test resutl object): object containing all the results obtained from the test Return: - output_result (string): String containing the trimmed output + output_result (string): String containing the trimmed output """ output_result = str(result) - error_fail_pattern = re.compile('^=*.(ERRORS|FAILURES).*=$', re.M) + error_fail_pattern = re.compile('^=*.(ERRORS|FAILURES).*=$', re.M) test_summary_pattern = re.compile('^=*.(short test summary info).*=$', re.M) # Check for any error or failure message case in the test result output @@ -109,7 +109,7 @@ def __output_trimmer(self, result): if test_summary_case is not None: test_result_message = test_summary_case.group(0) test_summary_output = output_result[output_result.index(test_result_message):] - + error_case_message = error_case.group(0) output_result = output_result[:output_result.index(error_case_message)] output_result += test_summary_output @@ -165,7 +165,7 @@ def run(self, ansible_inventory_path): create_path_task_unix = { 'name': f"Create {reports_directory} path (Unix)", 'file': {'path': reports_directory, 'state': 'directory', 'mode': '0755'}, - 'become':True, + 'become': True, 'when': 'ansible_system != "Win32NT"' } @@ -183,7 +183,7 @@ def run(self, ansible_inventory_path): 'shell': pytest_command, 'args': {'chdir': self.tests_run_dir}, 'register': 'test_output_unix', 'ignore_errors': 'yes', - 'become':True, + 'become': True, 'when': 'ansible_system != "Win32NT"' } @@ -245,8 +245,7 @@ def run(self, ansible_inventory_path): fetch_compressed_assets = { 'name': f"Copy compressed assets from {zip_src_path} to {self.tests_result_path}", - 'fetch': {'src': zip_src_path, - 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}, + 'fetch': {'src': zip_src_path, 'dest': f"{self.tests_result_path}/", 'flat': 'yes'}, 'ignore_errors': 'yes' } @@ -255,7 +254,7 @@ def run(self, ansible_inventory_path): AnsibleTask(run_test_task_unix), AnsibleTask(run_test_task_windows), AnsibleTask(create_plain_report_unix), AnsibleTask(create_plain_report_windows), AnsibleTask(fetch_plain_report), AnsibleTask(fetch_html_report), AnsibleTask(compress_assets_folder_unix), - AnsibleTask(compress_assets_folder_windows),AnsibleTask(fetch_compressed_assets) + AnsibleTask(compress_assets_folder_windows), AnsibleTask(fetch_compressed_assets) ] playbook_parameters = { @@ -274,11 +273,11 @@ def run(self, ansible_inventory_path): test_name=self.tests_path) # Trim the result report for a more simple and readable output - if Pytest.LOGGER.level != 10: + if Pytest.LOGGER.level != 10: output_result = self.__output_trimmer(self.result) else: output_result = str(self.result) - + # Print test result in stdout if self.qa_ctl_configuration.logging_enable: if os.path.exists(self.result.plain_report_file_path): diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py index 4b1cd91788..309667be8c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py @@ -14,6 +14,7 @@ from wazuh_testing.tools import file from wazuh_testing.qa_ctl.provisioning.local_actions import qa_ctl_docker_run + class QATestRunner(): """The class encapsulates the build of the tests from the test parameters read from the configuration file @@ -170,8 +171,8 @@ def run(self): tmp_config_file = os.path.join(gettempdir(), 'wazuh_qa_ctl', tmp_config_file_name) # Save original directory where to store the results in Windows host - original_result_paths = [ self.test_parameters[host_key]['test']['path']['test_results_path'] \ - for host_key, _ in self.test_parameters.items()] + original_result_paths = [self.test_parameters[host_key]['test']['path']['test_results_path'] + for host_key, _ in self.test_parameters.items()] # Change the destination directory, as the results will initially be stored in the shared volume between # the Windows host and the docker container (Windows tmp as /wazuh_qa_ctl). diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py index e5b3117d3a..c982f4b172 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py @@ -139,8 +139,7 @@ def __set_local_internal_options(self, hosts, modules, component, system, wazuh_ set_local_internal_configuration_unix = { 'name': 'Set custom local internal configuration (Unix)', - 'lineinfile': {'path': local_internal_options_path, - 'line': "{{ item }}"}, + 'lineinfile': {'path': local_internal_options_path, 'line': "{{ item }}"}, 'with_items': local_internal_options_content, 'become': True, 'when': 'ansible_system != "Win32NT"' @@ -148,8 +147,7 @@ def __set_local_internal_options(self, hosts, modules, component, system, wazuh_ set_local_internal_configuration_windows = { 'name': 'Set custom local internal configuration (Windows)', - 'win_lineinfile': {'path': local_internal_options_path, - 'line': "{{ item }}"}, + 'win_lineinfile': {'path': local_internal_options_path, 'line': "{{ item }}"}, 'with_items': local_internal_options_content.copy(), 'become': True, 'become_method': 'runas', From 63145900dae348afb1f7050f18a626595763169d Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 5 Nov 2021 10:47:33 +0100 Subject: [PATCH 161/181] style: Standardize the order of imports in qa-ctl #2173 --- .../wazuh_testing/qa_ctl/configuration/config_generator.py | 1 - .../wazuh_testing/qa_ctl/deployment/vagrantfile.py | 2 +- .../qa_ctl/provisioning/ansible/ansible_inventory.py | 1 + .../qa_ctl/provisioning/ansible/ansible_playbook.py | 1 - .../wazuh_testing/qa_ctl/provisioning/qa_provisioning.py | 3 +-- .../qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py | 1 - .../provisioning/wazuh_deployment/wazuh_local_package.py | 1 - .../qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py | 2 +- deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py | 1 + .../wazuh_testing/qa_ctl/run_tests/test_launcher.py | 1 - 10 files changed, 5 insertions(+), 9 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 085d4d7d91..e440b046e6 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -1,7 +1,6 @@ import sys import re from os.path import join, exists - from tempfile import gettempdir from packaging.version import parse diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py index f9dd70c23c..f35fbea5ad 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py @@ -1,9 +1,9 @@ # Copyright (C) 2015-2021, Wazuh Inc. # Created by Wazuh, Inc. . # This program is free software; you can redistribute it and/or modify it under the terms of GPLv2 -from pathlib import Path import os import json +from pathlib import Path from wazuh_testing.qa_ctl import QACTL_LOGGER from wazuh_testing.tools.logging import Logging diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py index 18e076f0ae..4947864723 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_inventory.py @@ -3,6 +3,7 @@ import copy import json from tempfile import gettempdir + from wazuh_testing.tools.time import get_current_timestamp diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_playbook.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_playbook.py index 2adc5b4617..719e6c079c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_playbook.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_playbook.py @@ -1,6 +1,5 @@ import os import yaml - from tempfile import gettempdir from wazuh_testing.tools.time import get_current_timestamp diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index f075864b81..d228b960ca 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -1,9 +1,8 @@ import os import sys from tempfile import gettempdir - - from time import sleep + from wazuh_testing.qa_ctl.provisioning.ansible.unix_ansible_instance import UnixAnsibleInstance from wazuh_testing.qa_ctl.provisioning.ansible.windows_ansible_instance import WindowsAnsibleInstance from wazuh_testing.qa_ctl.provisioning.ansible.ansible_inventory import AnsibleInventory diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py index 7a384298a9..edd3ee9772 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py @@ -1,6 +1,5 @@ import os - from abc import ABC, abstractmethod from pathlib import Path from tempfile import gettempdir diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_local_package.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_local_package.py index b0896e460a..1ddd12d68d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_local_package.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_local_package.py @@ -1,5 +1,4 @@ import os - from pathlib import Path from wazuh_testing.qa_ctl.provisioning.wazuh_deployment.wazuh_package import WazuhPackage diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py index 5e472c24c7..411d2decc1 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_s3_package.py @@ -1,6 +1,6 @@ import os - from pathlib import Path + from wazuh_testing.qa_ctl.provisioning.wazuh_deployment.wazuh_package import WazuhPackage from wazuh_testing.qa_ctl.provisioning.ansible.ansible_task import AnsibleTask from wazuh_testing.qa_ctl import QACTL_LOGGER diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py index b5ee6245ea..b4abd5f95d 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/pytest.py @@ -2,6 +2,7 @@ import re from datetime import datetime from tempfile import gettempdir + from wazuh_testing.qa_ctl.run_tests.test_result import TestResult from wazuh_testing.qa_ctl.provisioning.ansible.ansible_runner import AnsibleRunner from wazuh_testing.qa_ctl.provisioning.ansible.ansible_task import AnsibleTask diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py index c982f4b172..417512a82c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/test_launcher.py @@ -1,5 +1,4 @@ import os - from tempfile import gettempdir from wazuh_testing.qa_ctl.provisioning.ansible.ansible_runner import AnsibleRunner From 6ce88fe8dc7d445b17074e3a779e91e4292b5762 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 5 Nov 2021 11:04:47 +0100 Subject: [PATCH 162/181] refac: Rename local actions methods #2173 In addition, it has been standardize the qa-ctl script according to the PEP-8 standard. --- .../qa_ctl/configuration/config_generator.py | 4 +-- .../qa_ctl/deployment/vagrant_wrapper.py | 4 +-- .../qa_ctl/provisioning/local_actions.py | 19 ++++++------ .../wazuh_testing/scripts/qa_ctl.py | 29 +++++++++++-------- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index e440b046e6..bdaeea4d87 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -11,7 +11,7 @@ from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.s3_package import get_s3_package_url from wazuh_testing.qa_ctl.provisioning.wazuh_deployment.wazuh_s3_package import WazuhS3Package -from wazuh_testing.qa_ctl.provisioning.local_actions import run_local_command_with_output +from wazuh_testing.qa_ctl.provisioning.local_actions import run_local_command_returning_output class QACTLConfigGenerator: @@ -137,7 +137,7 @@ def __get_test_info(self, test_name): f"{join(self.qa_files_path, 'tests')} --no-logging" test_data_file_path = f"{join(gettempdir(), 'wazuh_qa_ctl', test_name)}.json" - run_local_command_with_output(qa_docs_command) + run_local_command_returning_output(qa_docs_command) # Read test data file try: diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrant_wrapper.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrant_wrapper.py index fcd994788b..db7e10d511 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrant_wrapper.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrant_wrapper.py @@ -9,7 +9,7 @@ from wazuh_testing.qa_ctl.deployment.instance import Instance from wazuh_testing.qa_ctl import QACTL_LOGGER from wazuh_testing.tools.logging import Logging -from wazuh_testing.qa_ctl.provisioning.local_actions import run_local_command_with_output +from wazuh_testing.qa_ctl.provisioning.local_actions import run_local_command_returning_output if 'RUNNING_ON_DOCKER_CONTAINER' not in os.environ: import vagrant @@ -56,7 +56,7 @@ def run(self): VagrantWrapper.LOGGER.debug(f"Running {self.vm_name} vagrant up") filter_command = 'findstr' if sys.platform == 'win32' else 'grep' - if len(run_local_command_with_output(f"vagrant box list | {filter_command} {self.vm_box}")) == 0: + if len(run_local_command_returning_output(f"vagrant box list | {filter_command} {self.vm_box}")) == 0: VagrantWrapper.LOGGER.info(f"{self.vm_box} vagrant box not found in local repository. Downloading and " 'running') self.vagrant.up() diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py index 44a8020b61..e6423d23d1 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py @@ -14,8 +14,8 @@ LOGGER = Logging.get_logger(QACTL_LOGGER) -def run_local_command(command): - """Run local commands without getting the output, but validating the result code. +def run_local_command_printing_output(command): + """Run local commands printing the output in the stdout. In addition, it is validate the result code. Args: command (string): Command to run. @@ -38,13 +38,14 @@ def run_local_command(command): QACTL_LOGGER) -def run_local_command_with_output(command): - """Run local commands getting the command output. +def run_local_command_returning_output(command): + """Run local commands catching and returning the stdout in a variable. Nothing is displayed on the stdout. + Args: command (string): Command to run. Returns: - str: Command output + str: Command output. """ if sys.platform == 'win32': run = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) @@ -82,7 +83,7 @@ def download_local_wazuh_qa_repository(branch, path): f"{mute_output} && mv wazuh-* wazuh-qa {mute_output} && rm -rf *tar.gz {mute_output}" LOGGER.debug(f"Downloading {branch} files of wazuh-qa repository in {wazuh_qa_path}") - run_local_command_with_output(command) + run_local_command_returning_output(command) def qa_ctl_docker_run(config_file, qa_branch, debug_level, topic): @@ -102,8 +103,8 @@ def qa_ctl_docker_run(config_file, qa_branch, debug_level, topic): 'dockerfiles', 'qa_ctl') LOGGER.info(f"Building docker image for {topic}") - run_local_command_with_output(f"cd {docker_image_path} && docker build -q -t {docker_image_name} .") + run_local_command_returning_output(f"cd {docker_image_path} && docker build -q -t {docker_image_name} .") LOGGER.info(f"Running the Linux container for {topic}") - run_local_command(f"docker run --rm -v {os.path.join(gettempdir(), 'wazuh_qa_ctl')}:/wazuh_qa_ctl " - f"{docker_image_name} {docker_args}") + run_local_command_printing_output(f"docker run --rm -v {os.path.join(gettempdir(), 'wazuh_qa_ctl')}:/wazuh_qa_ctl " + f"{docker_image_name} {docker_args}") diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 127f25ea80..58209d1b65 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -79,7 +79,7 @@ def validate_configuration_data(configuration_data, qa_ctl_mode): # Check that qa_ctl_launcher_branch parameter has been specified and its valid for Windows manual mode if sys.platform == 'win32' and qa_ctl_mode == MANUAL_MODE: if 'config' not in configuration_data or 'qa_ctl_launcher_branch' not in configuration_data['config']: - raise QAValueError('qa_ctl_launcher_branch was not found in the configuration file. It is required if ' \ + raise QAValueError('qa_ctl_launcher_branch was not found in the configuration file. It is required if ' 'you are running qa-ctl in a Windows host', qactl_logger.error, QACTL_LOGGER) # Check that qa_ctl_launcher_branch exists @@ -87,7 +87,7 @@ def validate_configuration_data(configuration_data, qa_ctl_mode): repository=WAZUH_QA_REPO): raise QAValueError(f"{configuration_data['config']['qa_ctl_launcher_branch']} branch specified as " 'qa_ctl_launcher_branch does not exist in Wazuh QA repository.', qactl_logger.error, - QACTL_LOGGER) + QACTL_LOGGER) qactl_logger.debug('Schema validation has passed successfully') @@ -131,7 +131,7 @@ def set_parameters(parameters): parameters.user_version = parameters.version if parameters.version else None try: - parameters.version = parameters.version if parameters.version else github_checks.get_last_wazuh_version() + parameters.version = parameters.version if parameters.version else github_checks.get_last_wazuh_version() except QAValueError: raise QAValueError('The latest version of Wazuh could not be obtained. Maybe there is no valid (non-rc) one at ' 'https://github.com/wazuh/wazuh/tags. Try specifying the version manually using the ' @@ -139,7 +139,7 @@ def set_parameters(parameters): parameters.version = (parameters.version).replace('v', '') - short_version = f"{(parameters.version).split('.')[0]}.{(parameters.version).split('.')[1]}" + short_version = f"{(parameters.version).split('.')[0]}.{(parameters.version).split('.')[1]}" parameters.qa_branch = parameters.qa_branch if parameters.qa_branch else short_version @@ -154,7 +154,8 @@ def set_environment(parameters): if parameters.run_test: # Download wazuh-qa repository locally to run qa-docs tool and get the tests info - local_actions.download_local_wazuh_qa_repository(branch=parameters.qa_branch, path=os.path.join(gettempdir(), 'wazuh_qa_ctl')) + local_actions.download_local_wazuh_qa_repository(branch=parameters.qa_branch, + path=os.path.join(gettempdir(), 'wazuh_qa_ctl')) def validate_parameters(parameters): @@ -172,14 +173,14 @@ def _validate_tests_os(parameters): tests_path = os.path.join(WAZUH_QA_FILES, 'tests') test_documentation_command = f"qa-docs -I {tests_path} -t {test} -o {gettempdir()} --no-logging" test_documentation_file_path = os.path.join(gettempdir(), f"{test}.json") - local_actions.run_local_command_with_output(test_documentation_command) + local_actions.run_local_command_returning_output(test_documentation_command) test_data = json.loads(file.read_file(test_documentation_file_path)) for op_system in parameters.operating_systems: # Check platform platform = 'linux' if op_system == 'ubuntu' or op_system == 'centos' else op_system - if not platform in test_data['os_platform']: + if platform not in test_data['os_platform']: raise QAValueError(f"The {test} test does not support the {op_system} system. Allowed platforms: " f"{test_data['os_platform']} (ubuntu and centos are from linux platform)") # Check os version @@ -204,7 +205,7 @@ def _validate_tests_os(parameters): raise QAValueError('The --dry-run parameter can only be used with -r, --run', qactl_logger.error, QACTL_LOGGER) if (parameters.skip_deployment or parameters.skip_provisioning or parameters.skip_testing) \ - and not parameters.config: + and not parameters.config: raise QAValueError('The --skip parameter can only be used when a custom configuration file has been ' 'specified with the option -c or --config', qactl_logger.error, QACTL_LOGGER) @@ -228,13 +229,16 @@ def _validate_tests_os(parameters): for test in parameters.run_test: tests_path = os.path.join(WAZUH_QA_FILES, 'tests') # Validate if the specified tests exist - if f"{test} exists" not in local_actions.run_local_command_with_output(f"qa-docs -e {test} -I {tests_path} " - ' --no-logging'): + check_test_exist = local_actions.run_local_command_returning_output(f"qa-docs -e {test} -I {tests_path} " + '--no-logging') + if f"{test} exists" not in check_test_exist: raise QAValueError(f"{test} does not exist in {tests_path}", qactl_logger.error, QACTL_LOGGER) # Validate if the selected tests are documented - test_documentation_check = local_actions.run_local_command_with_output(f"qa-docs -t {test} -I {tests_path} " - '--check-documentation --no-logging') + test_documentation_check = local_actions.run_local_command_returning_output(f"qa-docs -t {test} -I " + f"{tests_path} " + '--check-documentation ' + '--no-logging') if f'{test} is not documented' in test_documentation_check: raise QAValueError(f"{test} is not documented using qa-docs current schema", qactl_logger.error, QACTL_LOGGER) @@ -394,5 +398,6 @@ def main(): if not RUNNING_ON_DOCKER_CONTAINER and arguments.run_test: qactl_logger.info(f"Configuration file saved in {config_generator.config_file_path}") + if __name__ == '__main__': main() From fdbb48a47e7f53ba27ba921721d9efaebe46f263 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 5 Nov 2021 11:06:49 +0100 Subject: [PATCH 163/181] refac: Bump qa-ctl version to v0.2 #2173 --- deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json b/deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json index 5d026e9edc..aeeb7caeaf 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json @@ -1,4 +1,4 @@ { - "version": "0.1", + "version": "0.2", "revision": 1 } From c612fd239df4c1d3cf3e13a886c75fca7f65687a Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 5 Nov 2021 12:37:49 +0100 Subject: [PATCH 164/181] add: Updated CHANGELOG.md #2173 --- .../wazuh_testing/qa_ctl/CHANGELOG.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md b/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md index a53ee068f6..99597f04eb 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md @@ -1,6 +1,29 @@ # Change Log All notable changes to this tool will be documented in this file. +## [v0.2] - 2021-11-05 +### Added +- Added operating systems validation for specified OS in tests ([#2168](https://github.com/wazuh/wazuh-qa/pull/2168)) +- Added Windows testing support in `qa-ctl` ([#2152](https://github.com/wazuh/wazuh-qa/pull/2152)) +- Updated `qa-docs` usage in `qa-ctl` ([#2081](https://github.com/wazuh/wazuh-qa/pull/2081)) +- Added `--os` parameter to specify the systems where to launch the tests ([#2064](https://github.com/wazuh/wazuh-qa/pull/2064)) +- Added no-validation flag for `qa-ctl` docker run on windows ([#2028](https://github.com/wazuh/wazuh-qa/pull/2028)) +- Added documentation tests validation precondition for automatic mode ([#2023](https://github.com/wazuh/wazuh-qa/issues/2023)) + +### Changed +- Updated `JSON Schema validator` ([#2164](https://github.com/wazuh/wazuh-qa/issues/2164)) +- Removed `pytest` error traceback test results ([#2156](https://github.com/wazuh/wazuh-qa/pull/2156)) +- Updated `local internal options` configutation of Wazuh ([#2102](https://github.com/wazuh/wazuh-qa/pull/2102)) +- Replaced `git clone` usage for direct downloads ([#2046](https://github.com/wazuh/wazuh-qa/pull/2046)) +- Changed `GitHub API requests` with `checks on resource URLs` for qa-ctl parameter validations ([#2033](https://github.com/wazuh/wazuh-qa/pull/2033)) +- Renamed `qa-ctl` temporary files directory ([#2029](https://github.com/wazuh/wazuh-qa/pull/2029)) +- Updated `qa-ctl` help menu information ([#2026](https://github.com/wazuh/wazuh-qa/pull/2026)) + +### Fixed +- Fixed `Docker` issues for `qa-ctl` (manual mode) in `Windows` ([#2147](https://github.com/wazuh/wazuh-qa/pull/2147)) +- Fixed `qa-ctl` configuration path separators for `windows` ([#2036](https://github.com/wazuh/wazuh-qa/pull/2036)) + + ## [v0.1] ### Added From 385078ae686573c610dbf94eba228bcf3d1e67e7 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 5 Nov 2021 12:48:27 +0100 Subject: [PATCH 165/181] fix: Fix a bug in qa-ctl config generator #2182 --- .../qa_ctl/configuration/config_generator.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index bdaeea4d87..2fabab7b5c 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -1,5 +1,6 @@ import sys import re +import copy from os.path import join, exists from tempfile import gettempdir from packaging.version import parse @@ -484,7 +485,7 @@ def __set_testing_config(self, tests_info): system = QACTLConfigGenerator.BOX_INFO[vm_box]['system'] system = 'linux' if system == 'deb' or system == 'rpm' else system - modules = test['modules'] + modules = copy.deepcopy(test['modules']) component = 'manager' if 'manager' in test['components'] else test['components'][0] # Cut out the full path, and convert it to relative path (tests/integration....) @@ -512,13 +513,11 @@ def __process_test_data(self, tests_info): self.config['tests'] = {} if not self.systems: - for _ in tests_info: - self.__set_testing_config(tests_info) + self.__set_testing_config(tests_info) # If we want to launch the test in one or multiple systems specified in qa-ctl parameters elif isinstance(self.systems, list) and len(self.systems) > 0: for _ in self.systems: - for _ in tests_info: - self.__set_testing_config(tests_info) + self.__set_testing_config(tests_info) else: raise QAValueError('Unable to process systems in the automatically generated configuration', QACTLConfigGenerator.LOGGER.error, QACTL_LOGGER) From 81da21a64cd78eb41c6d4617a8f6236c3ed728bc Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Mon, 8 Nov 2021 09:24:39 +0100 Subject: [PATCH 166/181] fix: Fix tests section of qa-ctl config generation #2183 Now the test section is generated correctly when running tests for several systems --- .../qa_ctl/configuration/config_generator.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 2fabab7b5c..0954ff1eb8 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -385,8 +385,8 @@ def __process_deployment_data(self, tests_info): for test in tests_info: if self.__validate_test_info(test): version = self.SYSTEMS[system]['os_version'] - component = 'manager' if 'manager' in test['components'] else 'agent' platform = self.SYSTEMS[system]['os_platform'] + component = 'manager' if 'manager' in test['components'] and platform == 'linux' else 'agent' self.__add_deployment_config_block(test['test_name'], version, component, platform) else: @@ -476,7 +476,20 @@ def __set_testing_config(self, tests_info): Args: test_info(dict object): dict object containing information of all the tests that are going to be run. """ - test_host_number = len(self.config['tests'].keys()) + 1 + # Calculate the host that will run the test + + # If there is no test config block, then start in host_+1 + if len(self.config['tests'].keys()) == 0: + test_host_number = len(self.config['tests'].keys()) + 1 + else: + last_config_test_item = len(self.config['tests'].keys()) + instance = f"host_{last_config_test_item}" + # If the last test was for manager, then move on to the next one. + if self.config['tests'][instance]['test']['component'] == 'manager': + test_host_number = len(self.config['tests'].keys()) + 1 + # If the last test was for agent, then 1 host must be skipped, since it is the manager for an agent test. + else: + test_host_number = len(self.config['tests'].keys()) + 2 for test in tests_info: instance = f"host_{test_host_number}" @@ -486,7 +499,7 @@ def __set_testing_config(self, tests_info): system = 'linux' if system == 'deb' or system == 'rpm' else system modules = copy.deepcopy(test['modules']) - component = 'manager' if 'manager' in test['components'] else test['components'][0] + component = self.config['provision']['hosts'][instance]['wazuh_deployment']['target'] # Cut out the full path, and convert it to relative path (tests/integration....) test_path = re.sub(r".*wazuh-qa.*(tests.*)", r"\1", test['path']) From 64db7bf70298a762f7c0f3d64567b916abe9a48b Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Tue, 9 Nov 2021 11:02:38 +0100 Subject: [PATCH 167/181] add: Add ansible env vars to increase the timeout connections #2190 --- .../qa_ctl/provisioning/ansible/ansible_runner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py index 9e2381aed3..65d16d9553 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py @@ -52,7 +52,8 @@ def run(self, log_ansible_error=True): f"{self.ansible_inventory_path} inventory") runner = ansible_runner.run(private_data_dir=self.private_data_dir, playbook=self.ansible_playbook_path, - inventory=self.ansible_inventory_path, quiet=quiet) + inventory=self.ansible_inventory_path, quiet=quiet, + envvars={'ANSIBLE_GATHER_TIMEOUT': 30, 'ANSIBLE_TIMEOUT': 20}) ansible_output = AnsibleOutput(runner) if ansible_output.rc != 0: @@ -85,7 +86,7 @@ def run_ephemeral_tasks(ansible_inventory_path, playbook_parameters, raise_on_er AnsibleRunner.LOGGER.debug(f"Running {ansible_playbook.playbook_file_path} ansible-playbook with " f"{ansible_inventory_path} inventory") runner = ansible_runner.run(playbook=ansible_playbook.playbook_file_path, inventory=ansible_inventory_path, - quiet=quiet) + quiet=quiet, envvars={'ANSIBLE_GATHER_TIMEOUT': 30, 'ANSIBLE_TIMEOUT': 20}) ansible_output = AnsibleOutput(runner) if ansible_output.rc != 0 and raise_on_error: From 279ba5eedd1e27537c2be69afddd7981d138e38a Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 3 Dec 2021 10:43:54 +0100 Subject: [PATCH 168/181] fix: Fix os_version deployment data priority #2173 Done in config generator --- .../wazuh_testing/qa_ctl/configuration/config_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 0954ff1eb8..b401da27c5 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -365,9 +365,9 @@ def __process_deployment_data(self, tests_info): if self.__validate_test_info(test): os_version = '' if 'CentOS 8' in test['os_version']: - os_version = 'Ubuntu Focal' - elif 'Ubuntu Focal' in test['os_version']: os_version = 'CentOS 8' + elif 'Ubuntu Focal' in test['os_version']: + os_version = 'Ubuntu Focal' elif 'Windows Server 2019' in test['os_version']: os_version = 'Windows Server 2019' else: From b7163e7069a2846c1cbed76c531252980865fc8b Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 11:21:25 +0100 Subject: [PATCH 169/181] fix: Fix some UNIX function usages of the `file` module for Windows #2306 In addition, it has been added a new functions for downloading text files --- .../wazuh_testing/wazuh_testing/tools/file.py | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/tools/file.py b/deps/wazuh_testing/wazuh_testing/tools/file.py index 0f4c254aaa..67953acc98 100644 --- a/deps/wazuh_testing/wazuh_testing/tools/file.py +++ b/deps/wazuh_testing/wazuh_testing/tools/file.py @@ -10,6 +10,7 @@ import shutil import socket import stat +import sys import string import xml.etree.ElementTree as ET import zipfile @@ -159,8 +160,16 @@ def download_file(source_url, dest_path): def remove_file(file_path): + """Remove a file or a directory path. + + Args: + file_path (str): File or directory path to remove. + """ if os.path.exists(file_path): - os.remove(file_path) + if os.path.isfile(file_path): + os.remove(file_path) + elif os.path.isdir(file_path): + delete_path_recursively(file_path) def validate_json_file(file_path): @@ -252,7 +261,9 @@ def copy(source, destination): """ shutil.copy2(source, destination) source_stats = os.stat(source) - os.chown(destination, source_stats[stat.ST_UID], source_stats[stat.ST_GID]) + + if sys.platform != 'win32': + os.chown(destination, source_stats[stat.ST_UID], source_stats[stat.ST_GID]) def bind_unix_socket(socket_path, protocol='TCP'): @@ -264,7 +275,7 @@ def bind_unix_socket(socket_path, protocol='TCP'): socket_path (str): Path where create the unix socket. protocol (str): It can be TCP or UDP. """ - if not os.path.exists(socket_path): + if not os.path.exists(socket_path) and sys.platform != 'win32': sock_type = socket.SOCK_STREAM if protocol.upper() == 'TCP' else socket.SOCK_DGRAM new_socket = socket.socket(socket.AF_UNIX, sock_type) new_socket.bind(socket_path) @@ -298,14 +309,15 @@ def set_file_owner_and_group(file_path, owner, group): Raises: KeyError: If owner or group does not exist. """ - from pwd import getpwnam - from grp import getgrnam + if sys.platform != 'win32': + from pwd import getpwnam + from grp import getgrnam - if os.path.exists(file_path): - uid = getpwnam(owner).pw_uid - gid = getgrnam(group).gr_gid + if os.path.exists(file_path): + uid = getpwnam(owner).pw_uid + gid = getgrnam(group).gr_gid - os.chown(file_path, uid, gid) + os.chown(file_path, uid, gid) def recursive_directory_creation(path): @@ -385,3 +397,22 @@ def count_file_lines(filepath): """ with open(filepath, "r") as file: return sum(1 for line in file if line.strip()) + + +def download_text_file(file_url, local_destination_path): + """Download a remote file with text/plain content type. + + Args: + file_url (str): Remote URL path where the text file is located. + local_destination_path (str): Local path where to save the file content. + + Raises: + ValueError: if the URL content type is not 'text/plain'. + + """ + request = requests.get(file_url, allow_redirects=True) + + if 'text/plain' not in request.headers.get('content-type'): + raise ValueError(f"The remote url {file_url} does not have text/plain content type to download it") + + open(local_destination_path, 'wb').write(request.content) From 22ada7736c6650c34316a5578695e900d7aeae73 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 11:22:53 +0100 Subject: [PATCH 170/181] add: Add automatic path generation in the QA repo download #2306 --- .../wazuh_testing/qa_ctl/provisioning/local_actions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py index e6423d23d1..3dcb80bae5 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/local_actions.py @@ -8,7 +8,7 @@ from wazuh_testing.tools.github_checks import branch_exists from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.exceptions import QAValueError -from wazuh_testing.tools.file import delete_path_recursively +from wazuh_testing.tools.file import delete_path_recursively, recursive_directory_creation LOGGER = Logging.get_logger(QACTL_LOGGER) @@ -64,6 +64,9 @@ def download_local_wazuh_qa_repository(branch, path): branch (string): Wazuh QA repository branch. path (string): Local path where save the repository files. """ + # Create path if it does not exist + recursive_directory_creation(path) + wazuh_qa_path = os.path.join(path, 'wazuh-qa') mute_output = '&> /dev/null' if sys.platform != 'win32' else '>nul 2>&1' command = '' @@ -83,6 +86,7 @@ def download_local_wazuh_qa_repository(branch, path): f"{mute_output} && mv wazuh-* wazuh-qa {mute_output} && rm -rf *tar.gz {mute_output}" LOGGER.debug(f"Downloading {branch} files of wazuh-qa repository in {wazuh_qa_path}") + run_local_command_returning_output(command) From 7e67184be9a32f13af1fd09b709a4400993ae2a0 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 11:24:10 +0100 Subject: [PATCH 171/181] add: Add configuration generator for deployment and task running section #2306 --- .../qa_ctl/configuration/config_generator.py | 71 ++++++++++++++++++- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index b401da27c5..cc69e12703 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -113,7 +113,7 @@ class QACTLConfigGenerator: } } - def __init__(self, tests, wazuh_version, qa_branch='master', + def __init__(self, tests=None, wazuh_version=None, qa_branch='master', qa_files_path=join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa'), systems=None): self.tests = tests self.wazuh_version = wazuh_version @@ -125,6 +125,9 @@ def __init__(self, tests, wazuh_version, qa_branch='master', self.qa_branch = qa_branch self.qa_files_path = qa_files_path + # Create qa-ctl temporarily files path + file.recursive_directory_creation(join(gettempdir(), 'wazuh_qa_ctl')) + def __get_test_info(self, test_name): """Get information from a documented test. @@ -365,9 +368,9 @@ def __process_deployment_data(self, tests_info): if self.__validate_test_info(test): os_version = '' if 'CentOS 8' in test['os_version']: - os_version = 'CentOS 8' - elif 'Ubuntu Focal' in test['os_version']: os_version = 'Ubuntu Focal' + elif 'Ubuntu Focal' in test['os_version']: + os_version = 'CentOS 8' elif 'Windows Server 2019' in test['os_version']: os_version = 'Windows Server 2019' else: @@ -567,3 +570,65 @@ def destroy(self): self.__delete_ip_entry(host_ip) file.delete_file(self.config_file_path) + + def get_deployment_configuration(self, instances): + """Generate the qa-ctl configuration required for the deployment of the specified config-instances. + + Args: + instances(list(ConfigInstance)): List of config-instances to deploy. + + Returns: + dict: Configuration block corresponding to the deployment of the instances + + Raises: + QAValueError: If the instance operating system is not allowed for generating the qa-ctl configuration. + + """ + deployment_configuration = {'deployment': {} } + + for index, instance in enumerate(instances): + try: + box = self.BOX_MAPPING[instance.os_version] + except KeyError as exception: + raise QAValueError(f"Could not find a qa-ctl box for {instance.os_version}", + QACTLConfigGenerator.LOGGER.error, QACTL_LOGGER) from exception + + instance_ip = self.__get_host_IP() + # Assign the IP to the instance object (Needed later to generate host config data) + instance.ip = instance_ip + + deployment_configuration['deployment'][f"host_{index + 1}"] = { + 'provider': { + 'vagrant': { + 'enabled': True, + 'vagrantfile_path': join(gettempdir(), 'wazuh_qa_ctl'), + 'vagrant_box': box, + 'vm_memory': instance.memory, + 'vm_cpu': instance.cpu, + 'vm_name': instance.name, + 'vm_system': instance.os_platform, + 'label': instance.name, + 'vm_ip': instance_ip + } + } + } + + return deployment_configuration + + def get_tasks_configuration(self, instances, playbooks, playbook_path='local'): + tasks_configuration = {'tasks': {}} + + for index, instance in enumerate(instances): + instance_box = self.BOX_MAPPING[instance.os_version] + host_info = QACTLConfigGenerator.BOX_INFO[instance_box] + host_info['host'] = instance.ip + + playbooks_dict = [{'local_path': playbook} if playbook_path == 'local' else \ + {'remote_url': playbook} for playbook in playbooks] + + tasks_configuration['tasks'][f"task_{index + 1}"] = { + 'host_info': host_info, + 'playbooks': playbooks_dict + } + + return tasks_configuration From b4305e2f409d666cfd194009f701e3392fede18d Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 11:24:59 +0100 Subject: [PATCH 172/181] fix: Fix some typos in logs of Vagrantfile class #2306 --- .../wazuh_testing/qa_ctl/deployment/vagrantfile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py index f35fbea5ad..aabfaf022b 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py @@ -94,7 +94,7 @@ def read_vagrantfile_template(self): List: List with the content of the template vagrant template.""" with open(self.TEMPLATE_FILE, 'r') as template_fd: return template_fd.readlines() - Vagrantfile.LOGGER.debug(f"Read vagrantfile {self.TEMPLATE_FILE} template") + Vagrantfile.LOGGER.debug(f"Read Vagrantfile {self.TEMPLATE_FILE} template") def write_vagrantfile(self): """Replace the self.REPLACE_PATTERN line with a string with the parameters in JSON format and write the new @@ -106,10 +106,10 @@ def write_vagrantfile(self): with open(self.file_path, 'w') as vagrantfile_fd: vagrantfile_fd.writelines(read_lines) - Vagrantfile.LOGGER.debug(f"Vagranfile written in {self.file_path}") + Vagrantfile.LOGGER.debug(f"Vagrantfile written in {self.file_path}") def remove_vagrantfile(self): """Removes the file self.file_path if it exists.""" if os.path.exists(self.file_path): os.remove(self.file_path) - Vagrantfile.LOGGER.debug(f"{self.file_path} Vagranfile was removed") + Vagrantfile.LOGGER.debug(f"{self.file_path} Vagrantfile was removed") From 9c9d8341ece2f10ed762d88dab4922b878afa62b Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 11:30:16 +0100 Subject: [PATCH 173/181] add: Add functionality to remove qa-ctl hosts from known_hosts file #2306 In addition, the corresponding functions have been modularized to read data from ansible instances --- .../qa_ctl/provisioning/ansible/__init__.py | 55 +++++++++++++++++++ .../provisioning/ansible/ansible_runner.py | 5 +- .../qa_ctl/provisioning/qa_provisioning.py | 52 +++--------------- .../qa_ctl/run_tests/qa_test_runner.py | 46 ++-------------- 4 files changed, 74 insertions(+), 84 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py index e69de29bb2..229a08d1c8 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py @@ -0,0 +1,55 @@ +import sys +import os +from pathlib import Path + +from wazuh_testing.qa_ctl.provisioning.ansible.unix_ansible_instance import UnixAnsibleInstance +from wazuh_testing.qa_ctl.provisioning.ansible.windows_ansible_instance import WindowsAnsibleInstance +from wazuh_testing.qa_ctl.provisioning.local_actions import run_local_command_returning_output + + +def read_ansible_instance(host_info): + """Read every host info and generate the AnsibleInstance object. + + Args: + host_info (dict): Dict with the host info needed coming from config file. + + Returns: + instance (AnsibleInstance): Contains the AnsibleInstance for a given host. + """ + extra_vars = None if 'host_vars' not in host_info else host_info['host_vars'] + private_key_path = None if 'local_private_key_file_path' not in host_info \ + else host_info['local_private_key_file_path'] + + if host_info['system'] == 'windows': + instance = WindowsAnsibleInstance( + host=host_info['host'], + ansible_connection=host_info['ansible_connection'], + ansible_port=host_info['ansible_port'], + ansible_user=host_info['ansible_user'], + ansible_password=host_info['ansible_password'], + ansible_python_interpreter=host_info['ansible_python_interpreter'], + host_vars=extra_vars + ) + else: + instance = UnixAnsibleInstance( + host=host_info['host'], + ansible_connection=host_info['ansible_connection'], + ansible_port=host_info['ansible_port'], + ansible_user=host_info['ansible_user'], + ansible_password=host_info['ansible_password'], + host_vars=extra_vars, + ansible_ssh_private_key_file=private_key_path, + ansible_python_interpreter=host_info['ansible_python_interpreter'] + ) + + return instance + + +def remove_known_host(host_ip, logger=None): + if sys.platform != 'win32': + known_host_file = os.path.join(str(Path.home()), '.ssh', 'known_hosts') + if os.path.exists(known_host_file): + if logger: + logger.debug(f"Removing {host_ip} from {known_host_file} file") + + run_local_command_returning_output(f"ssh-keygen -f {known_host_file} -R {host_ip}") diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py index 65d16d9553..8cec6550ce 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/ansible_runner.py @@ -22,21 +22,24 @@ class AnsibleRunner: ansible_playbook_path (string): Path where is located the playbook file. private_data_dir (string): Path where the artifacts files (result files) will be stored. output (boolean): True for showing ansible task output in stdout False otherwise. + task_id (str): Runner task id. It allows to identify the task. Attributes: ansible_inventory_path (string): Path where is located the ansible inventory file. ansible_playbook_path (string): Path where is located the playbook file. private_data_dir (string): Path where the artifacts files (result files) will be stored. output (boolean): True for showing ansible task output in stdout False otherwise. + task_id (str): Runner task id. It allows to identify the task. """ LOGGER = Logging.get_logger(QACTL_LOGGER) def __init__(self, ansible_inventory_path, ansible_playbook_path, - private_data_dir=join(gettempdir(), 'wazuh_qa_ctl'), output=False): + private_data_dir=join(gettempdir(), 'wazuh_qa_ctl'), output=False, task_id=None): self.ansible_inventory_path = ansible_inventory_path self.ansible_playbook_path = ansible_playbook_path self.private_data_dir = private_data_dir self.output = output + self.task_id = task_id def run(self, log_ansible_error=True): """Run the ansible playbook in the indicated hosts. diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py index d228b960ca..71c0f086b0 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/qa_provisioning.py @@ -3,8 +3,7 @@ from tempfile import gettempdir from time import sleep -from wazuh_testing.qa_ctl.provisioning.ansible.unix_ansible_instance import UnixAnsibleInstance -from wazuh_testing.qa_ctl.provisioning.ansible.windows_ansible_instance import WindowsAnsibleInstance +from wazuh_testing.qa_ctl.provisioning.ansible import read_ansible_instance, remove_known_host from wazuh_testing.qa_ctl.provisioning.ansible.ansible_inventory import AnsibleInventory from wazuh_testing.qa_ctl.provisioning.wazuh_deployment.wazuh_local_package import WazuhLocalPackage from wazuh_testing.qa_ctl.provisioning.wazuh_deployment.wazuh_s3_package import WazuhS3Package @@ -52,56 +51,23 @@ def __init__(self, provision_info, qa_ctl_configuration): self.__process_inventory_data() - def __read_ansible_instance(self, host_info): - """Read every host info and generate the AnsibleInstance object. - - Args: - host_info (dict): Dict with the host info needed coming from config file. - - Returns: - instance (AnsibleInstance): Contains the AnsibleInstance for a given host. - """ - extra_vars = None if 'host_vars' not in host_info else host_info['host_vars'] - private_key_path = None if 'local_private_key_file_path' not in host_info \ - else host_info['local_private_key_file_path'] - - if host_info['system'] == 'windows': - instance = WindowsAnsibleInstance( - host=host_info['host'], - ansible_connection=host_info['ansible_connection'], - ansible_port=host_info['ansible_port'], - ansible_user=host_info['ansible_user'], - ansible_password=host_info['ansible_password'], - ansible_python_interpreter=host_info['ansible_python_interpreter'], - host_vars=extra_vars - ) - else: - instance = UnixAnsibleInstance( - host=host_info['host'], - ansible_connection=host_info['ansible_connection'], - ansible_port=host_info['ansible_port'], - ansible_user=host_info['ansible_user'], - ansible_password=host_info['ansible_password'], - host_vars=extra_vars, - ansible_ssh_private_key_file=private_key_path, - ansible_python_interpreter=host_info['ansible_python_interpreter'] - ) - - return instance - def __process_inventory_data(self): """Process config file info to generate the ansible inventory file.""" QAProvisioning.LOGGER.debug('Processing inventory data from provisioning hosts info') for root_key, root_value in self.provision_info.items(): - if root_key == "hosts": + if root_key == 'hosts': for _, host_value in root_value.items(): for module_key, module_value in host_value.items(): - if module_key == "host_info": + if module_key == 'host_info': current_host = module_value['host'] + + # Remove the host IP from known host file to avoid the SSH key fingerprint error + remove_known_host(current_host, QAProvisioning.LOGGER) + if current_host: - self.instances_list.append(self.__read_ansible_instance(module_value)) - elif root_key == "groups": + self.instances_list.append(read_ansible_instance(module_value)) + elif root_key == 'groups': self.group_dict.update(self.provision_info[root_key]) inventory_instance = AnsibleInventory(ansible_instances=self.instances_list, diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py index 309667be8c..e408b5bc33 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tests/qa_test_runner.py @@ -2,8 +2,7 @@ import sys from tempfile import gettempdir -from wazuh_testing.qa_ctl.provisioning.ansible.unix_ansible_instance import UnixAnsibleInstance -from wazuh_testing.qa_ctl.provisioning.ansible.windows_ansible_instance import WindowsAnsibleInstance +from wazuh_testing.qa_ctl.provisioning.ansible import read_ansible_instance, remove_known_host from wazuh_testing.qa_ctl.provisioning.ansible.ansible_inventory import AnsibleInventory from wazuh_testing.qa_ctl.run_tests.test_launcher import TestLauncher from wazuh_testing.qa_ctl.run_tests.pytest import Pytest @@ -39,43 +38,6 @@ def __init__(self, tests_parameters, qa_ctl_configuration): self.__process_inventory_data(tests_parameters) self.__process_test_data(tests_parameters) - def __read_ansible_instance(self, host_info): - """Read every host info and generate the AnsibleInstance object. - - Attributes: - host_info (dict): Dict with the host info needed coming from config file. - - Returns: - instance (AnsibleInstance): Contains the AnsibleInstance for a given host. - """ - extra_vars = None if 'host_vars' not in host_info else host_info['host_vars'] - private_key_path = None if 'local_private_key_file_path' not in host_info \ - else host_info['local_private_key_file_path'] - - if host_info['system'] == 'windows': - instance = WindowsAnsibleInstance( - host=host_info['host'], - ansible_connection=host_info['ansible_connection'], - ansible_port=host_info['ansible_port'], - ansible_user=host_info['ansible_user'], - ansible_password=host_info['ansible_password'], - ansible_python_interpreter=host_info['ansible_python_interpreter'], - host_vars=extra_vars - ) - else: - instance = UnixAnsibleInstance( - host=host_info['host'], - ansible_connection=host_info['ansible_connection'], - ansible_port=host_info['ansible_port'], - ansible_user=host_info['ansible_user'], - ansible_password=host_info['ansible_password'], - ansible_ssh_private_key_file=private_key_path, - ansible_python_interpreter=host_info['ansible_python_interpreter'], - host_vars=extra_vars - ) - - return instance - def __process_inventory_data(self, instances_info): """Process config file info to generate the ansible inventory file. @@ -89,8 +51,12 @@ def __process_inventory_data(self, instances_info): for module_key, module_value in host_value.items(): if module_key == 'host_info': current_host = module_value['host'] + + # Remove the host IP from known host file to avoid the SSH key fingerprint error + remove_known_host(current_host, QATestRunner.LOGGER) + if current_host: - instances_list.append(self.__read_ansible_instance(module_value)) + instances_list.append(read_ansible_instance(module_value)) inventory_instance = AnsibleInventory(ansible_instances=instances_list) self.inventory_file_path = inventory_instance.inventory_file_path From 107dea025b1ef0e15db12a5504a55d8e6e33026e Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 11:33:44 +0100 Subject: [PATCH 174/181] add: Add module to launch ansible tasks using the `qa-ctl` tool #2306 --- .../qa_ctl/run_tasks/__init__.py | 0 .../qa_ctl/run_tasks/qa_tasks_launcher.py | 125 ++++++++++++++++++ .../wazuh_testing/scripts/qa_ctl.py | 12 ++ 3 files changed, 137 insertions(+) create mode 100644 deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/__init__.py create mode 100644 deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/qa_tasks_launcher.py diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/__init__.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/qa_tasks_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/qa_tasks_launcher.py new file mode 100644 index 0000000000..405b398bae --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/qa_tasks_launcher.py @@ -0,0 +1,125 @@ +import os +import sys +from copy import deepcopy +from tempfile import gettempdir + +from wazuh_testing.tools.file import download_text_file, remove_file +from wazuh_testing.qa_ctl.provisioning.ansible import read_ansible_instance, remove_known_host +from wazuh_testing.qa_ctl.provisioning.ansible.ansible_inventory import AnsibleInventory +from wazuh_testing.qa_ctl.provisioning.ansible.ansible_runner import AnsibleRunner +from wazuh_testing.qa_ctl import QACTL_LOGGER +from wazuh_testing.tools.logging import Logging +from wazuh_testing.tools import file +from wazuh_testing.tools.time import get_current_timestamp +from wazuh_testing.qa_ctl.provisioning.local_actions import qa_ctl_docker_run + + +class QATasksLauncher: + """Class to manage and launch the tasks specified in the qa-ctl configuration module. + + Args: + tasks_data (dict): Dicionary with tasks info. + qa_ctl_configuration (QACTLConfiguration): QACTL configuration info. + + Attributes: + tasks_data (dict): Dicionary with tasks info. + qa_ctl_configuration (QACTLConfiguration): QACTL configuration info. + """ + LOGGER = Logging.get_logger(QACTL_LOGGER) + + def __init__(self, tasks_data, qa_ctl_configuration): + self.qa_ctl_configuration = qa_ctl_configuration + self.ansible_runners = [] + self.tasks_data = tasks_data + + self.__process_tasks_data() + + def __process_tasks_data(self): + """Process tasks module info from the qa-ctl configuration file. + + Args: + tasks_data (dict): Dicionary with tasks info. + """ + QATasksLauncher.LOGGER.debug('Processing tasks module data') + + for task_id, task_data in self.tasks_data.items(): + playbooks_path = [] + inventory_path = '' + + if 'host_info' in task_data: + instance = read_ansible_instance(task_data['host_info']) + + # Remove the host IP from known host file to avoid the SSH key fingerprint error + if 'host' in task_data['host_info']: + remove_known_host(task_data['host_info']['host'], QATasksLauncher.LOGGER) + + inventory_instance = AnsibleInventory(ansible_instances=[instance]) + inventory_path = inventory_instance.inventory_file_path + + if 'playbooks' in task_data: + for playbook_data in task_data['playbooks']: + if 'local_path' in playbook_data: + playbooks_path.append(playbook_data['local_path']) + elif 'remote_url' in playbook_data: + # If a remote url file is specified, then download it and then save the playbook path + playbook_name = os.path.split(playbook_data['remote_url'])[1] + playbook_file_name = f"{get_current_timestamp()}_{playbook_name}" + playbook_file_path = os.path.join(gettempdir(), 'wazuh_qa_ctl', playbook_file_name) + + download_text_file(playbook_data['remote_url'], playbook_file_path) + playbooks_path.append(playbook_file_path) + + QATasksLauncher.LOGGER.debug(f"The {playbook_name} file has been downloaded from " + f"{playbook_data['remote_url']} in {playbook_file_path} path") + + # Create runner objects. One for each playbook using the same inventory for each host + for index, playbook in enumerate(playbooks_path): + self.ansible_runners.append(AnsibleRunner(inventory_path, playbook, + output=self.qa_ctl_configuration.ansible_output, + task_id=f"{task_id} - playbook_{index + 1}")) + + QATasksLauncher.LOGGER.debug('Tasks module data has been processed successfully') + + def run(self): + """Run the ansible tasks specified in the tasks module.""" + try: + if sys.platform == 'win32': + # If Windows, run the qa-ctl tasks in a linux container due to ansible is not compatible with Windows + tmp_config_file_name = f"config_{get_current_timestamp()}.yaml" + tmp_path = os.path.join(gettempdir(), 'wazuh_qa_ctl') + tmp_config_file = os.path.join(tmp_path, tmp_config_file_name) + + # Copy the tasks data to modify + container_tasks = deepcopy(self.tasks_data) + + # Copy the local playbooks to the wazuh qa-ctl path, that is shared with the container through a volume + for _, task_data in container_tasks.items(): + if 'playbooks' in task_data: + for playbook_data in task_data['playbooks']: + if 'local_path' in playbook_data: + file.copy(playbook_data['local_path'], tmp_path) + playbook_file = os.path.split(playbook_data['local_path'])[1] + + # Update local path to specify the playbooks path in the container + playbook_data['local_path'] = f"/wazuh_qa_ctl/{playbook_file}" + + # Write a custom configuration file with tasks section modified (local paths) + file.write_yaml_file(tmp_config_file, {'tasks': container_tasks}) + + try: + qa_ctl_docker_run(tmp_config_file_name, self.qa_ctl_configuration.qa_ctl_launcher_branch, + self.qa_ctl_configuration.debug_level, topic='launching the custom tasks') + finally: + file.remove_file(tmp_config_file) + else: + for task_runner in self.ansible_runners: + QATasksLauncher.LOGGER.info(f"Running {task_runner.task_id}") + task_runner.run() + QATasksLauncher.LOGGER.info(f"The {task_runner.task_id} has been finished successfully") + finally: + self.destroy() + + def destroy(self): + """Remove temporarily runner files.""" + for runner in self.ansible_runners: + remove_file(runner.ansible_inventory_path) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 58209d1b65..1e2be087bb 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -14,6 +14,7 @@ from wazuh_testing.qa_ctl.deployment.qa_infraestructure import QAInfraestructure from wazuh_testing.qa_ctl.provisioning.qa_provisioning import QAProvisioning from wazuh_testing.qa_ctl.run_tests.qa_test_runner import QATestRunner +from wazuh_testing.qa_ctl.run_tasks.qa_tasks_launcher import QATasksLauncher from wazuh_testing.qa_ctl.configuration.qa_ctl_configuration import QACTLConfiguration from wazuh_testing.qa_ctl import QACTL_LOGGER from wazuh_testing.tools.logging import Logging @@ -28,6 +29,7 @@ DEPLOY_KEY = 'deployment' PROVISION_KEY = 'provision' +TASKS_KEY = 'tasks' TEST_KEY = 'tests' WAZUH_QA_FILES = os.path.join(gettempdir(), 'wazuh_qa_ctl', 'wazuh-qa') RUNNING_ON_DOCKER_CONTAINER = True if 'RUNNING_ON_DOCKER_CONTAINER' in os.environ else False @@ -41,6 +43,7 @@ 'config_generator': False, 'instance_handler': False, 'qa_provisioning': False, + 'tasks_runner': False, 'test_runner': False } @@ -304,6 +307,9 @@ def get_script_parameters(): parser.add_argument('--skip-provisioning', action='store_true', help='Flag to skip the provisioning phase. Set it only if -c or --config was specified.') + parser.add_argument('--skip-tasks', action='store_true', + help='Flag to skip the tasks phase. Set it only if -c or --config was specified.') + parser.add_argument('--skip-testing', action='store_true', help='Flag to skip the testing phase. Set it only if -c or --config was specified.') @@ -376,6 +382,12 @@ def main(): qa_provisioning.run() launched['qa_provisioning'] = True + if TASKS_KEY in configuration_data and not arguments.skip_tasks: + tasks_dict = configuration_data[TASKS_KEY] + tasks_runner = QATasksLauncher(tasks_dict, qactl_configuration) + tasks_runner.run() + launched['tasks_runner'] = True + if TEST_KEY in configuration_data and not arguments.skip_testing: test_dict = configuration_data[TEST_KEY] tests_runner = QATestRunner(test_dict, qactl_configuration) From a3f60d8a3b55c471c510f85641e50323d37d05dc Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 11:33:51 +0100 Subject: [PATCH 175/181] add: Update the `qa-ctl` schema validator #2306 --- .../data/qactl_conf_validator_schema.json | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/data/qactl_conf_validator_schema.json b/deps/wazuh_testing/wazuh_testing/data/qactl_conf_validator_schema.json index 3e93dde811..a1ea150a6a 100644 --- a/deps/wazuh_testing/wazuh_testing/data/qactl_conf_validator_schema.json +++ b/deps/wazuh_testing/wazuh_testing/data/qactl_conf_validator_schema.json @@ -161,7 +161,7 @@ "system": { "type": "string", "enum": [ - "rpm", + "rpm", "deb", "windows", "macos", @@ -272,6 +272,76 @@ } } }, + "tasks": { + "type": "object", + "patternProperties": { + "^task_[0-9]*$": { + "type": "object", + "required": ["host_info", "playbooks"], + "properties": { + "host_info": { + "type": "object", + "required": [ + "ansible_connection", + "ansible_user", + "ansible_port", + "ansible_python_interpreter", + "host", + "system" + ], + "properties": { + "ansible_connection": { + "type" : "string" + }, + "host": { + "type" : "string" + }, + "ansible_user": { + "type": "string" + }, + "ansible_password": { + "type": "string", + "default": "empty" + }, + "ssh_private_key_file_path":{ + "type": "string" + }, + "ansible_port": { + "type": "integer" + }, + "ansible_python_interpreter": { + "type": "string" + }, + "system": { + "type": "string" + } + }, + "oneOf": [ + { + "required": ["ansible_password"] + }, + { + "required": ["ssh_private_key_file_path"] + } + ] + }, + "playbooks": { + "type": "array", + "items": { + "oneOf": [ + { + "required": ["local_path"] + }, + { + "required": ["remote_url"] + } + ] + } + } + } + } + } + }, "tests": { "type": "object", "patternProperties": { @@ -319,7 +389,6 @@ "system": { "type": "string" } - }, "oneOf": [ { From eb3813de6bb546c12921c6125f9e651c3129a194 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 11:53:18 +0100 Subject: [PATCH 176/181] doc: Documentate some non-documented qa-ctl functions #2306 --- .../qa_ctl/configuration/config_generator.py | 17 +++++++++++++---- .../qa_ctl/provisioning/ansible/__init__.py | 6 ++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index cc69e12703..f8e9cc1b53 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -582,9 +582,8 @@ def get_deployment_configuration(self, instances): Raises: QAValueError: If the instance operating system is not allowed for generating the qa-ctl configuration. - """ - deployment_configuration = {'deployment': {} } + deployment_configuration = {'deployment': {}} for index, instance in enumerate(instances): try: @@ -616,6 +615,16 @@ def get_deployment_configuration(self, instances): return deployment_configuration def get_tasks_configuration(self, instances, playbooks, playbook_path='local'): + """Generate the qa-ctl configuration required for running ansible tasks. + + Args: + instances (list(ConfigInstance)): List of config-instances to deploy. + playbooks (list(str)): List of playbooks path to run. + playbook_path (str): Playbook path configuration [local or remote_url]. + + Returns: + dict: Configuration block corresponding to the ansible tasks to run with qa-ctl. + """ tasks_configuration = {'tasks': {}} for index, instance in enumerate(instances): @@ -623,8 +632,8 @@ def get_tasks_configuration(self, instances, playbooks, playbook_path='local'): host_info = QACTLConfigGenerator.BOX_INFO[instance_box] host_info['host'] = instance.ip - playbooks_dict = [{'local_path': playbook} if playbook_path == 'local' else \ - {'remote_url': playbook} for playbook in playbooks] + playbooks_dict = [{'local_path': playbook} if playbook_path == 'local' else + {'remote_url': playbook} for playbook in playbooks] tasks_configuration['tasks'][f"task_{index + 1}"] = { 'host_info': host_info, diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py index 229a08d1c8..11e0cb3f8e 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py @@ -46,6 +46,12 @@ def read_ansible_instance(host_info): def remove_known_host(host_ip, logger=None): + """Remove an IP host from SSH known_hosts file. + + Args: + host_ip (str): Host IP to remove from SSH known_host file. + logger (logging.Logging): Logger where log the messages. + """ if sys.platform != 'win32': known_host_file = os.path.join(str(Path.home()), '.ssh', 'known_hosts') if os.path.exists(known_host_file): From 59115ee9ab37962721b3a965c14cd7027fb0d09c Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 16:08:07 +0100 Subject: [PATCH 177/181] add: Add name field to the qa-ctl playbook configuration #2306 --- .../qa_ctl/configuration/config_generator.py | 11 ++++++----- .../qa_ctl/run_tasks/qa_tasks_launcher.py | 5 ++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index f8e9cc1b53..e9bb657cdb 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -614,13 +614,13 @@ def get_deployment_configuration(self, instances): return deployment_configuration - def get_tasks_configuration(self, instances, playbooks, playbook_path='local'): + def get_tasks_configuration(self, instances, playbook_info, playbook_type='local'): """Generate the qa-ctl configuration required for running ansible tasks. Args: instances (list(ConfigInstance)): List of config-instances to deploy. - playbooks (list(str)): List of playbooks path to run. - playbook_path (str): Playbook path configuration [local or remote_url]. + playbook_info (dict): Playbook dictionary info. {playbook_name: playbook_path} + playbook_type (str): Playbook path configuration [local or remote_url]. Returns: dict: Configuration block corresponding to the ansible tasks to run with qa-ctl. @@ -632,8 +632,9 @@ def get_tasks_configuration(self, instances, playbooks, playbook_path='local'): host_info = QACTLConfigGenerator.BOX_INFO[instance_box] host_info['host'] = instance.ip - playbooks_dict = [{'local_path': playbook} if playbook_path == 'local' else - {'remote_url': playbook} for playbook in playbooks] + playbooks_dict = [{'name': playbook_name, 'local_path': playbook_path} if playbook_type == 'local' else + {'name': playbook_name, 'remote_url': playbook_path} for playbook_name, playbook_path + in playbook_info.items()] tasks_configuration['tasks'][f"task_{index + 1}"] = { 'host_info': host_info, diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/qa_tasks_launcher.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/qa_tasks_launcher.py index 405b398bae..0e504619df 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/qa_tasks_launcher.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/run_tasks/qa_tasks_launcher.py @@ -74,9 +74,12 @@ def __process_tasks_data(self): # Create runner objects. One for each playbook using the same inventory for each host for index, playbook in enumerate(playbooks_path): + task_id = f"{task_data['playbooks'][index]['name']} task from playbook {playbook}" if 'name' in \ + task_data['playbooks'][index] else f"Running playbook {playbook}" + self.ansible_runners.append(AnsibleRunner(inventory_path, playbook, output=self.qa_ctl_configuration.ansible_output, - task_id=f"{task_id} - playbook_{index + 1}")) + task_id=task_id)) QATasksLauncher.LOGGER.debug('Tasks module data has been processed successfully') From 369902464c08f0277fe46a8811893f15813cf24b Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Thu, 9 Dec 2021 16:24:44 +0100 Subject: [PATCH 178/181] add: Bump the qa-ctl version to v0.3 #2306 --- .../wazuh_testing/qa_ctl/CHANGELOG.md | 25 ++++++++++++++++++- .../wazuh_testing/qa_ctl/VERSION.json | 2 +- .../wazuh_testing/scripts/qa_ctl.py | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md b/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md index 99597f04eb..97b3b0b366 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md @@ -1,14 +1,35 @@ # Change Log All notable changes to this tool will be documented in this file. +## [v0.3] - 2021-12-09 + +### Added + +- Added new module to be able to launch custom ansible tasks with `qa-ctl`. +- Added new methods to avoid the SSH fingerprint check ansible error. +- Added generation of independent configuration blocks for instance and task deployment. + +### Changed + +- Improved modularization of functions for reading data from ansible instances. + + +### Fixed + +- Fixed some typos in qa-ctl logs. +- Fixed some uses of UNIX libraries in `file` module under Windows. + + ## [v0.2] - 2021-11-05 + ### Added - Added operating systems validation for specified OS in tests ([#2168](https://github.com/wazuh/wazuh-qa/pull/2168)) - Added Windows testing support in `qa-ctl` ([#2152](https://github.com/wazuh/wazuh-qa/pull/2152)) - Updated `qa-docs` usage in `qa-ctl` ([#2081](https://github.com/wazuh/wazuh-qa/pull/2081)) - Added `--os` parameter to specify the systems where to launch the tests ([#2064](https://github.com/wazuh/wazuh-qa/pull/2064)) - Added no-validation flag for `qa-ctl` docker run on windows ([#2028](https://github.com/wazuh/wazuh-qa/pull/2028)) -- Added documentation tests validation precondition for automatic mode ([#2023](https://github.com/wazuh/wazuh-qa/issues/2023)) +- Added documentation tests validation precondition for automatic mode ([#2023](https://github.com/wazuh/wazuh-qa/issues/2023)) + ### Changed - Updated `JSON Schema validator` ([#2164](https://github.com/wazuh/wazuh-qa/issues/2164)) @@ -19,6 +40,7 @@ All notable changes to this tool will be documented in this file. - Renamed `qa-ctl` temporary files directory ([#2029](https://github.com/wazuh/wazuh-qa/pull/2029)) - Updated `qa-ctl` help menu information ([#2026](https://github.com/wazuh/wazuh-qa/pull/2026)) + ### Fixed - Fixed `Docker` issues for `qa-ctl` (manual mode) in `Windows` ([#2147](https://github.com/wazuh/wazuh-qa/pull/2147)) - Fixed `qa-ctl` configuration path separators for `windows` ([#2036](https://github.com/wazuh/wazuh-qa/pull/2036)) @@ -26,6 +48,7 @@ All notable changes to this tool will be documented in this file. ## [v0.1] + ### Added - Added new folder level for temporary files ([#1993](https://github.com/wazuh/wazuh-qa/pull/1993)) - Added new implementation for generating `qa-ctl` configuration paths ([#1982](https://github.com/wazuh/wazuh-qa/pull/1982)) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json b/deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json index aeeb7caeaf..a82f8b2027 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/VERSION.json @@ -1,4 +1,4 @@ { - "version": "0.2", + "version": "0.3", "revision": 1 } diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 1e2be087bb..0913e3807a 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -260,7 +260,7 @@ def get_script_parameters(): """ description = \ ''' - Current version: v0.2 + Current version: v0.3 Description: qa-ctl is a tool for launching tests locally, automating the deployment, provisioning and testing phase. From d35748c40a16b1fbcabc7860f6d4534e0c3c09f7 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 10 Dec 2021 15:51:35 +0100 Subject: [PATCH 179/181] add: Add CentOS 7 box to qa-ctl #2306 #2242 --- .../wazuh_testing/qa_ctl/CHANGELOG.md | 4 +-- .../qa_ctl/configuration/config_generator.py | 34 +++++++++++++++---- .../qa_ctl/deployment/vagrantfile.py | 1 + .../wazuh_testing/scripts/qa_ctl.py | 6 ++-- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md b/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md index 97b3b0b366..34dd379aa0 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/CHANGELOG.md @@ -1,14 +1,14 @@ # Change Log All notable changes to this tool will be documented in this file. -## [v0.3] - 2021-12-09 +## [v0.3] - 2021-12-10 ### Added - Added new module to be able to launch custom ansible tasks with `qa-ctl`. - Added new methods to avoid the SSH fingerprint check ansible error. - Added generation of independent configuration blocks for instance and task deployment. - +- Added CentOS 7 support. ### Changed - Improved modularization of functions for reading data from ansible instances. diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index e9bb657cdb..8c393d728a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -46,28 +46,33 @@ class QACTLConfigGenerator: LINUX_DEFAULT_WAZUH_INSTALL_PATH = '/var/ossec' BOX_MAPPING = { - 'Ubuntu Focal': 'qactl/ubuntu_20_04', + 'CentOS 7': 'qactl/centos_7', 'CentOS 8': 'qactl/centos_8', + 'Ubuntu Focal': 'qactl/ubuntu_20_04', 'Windows Server 2019': 'qactl/windows_2019' } SYSTEMS = { - 'centos': { + 'centos_7': { + 'os_version': 'CentOS 7', + 'os_platform': 'linux' + }, + 'centos_8': { 'os_version': 'CentOS 8', 'os_platform': 'linux' }, - 'ubuntu': { + 'ubuntu_focal': { 'os_version': 'Ubuntu Focal', 'os_platform': 'linux' }, - 'windows': { + 'windows_2019': { 'os_version': 'Windows Server 2019', 'os_platform': 'windows' } } DEFAULT_BOX_RESOURCES = { - 'qactl/ubuntu_20_04': { + 'qactl/centos_7': { 'cpu': 1, 'memory': 1024 }, @@ -75,6 +80,10 @@ class QACTLConfigGenerator: 'cpu': 1, 'memory': 1024 }, + 'qactl/ubuntu_20_04': { + 'cpu': 1, + 'memory': 1024 + }, 'qactl/windows_2019': { 'cpu': 2, 'memory': 2048 @@ -91,6 +100,15 @@ class QACTLConfigGenerator: 'system': 'deb', 'installation_files_path': LINUX_TMP }, + 'qactl/centos_7': { + 'ansible_connection': 'ssh', + 'ansible_user': 'vagrant', + 'ansible_password': 'vagrant', + 'ansible_port': 22, + 'ansible_python_interpreter': '/usr/bin/python3', + 'system': 'rpm', + 'installation_files_path': LINUX_TMP + }, 'qactl/centos_8': { 'ansible_connection': 'ssh', 'ansible_user': 'vagrant', @@ -368,9 +386,11 @@ def __process_deployment_data(self, tests_info): if self.__validate_test_info(test): os_version = '' if 'CentOS 8' in test['os_version']: - os_version = 'Ubuntu Focal' - elif 'Ubuntu Focal' in test['os_version']: os_version = 'CentOS 8' + elif 'Ubuntu Focal' in test['os_version']: + os_version = 'Ubuntu Focal' + elif 'CentOS 7' in test['os_version']: + os_version = 'CentOS 7' elif 'Windows Server 2019' in test['os_version']: os_version = 'Windows Server 2019' else: diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py index aabfaf022b..e597201011 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/deployment/vagrantfile.py @@ -64,6 +64,7 @@ def __get_box_url(self): """ box_mapping = { 'qactl/ubuntu_20_04': 'https://s3.amazonaws.com/ci.wazuh.com/qa/boxes/QACTL_ubuntu_20_04.box', + 'qactl/centos_7': 'https://s3.amazonaws.com/ci.wazuh.com/qa/boxes/QACTL_centos_7.box', 'qactl/centos_8': 'https://s3.amazonaws.com/ci.wazuh.com/qa/boxes/QACTL_centos_8.box', 'qactl/windows_2019': 'https://s3.amazonaws.com/ci.wazuh.com/qa/boxes/QACTL_windows_server_2019.box' } diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 0913e3807a..773568647f 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -182,7 +182,8 @@ def _validate_tests_os(parameters): for op_system in parameters.operating_systems: # Check platform - platform = 'linux' if op_system == 'ubuntu' or op_system == 'centos' else op_system + platform = QACTLConfigGenerator.SYSTEMS[op_system]['os_platform'] if op_system in \ + QACTLConfigGenerator.SYSTEMS.keys() else op_system if platform not in test_data['os_platform']: raise QAValueError(f"The {test} test does not support the {op_system} system. Allowed platforms: " f"{test_data['os_platform']} (ubuntu and centos are from linux platform)") @@ -295,7 +296,8 @@ def get_script_parameters(): parser.add_argument('--no-validation', action='store_true', help='Disable the script parameters validation.') parser.add_argument('--os', '-o', type=str, action='store', required=False, nargs='+', dest='operating_systems', - choices=['centos', 'ubuntu', 'windows'], help='System/s where the tests will be launched.') + choices=['centos_7', 'centos_8', 'ubuntu_focal', 'windows_2019'], + help='System/s where the tests will be launched.') parser.add_argument('--qa-branch', type=str, action='store', required=False, dest='qa_branch', help='Set a custom wazuh-qa branch to use in the run and provisioning. This ' From 4cae16951333567378c33838d84692c03a80e8a9 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 10 Dec 2021 17:12:15 +0100 Subject: [PATCH 180/181] fix: Fix centos_7 wazuh_deployment in qa-ctl #2306 --- .../qa_ctl/configuration/config_generator.py | 2 +- .../wazuh_deployment/wazuh_deployment.py | 17 +---------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index 8c393d728a..d29cd189ef 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -105,7 +105,7 @@ class QACTLConfigGenerator: 'ansible_user': 'vagrant', 'ansible_password': 'vagrant', 'ansible_port': 22, - 'ansible_python_interpreter': '/usr/bin/python3', + 'ansible_python_interpreter': '/usr/bin/python', 'system': 'rpm', 'installation_files_path': LINUX_TMP }, diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py index edd3ee9772..1baf48f2d9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/wazuh_deployment/wazuh_deployment.py @@ -92,26 +92,11 @@ def install(self, install_type): 'when': 'ansible_os_family|lower == "debian"' })) - tasks_list.append(AnsibleTask({ - 'name': 'Install Wazuh Agent from .rpm packages | yum', - 'yum': {'name': f'{self.installation_files_path}', 'disable_gpg_check': 'yes'}, - 'become': True, - 'when': ['ansible_os_family|lower == "redhat"', - 'not (ansible_distribution|lower == "centos" and ' + - 'ansible_distribution_major_version >= "8")', - 'not (ansible_distribution|lower == "redhat" and ' + - 'ansible_distribution_major_version >= "8")'] - })) - tasks_list.append(AnsibleTask({ 'name': 'Install Wazuh Agent from .rpm packages | dnf', 'dnf': {'name': f'{self.installation_files_path}', 'disable_gpg_check': 'yes'}, 'become': True, - 'when': ['ansible_os_family|lower == "redhat"', - '(ansible_distribution|lower == "centos" and ' + - 'ansible_distribution_major_version >= "8") or' + - '(ansible_distribution|lower == "redhat" and ' + - 'ansible_distribution_major_version >= "8")'] + 'when': 'ansible_os_family|lower == "redhat"' })) tasks_list.append(AnsibleTask({ From c052033e13325b25b5f402a54f0a63cc20ae201f Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Fri, 10 Dec 2021 17:12:37 +0100 Subject: [PATCH 181/181] fix: Fix remove_known_host output when the host was not found #2306 --- .../wazuh_testing/qa_ctl/provisioning/ansible/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py index 11e0cb3f8e..7587abb995 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/provisioning/ansible/__init__.py @@ -58,4 +58,4 @@ def remove_known_host(host_ip, logger=None): if logger: logger.debug(f"Removing {host_ip} from {known_host_file} file") - run_local_command_returning_output(f"ssh-keygen -f {known_host_file} -R {host_ip}") + run_local_command_returning_output(f"ssh-keygen -f {known_host_file} -R {host_ip} &> /dev/null")

5n7|a)w{;HNE~bGk|!YX$=zqZ*F@FbLl@t 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 From 8f7fa9c53df7d2a7fcaca78005abda10b5f6b994 Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Wed, 13 Oct 2021 15:31:20 +0200 Subject: [PATCH 103/181] refac: Rename github_repository module to github_api_requests #2012 --- .../tools/{github_repository.py => github_api_requests.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename deps/wazuh_testing/wazuh_testing/tools/{github_repository.py => github_api_requests.py} (100%) diff --git a/deps/wazuh_testing/wazuh_testing/tools/github_repository.py b/deps/wazuh_testing/wazuh_testing/tools/github_api_requests.py similarity index 100% rename from deps/wazuh_testing/wazuh_testing/tools/github_repository.py rename to deps/wazuh_testing/wazuh_testing/tools/github_api_requests.py From c5ba57cc085a7294db1e009fe6f95bc22ae6dc9b Mon Sep 17 00:00:00 2001 From: jmv74211 Date: Wed, 13 Oct 2021 15:48:56 +0200 Subject: [PATCH 104/181] add: Add github_checks module #2012 Needed to validate parameters without using the github API. --- .../wazuh_testing/tools/github_checks.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 deps/wazuh_testing/wazuh_testing/tools/github_checks.py diff --git a/deps/wazuh_testing/wazuh_testing/tools/github_checks.py b/deps/wazuh_testing/wazuh_testing/tools/github_checks.py new file mode 100644 index 0000000000..7947b484fe --- /dev/null +++ b/deps/wazuh_testing/wazuh_testing/tools/github_checks.py @@ -0,0 +1,91 @@ +import requests +import re + +from wazuh_testing.tools.exceptions import QAValueError + + +def _get_status_code(url): + """Make a request to the specified URL and get its status code. + + Returns: + int: Request status code + """ + return (requests.get(url)).status_code + + +def _check_status_code(status_code, exception_message): + """Check if the status code has an expected value. + + Parameters: + status_code (int): Request status code. + exception_message (str): Text to display in the exception message. + + Raise: + Exception if status code is distinct from 200 and 400 (unexpected). + + """ + if status_code != 200 and status_code != 404: + raise Exception(f"{exception_message}. Status code {status_code}") + + +def version_is_released(version, organization='wazuh', repository='wazuh'): + """Check if the specified version has been released in the specified github repository. + + Parameters: + version (str): Version to check. + organization (str): Github repository organization name. + repository (str): Github repository name. + + Returns: + boolean: True if the version has been released, False otherwise. + """ + v_version = f"v{version}" if 'v' not in version else version + + url = f"https://github.com/{organization}/{repository}/releases/tag/{v_version}" + status_code = _get_status_code(url) + + _check_status_code(status_code, f"Could not check if {v_version} has been released. URL: {url}") + + return True if status_code == 200 else False + + +def branch_exists(branch_name, organization='wazuh', repository='wazuh'): + """Check if the specified branch exists in the github repository. + + Parameters: + branch_name (str): Branch name to check. + organization (str): Github repository organization name. + repository (str): Github repository name. + + Returns: + boolean: True if branch exists in the github repository, False otherwise. + + """ + url = f"https://github.com/{organization}/{repository}/tree/{branch_name}" + + status_code = _get_status_code(url) + + _check_status_code(status_code, f"Could not check if {branch_name} exists. URL: {url}") + + return True if status_code == 200 else False + + +def get_last_wazuh_version(): + """Get the last Wazuh version that has been tagged, regardless of possible release candidate tags. + + Raises: + QAValueError: If could not find a valid wazuh tag in the first github tags page (It can happen if on the first + page, all tags are rc tags.). + Returns: + str: Last Wazuh tag (no rc). + """ + url = 'https://github.com/wazuh/wazuh/tags' + req = requests.get(url) + last_tags = re.findall(r'', req.text) + + try: + last_wazuh_version = re.compile(r' Date: Wed, 13 Oct 2021 16:04:19 +0200 Subject: [PATCH 105/181] refac: Update qa-ctl github validations #2012 --- .../qa_ctl/configuration/config_generator.py | 3 +-- .../wazuh_testing/scripts/qa_ctl.py | 21 ++++++++++++++----- .../wazuh_testing/tools/github_checks.py | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py index b367d32a89..57943612b9 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py +++ b/deps/wazuh_testing/wazuh_testing/qa_ctl/configuration/config_generator.py @@ -10,7 +10,6 @@ from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.s3_package import get_s3_package_url from wazuh_testing.qa_ctl.provisioning.wazuh_deployment.wazuh_s3_package import WazuhS3Package -from wazuh_testing.tools.github_repository import get_last_wazuh_version from wazuh_testing.qa_ctl.provisioning.local_actions import run_local_command_with_output @@ -68,7 +67,7 @@ class QACTLConfigGenerator: def __init__(self, tests, wazuh_version, qa_branch='master', qa_files_path=join(gettempdir(), 'qa_ctl', 'wazuh-qa')): self.tests = tests - self.wazuh_version = get_last_wazuh_version() if wazuh_version is None else wazuh_version + self.wazuh_version = wazuh_version self.qactl_used_ips_file = join(gettempdir(), 'qa_ctl', 'qactl_used_ips.txt') self.config_file_path = join(gettempdir(), 'qa_ctl', f"config_{get_current_timestamp()}.yaml") self.config = {} diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py index 79fb811b49..74b5e708a5 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_ctl.py @@ -18,9 +18,9 @@ from wazuh_testing.tools.logging import Logging from wazuh_testing.tools.exceptions import QAValueError from wazuh_testing.qa_ctl.configuration.config_generator import QACTLConfigGenerator -from wazuh_testing.tools.github_repository import version_is_released, branch_exist, WAZUH_QA_REPO +from wazuh_testing.tools import github_checks +from wazuh_testing.tools.github_api_requests import WAZUH_QA_REPO from wazuh_testing.qa_ctl.provisioning import local_actions -from wazuh_testing.tools.github_repository import get_last_wazuh_version from wazuh_testing.tools.file import recursive_directory_creation @@ -91,6 +91,10 @@ def set_qactl_logging(qactl_configuration): def set_parameters(parameters): """Update script parameters and add extra information. + Raises: + QAValueError: If could not find a valid wazuh tag in the first github tags page (It can happen if on the first + page, all tags are rc tags.). + Args: (argparse.Namespace): Object with the user parameters. """ @@ -101,7 +105,14 @@ def set_parameters(parameters): qactl_logger.set_level(level) parameters.user_version = parameters.version if parameters.version else None - parameters.version = parameters.version if parameters.version else get_last_wazuh_version() + + try: + parameters.version = parameters.version if parameters.version else github_checks.get_last_wazuh_version() + except QAValueError: + raise QAValueError('The latest version of Wazuh could not be obtained. Maybe there is no valid (non-rc) one at ' + 'https://github.com/wazuh/wazuh/tags. Try specifying the version manually using the ' + '--version parameter in the qa-ctl parameters.', qactl_logger.error, QACTL_LOGGER) + parameters.version = (parameters.version).replace('v', '') short_version = f"{(parameters.version).split('.')[0]}.{(parameters.version).split('.')[1]}" @@ -156,12 +167,12 @@ def validate_parameters(parameters): qactl_logger.error, QACTL_LOGGER) # Check if Wazuh has the specified version - if not version_is_released(parameters.version): + if not github_checks.version_is_released(parameters.version): raise QAValueError(f"The wazuh {parameters.version} version has not been released. Enter a right version.", qactl_logger.error, QACTL_LOGGER) # Check if QA branch exists - if not branch_exist(parameters.qa_branch, WAZUH_QA_REPO): + if not github_checks.branch_exists(parameters.qa_branch, repository=WAZUH_QA_REPO): raise QAValueError(f"{parameters.qa_branch} branch does not exist in Wazuh QA repository.", qactl_logger.error, QACTL_LOGGER) diff --git a/deps/wazuh_testing/wazuh_testing/tools/github_checks.py b/deps/wazuh_testing/wazuh_testing/tools/github_checks.py index 7947b484fe..5498611868 100644 --- a/deps/wazuh_testing/wazuh_testing/tools/github_checks.py +++ b/deps/wazuh_testing/wazuh_testing/tools/github_checks.py @@ -20,7 +20,7 @@ def _check_status_code(status_code, exception_message): status_code (int): Request status code. exception_message (str): Text to display in the exception message. - Raise: + Raises: Exception if status code is distinct from 200 and 400 (unexpected). """ From 252ece11dee0d956ca90436fa9417de0461af36b Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 14 Oct 2021 10:49:59 +0200 Subject: [PATCH 106/181] refac: Separate parsing and API code. #1864 --- .../wazuh_testing/qa_docs/lib/index_data.py | 4 ++-- .../wazuh_testing/wazuh_testing/scripts/qa_docs.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py index 1bc9025b17..a35cc72dc4 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/lib/index_data.py @@ -26,7 +26,7 @@ class IndexData: """ LOGGER = Logging.get_logger(QADOCS_LOGGER) - def __init__(self, index, config): + def __init__(self, index, path): """Class constructor Initialize every attribute. @@ -34,7 +34,7 @@ def __init__(self, index, config): Args: config (Config): A `Config` instance with the loaded configuration. """ - self.path = config.documentation_path + self.path = path self.index = index self.regex = re.compile(".*json") self.es = Elasticsearch() diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index b36f17dc6b..b94eae7779 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -107,7 +107,7 @@ def check_incompatible_parameters(parameters): raise QAValueError('The -s, --sanity-check option must be run with -I, --tests-path option.', qadocs_logger.error) - if parameters.tests_path is None and (default_run or test_run): + if parameters.tests_path is None and (default_run or test_run or parameters.sanity): raise QAValueError('The following options need the path where the tests are located: -t, --test, ' ' -e, --exist, --types, --modules, -s, --sanity-check. You must specify it by using ' '-I, --tests-path path_to_tests.', @@ -235,7 +235,7 @@ def index_and_visualize_data(args): """Index the data previously parsed and visualize it.""" # Index the previous parsed tests into Elasticsearch if args.index_name: - index_data = IndexData(args.index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + index_data = IndexData(args.index_name, OUTPUT_PATH) index_data.run() # Launch SearchUI with index_name as input @@ -246,7 +246,7 @@ def index_and_visualize_data(args): # Index the previous parsed tests into Elasticsearch and then launch SearchUI elif args.launching_index_name: qadocs_logger.debug(f"Indexing {args.launching_index_name}") - index_data = IndexData(args.launching_index_name, Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + index_data = IndexData(args.launching_index_name, OUTPUT_PATH) index_data.run() # When SearchUI index is not hardcoded, it will be use args.launching_index_name run_searchui(args.launching_index_name) @@ -273,9 +273,11 @@ def main(): sanity.run() # Parse tests, index the data and visualize it - else: - parse_data(args) - index_and_visualize_data(args) + else: + if args.test_types or args.test_modules or args.test_names or args.test_exist or args.sanity: + parse_data(args) + if args.index_name or args.app_index_name or args.launching_index_name: + index_and_visualize_data(args) if __name__ == '__main__': main() From 5423531755d4389829fdb8886beb3250343aefb5 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 14 Oct 2021 10:53:01 +0200 Subject: [PATCH 107/181] refac: Change the ES RAM limit in `entrypoint.sh`. #1864 --- .../wazuh_testing/qa_docs/dockerfiles/entrypoint.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh index de12b96742..37b9fe8b38 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh @@ -26,8 +26,8 @@ cd /usr/local/lib/python3.8/dist-packages/wazuh_testing-*/wazuh_testing/qa_docs/ npm install # Limit ES RAM -echo "-Xms1g" >> /etc/elasticsearch/jvm.options -echo "-Xmx1g" >> /etc/elasticsearch/jvm.options +echo "-Xms256m" >> /etc/elasticsearch/jvm.options +echo "-Xmx256m" >> /etc/elasticsearch/jvm.options # Start services service elasticsearch start && service wazuh-manager start From ecfd18a3d224efb6db52a6555da8b19e986d2c71 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 14 Oct 2021 11:24:08 +0200 Subject: [PATCH 108/181] fix: Fix `qa-docs` compound runs. #1864 --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index b94eae7779..9377cd05a0 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -226,7 +226,7 @@ def parse_data(args): else: qadocs_logger.info(f"Parsing all tests located in {args.tests_path}") - if not args.test_exist: + if args.test_types or args.test_modules or args.test_names: qadocs_logger.info('Running QADOCS') docs.run() @@ -274,10 +274,8 @@ def main(): # Parse tests, index the data and visualize it else: - if args.test_types or args.test_modules or args.test_names or args.test_exist or args.sanity: - parse_data(args) - if args.index_name or args.app_index_name or args.launching_index_name: - index_and_visualize_data(args) + parse_data(args) + index_and_visualize_data(args) if __name__ == '__main__': main() From 269739375d72ea96b5bfb9664b576888488b001a Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 14 Oct 2021 11:55:16 +0200 Subject: [PATCH 109/181] fix: Revert ES RAM limit. In some cases, we can get a timeout. --- .../wazuh_testing/qa_docs/dockerfiles/entrypoint.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh index 37b9fe8b38..de12b96742 100755 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/dockerfiles/entrypoint.sh @@ -26,8 +26,8 @@ cd /usr/local/lib/python3.8/dist-packages/wazuh_testing-*/wazuh_testing/qa_docs/ npm install # Limit ES RAM -echo "-Xms256m" >> /etc/elasticsearch/jvm.options -echo "-Xmx256m" >> /etc/elasticsearch/jvm.options +echo "-Xms1g" >> /etc/elasticsearch/jvm.options +echo "-Xmx1g" >> /etc/elasticsearch/jvm.options # Start services service elasticsearch start && service wazuh-manager start From b7eedad7aabf7ba5882772ae77058f00fdf3a12a Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 14 Oct 2021 11:57:16 +0200 Subject: [PATCH 110/181] fix: Fix qa-docs compound runs. The last changes were wrong. --- deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py index 9377cd05a0..d54326bd79 100644 --- a/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py +++ b/deps/wazuh_testing/wazuh_testing/scripts/qa_docs.py @@ -191,8 +191,6 @@ def run_searchui(index): def parse_data(args): """Parse the tests and collect the data.""" - docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) - if args.test_exist: doc_check = DocGenerator(Config(SCHEMA_PATH, args.tests_path, test_names=args.test_exist)) @@ -224,7 +222,10 @@ def parse_data(args): # Parse the whole path else: - qadocs_logger.info(f"Parsing all tests located in {args.tests_path}") + if not (args.index_name or args.app_index_name or args.launching_index_name): + qadocs_logger.info(f"Parsing all tests located in {args.tests_path}") + docs = DocGenerator(Config(SCHEMA_PATH, args.tests_path, OUTPUT_PATH)) + docs.run() if args.test_types or args.test_modules or args.test_names: qadocs_logger.info('Running QADOCS') From 61293462c37758928193cdc98bf0e924035c6dba Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 14 Oct 2021 12:03:56 +0200 Subject: [PATCH 111/181] doc: Update `qa-docs` README. #1864 --- .../wazuh_testing/qa_docs/README.md | 170 +++++++++++++----- 1 file changed, 127 insertions(+), 43 deletions(-) diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md index 56796afc65..99a5fccb6a 100644 --- a/deps/wazuh_testing/wazuh_testing/qa_docs/README.md +++ b/deps/wazuh_testing/wazuh_testing/qa_docs/README.md @@ -31,7 +31,7 @@ And each test method inside the file will have a test documentation block with t this test. Additional group information is parsed from the README files found in the repository. Each README file represents -a group, and every test file at the same or lower folder level is considered as belonging to it. +a group and every test file at the same or lower folder level is considered as belonging to it. Also, groups could be nested, so a README file found under the level of a group will generate a new group that belongs to the first one. @@ -42,11 +42,11 @@ within the `qa-docs` wiki. ### Parsing Running `qa-docs` as specified in the [Usage section](#usage) will scan every test and group file found into the include paths of the documentation, it will extract the module and tests blocks from each test file and discard any -non-documentable field. Also, complementary test-cases information will be extracted from a dry-run of pytest if there +non-documentable field. Also, complementary test-cases information will be extracted from a dry-run of Pytest if there isn´t a description for them. ### Output -The parsed and filtered documentation information will be added to the output folder within `qa-docs` build installation. +The parsed and filtered documentation information will be added to the output folder within the `qa-docs` build installation. Each test file will generate a JSON and a YAML file with the documentation information. Each of these files contains a structure with the module description and every test function in the module. Each test function @@ -60,10 +60,10 @@ any missing mandatory field. ### Indexing The JSON files generated by `qa-docs` are intended to be indexed into elasticsearch and later be displayed by the Search-UI App. -So, DocGenerator treats each JSON file as a document that will be added to elasticsearch index. +So, DocGenerator treats each JSON file as a document that will be added to the ElasticSearch index. ### Local launch -Together with the Indexing functionality, the tool has the capability to locally lunch SearchUI to visualize the +Together with the Indexing functionality, the tool can locally lunch SearchUI to visualize the documentation content into the App UI. ### Diagram @@ -73,34 +73,34 @@ documentation content into the App UI. ├── wazuh-testing . ├── qa-docs . | ├── dockerfiles - . | │ ├── qa_docs_base.Dockerfile | The dockerfile that builds a docker image with the `qa-docs` dependencies properly installed - | | └── qa_docs_tool.Dockerfile | The dockerfile that builds a docker image with the `qa-docs` running a specific branch + . | │ ├── Dockerfile | The dockerfile that builds a docker image with the `qa-docs` dependencies properly installed + | | └── entrypoint.sh | Custom entrypoint to run `qa-docs` | ├── lib | | ├── __init__.py - | │ ├── code_parser.py | The module in charge of parsing documentation blocks - | │ ├── config.py | The module in charge of parsing the configuration file - | | ├── index_data.py | The module in charge of the index management - | │ ├── pytest_wrap.py | The module in charge of dry-running pytest to collect complementary information - | │ ├── sanity.py | The module in charge of performing a sanity check - | │ └── utils.py | The module with utility functions - | ├── search_ui | search-ui module directory + | │ ├── code_parser.py | The module in charge of parsing documentation blocks + | │ ├── config.py | The module in charge of parsing the configuration file + | | ├── index_data.py | The module in charge of the index management + | │ ├── pytest_wrap.py | The module in charge of dry-running Pytest to collect complementary information + | │ ├── sanity.py | The module in charge of performing a sanity check + | │ └── utils.py | The module with utility functions + | ├── search_ui | search-ui module directory | ├── __init__.py - | ├── CHANGELOG.md | Record of all notable changes made - | ├── deploy_qa_docs.sh | Script that build the qa-docs images and run them using a specific branch - | ├── doc_generator.py | The main module and the entry point of the tool execution - | ├── requirements.txt | Contains the modules that qa-docs needs + | ├── CHANGELOG.md | Record of all notable changes made + | ├── deploy_qa_docs.sh | Script that builds the qa-docs image and runs it using a specific branch + | ├── doc_generator.py | The main module and the entry point of the tool execution + | ├── requirements.txt | Contains the modules that qa-docs needs | ├── README.MD - | ├── schema.yaml | The configuration file of the tool - | └── VERSION.json | Tool version + | ├── schema.yaml | The configuration file of the tool + | └── VERSION.json | Tool version ├── scripts - | ├── qa_docs.py | Tool script used by qa framework + | ├── qa_docs.py | Tool script used by qa framework . . . . ## Schema The schema file of the tool is located at **qa-docs/schema.yaml**. -The shema fields are specified in the `qa-docs documenting test`[wiki](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks) in the schema section. +The schema fields are specified in the qa-docs documenting test [wiki](https://github.com/wazuh/wazuh-qa/wiki/Documenting-tests-using-the-qadocs-schema#schema-blocks). ## Installation @@ -120,10 +120,12 @@ For a detailed usage visit the `qa-docs documentation generation` [wiki](https:/ ### Dependencies -First of all, the wazuh-qa framework must be installed following the [`installation section`](#installation). +First of all, the wazuh-qa framework must be installed following the [installation section](#installation). The `requirements.txt` file specifies the required Python modules that need to be installed before running the tool. -Before indexing is mandatory to have `ElasticSearch` up and running. Also, before launching the api is mandatory to have `npm` installed. +Before indexing is mandatory to have `ElasticSearch` up and running. Also, before launching the API is mandatory to have `npm` installed. + +#### ElasticSearch installation ##### ES installation on Linux: @@ -139,28 +141,28 @@ systemctl start elasticsearch.service ``` - Ubuntu ``` -wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add - -echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list -apt install elasticsearch +curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | apt-key add - && \ +echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | tee -a /etc/apt/sources.list.d/elastic-7.x.list && \ +apt update && \ +apt install -y elasticsearch && \ systemctl start elasticsearch.service ``` ##### ES installation on Windows: -- Using `Chocolatey` +- Using `Chocolatey`(you want to install it, visit https://chocolatey.org/install) ``` choco install elasticsearch ``` -##### ES RAM issue +##### ES RAM management - Linux -If you do not want to have ES consuming 70% of your available RAM, run this: ``` -echo "-Xms1g" >> /etc/elasticsearch/jvm.options -echo "-Xmx1g" >> /etc/elasticsearch/jvm.options +echo "-Xms256m" >> /etc/elasticsearch/jvm.options +echo "-Xmx256m" >> /etc/elasticsearch/jvm.options ``` - Windows @@ -168,22 +170,38 @@ echo "-Xmx1g" >> /etc/elasticsearch/jvm.options Add the followings line to `config/jvm.options`: ``` --Xms1g --Xmx1g +-Xms256m +-Xmx256m ``` -`-XmsAg` defines the max allocation of RAM to ES JVM Heap, where A is the amount of GBs you want. +`-XmsAm` defines the max allocation of RAM to ES JVM Heap, where A is the amount of MBs you want. You can also +use `-XmsAg` if you mean GBs. + +It is recommended to use 1GB as the limit. In some cases, we cant get a time out if we use 256MB. ##### For more options check the official website: https://www.elastic.co/es/downloads/elasticsearch +#### npm installation + +- Ubuntu + +``` +apt-get update +sudo apt-get install npm +``` + +- Windows + +Windows installer: https://nodejs.org/en/download/ and follow the setup wizard steps. + ### Parsing #### Complete run qa-docs -I /path-to-tests-to-parse/ -Using just the `-I` flag , the tool will load the schema file and run a complete parse of the paths in the +Using just the `-I` flag, the tool will load the schema file and run a complete parse of the paths in the configuration to dump the content into the output folder located in the `qa-docs` build directory. e.g: `qa-docs -I /wazuh-qa/tests/` #### Parse specific type(s) @@ -200,7 +218,14 @@ Using `--modules` flag you can parse only the tests inside the modules(s) folder qa-docs -I /path-to-tests-to-parse/ -t(--test) TEST_NAME1 TEST_NAME2 Using `-t, --test` flag you can parse only the tests that you want. The documentation parsed will be printed, if you want to save it you have to use the `-o` -flag and specify the output directory. e.g: `qa-docs -I /wazuh-qa/tests/ -t test_cache test_cors -o /tmp` +flag and specify the output directory. e.g: `qa-docs -I /wazuh-qa/tests/ -t test_cache test_cors -o /tmp`. + +This option is not compatible with API-related options, because the output is printed or saved with `-o` in a custom directory. + +#### Check if test(s) exist + qa-docs -I /path-to-tests-to-parse/ -e(--exist) TEST_NAME1 TEST_NAME2 + +With this option the tool prints if test(s) do(es) exist. ### Sanity Check qa-docs -I /path-to-tests-to-parse/ -s @@ -224,20 +249,69 @@ Using `-d`, the tool runs in DEBUG mode, logging extra information in the log fi Using `-v`, the tool will print its current version. ### Index output data - qa-docs -I /path-to-tests-to-parse/ -i + qa-docs -i -Using `-i` option, the tool indexes the content of each file output as a document into ElasticSearch. The name of the index +Using `-i` option, the tool indexes the content of each file output previously generated as a document into **ElasticSearch**. The name of the index must be provided as a parameter. +If you want to use the **ES Query API** for index management: + +- List your indexes: +``` +curl -XGET 'localhost:9200/_cat/indices?v' +``` + +- Index a JSON: +``` +curl -XPOST -H "Content-Type: application/json" localhost:9200/index/type/_bulk --data-binary @data.json +``` + +- Remove an index: +``` +curl -XDELETE localhost:9200/index_name/ +``` + +For detailed API information, you can visit https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update-by-query.html + and https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html. If you want to research **ES Python Client** you can visit + https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/index.html. + + ### Local api launch using index - qa-docs -I /path-to-tests-to-parse/ -l + qa-docs -l -Using `-l` option, the tool launches the application with the index previously indexed. The name of the index must be provided as a parameter. +Using `-l` option, the tool launches the application with a previously generated index. The name of the index must be provided as a parameter. ### Index output data and launch the api - qa-docs -I /path-to-tests-to-parse/ -il + qa-docs -il + +Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the API. The name of the index must be provided as a parameter. e.g: `qa-docs -I /wazuh-qa/tests/ -il qa-tests`. A previous run must be performed, e.g.`qa-docs -I /wazuh-qa/tests/` so the output data is previously generated. + +### Sample executions + +- Complete tests directory parse +``` +qa-docs -I /path-to-tests/ +``` + +- Parse `fim` module +``` +qa-docs -I /path-to-tests/ --type integration --modules test_fim +``` + +- Index the parsed data +``` +qa-docs -i my_index +``` -Using `-il` option, the tool indexes the content of each file output as a document into ElasticSearch and then launches the api. The name of the index must be provided as a parameter. e.g: `qa-docs -I /wazuh-qa/tests/ -il qa-tests`. A previous run must be performed, e.g.`qa-docs -I /wazuh-qa/tests/` so the output data is previously generated. +- Launch `search-ui` +``` +qa-docs -l my_index +``` + +- Parse `vulnerability_detector` module, index the output data, and launch `search-ui` +``` +qa-docs -I /path-to-tests/ --type integration --modules test_vulnerability_detector -il vd-index +``` ## Docker deployment @@ -245,4 +319,14 @@ If you prefer, you can run the script inside the `qa-docs/` directory, which wil ``` ./deploy_qa_docs.sh 1796-migrate-doc-schema-2 +``` + +You can also parse a specific test type or modules: + +``` +./deploy_qa_docs.sh 1796-migrate-doc-schema-2 integration +``` + +``` +./deploy_qa_docs.sh 1796-migrate-doc-schema-2 integration test_active_response test_agentd ``` \ No newline at end of file From 4405619a4bf1e97f81fbf9f4b74e29480d1b9e51 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 14 Oct 2021 12:09:03 +0200 Subject: [PATCH 112/181] Add: Add `qa-docs` diagram and remove old one. #1864 --- .../qa_docs/DocGenerator_diagram.png | Bin 196436 -> 0 bytes .../wazuh_testing/qa_docs/qa_docs_diagram.png | Bin 0 -> 195003 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/DocGenerator_diagram.png create mode 100644 deps/wazuh_testing/wazuh_testing/qa_docs/qa_docs_diagram.png diff --git a/deps/wazuh_testing/wazuh_testing/qa_docs/DocGenerator_diagram.png b/deps/wazuh_testing/wazuh_testing/qa_docs/DocGenerator_diagram.png deleted file mode 100644 index 8b6c42fb5f1884344162e197fddf552fc5351b33..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 196436 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%0A?Ydu=Y@R=SyrTcD zM=c*l79JgaKH>jodMMf4^~bkPDIYyydY7-iRv3+c ztr{mFdmBapT=E}3o^9TE{g+xmvYmc}=YqP9cn5vMPlsBgDpMh6j9Bm^czVY4gHBp0x523;6M=N;mO-chnh#7 z?Ly1w;xAJ^P7XfW3MLt~=+{k^+-*t!`8DhZ+?VXDEwlPI&BZsYSRC?jL@)0-S{uLb z07Xn5%!;XIzjy$ffJMMbVzzGG`lD96>z7(2)!$bO1On)CuKiLAk5qg!G-N=J9zBK( z8L}$;-h+vm!G@by&h#D2L;6#{8#IUwlH9)i`!_*Qun#o?KzUq|J1I3al}w@F;6i_$ ztbz#@=R%KfFd%21x_-Sc=5WkkV>Zs2zj9?CXsy2)fW?fMwFEr}{s5liD86uE37`ba z|6}aU<8p4d_wOV`hDvBaN>QfBR3tR0WJpqmM5ZJ};Vv3Tq7q6;p+X@;nTm)EMIsj& zB6CWnq)DRkywC1?fA`+s-|u-|&;H}y_mHm7^*PUTtz#X>vDS&A)_#PWOP2?a&tVbt z@%5dSY!?_5q^PDQw|r`NGs?*`8s&uF_2% zDF5~KmC>8C9)*@+qrUO-<$kf-@JempzWpLDt=5-;S|%i~C`rGliDy=$3X*2V4qN_A zzIX55f$|0h2H*2XzR@Y{)ZJhg>2=Ac_y)}Z14J{7(rn*|V$*5)SrM7e+a!8LC4Xf_ zG}^HORc%bO;`f}dpWM6}R#unZ$ko4Z{s+aI=J!wN-3j)(>LTeGDDPAA&_c@bs*j3> z(}XyOV>TLLQp(-B-RA3TIv-vJLYXGBI&C&hLcU4|MW}U^sRTjmT37ne6Jxl{bJ~^D z;cAtRvezlCO{vipOmu#G!A{qyZjR{X6G;H&8;><4}=FAv_a;{$#nQ`zxAkC?Ik#_8$l zq@&ikMzt4H)|AC0IY`?17JHkRNTb&gz;O3pCqf0v2f&+sF5jJ@Ad$q6O?|AUsv2_m zaBrwhVdx*V;>)YcBLxv}+(~uVHf-RJF8}2|CEj@QqD5NZl2U@ifVr3K6n+jrzv9In zu~&KTCLv`JFEz{NS&i>iI8RG4ID2q--J?v6#=dNN?adKuvZ9MK%)~oyy?wvKyok<~ z4R9JWXU%eUak=dh?>m76!hU9h-nw^hJ5t11oOc5T4)m_hAGzs#aXs9fyvYLy5O}1J zw6x(Svce9U7*O})#}a&$cC>v?uWSI>$!dHoO6z~Y{mp$BNr_%Ym{F&W9b4Ph?Yoek zZlCQCH8JYLOPq!E^bbXPK39;a4PO=*6QeRU&ZvdxSO{tER_d=e#LU9t-2CeS!&F+F ze>mlZF}zlweBjotoqP0{45JgZ)WbLV_9KhsE_L!0fw~usKm7$0AHM z>B7_xW=8Rbo_$PYsh-FC_!vo{KiHlfd=J<3(G_@Jt``*OKl=C!27A7<^L0G`_u0d% zR;^0Fw3O$~aRGqGCiz#%>Bd=mgzop(ZneqLpEsy@)9m%s>Y$o<-e+Wc5kx}DL_i5w z9&A25Rh9U$e_NTRscaxpiEz=kZ(q9NP5(4Cc5$|!V>H#$ zGJ%xMV$iMK`?dcccF4Wh;c}K;-mO@%qLMevagPZR!RGwRSE~K{_osnn}U)qI{69u?0iu_ol|&b>6OW5S% z$oGp1fwGfYRq=Yvq)C(HO;)|T(dxiDS0|Z=QL5^1fiU!aD~=vL8u#a93g*aeof!Ku zc=&LNrGc}4_08H(U!Z{P0y``cdwv-00HsK4Al zuP$Fx_N(AT+1B{b070I42aoSYS;G1m++HkUc?bxLCBJWs6VJ9_TXlk#Tl>a}KK~rP zAvM2~&rfZRapQ<_)|HOp?-g542r1JGFZO=7E56up9B_8Xh!K5=nDqbj3VchXktzr& zi?#d8c9`*_^69oR)oGW2@UT(N&dvw7l~D+cPqLks>NG}3JXlzQ@$xH{pPbD51uHvK zED+MwG-RkT77@5%>1{AwI%Rqt99esD-KA<4bV;n!n6V9xdyTxlZv;rpU$!g{7kI7H z2E3bM-B{T4yO3wMpFBykIedS-_8 zl%nAg5z>iWB#iL5jbSK=FaoiEm`1x0(e`b@9^>>~gCI~SWGl*DBSO(qKZ>K>M@MHH zT=vKF8^}RkUS2vTKQU8$|M6o&lI=r^c*+c!k5k9Q8Nc#gLe{$}*>VS$jn0hYO6-p5 z1sA`(M^`ZNYcP0Gdi(b6&MGP~-8y@2QIcq9m^Y>P4j+ww|$1&V* zQ1?WyL3ajvG({=Ncr==i%t`8@Xf@@*wvUQ8$2ASC`oBgE4<}#o&f4gH?%J_HvSl;R z^}LjLbnEr&ID`giy|Op_-vW1c3AXT%0wb#-<5RLe9=UklRP#>TSUTr=X~K}>+_?%SZ$Ez)>F2|T59TXk zwe~3&SBH-Hk4OJj=h0qiOiHp}EZ8Ck# z+T&8nbFD5{-=GMPH#tp*hV&_meKn0&FK#b$=we!z_5XDedzrtwG5%M^DiNBFLaz1u z{h*x&wWH3m-CBm%5lbi)SI310eB=zE^Ws*5h#x>O}lWh(y-@U!c*s7 z4w4T*Bck^Oz1M)#N(So|k)-Dxl`U9eg|1z1W<IlTf?#@QF9wqK7p0Tk!E}^pECE zC9lJ#BFV79%obeBJzw4&BIX}E^o`K9wCKDpDX(P$keJ6?^5rrX(4W74odr_1FYBmo zyj?y3yHfSr>n)crUv2;~>ld5S*|gSkwJ9fezc9dS|9VM86kd^IlOsx`Onv+wPGAG| zxlq0>IM}NC>|Ll|;@&|Pfpk^;>C+u3?5Hm!@f}%NWB2UY^RvGG3}Zc{1n(I6Fw5Wn z%Gy4C`slB(S;_`ly=v8bN}gj+mQkk#%Ll;f?cTL(pRr0_rH+L}$N*vV^ee<)2p0E9CXbbRTrijLQKl*-y#^CVjZ z(Dd@<18{)%Fp(8De<@UT1(WX-M5{?df%4mXjNXH)pR{7ch!KF{D*i)@jLwqUv^mxH zM$iinMVLp_keYdBK`ScyV|M*sd%f>K1<~;ulP2UDE&9^HX+!z&{rmST4_~Il8&UZ@ zS>4;1;_&6mmu$a75Jt4VsOP1{JUM#Cop}!osYzf97N+z7$Q3?+dheI-YL&uN)u3;6 z{}nO*y|~`B+TIG1%WFOboH;WDrz0)eyoGE=4p>f{m^ZI?Xp5kJpQpqQYpWpxXdHJx zyBhxG-M4Qko@eal7`0Izxw1Q)=H0P3hiV-4d!6Cgm+=7C?%&@5cg8AOpe0>b`T57p z%uHTcdFI<%B_(ocN$bfq8QVuimy8t0I%5St)lSGzW?#sX1&q`I%g#DIzXcS}t;dfe z(SoW!KI{!`#PFEbG;4JcGREK`L*B6#j#+*^G-Lo9nizN850Eh}WUn(@KzYpiVJ5OL z^QH?HXky=Z_Wbz}x2FR@GMv|5)5QRj%DTGS4jJR~ma!{c=iY&D(RP+#wh1}b@n73v$-;%EyvFCeKX%C& z6IlwK8|;l^HV38oxR+NpI4DR44jd@D;Fdl-)XGGb?y3C;omN!(RJ^_d#(jE9ZJtkM ztCcHP+MS%KFwWh#=tl>2Q8T1`Wv0_AVnytyT5-~*(WRLlfDUy!-0 zW-`NPx>WigPZ^9J-C0rb28?CI!K=ilm#<#MK$K6}P!L)M#L3Cc-3F@YvbR$&7r(1s zm6{;D2J}KLEiD~C)gcSau0E@R^q3^+lIE2nN&ng_$J1)%Uf?r(RR6PML`80Gw^t<& zML)oQK$Qc3z1A2tDi+JA&chPuZFQD_A|RM=pwP4DeUwcvQJoj0|E{lpS6)7O&6;5& z-Ct+lyxEcoDpDPL8f}x8riP@`O^gL|o9^XhU?K~5+VUE#vht`kIsla;j0f2p6H{4o zr?ZsMa8K2gawH}`He951YK#2Eix+kD-a?GXYREwL5N;nK43^Fkjb3)AW=FwZ%kc@g zRc}B#D#Hno;EXo>*iJNF&8QLTmt6Xz6-*u+w&>LBui}iW>)hg=zot{G^cPi_M&Mh{ zlhn!CY3-Wnhw0hXP+U^--MVe%;9^I+ZT9e6^mab+oq+wy1HaT#g(n0p>hxjC+T@U+9PIeVWL%MD3!yG zbjUXb`>4e6qq`V?E}eZpHEv(+*PfDjJjEczZ@Yd` z?Hn8&6crWq!<_I+s?an$Lo<$(3>DeIL>B2UAJ6lUp+j#c+p@C!gVv|zU>S&igym6NE;uG8MswSj6H^zy0T^w39Ia6>cHy$!2mIza-uiiB z6Bl2Mx@M4)k^Lxr4)eZHYI8L{pIC0{B&h>jKbIvYmD_kupa&6l> zvt_=%xX_6fgO#r{@36qh@QDa3k6d4%lw9Lnw79sTZV(}vZBX{iVT*R0g}Hf)!StC2 z%6B*PFmtboNI73VVZwx$SAEqs@&pT3u8cc)75b03k90b!{GoQ-h2_s9m@`-6cKOln z&STNLJ6l^@<73Q6zZ)vM{@1VIqeuJFpV~)PHyF=|j-E2p5L;7cGZ2hv+M51<8b{v0 zf8R0FEBx|b6`Y3~bQ?yQ|3pMRY=5F2)r4YWN^3Qkr8DQwy+^!eHw*hDFHjcN<_YP?Eh=8UL6o++39is;>}yPCb-r?v&M{AF+1I-Z;t0pO{Q95 zxa`LN4lD>W@>qBA(xn2LTQL>IA8LA}+WwzX`r%}!qJs(&I*9I(CV(@HyD9aVG^u^3 zo<_X}4cfunJx)w6-x()=XA@b`I<~E>NU|Vi+Ch^o!@}>P5t8`2jkBs-v0yOG^3O2P|GlWgC8`C?5MxboHz&iPsOG(XL%%V)IjcsG+eO2Y)H!+CJ1d=wz=wMZ^e9;(BZUgH5SGJqhq3Ok2HxUhVB8mvmVpIdq_ zC6sBC+1JUYZ{NNh2SrGEYbUeXvP;15X*WKtfIW)WT$bXPp+9-z-TgMo#oh$uNQv5r z<>O?plL%@i=q*kw^9KLGuH@UiA%au?Pt!azT;YpJ;=E&$P`U|l3)pAkIS#1OEmdUlb+i1jifZW?^Ur0Kn$IytLL@L1G=% zF9*eB>8ndHu8ba)N?hWHygYW7y;1zAwf#(Fp(yi+ddFs*=*ov>GeGrJYC?APey9F- zdIWuYUp#-Fot+W}^z3KP_JV58EWX>7lJOq16c0B%-DT;tIL^rX1_TRr<6zmq5CqtvU!R}bN|$MZ{fX6o{nfW$ zzYr>w>Eyunh0MPhx_N~Zb&-px54Ubbd?{W2dm_7i{GvsRHf8OyEvT%iDS<0BUojzk zp%6!!p>AsR_UryNJqBTG~DkdY&Av-4?um}ywYj9bAAL8;e1 z#jQ59b*G-&QI6o;4)PU@ysHAI8rREc$Ut{%7#gY|fuj57@SSUmlt5K@YS?SAgr_mn z^csHX3g7}&=PfS;VWpj!(bmVO=JA>Q5FNVvu@vZ*(ZvW4f@L&f@7`@&wzRUhwP?S)UOky0^wy>~P^MPHi+0_hm=xFJ?SLj@u9$&fJZNSU~SsxO(YeB%gE9;~7 zjdWjf%=*n*$SeVLpc(opq>JbVZv$V>H<;p*P{K#zj^(Exu3BC2704g%AT2aVcWCCY;PBp^_AyS5J zlc-X1la2Sr#`5m>NH1-)W(mpfJVG8rd+&+t@T*2wv`ad@&OI3~z#*au^5Rqv4}C(( z?d#WDd257mhoBM8B> zGQEcE8Wl%jJqyPlja@6(i5j^BpbPrBQG2p zVH(j&P((qGcTm~O)1h6xl+jJo_E~U{kNjZzg^Ew>kQ+7b>vF1@w4#0GH(9spNo(Jd z^fzh+Ub<5(vp#f~+cbLZXC=BoL9sQqqEhB)x3>K`dV0-p)i6L@2c17HR^9Vru-Hqqy{aRGoTW9QDP zo1#)f@hxE1j3?Oz>DEoxZz`LV8vscC4gYH;><*C)rjz>};`4S#QG z`}pH$a{9av|0}1LRcCH1)7}Cr2(cciRFiSgAVjQ7Hry07+vh8TjE#FbI220n9JMu| z&U5u5w%)97=KFf;#;LUqGB~Pakr?0MO`Cs<2f1!p(eL~C4_V`Xb>a_v>lqLHE3C9q zHPKdLj@;Vw-g1FXV#WVL7vaVA2r4P@3|gcW!ID*}vYRqK^pYsn$I?)?DJout>cOgW z?a5G%9L;}nIXr-iI>re&0FtZSk7<%Ywnih{w_iO)cLy);Y9}G?gjkQ(irBwPz0qmM zfrgt8eCv-ti$-xE4lNFG4c1Z0#nW6!gObQTP6Y zeioN61cJS~o*Axq=fq9OC_>E4*gv~@ElZcyO*f6#tKPk!cbuF?vpMCkO{d_*!Ml9R zwQY^`x+fl+!?x(wQG8T8k3ql5R|+PBaa2m5xM{QWMfyOYnqkr{^RB-)|7S_vn`0Ln z@Qq>&f9RkgL-T|kRtt+aZZJF${~5<0<>YLk z+uPB1WPjc?AK};Fb)}G@AP?Blbb0K5KZ?Eny|#8SN}J04EnBw&hDmCui&9hofK7fv zE44MAq23e=z3#4#j2^PPM@>{}vvjj~)00y>_orVbPG|o9c6pSeAmMMznA`UwTjhC@*0F5oH6dTr`8tpX|=;mC&~5A zslG8j;n!iHufoCqBnaPMpDIk}b>~(y@RbR`Bw+kt7y5rM% zRr`tyb`)RZUq8dvI&GiEomL**OFNj>y*V*BOGQm>H({S) z;kP}?$>)06$mk4eJ5hmi_c;S6&ExJ95|H7}>l?Ibyed|2{6sR(IOf zqUA5r!vMtZK70^;z!+eJEE3m>Y(EPGAO#m~?=+vGNn@9##WKY6ZE>*)P0hzhB5*Yu zf-b>@QUFYSWW3^hZ%K<3rz;`h;R=|6?uPqor?!Sa!CBC9k>QSl&|E0GYZ6JPsEIjh z?wQxgI#Hh3dk>NEX3m*&^wR~fW1bg((HJ%?@=w$6sV?46nrx}Y%Vs;93nX) zgyW3#ajJhJ^O2$~&&df!ydfr6<<^4y^ zvv_RJZ!ZlGl#y1V{2Awu;P%Y1aQ7U_=^LOl@}@4y6H&3wz>hh$rG-OH%Xe>*?VX_M-XO+VUE?1F*{9$8n4*}ai>*PTyNb0qT+)hpiaB{ zXkR^IdVY1S_rwf-iueJ1Udx`vMmQyavctcDj?vTVZ+DGpxtWs;I+;58ISRB3Iw@DY#& z6J!sX#UldQCOq~;(2TPoStlXZc7 z+WT+W?^sPO3$sXP?3kiM)qJ8Meq`coP5BFqCA_bDd%VzfQx+*Ow}4-US95~kSx1Zz z^RuM}%s#IZcFdhdN3%m>vQY5T!VjeBSyxx>cQT45!4n3O$6e?!co>?}zjM5m)r%DS z6X!Dfj4rOH0WpwhJ!Q(2d+}q{Eq&kqsH(d0;>A8x(|&7-iS41sm#6HTsY=WUr`NBgi;IiUqd2@ki~+OG2J)>7xDbM@4VXMtwa{uG zSewS-jpxn{B}lIM_A&;c$>o!aZd|vHzZy=~zhIE|9$L=A#J7;sM@$d~LY~l;j5%y< zJNC_gt9ob&XNKwC71wI{I3Wt*5;@3IK6~*(_+8QOr?M|9tE(5{1#D38R;`-7c`rW3$xXD(d8T`(={N1A_A{Z^**fm|xTf4^2#6ifK{(r|rWQ?6@CJ=`s)R%n+^i+9w`W-=N&@ee=CL%(MbRVs(fLU zB?(6z88o-p8$0E2CJWI$nfHfE0u{7FP+ ziix26zUv?DRxi%@(B={BkY{Zp9-5D@3P+wBmF6F~cq__Y>EsXm71qbIrZF=CTRB#E z+Pww$CbSp=%Hr`yxX1{yZXYQ9J6rzU+qc(v@Ol8_>UHZrpmU)9MFS3zV)i9@rNC>g ze&3oZ=LqG8aGJxdiW$P&?#G1nw(N42;bUl9J^7`8%2}Tt6}?a5?`c@rNMP`+EH)QR zGjKxR@hJXb$BY2r8Z3$?af?>w0#et2FOzmIQApA1!NEb)tYZF=Xx(LhM?Lx$Q?Y1x zG^E*fz8CD5ke&rV``h14=G`hEz`kgi9HY#Ts(ge1VZb+$W#m`eOvgoy*tA6aB&D+D zrIM$8Vx97wB28qQI+Hemu~oe7N)aC6Cux^d61JidQ_$}IVU zue#5$h;SA};|d-DQHtI0p@;wMxSE{GYIpIno7Dqh=s z>c8_W8n+cb2Jm11Wz#ACb{xhQ5KPHH zrO)z=nL!DZ@0O;rC8(LR*XowLVMpR=ot3^T@Px?7$b5&i_MrbbWnNZ_k+O^V%7S!2 z%5IdM{$(Rz^~rwTaIB;%5?(M0$FMh&V6T+=)g9)$yHCYO$juVhU%z$=E&^DhA}+T{ zVZ?wO#sP=V1=?|c*grVU&FlRqhd76MrC3dCpR8NDCTX>Q!_PCCP6(>gs1b0e#>Ko) zni%2bhcJ$LSXOzDS5IZ-T-+Ry5}@XEKcCri=6F_rm|XgjgeBO2DjnBD&U%g)ub~I; zN%DFvTuU!SO^7hn=^D}u43><(y2Y-3i`)wOnk>8IHhEa;53dZ)8X5s8+mp-0uzIp$ z_f{R4U=&a7f_5>z=?^)M0S$;!wNGQ>B9y&*Vhy6HL>wI`=OySB^o90!)d>wa`9Xj3 z=X{Z^**zf)NYK4;W=Z8)D#d&IMpC0DeQouO0Y``pQNMrk;0h-4ojc#86>htHa`JP| z_f8HoIj}_nrW!JKOZ5Ig+f`$xx`UB*G@cD>) z&&nTU%G%RrsjViA2H2S>T_0Wb-4JLwxqmFb3tfoO#D+7gzY6_nPFc#ApF0-ZGk+67qJo>)HK)+ zb0dcnPh{G(c~8a9@Hyon3j#HJ(Ks?4kDaLaF0Dz{3^-aw*>UkB#+8sd4B%R^KNUA@htMvrzmR)I7}*5bURJkqL7))2=2b;yoi-Sfuv9e}|m+0Hfl z!~O`h%1q~*K`?`xNZF+izAhHR2tJTA=g%h!Bh}|>I$mOxvSKCZP`lT4OF+tLU}#8Q znJ@@yu2^50NpId)+NW4l9qG}D6BaKE<=SXj{WmQec}g6efc?KXK$i38&kx^LhQR}j zv6cVy9_}4>hk+h4wsnIDCq`g%AmAD7Y`so-tz5P&wvYCOq@<)OZ{ixH)qCvAkUftY zWbY2hzZireSkNGFR3gS>i)F-D0vRLR$G(46!Eu%CHlRE7^RVrlEo zxcrT4*7Qk8jB1d;&JK-?ax$Wve*;fMDvI++!S0c_t`&cbD&4*NTKwRXl~zJEtZ8o8 z6S6l?`jD1WlJo^?l*wSngWm>kVq!&N}m%*lz=X9)zf+@*Tw6{nS$uxj;cXFLvh zS8mceKx31X-j?5Zlce?PE*h=*C=VzHWwf6&XAZZH97=pg`|Z-$+>RYc787K{`xJ>v zEK+Y?Ps|sX^*6}}@GBP0PVHlxOp%UpA{?{J*U$`>+~{}4#f2YTSA_l#(ZNxYfjt|r zZ0O&a&G}vB9d5`(7W)1jnM8MaiO?nfvsspPD=AfzocfuO|QiyNc1@&84s z9{Nll+Rn(xNEiYkGDE~{*>Ugyf_TItnh9G5gEQQ%>#o@0_xRq-XQYnk!ZH!9A}K?IdoXUcMp5fX@Z&A z14=2oeqGSAmcPza*+_VVDGWMsqQB@KyLGEI94rjrxdZ9GEGrbsTU?sWMB@Wy;7W-W z9l^h@S+j`tbxOF!*3HAdZygK^>p(Fx?d(uu)Yiudkq;g`nB;rxRY${gj~)N$)Ydwb zJ%`CnJ`6VY?pKEBI^)N?{<7!qGB5mkQPJ#KC!;Jw6ul+kN?mgnJEhTHXFIHH&&zFP zJmutbeb^B#K(fZKGt|eJxDYG*L5zEu71%)8BkVl3(Ldx` zAwA7U)$Ptno2&w{WYs&)EwnTyxJoI6(!M*PigQqTh*(BF7jwg$ZDsx!yU|=V_#ih28DC z{)+ayj8DjEcBxJW=&0xt(?R>XWR~+sW1T@?^y<;GEmqH`K*m&*g2-JWZMdT<=-}_E zMGg&wS|hwH&)4g2z-dcU5;@RLc8^s^JAP&>@OA;QX~`?Cp(W4q47HQvCst+#p2Z{) zD1UP9hw`ykEavzYwbaDO~QIQ1|)Xg5Z_!X~^m^ zdAU>K5zAEjQ>sFMprWK^8Oi~Hp6dkLX$Ut3n>C;Lka<4hnt(~=!;?j= z6mvQR@2q`xjykqGx1;KpkvNLuNvTzgN7H%3%or&&o%}ss#!QJBwyx*sb(0h%wMi<4 zeXe+=99a1s2TjO<=L7ZneysMKIa3)EMlq7^OT3vd_U1?(R<4yR^)7I&Wp z%9BtB-CV0$*#K^4r1>T&iBRkUcxFx}F5g}3{rV&39k#sg<58i%J$Ls>!}&6XYH5J} z=#b0t=QbbwopAAFR*C58%GpGEkE4njRy31JK(%TH>HS)2M+WAuZJ8XSqrHKrxJ}n z!Z}KDll*nXLBsRYT|O5B%LEHb*jk7Kh>AGMQ%r>hK#DGLhNiGmN6$5oc=59H!-54u zEAPKpv?wD(2ur8yoSK7H2QN~43!+$jrG@cQ+k1|g{e0~07cbOcV6Qg*n3EI-GI??n-ak1w@6Oj z%+*YsKdMs;N1`(i3h6Kna7U8{06 zb*j;Y)2DkG8pep}CNb5Z0F1Q13bwoLb}}lQav}9foAvAN-fjDfJ2>k-MV~pe8vWeP z+p-op$cAGP^v`ZJUp|=1gJ;^!31Y%lZkf#>(EsGweC?(`SQ!XER z?Z$++sO@tjio@8-x4|+$DOhTqdf&GHreoD;7gDCZi>VIN@NSR-guhf62k7^~D=M1F z=^fin@d{XiUc`~c7hm`~(;HOr5XFzRR66Lz$4@-JYfw)kFU-Q?GBjFg#5{F2Fd-W1 zsHKJs>5Vh3i}fi)+5`-t((m3Y`YxI!x-*9Qr%+nhg%*96$FI(H1cSCy;2=ITj zryOHUz+CJjX)cVAY9bvk{Cl7KuHc0U@pxWdUW%7f_XqbjsIK;d^2zHtt<~i5IIi_F zT^W$^`LWgRX&;#Eh4rl|w^tkv0_j!dMZ>kF|1cZH^4vCYQy-1n(Mufn)f8v^uYRBM2op7jp%Ri+ZK8+35 zs=&$V-Q}NkQo=QZi?37Sw=4C6|Xsw*7-X3hBv!MKvT`OAPTqKUB7ZWJS{o~xe&gJGi~ z=^VW<@E1IrV3hLwGBYZJmrPv;*wj{~K6z~-dmXh+Oz)tXXkx$HL^B=)w?#2@kE{l_ zkaXGgD`0c8pwdD}u>j;4W8YCIE~QYm9N962=M%Ip9Fqd`PwyU0YpemT{OxeyOxLWME4=~1ij6hngdZIJtS!cS8wElm?g z#W0XEX*9d$NvuuQKJz)jwS-%Z)q1LIq`DQ|; zV#5u15uri}AgfXE^;Mi&JkYF#hFNT64OM#rD*_zrXMgfoV&7JnAk?9ay($-oi#h=; zgq)3BrAVGC5_giJ`bUkT6(a7-kBL=n{(0X;Kg0zlR$Jp7CdPU>*(n|Iirc$4ms7KP z?b;j+tJKLYT_2^VV$Wv&5G<_)Q>RvWdeWXPfpj3C<9wdQoh<1@Qm*RWx^rhIPIN*4 z|9Zjo%*}sB$M?Y}edG7FYxAirBKm1M))u+rJ*Yksi9pXUr4vZ8z0Ii@&Y>0-%~@{x zH_=DYLz|}-=)^ZRH0T?-D|Lm9k~|tUo*iL4dv^!~2I0391P}e%!Zp6dInyOCx|NDc zxky)AeRNOg=nSgceCWo5Mu&nABZCvQ@G6$fNYJXCt{WFUZdh``@C}QKii(7MT-v}f z!B<*}Q2qz^4$EGq+4vj3d<>)YyS zGrs(J6VfN!ymcF8dAn8FO17DYJ(M~pRY@l&A6;mXpE+mQ>qk4H^;Du_Rg-V)yU+Bt z&cClaG%BLteU~nJx{2?`v``5Da_x6ro2=(U1`lo5e(=zfbzg?3c7v=;i?2QX^y*2; zY>BwLMZ-!F1+isF)iOq^9DpygE&WsB|Hm{!OAMrt5;)!PwL4dmem3x-?(X(4sF`BVfwnMv$F=9F5KZl3lK#ty`!EK$l~?{EvsTW zB%}ljQPr-BDC+_6(H}aro(p5XS5}UX(DI|q_ITnjUp0HUh0Tnazr+~XUx`PL?n@k? z5$Djs1O1Qs{iTVTUDz(2jw-a576AmjBX9S<#^JV;6=Wk7& zf3%f(y7Qxjv~@zCwCNggh2xOC1GT!8ejTKv^VZ$}w;HBXP992Qd#dlBOf?bB#SvOm zUe}RcM4Ko+Tx}?CLDgDCe0*}E8~lL2R|>jsZ!1L{-V5kM6=OZDB7UM2W%$(VQ`+C) zmhbz8uP8scC4OR57ECNJ4gO6=)Ml0uX8ggQ+{aQ{t#Ukch;hSrQ~opIdgs8~d#57) zZPT(6OdAzW0qg@pN8hd4;&mredHnfRn6(#J8KE@X0*H5C~1ourUbYn+7>Vc)w|Ri z_bc?MPSixY4ewCl<>uuHgABQ&fPuXk?H8J)YE))&y5S4Sh)D@+wa-^ zCBs!a2)^^tqt5t~>{A@uDg~FpuO9DyfH7HqFt=l$juZkCC!T0`Yex_3b?ef1G}9TN zp|!Vy@AYuY=xNvO!?&@{@#E$LTm zZMg7fr6?x1Q9sei5K5acdYH%SB5Fo48v{IOHEGhY^;fwVNn}kl7%zH<*WL(+t zrdd2iY#c&%-)J~@SIl>T04iEZ9x61BF)~_Xzw57$4>nO7=B+K~mcs^VYD<4Z_x0YN z`w#9=9NYU?zX4;GU)C*8ZCB~2W_(tE$nDcHISPkXKCw|4>;5`3hLx`7)&hOJH+X2J z;DH8tCbpTL-Z#nC^{lMH^dpm;#nobiEI{G(v5$}X;Ymb2+<9fIE@f25l(6;}RGv(k zEa&DnutF<}X8ee39{A4Py9=?t{QUi!xyF`eW->y*0MoX4pdg_dn?wf&j%VSY2BI+g z&v13I3{+P1T?Y0H9@TS!rBUG#ccv#Uf_SW&c}+-&wY8<}IQl#uoSHL|ZlXK@jW>op zAt9=$sE}m0(3_{4mZp{C5GRQlx)nC#?Af!TpoInNK4yI+A3!yv#$?%x>udXA5on-! z_u9PvlCbBtLis)8|Hn^?EE0$guNoEvX>fPpw8aXN!jrR1k)-x3<^s8ed7DW|WKZGc z&&bH2t<`!*>!rCP8NDj{4Uq#p>b}i|BAdx*)&X&B&jWCMMkBUoWjGHk-2C~*%SMMs z-`u6gPd2ORy-C|X$(@u-)JbZ>$O~JF%w0YRD=v0|0$}fJM%uL#MWC%$X==X9%3nNg%eiAzbw~HjZP5#EbesXP;M3 zT0tRhd%M$Xw-F|RW_&PdXhS1$P?!^^y%|6;!TrnDP3)0sj5&I2r+tFfhAG7C*#2q0 z;~9}`G#VU+1pbi;2r1t8%+-WdZHPTnidxYWzS43~x_e*_qbO-Q(3zqgO_F z?l)L1O$yHRI%AdBdGFZ257L!WF^8LLOyqLWwzJ=N5#Sp2kL!;xi$&6PpzE1ZuA6-S|aQC+I*}kw<9gn`C!`gd()bXDul&>c6q0UsPL9aJ2%IGqX~o{q zp>PRFw>r|DxKO`2iHv$NyV3|h1mWe&{*eO)wBHC1E=(^F)lisy4Go_kJEi?Fq>Rft z1=E77=yP}B3uq@N7mQq5SlDfg$#s;w@hevjN*MioO8h|W=gh7UubDFpjNFQ0=p0=F zqI_i0H=PYGLpK6QNB%M%H7ZuzRJVL;?65n>+=U>G4P)BzlcJr_N|8T*6DLlw=8>6q zYZETXH=R(v;V(TtN1@Sje%r^1>-uGTZn`EzN~vbUfAV!zn%TE`{mq{J$Qm5w)x@RB zL-4II!y7wL%LuI~MakjAhgE0^^_hg{1Nb*Yc}nMyz?+O62#=4KU`a9~T;$%lqr28` z`j&i|?{#%9ltv{VKES;xHC68Pw|h2rs^;fDT3Ul)zdhpLGp(vupFT%k-fl@dA8oMX zh?g+6sW!86l9$eG?3<*yTZsTu(tZE0>}saE?muwAkZOvc1tz;&Kg;^iimA-{rs}|; z8#>{zGapB9@ZQW!qXTcsK73#j>q44%t?cc)63-H;A;(QqSJq!7%WuG7h9|&u%9I_` zHi#BT#G*U-`HQgu=_s}1OE0c$pg}MJbpUN8&GD+@-#e#`Q10LfKafmI$P6>quXHQr z|AjI|4Yisq@_EGV0modOSDY$6kf@(iEpAPCp&HgY$@UI$j2_te)O}UPr@WXsXD%E> z)9+KI2}}$f-sP_K%1(Itv2f-7Mn0n({w;)yhk6#;t>8QGzf4#$(5kX*D*6AwmsJF` z!qj4!EOY_37$ZOsKo6>s*E!VvRX0g#x6403qbsE7~!0&6|8V ztF6p%g@-$3_Kcgn?5K%RH(8kgk6tN;0mp9?RGj#r6F1tx;N!{pE9iD1%5p*&SQ!qS z<~IVJG)9groNq8$N3mmF`HQ|4o@>`WQM*xO=RC9pBYW;MZX+*EvrwMha*IQ#sA&7m z6L*EZmy~7yIA(QEPFPZhf0aY2}}A z6GGD1xy*w&754tMS3|?Xl1S54>y|C+M~;>W`E?DyRj-QaI!g5ID4*{OOapjVF%8N4 z4F}e%=A#1m;9dNmhQ0_-bU7bf-mzbhyrgz|4<@kD+%L?gAU3Or?4tqdaL-}_(3#>) zp+x-T9$XzA9idvB-MiA#NKc0=!+VMOX$cRP{v<^U3k`Ozr0i8??l34#@a7kU0q#;^M-b352ma^H5zz+Xm4m}C}t!m^q!y$AOKPNQ1wV{ zXHYP|Xm?bUIZp>FfUd8i9v3bD!ay>6wyFRj71xWb4hAiMXaQ%&_`4GDj3Cc}bO-LV zXMf<$zQtv=w*d&kQQ2;b$$jsXsYW9|?8EZHh$kz4In^$(D@YQ=j#fpE>rBdfNe;VjoZ0;x$NKR;F~E zqo>S_ElF=2@+TYd29H9Bj(WqrJO?AaQX1hq8xcJ0oxB$e*nfn!B8M+hj9xlioS}PT z2`{)?3-Z9ZszBAfXCxL_TGGi zxtJH{;c*BD!ddw_5rhPWr0m?cPfZN*B&RS|A6L3&YIE)J?@|NX)+7XniAfQBxCge4 zj7+|>BE89%FONp%)E+(hvfWHyQ|EhJJ&pk4Oi>FJw}Y^u*t6%L=qmuQ9-A_Z{6^e* zpIwa+E5WXx^_G55y7}3?h!bqN5ZcaFz%3OjbWO0_=Eb#VHAP8w{pvCNu1#mYQTrlK zsA#2jTV9_%GASkiLW#(C7+$lqXJl6qsH{1T{nR}DCtTQ5*=_ptm||}@YEet#(oF!> zi&l;xbXBtJLv&xIcCFB%L_wYq=_T$`arpSe*1pp5cSBiud6;eTqUOsS-r!KP=@&&h z?#zX%soh5|aipPjAG*cJ=zT)Qgs>zdb9TIgWGrRwB?oG+NrL0v^aFz zwrk*omoFVRr$;V=%@)M^>mPt#t^wO~;jSh(@seTNRMu04HfALsL(Zrh=Nd#N2g$AoBEmBvkSop`czz0KUcm;VI% zPMxr1NuLOU;&eE>E63Jvn^&2I@xq zLZc3)gDRYw=g6XMZo!`s&YNf%bko%&+{UZyeh=?A@3TFR&3rbAl93GGBKQ9NC8Ul~ zhQsb!&vJ-Ule@D*=cG?%lyE;Rg0(IwDLGAt06v6?2P#Mi z@@Zhtc_d12sLXO6t5&?axSrx)Wz72X4Zj;2d=ALA$(kX)szJTYvpB+cYe6DUr@&H)FZFn#Z@I&^0zyQ5xC9yeysN?saxC1k;%M$2Apc9MAHJJgyd=R9J|J zoivv&#LfF(c>b*A$u5$xEkhs_EDqXy417%U|2d;O!pbd@K&|uQkTO7=XyL`A504Nt z(%mU}2Jg6BckI|^(94oheC7%d5Mp(5i+vE7K=$kYP1Wl3-0tO~3Zq#!c6qJ57spb8 z9J>!w?Z9R^3~u9YzB`7J7T(3Cm48ZiYyQ0yy^i&4roU!dkC6%ZY*uqPM3ffya{O;) zqk0eHlD7ccf6Wqu zZw}e@!!qcC!K5`OWR%P55Q|w5p4d#Vd_HJFj1eFi%j#OcdHVWuVTeR?5MW(ELgwNh z$W{N6Y(APru#08F_)JFNlv5OI4E{(;_qI+%x#=haZ&Kv_~ViRNfm20zi@mIQam z^a95ngPhU4bep>R%hnx95IT`8khX0UUEAqSNREu#xUAqQ} zqyhwG(_>qfdC*1Df|4YqbGw3>2KjxvNG7X{@MKLOO+}~0{Q14ca_i2P#_9HzFK2FB zW$IiS8d^rD2~bWI;Z8JZd+Q?WK4q}L0w z?ZKIa7Q$d(w-+>7K;INg{ORH0t&Zs$l`r>>5$0ZSwfMn9=5pTiSF~x%osqqytjxyS zC0+DNsV#hOU;V`tS_M{goXwc#D(lRi^|CTgf>16K@@83 z?=s0q9WxMP;d}M%yOojvP51^*;}%P4$rAc1s2@K~3=7%Pyz`aIYrotgzA_C-TtJ!X zb(QITyrT@i#!-SYp+}8YR_cq5Opr4A1T*-2(t%M=mNxG=7=jWzcYxsIn3Fx_<;USS z(Nzn{>h`&_B<#RB1MM77dQef#t=6p@p_lnxngXH!+DUh!$|AIcQYDTa0pJr{#k8?e zi`Z4d7b28+3^PTvoYdbivg6NVkk)2Zm`d5tTvJ+W!7%ade$_{Fq`iQt6hF zvGG+qol8WD2XKG9;@fn1g#GH&uTPv@Tz3d_NHS@+q$3!w_WY&jmb`VG&4F!xc0=yH z@*iH_WWS#S#@^ncWd#Sk&;6?F_Y1eOJq4I zG*R(Y{XiT3gb4h-q&OoiENpqpad5Zvt6qVz3TS8BOkMTggb}dofSJ9Fi>DtpE_xC; zyp`vr53(oKtg}*M4|_F1FN(H(?q3oU1_V2GakYK+?vOI5RH@dc9%^#^)tM%T{@>yw z!(#|MnnQ=~hDL+>6`tL-_oL(Dc9@Rf$f*pMf`o z2F_jo8-?E;`Z~?ImSj@cH_mk0#dXj!Q&o>0i*MD%<+?P>wov?4?quhGp~8j#m1+CD zI6Bw-WaIf|Px9WncL*-S0t;g?r>~ipon7Pr8CoC8nBn>k(rIIhmWwJKlkGm6eJ?t@ zIMnHX7!WffZZT*%pH3H1LehfIzIg+NUMVtof z;4;QJG)I@#e>2_My{r_mB6L%)OrEB`;&Q6)-bceN=D$J%deS||bJOZLv&WOItxf50 zr9<@ED$A9`*G@e~F=?j_Pxg-8F5IN-D@f98z)x*lHPwD1q5&w1ksHd~UCO#hiRN3d zG+2ymvBu*RDnv(U`^bsfRD_W?E4p<$;jXhiL!;7%FG~{o**0?k)TYVb!SLGbA$2Zm z+*JIB41a3d$#Wr5Y5$lFU3DgoIhRs)9B<}-U8A-rDF?PyV#M?K@^m1*&B8WkGtAJjf81Dd@u<)(P-I6O zny<~7(y^8b3CG07LwA0oU5K`4k3fCXhyM>>?*Z5IzyAMclkAMFNJs-ABPlBs5iLSe zw#@h_8a9;~nW>aeL`e~mQAQ%6L}uh|Q(0;K?^pPIKIfe8?|*K$b8hE+4)q?-*Ymor z$GUPtv|N^%tyl^jwt*~MBtWup^3=!8e{RFVT&Ggbbjs5Jzj-YRtrnn78IH6g@tpJ3 zy6%}C06;IHHjg`)`fIMzN%uSSl8*lwyN=o+Gehfg?I{u-2kwsnYQGT5faQH2Xw7F!F-@9#_^P=)!0eH~4Fe_29bL$eetAygnojKM=8DSI#Z*b@BlGRy?)9C-40+`uBLX%#FJg9r30xXU3v z@uew+LO+2v%0$U*O{=oQwCvKQfeh(rBGvdQRaAz~t5TZl&cuuq0Cos~>1$T$g4~1{ z*CvM&!M?lg)2EyU`xes933@Ns&+y@4bd-`GszlN6llKrjkkii4zwqQr-Y%S4Z@15F z03|qItKf~C7=j_d9z1wJ&9fM4bU!Q1uQH933gr|Z>g`*z1ZUNJk-Fg^P`c~JbbzSptltI5#VQ;^u9{;n2(f7Z#^Cz-3s zKwn4P4PUoUw%eyYf9Smsu)z=c%=iU*!%``TRmNWkW z3!es<&9og8vg&suu*DEIH_b>Wom%b^4L1IJxA?0-GyXR_<^=|S#m!AV*i zEoK;VR_Km&PcF>e`euWVVsqiP9h9*rUd3}nQBGCIcd^!JrXyoEWY3HD??*uP5J7T6 zrsWj~ui)tWqQ9rSlY2tBdjBwPc>H9pLgd@eU%$SFV0>zR;*{0rBCkyNxwXIJh6?P% zq1LW#dX)#*j81%fe|4pC6~{mPltJ5|@vR5m)1!-cG$6vdd78#Ec%zF>sHhd@a|wIAp(NeSmd_Qf_lVptmn~ ztO;$r1x=XYQz&9zt|@Dal&VV`lpc1fS#G29JSi1b$S%M_>r-k?#0~qopdfN?_tXvl z5ZHE=55|Xwge=F;9oA=J&}zC8F-vD9@a<i zL^IaBce}H6Z};{&=i=(6?y0zy*GGTw9vzU&!Y)Je@P=JIH_0TEF=eVvb)o1-3daXGfsp};OrnjM@Qn^J5&^?MCU+SP}_cdHug+|wj0%l?U3#Z_RO?}3Qeux-}7 zN2dpX*<8PN&5_BP%iSdYk;uJ3i}!sx1NO;ba(<{>Gj%n`@A5CQYGu;3^mOTtz&N(; z+c)v?tFcuD+0@?r=j(ddWaE%knr2HcU)sI~u@agG0G3&*Q2Cjmm3#et5E{#36xDkJmQ!tQ%Qra6C1fBpB9 zxt6!iQY&s3DM!X&hfch4@WJOXc@F12)P#qfNU#P$ee=Wb4f;Igchh$1{GJ>9CL%4K z-5q5THP=da?a`yUeiOpG{6i!KERUGu{%ZGh=v*^@H-_O#z`9kiks%!XjOK!DjmrJO zdzh6>_gjWoYBfWq53cy)$no?3H(yP=zIao{~&qf;|&VBMQz=x=>xfS1 z^L!J)*NE9$yoOwzm9T1vt5&JJDBYVEft;`MXBad(;>(~1MAysGU~Z*)(Ej|s8rvrs z^|Jkhfsq=Hp-ZwLm0(Cn4_+~F{{@C}hTQ8;=FDdda#PWA{T`6Yz9*Fj7*6L5BZfn)Y+NW0CMG8QX%u){s-^h%=}3cgowY%lIjn=9P*gU1R%*LHG)%WvfEt(4Y^N^TkRU`2 z4CIYP8zPcjYt*PgV?8`BislrO-;}`^PD?>Y62567oNr8eMvDKzS{J&haHOedagE_4L2xpv*E+5wCU2N-q4{}uWssI zOS|bW(CEJbW-i%>JIEcV!pVKUrYYL==lmojLhzeKufsOW0y6r{YL0YZj4Xgl1lpwN+m_LlaBfMbyu-E~Bxl?)U6-c86>!FqxMQe1W0*Dg z`57~Mh+PJ?Bxh6-z_w_qGfv7Mo{zt*i)>v_bXpn(zA zvOY*zS$PIFMHo!EosgYR9GYEMMP;zRCS=DIin=ULA-ha&nZj8@SfDf41he2zRIl6e z8t-;dhv^;#*E<@1UD|L%+_Kp%Lkteh-gMzigULDNp%5@U`y|=*#wsW|Y#4bNZKbbb z(p0+5B45t7@E_a?9QLg|{*Yf}uy*LzZy!KA8WT}Re7T{~j zaiwtAk+j5C9h0YV%Aphja{UU{pW(mY1lsd#$X~dBaDRd!L@1*$DK!eYb@Akm=f{caQzFqRt!Br&X?WFYym-*|itOaEUfqk@WH0 ze}i_&oxUiU`^i`Zt^B5yNpmB&=toa^*z&qn&0P0S3HndHgwBf8x;e(^ND*g&iAkSF zV&%2U(Y9@vm~<%=FZn7(Z+Kzbx;edUjr;TQ%FzX#fX6oewhN?T3~yfM-V3xw0(@eW zZNxnV5~u_OUN|_n&xMcys}_|U9aWK9s3Za-1Zb^V-J}B;d)}iD|ARY%dP(IIB$Q%W z$Yw~|&8sxKFU=}LP6_jbJ7N>3;JVWie9#HWg;z8CqHi_xmq|p4!#~hp6OOd~PU8H9 zD*3zU=K&zrqNBM1WA?9hAK(NzE~Q(bxDz%Oth#n>nEO*?W|C&h z)&!A_L()s|K0=_H0Bzx%K3X>fU4COC21i&Iuy^X(R9ZHfD|qizs}2SRjkWe|J_ntAwnG;eJ+iq;diVV-w98t z4FFiHPMv_O4H?G~3R8EME?pXNf*#ZjnnWNfP0hha4SQ7?Y!Zp&0E}-`o5Q{TA^aR_ zS+n+F;^K(~h4dRR=2Q84rYy)IbPcJSvfDy1&mtwhn<4(1T&`0WWM=C#qxK*u@t(T` zb840boJtG{QKB>4=A(FMRt2^Gd50XL{v^wcETPF9YwnoqI>+KF7eh+{j5)FsQwlwU z6qXzv%^;IwPjGM2h}=ke$`bNhQzLErBl+|%#^ZNcJOM;fOSNTKE3 zi?~_NDzM$cdn@72_FHSKAvJO?ub1NP|JOW=DsitC-E1(n*N!Jyx~4|z^gwhGTTY=s zn`voO4tuvkV{+!LTgR+#U89m?(a0;dG&||^oRoT!qW0f1<}ES#eR&o59+@W+i^&{R zPS&j+jqeXK1@#{P>--^ zj8LD|jH+@;6M4IdYQHzYt!^~9Bg=c2lUoZ5LG!fJF#dp>SMj$w?!o5a+U?k=Vx);aoS_lP#pn*RqOvASG$8(om= zxA)^hW{j9ZxyW=Gee=qr;tW|v2y{jc)TWY(aaLQee zcp|&Dw~vA^q5D!}=e(xBFc}2ATMmRK=5Fr$iVs*eF)m!61Hu3KxOeIBTl!_PL}Na_ z(rw2u`7{)K@s{~}AP2#E6lE&P${KAz&izHUhno!ruR7-e4(A6yjfR6;gsTc(WXc-3 zTgUG$oNxp3aO;7jfss{sJZkdLKRX_vcsL-Rp1QV~|Dx*A&%5L&@^uz0p$$#R%95#; z_=UBaWLD0#^<8L@d^_l2eHOpdgoPn!KPHPrUv4+IL)?tCH8`x)v zP3C?G8WmOCffA~@D76nUt%9%SsD&z=|h< z+eoi-gvk`S0Q|R=%{f)(G)X3!YIyLp+guk@&jnPC*Z7VS_XP)U9=QJ*S_IWym92k-dkxX2>8k}65M2vvcbJ_aa(Ytr&dH^K7*ib0cw)t8;thDVABSvu56 zyPR4gA}lg7CeNOIB=)Caj~*w}y4dIC#~Od}QGEaOR6z#0(xbCWHsh%Z*t4NhcuFB( z{GWYnHH4TK$u;W0^Cj=cHXzJ$1b<3r!lt;%dg;!U z2bj&WA3J`wd&hqWk3WNEq=fVVDED8lc(pv?dl;*=MhyKf{SkDUyh_x^w!xFz1Mq}o zY)?VBRxc#TmKNt?cyDovI>z=ApfW`x-fpW!XGB zU`yFZLcB0moengV#2Vh19QoBpf!!1!3a(PrmZ~QGM~{4WV(pF2o&UbO9lVOB?)wBy zAiG0EO%F?XZ`|Xfjh*~h1wztBV&XY`TSaB1JoPUWigg6_G01UGB0%jVgwrs#xSl_M z9^6@;OGshayk#DK;-2>GsBDKoCYGH=rtB)r8funr1b+tW@&ZB_qZ;D8$tcJ+G}8~g z7lXvdb>YGd@kK(23rPlXEOJrmd+=DJE@f#ka~~U8YX>BzCWVs86LRNRiya#bjQna< z{n_Q8|~`YlQSAHLSmY( z$dj#nYX3K7Q|BiC2vM82T$QnDD^AGU*iIeX^BQCOt*NXmrq|{9)*dwIM1)YlC3#0? zjgB2_Fg3H0rs@S+Y773+@Ng^L&OwL5!}X_J6GQ-Z!kM36@JS_Ht?CaZN6<03Wxy?J z`xGc7E1n?J{G-k_e1QFKu3Iph-j#u(%88q5x$`%Hblw13^1 z5%6wXbPgJnu^|jo{%7u`M&!td3}BxpkMpJ4U6f4g)#JbM09V?wckgg^^MI8Q_OJ?I zgr|`<%B!MlKP#VnuT7BN&)>h_0J=%?T|meFv_-k^-ra!H^BK55gnzd1{1<8x%?Lw> z;pCiUU*8Hy;9ius)BW?a6&^tpr*>vzI_bYK)0%zu)RzI{{~^roltKNcb{e6*in^Lw z?=WX%N3}777)AKsG7BT|#W_LHp^~a;pWQs<=-80qzkRukH8j69M7a2U$$sHfiREPB zFgH*Be)n{vcE^t%(`6X@x^`TzAyBnzZSOUec;)|;V$LUjh})##JTn0qKn`ycXT{l1 zUsFyIAdamW_}L{gn9i$r=-BbydIkEZ0(}lmfoldtH0{F_(C4Fn;E5Z*;EA^M=p?1@ z^H~q_s1xhv(Awl(*l9P1#5p&2cdrpX#s`uedx~M%y@CU|?)eMr#JO{&9WdL5iLD(4 zK|Yi8G4K(&QwL=nxQa9-hA%<6xVo?H2}w5S(CNmW=r4+{D=yUkBQ695Zd>2F<1cO_ zIC_+$${h2P*v+nW$~fuwMpoeQ#9U8J&E*y)#pe2_r1o|}MDlOlvxiN8%8W1p&GScE z?s@xqD4nk)91~GDto7z@gMVEfjDUBLbDfBdRo4kRk~vF+Jj^aAF%*zux{K>Nf3W$5 zFu-XD_-k&Fe1FUYqn%~mr^=$=|Iq?0eFD7z2BMkctlM}JeUp!`ZzwP_KT5DAkTaO- zt7z^RX(bn$=t^YdDJg|$8)a7$U62?Nc)9FQYS1-s+GhwJAm>}bCg}(iA#YFBPe0NC zYggr0Q~WOc=cWoz5$R>%vWvA{BMmo+%eqf!xR4+b4^W}S~u2kDV`*H z96sm(f52MU)PT|!xw)E698$Gbq*mVVM(RDg2NOt zlyCT~L{&{82W9Sh!8xUnsHSVhQuEGzxG{E_jdlRrx<-{np~QHyU=`{q8pIU7z)FG} z@r;QCcSOog@|#HfyJgIc{7v@w@@2F8QGvzx&bzSO+ys>9w=}j;bYpRtUYNhSn z#6wq8QvTe;jw9onIgwYzrS-Pd<7=i}=A71mWYs_1J+`IW^vF9&2OFl33hX@qAt#vF zqa^R7w7<5WdOZH^v#9o8Tvsf1d^6|fyDR7>c$b%fKuOv(j8fj`MbwIL z>M3(9EKDi_bEVVx<^h{1k=2d9KXM%rY1Z})aj(xQX!(rHR!f; zDZK4v&R} zERCMV&)Msdcx{ElSL>lBV~%8=aO-T=khZeA_mO#dMVuvWjzb|K=uEv(w6)4FxzQzQ z`}aDn46WxCcgu2sq!`&FFE^vW*J^^%{bR4!hF;ssv7n4|ESl1}!OBE@@qB2iOFy>b zZqeI8n0u#96NqbHN<);73s;*BnpvF$%w#^}m(J*&J0%y+8oXXn!8AvE37_5QL{@!t zMoc4vZY7NOho*e?=FPZc7v~-xd-havr3rs{I4eH|31yRc&O7Mb9m6?jN)eLu^nj%- z+)?yF2gnasFQ&8LD%>;q^e-!+}1oAf8Dzu=NnfCu5hIM9y7ZuD$m2q_0j|?d>6t zAzWlVW0QQ;O?6=5Q$}#%^~=oI%wKF*Tk&w%vbDE&78*fr18&a8p2e5+8aq+qJnFI` z6{p&owNRZOGRH~%)t zwehDuy?f7RXNdb#ij@GN8xN{^fQc^B*a)Uzw_cICzJ9t+CD#+CZ4PT|Km4dSr@q}6 z*8w(dFU6Oy`DRG+$NhzCRyAqXk%$i}$c>yG&!!JoP1nmv-?2U`eaAPC(Ka=cY90;v z&E)BxYn`PT#M6xQUt^v+%e*!Yv6`v4yYq;(rLpcgb(>zVJ|^w?%e&F`I#fHL6sjF} zxG7900%yr1P?D$zNJp}4EMZsxCzsbxzq#V=+o8e%k_>voIjkibul-s^!pai>kirUF z@+m2F66UiT+1!Ur>)bxgMyA+VTJ{HYW;4QounhUqbKk#D9-u%P-GYc9d5tT>x(xV^ZrvV+C`yZuqdeD-tja4GHh=E?H)v3fD{Vn7A2^7 zTwR@Wq(y@;h+7skLc}o?TqSff>KJM$cF`O!CD%_w*CtCMrr0gUAPvOG!jNzYT$x(6 zbDUAHEC&k$?1!f;VU(mUp!X6J2*Fre=*Jj<@)U>5OB5z@dk7Qb+SL2Wa0`n(u>32T znT-LJ1+sgpX5PJ~>^BtV4Mil)bLYlR=g;VW|7(^x9P)2f!Q{xl=5<KL`Fxciw3H)Uj# z^!8>>B#r{JWI*GHrkq!Vj!ggvaSi{Qudy3ht@3Rg1)Pi_c-myuHqU(R*3Z-4>cLxa(`<#vBG25bxC=x;;L8Rl_x`wXUN1IdQY&U9}(TZNs42FJQTTk8Lu}wE$k?0IuJ*V8y-zl9xd9%!ZaxIk> zJvY!bw|K<2CJ_c}{k@uK#CWZqR5i=|cXMZ>7j(p0+kI*wh7ItM6Nyiuu7SN`5aJ5~ zMgGX%l z)Uq%Aa;3$*uEZLKXiaz{?$z>dT)BK!YO3jMN*&0vqeR|P9{$-L1U>L2fXeYNCEw7L z3Snnim-9acI&6heSi39N24K(8z0_0M+Ni<)Xd01%bSHQyd>_=g!Bhf20A&v`|fdmwDt6 zuwj!X(y7?TyO83$yd>k;a@oI5cnU6-9T>Uk{WoS!XCg2Sl zBxQv|b3!sW>8nRX6sY(pcoqtt9#1S#hId-(GEs#ElA}N=@;-2mn)jM})M~BmVH3)r z=g%(}DRH2`0AM;f-e`ssHZT?e&dvH6WS=eo#Up0Mp$`;Sr-7`nVNFuHzilb}cjMwxqaf)~fJR zaj8wxD@o)J|61R5OmzUr5rB&iV0s#O3 zy6daBa?2{{r%ltWh5Zh9u4wFn`&%v;Y|?OSWrK)6`n*}%4^T!hI4P!%XS=3hLvCzYDi$E&0_Pux^?9*Wb9!?FFW(evr0 za&V`9jZN@PMn*E3wnvuSqv`~UV@Bu?7}P*LKjy+Hsy4jYMIhES0|x+}i=;rTM=%p5 zOsVuvbw&q#bsLjk0y=`|MLamnLuv#VLb(A?n5PH!X|!?3ARGt;nw)U^h+v##~!kgl( z!EvGlwIzGPc^hE1TkT2#H>ie$>Qv{ss{wFF0N>r$T=!Q;Yr$?;Atb{YgXOi`}ehi+Iagn6Q7zp_UwV=u#%1oO&pC*5JIY1>ek1Pr2|#1 zB07UZE6WhzZ2V-c!VmAZB@^Pj_~^Uy|9~FWH0j%$CL<_29ak-EZY#7kLUz*94I4SK zgPvZL`JAppxtI?2_mBLfHZd$iG8RyxqEJ=v8Ot|_Bt`6>B$+uaERKvlfAE0zyZi&W zeN=tU@Ogn)7u*@%MRu)JHhsQe*_YS!J$2kZCMb3pd!Eq^LQB_xfIE<3AHcV}cVoA^ zmEn(d@ce26syYK74UOKGbQ5YkI4M^x(CYO@3T9*MN9McV?gFjn6fV#botHg{Z$|~wU)SF zO8?ma8Exv@>8_1@&Lt-Pfb=|)qY_)lFVIdaQk&S~QqJ~WuAVnm#@!oaxtL48VWV>O zWz$U(7KNpo=G&rQTOKn{BSCc)A?;Cih#XeDZ-RgIv zD9H8c&Zs$Sk6Pv)_PF-g+t*WBIev4)p~^NZ)c0R=aLpS=5;lFV=%${+j0}Sx6oMp*Z8(#@lETeclr@Ks!CXn~ux4b$Bh8p&Ook zMU7bb*~gExOG{E0rK2p8U<9R2>Lw%cdbW1Eh15w7EMi^BLg7p8MtU?#3*WT1Qzv6C z)7U$l+fdYAyN|~~b8*{`c~$te)4Z0Y7{U;VDV&>RvqyUV*#prUE9ESTl^-esW-ahUa%CdC`pjIi<4u_< zxVU~ox_@pF9Mfrhs=JRHc$axR&^31F1<1d#W{K=dW^XXjg{SVgyrTsrBvR|E7j&dBv;v2J5T~_1dv&Sm2&L zzBqGj=4ko0EC~(y934#txU3h^H2eLrtz>yRkA+s5j(X!xK_GU!&=~m5fhffNLMvCV zZ4hw87Cs0>IGm&X^~1lU6jFg35#$1Xb{!)xco9av`b39x4EAvPQU)r8wYq=eCv04Z5v68&;9b&@Arj{3vY!cZIn0YoO*Tr04dSlnmKIGhon zXx}*fDI_d1Uv?#n@}6+PT3WiRGGV%e<*HfJk*@hbcsMiqdVpuhWnEC6hC1oUZ`Haw zDiI;VI{*(*YSwS*q8J;=5}VG`l-p-_K}>`q4#%db$eHYSu@KlkY6nrG&lHNG&{P1T z4ZWn7;(W_Tg5`;>)vxICy`rDF?y%n}<}XlUG3rS+>E~w`ohnOH02CIEfktGmJXs=a z7C9LwmS|d5Dqec&+No3C#xRy=IvqJk`y=Ei76<662Dm5bvKUt+R+4@UKw`D5$`~ih z3n8P-aB%s>WsEKFu83~N!kPE~CS|1WU32RuRdGqGs zcj!ZFjDG_cni%2p-}md+&mFXACOeMU>7eQvBk~yhKn{r}bcSxnpQ?Y+&pVEL27XD6 zWND^b0!;=7CQ;Q`Zq%O_1AHF_b};Z!R?~FyW|LD3`2sf3b)h_h;B3iZTAuIL`KJqa zskT!$M2BelKp@Rfdy1UEbUa5vcJDHoN(LK{f0$Rdl*?Z{Bi1V#FAsDXZFxBITE9&% z=LWC*5OQEnc~j3A1&N=Iy!R$&zg@ls<1>yiBj$r|u%2J8B6Yyi^o8|($k!GKIBM3Q z(J!Aht+vLuf88Ta8!#svUay!I9q^Zy*4RevH>Iw7RNC?Wr(UhX^Me6vI27!2*i6px zjrTF!uO2+iqyhQP;R|@qMuhf;hV~WYm@FVa#C&)xBXB~cZ%{OE(8f)BcY01 zP!mbFq9IB7IM3WDiBJoi)vU4c-@V(6)1F+u8RX;yfIfxc0jqO)`zf}o2n1+=hzUkH zV^*AO;^0Dry{z5LcjnLS)<&Z+eFCr0({0(d?Xvvv@|#>(-N_^dR&f5{{0&I-j9sAtMnoSrr$3g|k)qY-#}n6AuZK>oX#$Wlw=X z=#&&m$)^63AWeHF{VV<;MyWiId)-&{tQ0L8rQf}CM+in}oEdRIw@!HeHh(^g)n1m+ zasrIL-m?;&^x+=?;_Q{EpL)hW`hTfsH6pP0R>Zv$ni#gN))%{=&CT>z0wQnumFI!j zA(zfbyka88@cce;fk|o4J#S5Z%3Cq|mhFt-c0M;OH^C3U%Cw(uR9*HK%twPo79d#I zOAJ|6l#9Qjp}C%xHaILp(roEo{siSP`}7AGWz;!oug5fiDC5bD>Z?wyR~#$}@b_P- zX_i=!sslZB0woWhh2)bUPC!yEo6~`PM9~N~vYaLkuqg#lfu>*7%FL)>E)fshvb=k8 z$CFoj!iBPgp_QCFSGpo~Hy$41es^?N`oI0Q_oAMF-G*nF}x!cWNka6DUe)&F+JI zfHT|3-YXcw{=dAftOco|rJ4>^u?fO!fbVzII19kN@>%v0L&9%VcBuu*BVziworG9$ znyMwKdCr{ncyNZ!l^N;vbx1&<^i-u40Yy)dnN`ZJ`s+``x^ z)@nsezIF9r(0$-Ja?^u*AL7my;w-lmTqNOFU^>I)NEdHH95x(W&};E|{R*=U@-Lf~ zdpQ@R34#}iY8B2Z<`eBw)9;jJBPu^&M1w9^=z1>mqIqF9p0KvNpFlL!;Bzvus8IhTNBZXiNmJx{IIN+|)_r)*yLovE5&43>r;G z5EGOX<}T?sZh&ns;PDCj)rs)|q~vo}UUI2Ud+}hq+ebFXap@<=VQIa@Nxb14UCwz4IhsIna<>$p_qx|GXxt>#t`T(dyXLTcC8CVE*wc*!H zhFLK|NFzboI8raGqa#tzwZoMLoH;tp`YC)Bj()IrjGh|RJzQN~>Dn%mIR=c3JjL(k zhU22J(vAOF+L}GOvt!SqN~AJW-<@mB^G+}tjjYXNtY9NOh758D3?Zi zNLa?Mule*UvaFffxafWMRtSIZlGRq0p7OMGwOgCCH zbIPY8QdUvxmi#(cquLG8ZJq!5RtHFM;!H18JxrGA__U z5z=cSylNxWaBi!qXjZb9r zp$iA_w0--Y0}xEhFDsZa&opV2G5m$e<8s5t$^%?qCl}(lXI+PtBuL@dso5sE5Z9e0 zYsJxt*@b*A968Bo5~?|1e9Y)$?>$ER^@=Aj*O-MB`eVZ*+G%U2I<}uJ=4W({tt zL44IH-|3j@SX39>ji_MMT!Q($U>!6ZwSssJ*^V|#EiJdyPrrQWlFG6DPls5Q4h`lQ zx4p&ItcFZ(AYpT}u2KP~^5kfE{vfJwMw_5_KMR;cMZP%wJBRjaNzjP_MG0=|ofygYcI8PS9WU(N)&u zCh%a5`VJr`dqYs3JJI0~+waS}Dl03Vhdl%IQlowQHE>9*Kr3}x)i}V%proYh30(hx zd`CMsG?`&}ySY|w!pebp+xG4~@*j`UVzv2#6!J^N!bAs6cS|XEiT8R1GLENEeBMl; zcQKcYp1uV79+c3PgGa}>hH-st+nJHo=b61bz30e+=IZK`A_v%YotV>u#}6_q27MJ0 zSlM;quem>S*3B$)gZqC?%}W?)7G16UjjhezWqAt4UeB3v_S=qAl4~{?HED1 zo2l=$h++qYWppK^Dc|JdfZQVJ z?GeV??l|ghV!+EL3}nEbIaZ=JpoYlGLf+14LT51Ah$;0#NPHZ^YZQJ}mCJjPpiFtQ z@Kv0t7+z#!FtEVF(w~bNQf835#9h(j-3v@PpV=KzX+DHGw1Gqf@d!Un@>U0i6Z~GN z1H;9UKlChNC%9DwGXAaOxV`^!l@wR2vOA^I{Cs>G#AQvKI1ysFfq`tJ9z=MWFaccQ z^cu|?r+c%}p4xnxY*_S4jQl|W7P~i2FP$FNB?wDnplCV>utcpaUTMzAr7rct-0IH9 zojIn~YhO2qO4i$HN?aOLxl%6_o^D8$iNV}_FCf+#)|A%HbTnTX(WUr=bZYRX-h21EE;r6Vz5P!OZy}JBQQK^<)dhcNSC1aJhKM_?bhs` zs1A`R@=+Zb+e#D7M2v65iI}y)>O%?&3P^W2b!M3ERRzURSvl*X?UhNo7B(*H5)7m3 zMRt#9bO$Q{N{`dM=R$JRYG$~U%9IkzZ;s(ie-M~POLYro)=uA0G>L8ErsV9Xw=J2C(}A-K!fzg=4%5>c5*Z!nM5ALc^?WcLuXwh z%RS0A>S2{hX!WK|qHxvoc=Plw;OdBl5j6qe2vM|TkyniGc#}EBR0`CXZH$Zp=`_i) zyUvMJNp{sLsfW7WLo^52okhFN{W8R={h-N_uK?=EZ@k%Vvymudl9K}&BfyZvf6W}# z^vF?Y>12|Ge9Xf&sC6~xn$rKZ#8cBa{KldWRSRaNa&QFCzXR`UQ64 z6Oq9VKrk8|gaCVd1p?dAPLPmaT`O%*!al$Vv(NiJkA5~VynK8<^5KdjVZ*2TH4 z%BasXepQN~_F`A#o?jDveHB!4;svYmAaHJ38?ff{AhTrG3izQCl7->FC>QD3Yn8o9 zE`)z0k>>^)yp6_+JC;v+r>3P%fov9CHysCm8XmPtG1^MBmP3&`QNyu54#J{%{M@(Z zpa@|+5kpd3E8qsx4{VF&@-wZot!OtNSVmeeLyLVcm;FO(PRq|UZ z&)z01j8<=YcHC0vpOw2H$LYry)>6Ly}Pa1GOE^+#(ZEDuY*~M*K*iLcBI_0ljkyXa~8*V#O=>`HKygKbSwO|`5 zPUy^Z7cNkflFzHMy-17I@6a7jMvO)yWxFec{a$vd^Ta=Efu7mMyP>i&njvA;nNUL> zOpMc~cBv?>(MemBn~-21aqwYX_EK7QdzqVr>lOHNH~Y^({v?n9oy|CpBzHHrni-FX z_~d|+K0Uy}A;_iVP>5v!DEiy2{{Bu2+q=$ea>=&6%l_b{%a*l3g1kl}$W94xoq#33 zJ@(Wj%0nfsz^D_fg#lE3aNhImMO`)LxF~9hFf}|@8gZuZ665yY-(E2yp&1c=5PE9 zFjNX10Y)wD9jWHWB+f##DGy5*<+_9H;}vbogGU$NEXr1%ayXr%6B7I>+Zb47sjjb| z0;vXo94G4vLxT`XgcBfxpkWw9d%>F8P6A&V8WIi=Vm>=eUpps#$25WvHEhO?i`bC; z`e{;9rb(}tKlaMS?9#Z;mA%ICtm2p%>C`tZbA{f_eP*uo;y-uuGp8{`Iu+A;CeM10 z%lQOJ(=nquw%L2+KW_a}%mFG*W=uHmF%Hw)tnfXv`qD5CyuM{sIfTdqRMr`uS@F>I zi=m^NuYdJ(Y>siYYUBI+ghjGI*Uo~a;x{M^>TZF*572Ob%~>Wli5lY#`WnjqrIM%R zrRt8moGw|<(J58cwbY6x_~gM$0oj-WS%E@B;#U0Olsn@56_-?}5Y0B>>|Y!4e9Pd8 zDD?DygVKXHcD9OdVp^UqTRCN&AYntlmHm>#eEvsn?bgfQ>(jD`Ym+ZUBW2gIROSbn3#%$FeR7yTIRI2~8HK@fZAX(LfzaP!BPhAi7MlDI_Fu-8aCP#YjM;PoqYP zk57}*S*s_KMqKSQc}wR^x#rDwsr@heI?X+6Y+l9b|DZLh(W7xSvKEIHcdWwUud zvr~Wgg-29O*6{}xceu$mhqu#l-2`22y=(*OdFlBwBLl`(n`_>`r`@Vk2NqAbnc2G^ z0&_sT@Pd624-aB~z!5}U^TRhyGfg5pS5&=*=}G83$2_oW91*!dv+2h^-jANG{e(R)_UD>>)O+V>Xv3zHE(hIgEcg|lg(v9os;eL7QAyO#C0yx!ZwD651-Z8Fp_>HC-SgcS(_0)3i|V=Rc`Z0Hi})nu1<>% z)9k9apf>@Zm*#xd{bM&lTg`duv*+E)3I+6$&OUbI{`u7lX-bfdc17GSofLZDV?)MAaZEvn3A|gDRblwB&q{&DO}XPJ12E3bjtWFF;v>t@KMqpqdXWE&tS&hE8DbFJ$#um|Fw{CZ4GzkGD@-vQF z_SeB%-5U=wb#B+beag{^Qw6>JDf8C6;fN=hGGs0*m=3i+c zOGded3+Qxw3`1X4onmF+Hr)Pd=7fz_+zX>m#U9voWX@6|IQR6NW6t32&TM{oTDE!!u5%=`6(s061V{LZn8?vKFNSpYchTRz?>%+*2d)vcW6*I{JH-{c6Gk(E)tm%`4Ilm8v`Hv>d(IHI^bXDK&cq7@EGK`w4 z@-YV*R7uDnE_Gtz5B%CYv^OqJ%P?P37r+8pbK*4r7Tqh%m?LXRxKT)ck;6&e&GN}N zU7h1qr_#~NvKm%0ucn!nWFg54m%z9(%^gE31h!xSDe+gYTxniY`%LrKuROXF5hkKb zr~7w&hH@x$mY5sNZgXf?&$WL@{@8GG0wJ>nH%AluZ!f=R_!SQl~iHAS_{bkC_cQ z*>GFT&E#1$T={~B2pE$8teroB5EDAT3SiRgKw!_sNfIN%@B!{s#FISp{;^gJC{stS ziNbU;Xk1Dmw-$0ORyoup@CS}Dw$#3(2y}xy3d~q*6tU~PQrIX#~{<*KTw6#}42 z<6j?QE<9`Xv116i%Rq;{k_g08f>V$r1O*Rzx7o8t!)2m>b7jPj`2@=(rI$%P#=eWb{#Nw1 z`WO4(uWJ>rD&znNyqNyE3x0r6xuZm5ty_bcdBfWULI1q)pgiW5id+*bMEx6!Uq9K4 z2xH#)>!7eH-+(2dLMO@OtUqotiSdc>z$x#ybYJK@)A`yjvgNzMY+nTdcvY3XRiR@= z*-j0TJq!t?)xg*tWO(Ge02Nu|c5Ko*`;H&6LQ1?%zND70F&Jx+kEWS;tR$42W`Mby zX)5J?r#*X?f=%+5DG%e$Ej3^r)~;Pz$#M`D%*7EeRpU+HjgLRku;MY@Brq0DE0lyO zN@YI1CA&)|Jjg8qYn=Ca-;ZcZ+k6imhYLxi9_%}vRvb6d=zjR3YBg)uzMPpEK%=pM z+hbG9J_Er!*-xiN6N{1ucPH6k#5aZQR{3%_esAA&Yn1`{*Kgo z{HDX5;dfN)#S-3u|Bz0oKd%u}jqdk(E7nyLNkCl5DKcDXz-9+58}IM?WfEU9uYGC6 zt#OHN+(i0KPPOMYMhLaz&7%`u@wE1`HAL?8vuBt2q~|2McL!}`+Hc_^VUc)sAkKMk zNo3aHH*v74X}LJhjP=>Roy3J^dZmjI(Ttg?YIu;Ugx@jY!<|pgrg5862N$*MwXxI2 z@Wj{edY>6<)pc{!7psYH)Ln&eu-qz0rcjnt(^$;XZ4d5kn0_-eURyh;oT3^!?To&; zP7vuL`%u5kB_9y2C+(&)uQSldy&-cnsu5xB)+gh+%mQ!CE5 zv2G*l!no`x+INz?z2Tr$+9bO^Hg{c4{U?7+cBDTj<|0}Tn!5gj7@&xx9^rM&CBI~H z_|e>iW7FR9cbPU=PPFU8eM5fji<>l7ZZEeE9qgSE?&?h^v*BBJ=PFBoa*Y&@NU5*7 zxsKIe0X0mGdTsizDHmp5cTK|2-O9=bMXosZhBqIL-snUVm#6c73gk)X-)0F{YopDZHuWrSYi*xDVQ0^CR1U;O z-AD!6+S3!JQ9cXt0Kl$1TuqE6 z00qrAUOAv=x*J2m#aS0b0YbV%{`nrc?(nBFXx%0Chi4W|r&Dq&7=VZotL;by10iEbBKvI$=cPRTJ~aGkxPJjLgwMb9lFKV4a#AZaD?{v-y{kHK-|O9b z_xb^pQ8|Z3+4`y7qE8?|CHE280HnI19PqtfJoSH356xK)JDc!fw@lRzdUt1+H_t6s zCa~(wZ?pHg9cy%$L=c9f0|yTh-F|^3S=4YDu#sgk<;u&o%(i18YXXH@e;KZ?&4A0x zAIN!Xc4cj!f2!iYhF6#HRzR8}?wG7s9tYz+qv#syk|Nvfo?yPefUq=1_ThlTB-{u> zDD4|ydsyZyzkR3hT*VZ4(AWI9NISE)+@1?WUn4nzZQ?Z{u~!Fiwl-8#n;Grc zKlsa+Ma*T09YpN}598x$%KjEmRIx}toH3$*8p=be4(+rR+Aoo!+1lTH^jInE!lesr$T*L8)YExnS~0L`d*F5+@NhFWkcF>UI0-0 z4Cs1_V|7PnREeGd-(K)3)N_Pg=wH<99y4w)$64HL!^PwFalU*$Z10k^P`xus79SVP zwfJJN15o8?N6XI;CBdATU)ECQUgqlPBmP3!gRp?NR98k{RN||KvNF7IlzC?PYp%Oxcaqd zQ#H*(sQ`HF$}i%mGt(ZickT$IPrP-4nx^2~@F0y7zK5x6Bvhh^3|LkI<6V1l>|Wn&RdH!2hkntP~Q zQG)h*g}T>|w*G{XI=&J!0uO6|qs<$wBW`#YlKtBcXG}?9fi$I?%M)6Ls2OGzk_lcR zplecm$YSpmUX74GJ{^NZ`%ceM1qgqY%{xQH4l9tD;Ueg@EGVK`<;&T4%{Kk3LHvI` z3K7v;dN@f2*M4R( zzj272zqN)tLvkjArmEj&JT^C=n_$bQb0adA`E?66Toj%yWFVdOgCuVg1b}pbQPvfA z922Z;2N%$Oj}Sdk2oB? z=xAspk!~5&o2PPv%c>9|H2x|N)k$w3(RJHo&W1?P!3BCN@p*UD(pt|`xs~dSV2+hS zyRR!O?O(Q5WDVvT_-B^|ZdKGK$P)fXb*Jjt4<{(d`8IsRoY<5p4V-g}gp}R8ckkG$ zhBNPIig9g??Ttp`-y8ytZvS$M-^zhDrXLq*E-6-ZsWJPj!T4c0#%P3?&qU-wkaJHc z1TrU>g(ZN3f93=S%PrgYQ=+qV#TrCxK&K+v zKXfIt44M~J#&cenk_Us{F#pxiM7zEo4UQ+R=$Ir{0m&}ld#>?h9zG*RLfVBoAfdd9 zzjk}5Fp7?2ZO$a3ia?q~)(YBPx#EbsYU#F;RTCU@lC#l=3|9Hr(s0pwpdm?4(}PK2&kTp6A0T5WvZ~(kOq-;bSr>-SeTho3J3qx$*2ZoZveA|c&2T?7WoBP(kCM~^vln8Ck z4W$RBM>s&MDmi#sB_c}eU^}@AsO#zMDc@k~DV%juw|yh%fm3igIX3})1EZ(JSWL4s zTJrXukLcZ%u#s)Fjk5Rg1aBpQ1;MRQeFn4cNraBmnq!?L!qKXv7Y+L#Asf6WA^^1# z#rn;gyKJq{gjLAtrr$YW#w^E=nDI<;_^=kbhjD~;@!%MIj)Q@sw@?Cu*$&!Kz$WSD zU+qTcC!#sb?ZbyXa>|2N+*xR2>MQ0vK4Vj&9$z(8aeC&L6jy@dR=Frcz&Jj?w1%`L zwWIaf!q(Ke15^y;D9Y0mzIafGN+S-;K2#o zX}ZN34*9jtKI$NJ3B#7snsCP_OW*1Jwm%bl#`*{Sq=eI_*Nf#dwsB<$ZXedp%XhUw!^ z&bTN`0>8B~@p?4F^DQT5?wTH|$`Safs1PrUKP62^bRuJBy+k=Vg#3uWci{Ssa6EkZ z5V2~BzF8+mnVIcp%&VRwR%6I&KOY6ncz12tCrsPnM;C}QIAFB*MuVtle z){w-s@*2%X3WN2&m11rbd z#YG2rMXp!a4jdVeywK)l>!E+RY>Nizey#{E5lQ<-1;!JT4~zrsScmt&)xiU8GBtME z;ZZQ1gc9Q;M7DO7CWuBuX^lZP^&GzdtEQ|DBzB2%lVv#zMm_G%R^nQgyAGzasu(15 zJ$3>=>+;6JJwa*x|9;&Q+>e!;c=NSX*V(jKl_iXGayoo_U+*wJ6M|lDTX#n2gV&eZ%yt z?9}`ACb^K-Y*uDud-La&BsT!N05*B&=&o&1{SuY!`<;%nt+uu=6jF;hu5^Ij1J{-n z<2h({@BYZ)!@FPmZ0q2XR~Vvkdy+e^%BgGTZWJ3<-CX!1cKQZF&nCo6ARvcrj_erQ z4?Rdp|NIgFJTO_&gFFF|F`wydbJ6x^ghtT#TK_3ix@hldVP_SYIChW`@*zW!b8%6g z%s!__zUgqlujxn$s>e=6qWU1lxdvG7iO|K3+Xbn!D{DC*-t(6)2Q!ufO>uu98dpLf z9ab@8@E<-8o>SPm4cqq(@5D|sqk6;xQjNKw(UT0Jh_R=udK;GWa?5dxUBw;Hd*REP zfd5VMSLvI-Qvh3tkZDzq#me=DG)UhVhVCKWH{2`9TJ^u~gSOxE->>^nY!S>?sUNYF zpDlWOZe+Np0XSoRJX=-Icb^qP6z)Y~_=Wt5vMs)_?DZ_@=a;Wgvydg)#dh-SID4kl zb!X3>9kBGTAE#awu|5c~QQogqEBdaRh+`zk83d0t^YAw{wi%@F@7jGl-HzlvWPRiO zM^ndE%|Uinzw%^&#{bpI1dLNBMMY$bG%G{M?trc99RIVmc?VvO_l$kz*c;G$NGuBj z3iqi*`arbs1p4^b6xThq#T0`8LFf8r1Jz57r;Rj1UeuRtSKKD6=;noCX2pE0Z$#(oOMdA8>C0A4s?lW&xmu8Po@&m+!|0$vDtA%Y!G(*}=Zt}J z)}Y@V(Yna8bF=E?PuTBaz1qzSsa8nWYC+tvN^~iWa*Jr|wrXB??C=8>3}^-8D|vat zXbe-%Bk|c`W^}wFM_VniB~ryUSJ8%O#IM8pvSO=mi1xi5r>d#uKk5xV?s(*y{*x~; zzk#$rtw%!nryaD@!-_!_Y2KxBfl3t;oM8D|FpBPT+M=(E%~D*o0v)3oO|KOD{dUvz z?%lf41hFJ;HE9?EBe72epzj5vP>PE=;rUxHfNe`g|!@=(c#4ZXhY%$vrL*PNeNDMErY_&9}^~; zt652R1O>J~V}?w_8QV>i1N%Py%=qZ#4T8nTvcZL>=)>l$u9*G#5;fSA3!_fv+BVHjvaX^N- z;N-ed%}-qT2YrpwxaxNIs>rK0Mx7iq;Nl*w1=hwd%aX=_Ot<3&X<1j>-rK(^Fhp*E zokL8#+KJ15U#?C%01)-4#g(g6VF|<`hD)bBcNjT79JDiC*+D@X1A+!K+!TiZ|HM?p zJx9tTK>+%v|H7Zby!(>lP`Q6t#xvadv<*6^wklWuV=QxwI^Pp8$Zx{XkGVy1d!SX9 zm5AzY?}0;z@9Sn*`)p_*77!7NmD~52M$8U3W48Uk>mgPbyDQ>?J13?N*Dsu8r{f8A z26=clWLlzm_$Bnq2sUN4pr%(yl}n{G^=*cQ+exl=&j#uLpx?C!TLe%SHpo&J7I$G-hO+;pAowSTLXf4pJ z2PVcQ)s9SP`~mx%mJcTX>r#T(Kx#{k^%!!WTo)rdqVe^BIs{it`ZaeGt1Y_kY>*udTiVa$D(lG$6XU;Ao_l0Yo zUbW9H;<}Qc&HTbb$znok4CcE0QsZ3NE5TjJhtqxG%VAY2JhAvRB?gf3aWlxp==>50 z7O>$;fImnov4EaDZ2FzK{m#FKumjnv`wkyNfaRE{d9PkIf6T4F46so?WkN0-ZM?^I zis;AGOivxd1i0|=DD~g?t=8BYc9S6X!2Ggf`w2?-wp9tekm#2+{7l?Ri|yo@h-I(gefyBV~*mkl6n3A zqwK!}dfxy4e?05hWRxv?B(i6U2xTP|Dw#!+Y!%8VqwG;;MJg(iC?Po|D}*FO35k-T zLi>BUS?74Yygu*W@BP>7?NHC><8dFi+jZR-=S#gL)MVXDAGptJJ-Av?Luer|M0cAt z+9!7WGSz(PGr&zkc?|=Nw^AZ&OyI?W^0SMtaFfr7AXt>2CqKKveGmz*mb zXf_ZQ9qPYG+su}-TC_G~$D`$x@!9(4&&;A&gQP-5!B1^yq4y|w+IE6jDN&U3*7n%5 z^d;0^X;9)kq;zvA$3}d?q-LIDG_h?7EAq2eeB6gdsiRp15mZKn!KK%RJ#vs>CctBk{RV@vs;W zMp4dIY-l;+2k~0JAQOb!?6&70xm*DN)&RP-<@C%+>FSX^@Xu$;jGFsQb%R>jehV^C ziFbT_ITdgIi0`cY@5>olrg1OJE&V&FtB-$pbaKSv_QV@GyjYf#8fANWmwLZ`jV4V> zT3ok(mpyfVoeM=@0(jmK9*mvwsjo7vx6MirWP%S~xim5-QgIE0jO5cOFd57E10|Lc z0q+=N#B&VujE9gI|Hg2&@R5Y+pcA3&p`2JmCX(nCA-}3Nd^DAENSViY=ATD=i87O8 zojRez_7lg?W^<>5@5s;K&|!Se<;i0Si{C&8kNf!*%sA!C@O-D$E6a+q)R{ymQmRStY0)u#rGFa8 z#}f2-)O^=Cjq&5gU1Od&8<{(I9~KWPg^GyZsPG8vxjgyKk~MTaZc`3{y}WGtdugaT z=i?=6EV;NZtyr|E-aB7j?$q~@AqO%LC6a}+9UCjlO&C@EPM$06>^8kx8fXQleHS3n zVn;>uFGa+>$(^V(&`x(3WgAe>STlbf>CE;HIzQHDiC1}v)T6Zk(rBj4m znYZRNLn$$hLmc4^*C0mZg#F7ZW!SJ`16F zka4O{d%LvJsKs+F2>I2RKdvvS9#r1){x)SOu+iEhSVN@(_5A1H3cw2TyzrTE5W|+Bgbh>i*z{G zKjZm|R3=3N0Es3scEy$j>GljTRA3H>Dts|SyTn%O10?p?pT(IVToCB!@0=8*(?q7Y zEYgBf6NEe zg zJqnGr(P}k0-@6q=QUj4EEQ#r6%ypweq9?hF8h!KDt+C)ayoQ~-cgy%U$r(Uo$yr?$ zEm~h2lO8KkH()!*TCkNe=p+6;C_t8HmFYBrP%S1VCMUA{Z1`^(%+EZJS5MMojGs=} zqtwOtMNZCmT*M5AQhsUwUp$}N&5tM!Ns3Uwj*iPg@tDuiqZDCl&M${im1+zyO}c)> zO;z|Spp;*kxjNkaB2A;~68hOgVSTk8$ouRwO{#OJc6s;gX^TgSpFmD-K1Gz(gQ6QSBjUFob=Oa1VYM zrSS$3&L#z~5KxWe`;jdw3BsIk6%;8CL7te}W5MH!O=0ith2=xDB{Nd?Rqjruhf1UPmtUu)XLbZgrx$J3GijT1@|Hu&h`MGSG`(&7f80MKUHN=ku(*lvF3A|~XmVkx2%*>`f8O?8%| zAniQWNr#5E_wGs8JSIuY{?qHlQ+G+Xp=jU^tuOkxF6Wd%$F{NYaw{GIFrY3 z;P=Z&i-knO;oM&1;UQtZOhGw7!&`L?4URi|;MMEb&L$@^sj}+jg$keyrO9uAq#Y53qQWp5oZ-;Y=^6P z;Ar(*wj75#gxkcA{N|vk7nRqb(FHRN_P#&&F?t!k4&KJlZ;}=YeM#TM8jeyLv?K%Q zn7Vrr-vt}uf96co<+du{<72?z1)@9XWb)cDvpWTG6?Wbd0c=U4QrO zc3=90$LD>#@oMLj4~1q)tEVlAH_M60Ibq;i0}3pIo+F>wSRPZQMW!i3E?*g2_d`Nu>}y!hcad(qXDT*bYQ- z4G0&Z){dP!|3-T(0PqeKPx5afWrk2+_3e!9r6v;N7}pT^Yabam1}5gs`B1b3FO?W? zI-ax_oNfZ*zl5!AXLPHQe4@!Hy9J7S=hcfgI-$AEFnIjFjfcWYJIvXbu|s7Y6YuXI zu|Ub}qPJId5wvva1-=XK?897pV}~yv?ts%BzA(mAuR&(ZLCd%Bm!^!ZxMw!(<=g|; zz6Z{{;Z(2$O_Hku^*^#IZ>Iq-_@aV+F+CMR9;fD-vQSl0NW@Qw`rtv(lwQmL3*0D? zAaf5`E<1LPbqajEW|^HQrvkV#U?y7HHMEqaV=7b1;sURS!w=LX1_B{4^)n(X95P4B z$E}#sf3ZUe+DrMV7vm4?;7uS=xRr0F^p1}qQeNoQK{osAdnR{Ci|8IWDEn-f>gCIO zT&6s2?Ywh#^8q6Y3g;(&vE^hcY7~8ECghWGjt6Opz8f=8;iQwe@X*%u;_|NS+g2o^ zQaDPoQ)w6yktNU}^<(l-v6O~BhjS$d&ebuBug%eQhj%wesd2~l?I%Q7H@1EEQwy-z zg2fEuB~!nPY_QIz7I^jS+FT}ZyqmTVxUe^Pw3cmTf%5c08mZy7Dl48Z^Dv5%X=>CV zJD@R?K6CHv*|cd>o6>#z_ur&WSX1Kji7V+gVOMKPPG8Ec_d47h(pikBuV24j_~J?h z#8mxBPo$FtQj@8q*f>c8Ak{hi#*&M>WOzzWj_rj-?QVa|E5ySxb;HE}S~`I_lZ@($ zt#CaEl|+jqyy|E3bT~<DNoW0zu%NED1yhCS}tPcAQ@@MHO={-eZgs{on!pq zFWq5pRVSU=;%5cJvmKW}rRk@oI_7rSbhr~lRHX*1^}6>^{q$z;X(czrG8FBjd|a;nAJ#RV5`oZtAi#UjJw!JBj-t|e@oAP zRu6_pIU*VW#~L5?jQkl{7+`g$C#&(RCzl@9a`NQrE1kRGhE}hGCv!_E6hJL8PHGGI zxM@?T9vZ0>HE$Xr+{&Mf3_;Rc2w<^ZR?4{1n~-*gC$!vAUOO8JKz|$i_MOKbA4Evehs(taE_zH= z^pr@dH7h?vRAo0yEsE&sfL{-!Wv!xYYdXcPhWFrXkr}syge9J+pyKfMNKcmxu96OF zYFiLU-1QthnpPoSeJ~G@T$ei>qJ=alkIBm582#(h_Eo_XeExDOPELKlp!g&UpaOQp z>0dlFYdAWMUj~V`?5XYHMmut!^{pQ7GUY`7lp4L_O58&C`?k<-tb1+eEW`3=CyR;- z^79{#FSk+VN)H?O(uwtGJkqmcyLNBGPeKC{?!J5f{>?DqfEEe15U2HEFhq7PGtinb)YbM0xwIA&+2@`ZKU*QqlG=c-E z=)LI_{hE!8Hlvi!B`M=VEq}gmE7im{&VdH2o1ZZE>Rxw%P4U=tCC{4HzSWl@6d1u5 zDTt%OntNb_pA|e_$c^-Mxg|I;hm`gEJWN|#0bnpGCJ(6NsMUc)+Y?wt*=mwJCGIs~ zgCtwi)6<9UkCxPmw*^Wi*fuG2W4KTV@80aVno9y0)%)^oF0URC90s31jNP^&BU&G>{vTGEF)5kSsF=jswS&xU0J4;4(iz|~Pl!~96 zt_9LviZ&VfhG=ovk=2L5zsZ5Zy(?qvm9q+{>wKzyZI#sv25L-qa%b zVigLI*_M_)SAL#fdOSDTC4=Q#c{&O>xw3{hKAs9a0B~F5^~MaeNR@(|@=8u!{P2>d zxFhr<7_(y2rg}u^K2Iw|!Z(zMNd$NspmF32{91))ni|2F9UT8U2LSD3)l|e~lJ!8} zQPVB<<-=YcO?<(x=>@JYeKl&|XmqMIeY;T*G)!+%O1a3{lUrU>kuxd$Z$~(V*0H$C8@3nz3*hMUu~spXWpZoM)=t1C9Sv2 z_-(7+SQR#__t;s~XJVCqYjCwiJE(Y?Sgw&6lCQ2%(^Z(=yt;+be1FTQwoewRELmkY z(aP6oc2{mQkyz5HaXk$|R`xY1s!WH5ldi1}u;{&r(&y* z(gNV{U62DaFKdtL?-_knQO+R>rtYNh2tkJ&QyirH9~Y=-g}_MudhHd~r+xb>unN-T zt&X556G3JBFyH=UJ{$v>&?qm-qbY5AL+jjpenfS6P1&i#T#zSy^4BV@&c9`LN(@$< z%0uW7WD6;~xp24z+BQ}WPhsSPfSKIpQY@1z$em~UGH;LX>JbyH#v~tgSRJkL`fPST zc^wp3q7mg)(IwAKDjnUxa~g&dsyk#?E$E~8rcsx@F0ys$F50oCIR!ov12V-W15F26 zvk#A;+!3CQ?{_e*5Ug__X_%aea%xgPn3cCEpoEaFR+a~;o5WoLQB^GT--43i75DG* zbIHopvsE=(m3nDPoK8T{8dD))=XCVY>TuIXXry&9a2cM3B?P*VZNo? z^%PIT8g)~rQ#i`}!kZ)6G%e+&Gx!{_n zZ@Q2EAjPrRCi26&%}Z7uCXf_RP#h;XC`2}fTVqJsySZGpP&n;-^r%P2gT7(X>l;gW zrBIPzJn5im3ZKSjWRi5r=&=x+&Nrnb&d+}hU)X_QaP`Yy*I{^)ItFM_JdR9Ds^Se( zAT1)_0dS=;#0Tx)NFAMzh^|D~sUPLIx-Z!MO9wYv2RgC$sZm}CL}lh4!@Q>c$MP^+ z9i^HUk=5DecrtlW9mXA+>{zAqagbS<;fF^oM*Z5zGmKTYg-74+S^jdqrPanJNN&TajXa{jEXsFuRX0+f;3QtAG7~&X*1cT-G|{ z;hgc;iMP2LojQ%uI%CfRr?u3H8VvbPwJt|65U9^ICo)dJ#Kr-Ea*~PBJgV!!ffo$Z z>?vU>fF9-aqGFVi7AqLM5r^&5&Z!mSZr^@Qb*zB1dOyp%7ZgA%r;0@?c zv-{wn*DMoftyHz#i<2F7I=@EBAfgcXh>h-T)9hpaRB>9~Bdwg1*=lK%qqV-ZqVdlI zf>YNO5m3Su?Q@;C{iS^LlCQsi0yj|`t@RyWjXM7NQMO@0>CVV@8vRD;Y!qkXeRbr7 z$(egnWhxK{EyJ)5H=4HUer5>I_x;mzZe)p0o~niZb7o8)5<^p$48EA*DYO01ULZ$D z%9WZ|h(y+p#f7<5GDA?t%{=y`3>N^0BMS5J!9>Xi7NacvEq_x|d|P?~f-!93NSnAW z-89CJ9h+FLg7}gP@q&%1>yq3TL#bo^rr|v5Mcor_GGeH{6I-{X-HQrj4oJQQLa2@A z44);>h_DdT}d~#|=FENdu_~>&J9p4@{ZkYwu^L8YJrMBaV zb{c)WSTxV>?#7x`I={g+AHG^^R^9*WB-;Dg7A{@2XOfz8Csjgi%dIY$-eeJp3eXuV~r=ZB${(Mmw za7flp{qUKiv?kT69&fWd*R0yCeHLZ0Pi{Y`(|Sw0S2ud}^vd36nk*hs&>$QAKHA5F zsZyJ3*qy#we4~H;5uxV}A6OP;bbIyfk7;9l_xjDWE5l*YKvD4ujkXFc|9-9#{I(T8 zb+nZ!Tv#hOX{#Cq7;YOO zcIb{;wqo?Cp4*zY&3m#fByk&9kj&5v^s1rsa-0VY@2F8XY}V&h?77Xkp5D`OcP^!# z`B^fZXzV%6@pv*{05xRnu)2e<67i*_0I)EY;v7qL2TDl`WEl>2mr1zB;URh_gcL0L zfjZIB!S$qCM^babG`yQk9r1GFsGh8*EP)=t7LE@YC{nuF&8>@(Dd5TGZQCSO8o`M~ zOJH@8@MgN3nIwi$SxcEgq+l%xbftDo8umFlt&qfrr z{K21#pmq;WHhvJV^b^HN*YiJTw8(x$kAF9k0mb2bRC1C|5exohiEvmaIP@0X|Ih+T_b zH%`=A5qQ~gbN~gxc7IO?SMkIX36fd0arH$VZ``;sor;jh)}T=%e-*V!Cj4@H)jP`r zipp-7Q}D=dev{BU1vPlByVX9$u)&ItE4(&_^NzxQ>Lb;u4V&%@aL` zUE8^Ek5BT6%;L5yb-x42r)E%0%g*}O)A@NK@A&OB*|#^)^O5o1^d_-CSG!Nqs<0=Y zZKeC>hB?L-6~zfvRPV8&h0OTbjBXKF^*W|@&VfcNrxotjD%>44=wmR53h3Vi^i$Nd zR8wl7FH^GfulFH;?TIv(DY+)6g1-cu@o#Y;w3)SG#)-2J3ZVHtf-stP2uz$DaC9Ec-4 z(oO@TFXn#siR-lD2bM77VVGrDLKvSBX3uxpI9*+al5gX5rb2mdB3fIn84Cx;kr4*R zyOhpXc{ite{ML5h>|H_+KMFhQF{feZ*`FMlnm) zaBS2bn;?7$N1Q~`VZxe-Auh1gg_BP>63#CM>h6@Fm-_WZje~IlM^^1MtipQ))2S)f zM9uk%Zs+WjIG!REO(h^@=?G<}L9lqbMZ7Kj81=LS^1ZNp40x$0?WDzOn5$>N{T7+K zU-s|yu*$oPm)xmiv_^khVbMW`&23%w|8}tkIDIf)UFsfo8E!(-%4k$?ooLhAS+Tv0 zTV!QR#D}lLp?~&W=dJWT5^x2hLT;c-%X{V#i(P0fW`#^XU;N=B4*YXvjoo6SnZg6Q zEQl|lTmy2Dh!*1tmidjqr+_E7UW3 zBL3mXVR${(Q&7reF#M!V`Y&t+s#Ns_#GyPoA%)?GCC4?Ap6I}Mb z7t?04?T?{^r=ksfnDuz?@gIDakP@{8=9io*);`R?AYBw15j2h5)eIbH3Kw7DMyk3- z(OVxHy+z++7DWaQBO*dA?($}9=;g#vFRs1wi%?3orp+};-1)zFJTKQ`f_eo2vm0UhGHXeZSQLK1CS|5|eDL zG8v=R2;`&F^?A;U+K;X2S2S|3R9c=o?ML0uzSwh(w@4zSDrTrv%(`UB@5cKF-q*O# zyoTUSV*PAQyAKpxp)gHEv()@D%Mhpllg|)XS?oImRvuTgD|MTwAXY7b{+BV%dHcl= z7veH6(3hSeY=wX^2^Vl;hL!>+4!IB&Pix*;2HIE%4qri1c(^Q7Cweu!-XtovYw4<$ z6etPtzp(E3-j+WAwA-yP)X*P8xOJ)|j1yoSPL4fT|iVyTe3c+0`?`6)CIz{Z&_ z4SN0PQVhC&zC~IaWgyZ$qo@URzH+E>ozjH_PSZlBT-t1%vYSrb2T?>3QQsa(wS{28 zE4_}F9$eZhR`7gvZ5Uc^99i#O~oXcWEc*OXS|YPxAZcH zI-?Bh1J7^(_Xoq5BZF=JjZ}k|yP0WEAhD!e2Az`KL?zmh#|%7_mBf&?XO@qd#JvhR z>Mhs>_zB(pMLzBBY96+m@gTmR?muVuKh?p$pCemEz6i}N#bE%1J zewWnM?7V*6V_lVN!lzb#A^Qm?yr`5f0|5=iamM5WNBX{gwk?11jltv@