diff options
Diffstat (limited to 'buildscripts/resmokelib/testing/symbolizer_service.py')
| -rw-r--r-- | buildscripts/resmokelib/testing/symbolizer_service.py | 294 |
1 files changed, 294 insertions, 0 deletions
diff --git a/buildscripts/resmokelib/testing/symbolizer_service.py b/buildscripts/resmokelib/testing/symbolizer_service.py new file mode 100644 index 00000000000..9e78b0dd6b7 --- /dev/null +++ b/buildscripts/resmokelib/testing/symbolizer_service.py @@ -0,0 +1,294 @@ +"""Symbolize stacktraces inside test logs.""" +from __future__ import annotations + +import os +import subprocess +import sys +import time +from datetime import timedelta +from threading import Lock + +from typing import List, Optional, NamedTuple + +from buildscripts.resmokelib import config as _config +from buildscripts.resmokelib.flags import HANG_ANALYZER_CALLED +from buildscripts.resmokelib.testing.testcases.interface import TestCase + +# This lock prevents different resmoke jobs from symbolizing stacktraces concurrently, +# which includes downloading the debug symbols, that can be reused by other resmoke jobs +_lock = Lock() + +STACKTRACE_FILE_EXTENSION = ".stacktrace" +SYMBOLIZE_RETRY_TIMEOUT_SECS = timedelta(minutes=4).total_seconds() + + +class ResmokeSymbolizerConfig(NamedTuple): + """ + Resmoke symbolizer config. + + * evg_task_id: evergreen task ID resmoke runs on + * client_id: symbolizer client ID + * client_secret: symbolizer client secret + """ + + evg_task_id: Optional[str] + client_id: Optional[str] + client_secret: Optional[str] + + @classmethod + def from_resmoke_config(cls) -> ResmokeSymbolizerConfig: + """ + Make resmoke symbolizer config from a global resmoke config. + + :return: resmoke symbolizer config + """ + return cls( + evg_task_id=_config.EVERGREEN_TASK_ID, + client_id=_config.SYMBOLIZER_CLIENT_ID, + client_secret=_config.SYMBOLIZER_CLIENT_SECRET, + ) + + @staticmethod + def is_windows() -> bool: + """ + Whether we are on Windows. + + :return: True if on Windows + """ + return sys.platform == "win32" or sys.platform == "cygwin" + + @staticmethod + def is_macos() -> bool: + """ + Whether we are on MacOS. + + :return: True if on MacOS. + """ + return sys.platform == "darwin" + + +class ResmokeSymbolizer: + """Symbolize stacktraces inside test logs.""" + + def __init__(self, config: Optional[ResmokeSymbolizerConfig] = None, + symbolizer_service: Optional[SymbolizerService] = None, + file_service: Optional[FileService] = None): + """Initialize instance.""" + + self.config = config if config is not None else ResmokeSymbolizerConfig.from_resmoke_config( + ) + self.symbolizer_service = symbolizer_service if symbolizer_service is not None else SymbolizerService( + ) + self.file_service = file_service if file_service is not None else FileService() + + def symbolize_test_logs(self, test: TestCase, + symbolize_retry_timeout: float = SYMBOLIZE_RETRY_TIMEOUT_SECS) -> None: + """ + Perform all necessary actions to symbolize and write output to test logs. + + :param test: resmoke test case + :param symbolize_retry_timeout: the timeout for symbolizer retries + """ + if not self.should_symbolize(test): + return + + dbpath = self.get_stacktrace_dir(test) + if dbpath is None: + return + + test.logger.info("Looking for stacktrace files in '%s'", dbpath) + files = self.collect_stacktrace_files(dbpath) + if not files: + test.logger.info("No failure logs/stacktrace files found, skipping symbolization") + return + + with _lock: + test.logger.info("Found stacktrace files. \nBEGIN Symbolization") + test.logger.info("Stacktrace files: %s", files) + + start_time = time.perf_counter() + for file_path in files: + test.logger.info("Working on: %s", file_path) + symbolizer_script_timeout = int(symbolize_retry_timeout - + (time.perf_counter() - start_time)) + symbolized_out = self.symbolizer_service.run_symbolizer_script( + file_path, symbolizer_script_timeout) + test.logger.info(symbolized_out) + if time.perf_counter() - start_time > symbolize_retry_timeout: + break + + # To avoid performing the same actions on these files again, we remove them + self.file_service.remove_all(files) + + test.logger.info("\nEND Symbolization \nSymbolization process completed. ") + + def should_symbolize(self, test: TestCase) -> bool: + """ + Check whether we should perform symbolization process. + + :param test: resmoke test case + :return: whether we should symbolize + """ + if self.config.evg_task_id is None: + test.logger.info("Not running in Evergreen, skipping symbolization") + return False + + if self.config.client_id is None or self.config.client_secret is None: + test.logger.info("Symbolizer client secret and/or client ID are absent," + " skipping symbolization") + return False + + if self.config.is_windows(): + test.logger.info("Running on Windows, skipping symbolization") + return False + + if self.config.is_macos(): + test.logger.info("Running on MacOS, skipping symbolization") + return False + + if HANG_ANALYZER_CALLED.is_set(): + test.logger.info( + "Hang analyzer has been called, skipping symbolization to meet timeout constraints." + ) + return False + + return True + + def get_stacktrace_dir(self, test: TestCase) -> Optional[str]: + """ + Get dbpath from test case. + + :param test: resmoke test case + :return: dbpath or None + """ + if not hasattr(test, "fixture") or test.fixture is None: + test.logger.info("Test fixture is not available, could not get dbpath") + return None + + dbpath = test.fixture.get_dbpath_prefix() + if not self.file_service.check_path_exists(dbpath): + test.logger.info("dbpath '%s' directory not found", dbpath) + return None + + return dbpath + + def collect_stacktrace_files(self, dir_path: str) -> List[str]: + """ + Collect all stacktrace files which are not empty and return their full paths. + + :param dir_path: directory to look into + :return: list of stacktrace files paths + """ + + files = self.file_service.find_all_children_recursively(dir_path) + files = self.file_service.filter_by_extension(files, STACKTRACE_FILE_EXTENSION) + self.file_service.remove_empty(files) + files = self.file_service.filter_out_non_files(files) + + return files + + +class FileService: + """A service for working with files.""" + + @staticmethod + def find_all_children_recursively(dir_path: str) -> List[str]: + """ + Find all children files in directory recursively. + + :param dir_path: directory path + :return: list of all children files + """ + children_in_dir = [] + for parent, _, children in os.walk(dir_path): + children_in_dir.extend(os.path.join(parent, child) for child in children) + return children_in_dir + + @staticmethod + def filter_by_extension(files: List[str], extension: str) -> List[str]: + """ + Filter files by extension. + + :param files: list of file paths + :param extension: file extension + :return: filtered list of file paths + """ + return [f for f in files if f.endswith(extension)] + + @staticmethod + def filter_out_non_files(files: List[str]) -> List[str]: + """ + Filter out non files. + + :param files: list of paths + :return: filtered list of file paths + """ + return [f for f in files if os.path.isfile(f)] + + @staticmethod + def remove_empty(files: List[str]) -> None: + """ + Delete files that are empty. + + :param files: list of paths + """ + for file in [f for f in files if os.stat(f).st_size == 0]: + os.remove(file) + + @staticmethod + def remove_all(files: List[str]) -> None: + """ + Delete all files. + + :param files: list of paths + """ + for file in files: + os.remove(file) + + @staticmethod + def check_path_exists(path: str) -> bool: + """ + Check that file or directory exists. + + :param path: file or directory path + :return: whether path exists + """ + return os.path.exists(path) + + +class SymbolizerService: + """Wrapper around symbolizer script.""" + + @staticmethod + def run_symbolizer_script(full_file_path: str, retry_timeout_secs: int) -> str: + """ + Symbolize given file and return symbolized output as string. + + :param full_file_path: stacktrace file path + :param retry_timeout_secs: the timeout for symbolizer to retry + :return: symbolized output as string + """ + + symbolizer_args = [ + "db-contrib-tool", + "symbolize", + "--client-secret", + _config.SYMBOLIZER_CLIENT_SECRET, + "--client-id", + _config.SYMBOLIZER_CLIENT_ID, + "--total-seconds-for-retries", + str(retry_timeout_secs), + ] + + with open(full_file_path) as file_obj: + symbolizer_process = subprocess.Popen(args=symbolizer_args, close_fds=True, + stdin=file_obj, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + + try: + output, _ = symbolizer_process.communicate(timeout=retry_timeout_secs) + except subprocess.TimeoutExpired: + symbolizer_process.kill() + output, _ = symbolizer_process.communicate() + + return output.strip().decode() |
