1#!/usr/bin/env python3 2# 3# Copyright (C) 2022 Collabora Limited 4# Author: Guilherme Gallo <guilherme.gallo@collabora.com> 5# 6# SPDX-License-Identifier: MIT 7 8""" 9Some utilities to analyse logs, create gitlab sections and other quality of life 10improvements 11""" 12 13import logging 14import re 15import sys 16from dataclasses import dataclass, field 17from datetime import datetime, timedelta 18from typing import Optional, Union 19 20from lava.exceptions import MesaCITimeoutError 21from lava.utils.console_format import CONSOLE_LOG 22from lava.utils.gitlab_section import GitlabSection 23from lava.utils.lava_log_hints import LAVALogHints 24from lava.utils.log_section import ( 25 DEFAULT_GITLAB_SECTION_TIMEOUTS, 26 FALLBACK_GITLAB_SECTION_TIMEOUT, 27 LOG_SECTIONS, 28 LogSectionType, 29) 30 31 32@dataclass 33class LogFollower: 34 current_section: Optional[GitlabSection] = None 35 timeout_durations: dict[LogSectionType, timedelta] = field( 36 default_factory=lambda: DEFAULT_GITLAB_SECTION_TIMEOUTS, 37 ) 38 fallback_timeout: timedelta = FALLBACK_GITLAB_SECTION_TIMEOUT 39 _buffer: list[str] = field(default_factory=list, init=False) 40 log_hints: LAVALogHints = field(init=False) 41 42 def __post_init__(self): 43 section_is_created = bool(self.current_section) 44 section_has_started = bool( 45 self.current_section and self.current_section.has_started 46 ) 47 self.log_hints = LAVALogHints(self) 48 assert ( 49 section_is_created == section_has_started 50 ), "Can't follow logs beginning from uninitialized GitLab sections." 51 52 @property 53 def phase(self) -> LogSectionType: 54 return ( 55 self.current_section.type 56 if self.current_section 57 else LogSectionType.UNKNOWN 58 ) 59 60 def __enter__(self): 61 return self 62 63 def __exit__(self, exc_type, exc_val, exc_tb): 64 """Cleanup existing buffer if this object gets out from the context""" 65 self.clear_current_section() 66 last_lines = self.flush() 67 for line in last_lines: 68 print(line) 69 70 def watchdog(self): 71 if not self.current_section: 72 return 73 74 timeout_duration = self.timeout_durations.get( 75 self.current_section.type, self.fallback_timeout 76 ) 77 78 if self.current_section.delta_time() > timeout_duration: 79 raise MesaCITimeoutError( 80 f"Gitlab Section {self.current_section} has timed out", 81 timeout_duration=timeout_duration, 82 ) 83 84 def clear_current_section(self): 85 if self.current_section and not self.current_section.has_finished: 86 self._buffer.append(self.current_section.end()) 87 self.current_section = None 88 89 def update_section(self, new_section: GitlabSection): 90 # Sections can have redundant regex to find them to mitigate LAVA 91 # interleaving kmsg and stderr/stdout issue. 92 if self.current_section and self.current_section.id == new_section.id: 93 return 94 self.clear_current_section() 95 self.current_section = new_section 96 self._buffer.append(new_section.start()) 97 98 def manage_gl_sections(self, line): 99 if isinstance(line["msg"], list): 100 logging.debug("Ignoring messages as list. Kernel dumps.") 101 return 102 103 for log_section in LOG_SECTIONS: 104 if new_section := log_section.from_log_line_to_section(line): 105 self.update_section(new_section) 106 107 def detect_kernel_dump_line(self, line: dict[str, Union[str, list]]) -> bool: 108 # line["msg"] can be a list[str] when there is a kernel dump 109 if isinstance(line["msg"], list): 110 return line["lvl"] == "debug" 111 112 # result level has dict line["msg"] 113 if not isinstance(line["msg"], str): 114 return False 115 116 # we have a line, check if it is a kernel message 117 if re.search(r"\[[\d\s]{5}\.[\d\s]{6}\] +\S{2,}", line["msg"]): 118 print_log(f"{CONSOLE_LOG['BOLD']}{line['msg']}{CONSOLE_LOG['RESET']}") 119 return True 120 121 return False 122 123 def feed(self, new_lines: list[dict[str, str]]) -> bool: 124 """Input data to be processed by LogFollower instance 125 Returns true if the DUT (device under test) seems to be alive. 126 """ 127 128 self.watchdog() 129 130 # No signal of job health in the log 131 is_job_healthy = False 132 133 for line in new_lines: 134 if self.detect_kernel_dump_line(line): 135 continue 136 137 # At least we are fed with a non-kernel dump log, it seems that the 138 # job is progressing 139 is_job_healthy = True 140 self.manage_gl_sections(line) 141 if parsed_line := parse_lava_line(line): 142 self._buffer.append(parsed_line) 143 144 self.log_hints.detect_failure(new_lines) 145 146 return is_job_healthy 147 148 def flush(self) -> list[str]: 149 buffer = self._buffer 150 self._buffer = [] 151 return buffer 152 153 154def fix_lava_color_log(line): 155 """This function is a temporary solution for the color escape codes mangling 156 problem. There is some problem in message passing between the LAVA 157 dispatcher and the device under test (DUT). Here \x1b character is missing 158 before `[:digit::digit:?:digit:?m` ANSI TTY color codes, or the more 159 complicated ones with number values for text format before background and 160 foreground colors. 161 When this problem is fixed on the LAVA side, one should remove this function. 162 """ 163 line["msg"] = re.sub(r"(\[(\d+;){0,2}\d{1,3}m)", "\x1b" + r"\1", line["msg"]) 164 165 166def fix_lava_gitlab_section_log(line): 167 """This function is a temporary solution for the Gitlab section markers 168 mangling problem. Gitlab parses the following lines to define a collapsible 169 gitlab section in their log: 170 - \x1b[0Ksection_start:timestamp:section_id[collapsible=true/false]\r\x1b[0Ksection_header 171 - \x1b[0Ksection_end:timestamp:section_id\r\x1b[0K 172 There is some problem in message passing between the LAVA dispatcher and the 173 device under test (DUT), that digests \x1b and \r control characters 174 incorrectly. When this problem is fixed on the LAVA side, one should remove 175 this function. 176 """ 177 if match := re.match(r"\[0K(section_\w+):(\d+):(\S+)\[0K([\S ]+)?", line["msg"]): 178 marker, timestamp, id_collapsible, header = match.groups() 179 # The above regex serves for both section start and end lines. 180 # When the header is None, it means we are dealing with `section_end` line 181 header = header or "" 182 line["msg"] = f"\x1b[0K{marker}:{timestamp}:{id_collapsible}\r\x1b[0K{header}" 183 184 185def parse_lava_line(line) -> Optional[str]: 186 prefix = "" 187 suffix = "" 188 189 if line["lvl"] in ["results", "feedback", "debug"]: 190 return 191 elif line["lvl"] in ["warning", "error"]: 192 prefix = CONSOLE_LOG["FG_RED"] 193 suffix = CONSOLE_LOG["RESET"] 194 elif line["lvl"] == "input": 195 prefix = "$ " 196 suffix = "" 197 elif line["lvl"] == "target": 198 fix_lava_color_log(line) 199 fix_lava_gitlab_section_log(line) 200 201 return f'{prefix}{line["msg"]}{suffix}' 202 203 204def print_log(msg): 205 # Reset color from timestamp, since `msg` can tint the terminal color 206 print(f"{CONSOLE_LOG['RESET']}{datetime.now()}: {msg}") 207 208 209def fatal_err(msg): 210 colored_msg = f"{CONSOLE_LOG['FG_RED']}" 211 f"{msg}" 212 f"{CONSOLE_LOG['RESET']}" 213 print_log(colored_msg) 214 sys.exit(1) 215 216 217def hide_sensitive_data(yaml_data, hide_tag="HIDEME"): 218 return "".join(line for line in yaml_data.splitlines(True) if hide_tag not in line) 219