github.com/canonical/ubuntu-image@v0.0.0-20240430122802-2202fe98b290/tests/lib/external/snapd-testing-tools/utils/log-parser (about) 1 #!/usr/bin/env python3 2 3 """ 4 This tool reads a spread log and creates a file with all the data 5 The output file includes the more important information extracted 6 from the log to be analyzed 7 """ 8 9 import argparse 10 import json 11 import os 12 import re 13 import sys 14 15 # Info types 16 ERROR_TYPE = 'Error' 17 DEBUG_TYPE = 'Debug' 18 WARN_TYPE = 'WARNING:' 19 20 # Results 21 FAILED_TYPE = 'Failed' 22 ABORTED_TYPE = 'Aborted' 23 SUCCESSFUL_TYPE = 'Successful' 24 25 # Printable names 26 ALL = 'all' 27 NONE = 'none' 28 ACTION = 'action' 29 OPERATION = 'operation' 30 INFO = 'info' 31 ERROR = 'error' 32 ERROR_DEBUG = 'error-debug' 33 FAILED = 'failed' 34 ABORTED = 'aborted' 35 SUCCESSFUL = 'successful' 36 37 RESULT = 'result' 38 START = 'Found' 39 SPREAD_FILE = 'spread.yaml' 40 41 EXEC_VERBS = ['Preparing', 'Executing', 'Restoring'] 42 INFO_TYPES = [ERROR_TYPE, DEBUG_TYPE, WARN_TYPE] 43 OPERATIONS = [ 44 'Rebooting', 'Discarding', 'Allocating', 'Waiting', 45 'Allocated', 'Connecting', 'Connected', 'Sending' 46 ] 47 RESULTS = ['Successful', 'Aborted', 'Failed'] 48 FAILED_LEVELS = ['task', 'suite', 'project'] 49 FAILED_STAGES = ['prepare', 'restore'] 50 51 52 class Action: 53 """ 54 Action represents the main spread tasks actions 55 The actions can be: Preparing, Executing and Restoring 56 """ 57 58 def __init__(self, verb, task, date, time, source_line): 59 self.type = ACTION 60 self.verb = verb 61 self.time = time 62 self.date = date 63 self.task = task 64 self.source_line = source_line 65 66 def __repr__(self): 67 return self.source_line 68 69 def __dict__(self): 70 return { 71 'type': 'action', 72 'date': self.date, 73 'time': self.time, 74 'verb': self.verb, 75 'task': self.task 76 } 77 78 79 class Result: 80 """ 81 Result represents the results for a spread run 82 The results can be: Successful, failed and aborted 83 """ 84 85 def __init__(self, result_type, level, stage, number, date, time, 86 detail, source_line): 87 self.type = RESULT 88 self.result_type = result_type 89 self.level = level 90 self.stage = stage 91 self.number = number 92 self.time = time 93 self.date = date 94 self.detail = detail 95 self.source_line = source_line 96 97 def __repr__(self): 98 if self.detail: 99 return '{}{}'.format(self.source_line, str(self.detail)) 100 return self.source_line 101 102 def __dict__(self): 103 prepared_detail = None 104 if self.detail: 105 prepared_detail = self.detail.__dict__() 106 return { 107 'type': self.type, 108 'date': self.date, 109 'time': self.time, 110 'result_type': self.result_type, 111 'level': self.level, 112 'stage': self.stage, 113 'number': self.number, 114 'detail': prepared_detail 115 } 116 117 118 class Info: 119 """ 120 Info represents the extra tasks information which is included in the 121 spread log. The info can be: Error, Debug and Warning 122 """ 123 124 def __init__(self, info_type, verb, task, extra, date, time, 125 detail, source_line): 126 self.type = INFO 127 self.info_type = info_type 128 self.verb = verb 129 self.time = time 130 self.date = date 131 self.task = task 132 self.extra = extra 133 self.detail = detail 134 self.source_line = source_line 135 136 def __repr__(self): 137 if self.detail: 138 return '{}{}'.format(self.source_line, self.detail) 139 return self.source_line 140 141 def __dict__(self): 142 prepared_detail = None 143 if self.detail: 144 prepared_detail = self.detail.__dict__() 145 return { 146 'type': self.type, 147 'date': self.date, 148 'time': self.time, 149 'info_type': self.info_type, 150 'verb': self.verb, 151 'task': self.task, 152 'extra': self.extra, 153 'detail': prepared_detail 154 } 155 156 157 class Detail: 158 """ 159 Detail represents the extra lines which are displayed after the info 160 """ 161 162 def __init__(self, lines_limit, lines): 163 self.lines_limit = lines_limit 164 self.lines = lines 165 166 def _get_lines(self): 167 if self.lines_limit < 0 or self.lines_limit > len(self.lines): 168 return self.lines 169 170 # Use self.lines_limit-1 because the last line is a '.' and we don't 171 # want to count it as a line in the log details 172 return self.lines[-self.lines_limit-1:] 173 174 def __repr__(self): 175 return ''.join(self._get_lines()) 176 177 def __dict__(self): 178 return {'lines': self.lines[-self.lines_limit-1:]} 179 180 181 class Operation: 182 """ 183 Operation represents other actions that the spread running can do while 184 executing tests like: Rebooting, Discarding, Allocating, Waiting, 185 Allocated, Connecting, Connected, Sending 186 """ 187 188 def __init__(self, verb, task, extra, date, time, source_line): 189 self.type = OPERATION 190 self.verb = verb 191 self.time = time 192 self.extra = extra 193 self.date = date 194 self.task = task 195 self.source_line = source_line 196 197 def __repr__(self): 198 return self.source_line 199 200 def __dict__(self): 201 return { 202 'type': self.type, 203 'date': self.date, 204 'time': self.time, 205 'verb': self.verb, 206 'task': self.task, 207 'extra': self.extra 208 } 209 210 211 class LogReader: 212 """ 213 LogReader manages the spread log, it allows to read, export and print 214 """ 215 def __init__(self, filepath, output_type, lines_limit, store_setup): 216 self.filepath = filepath 217 self.output_type = output_type 218 self.lines_limit = lines_limit 219 self.store_setup = store_setup 220 self.lines = [] 221 self.iter = 0 222 self.full_log = [] 223 224 def __repr__(self): 225 return str(self.__dict__()) 226 227 def __dict__(self): 228 return {'full_log': self.full_log} 229 230 def print_log(self, details, results): 231 if not self.full_log: 232 return 233 234 # Print the details 235 if details == ALL: 236 print(''.join(str(x) for x in self.full_log)) 237 elif details == NONE: 238 pass 239 elif details == ERROR: 240 print(''.join(str(x) for x in self.full_log if x.type == INFO and 241 x.info_type == ERROR_TYPE)) 242 elif details == ERROR_DEBUG: 243 print(''.join(str(x) for x in self.full_log if x.type == INFO and 244 (x.info_type == ERROR_TYPE or x.info_type == DEBUG_TYPE))) 245 else: 246 print(''.join(str(x) for x in self.full_log if x.type == details)) 247 248 # Print the results 249 if results == ALL: 250 print(''.join(str(x) for x in self.full_log if x.type == RESULT)) 251 elif results == NONE: 252 pass 253 elif results == FAILED: 254 print(''.join(str(x) for x in self.full_log if x.type == RESULT and 255 x.result_type == FAILED_TYPE)) 256 elif results == ABORTED: 257 print(''.join(str(x) for x in self.full_log if x.type == RESULT and 258 x.result_type == ABORTED_TYPE)) 259 else: 260 print(''.join(str(x) for x in self.full_log if x.type == RESULT and 261 x.result_type == SUCCESSFUL_TYPE)) 262 263 def export_log(self, filepath): 264 prepared_log = [] 265 for item in self.full_log: 266 if isinstance(item, str): 267 prepared_log.append(item) 268 else: 269 prepared_log.append(item.__dict__()) 270 with open(filepath, 'w') as json_file: 271 json.dump(prepared_log, json_file, indent=4) 272 273 def _next_line(self): 274 self.iter = self.iter + 1 275 return self.lines[self.iter-1] 276 277 def check_log_exists(self): 278 return os.path.exists(self.filepath) 279 280 def read_spread_log(self): 281 try: 282 with open(self.filepath, 'r', encoding='utf-8') as filepath: 283 self.lines = filepath.readlines() 284 except UnicodeDecodeError: 285 with open(self.filepath, 'r', encoding='latin-1') as filepath: 286 self.lines = filepath.readlines() 287 288 # Find the start of the log, the log file could include 289 # initial lines which are not part of the spread log itself 290 self.iter = 0 291 if self.store_setup: 292 while self.iter < len(self.lines): 293 line = self._next_line() 294 if self._match_start(line): 295 break 296 self.full_log.append(line) 297 298 if self.iter >= len(self.lines): 299 # Start not found, the log is either empty, corrupted or cut 300 self.iter = 0 301 302 # Then iterate line by line analyzing the log 303 while self.iter < len(self.lines): 304 line = self._next_line() 305 306 # The line is a task execution; preparing, executing, restoring 307 if self._match_task(line): 308 action = self._get_action(line) 309 if action: 310 self.full_log.append(action) 311 continue 312 313 # The line shows info: error, debug, warning 314 if self._match_info(line): 315 info = self._get_info(line) 316 if info: 317 self.full_log.append(info) 318 continue 319 320 # The line is another operation: Rebooting, Discarding, Allocating 321 # Waiting, Allocated, Connecting, Connected, Sending' 322 if self._match_operation(line): 323 operation = self._get_operation(line) 324 if operation: 325 self.full_log.append(operation) 326 continue 327 328 # The line is a result: Successful, Aborted, Failed 329 if self._match_result(line): 330 result = self._get_result(line) 331 if result: 332 self.full_log.append(result) 333 continue 334 335 def _match_date(self, date): 336 return re.findall(r'\d{4}-\d{2}-\d{2}', date) 337 338 def _match_time(self, time): 339 return re.findall(r'\d{2}:\d{2}:\d{2}', time) 340 341 def _match_info(self, line): 342 parts = line.strip().split(' ') 343 return len(parts) > 3 and \ 344 parts[2] in INFO_TYPES and \ 345 self._match_date(parts[0]) and \ 346 self._match_time(parts[1]) 347 348 def _match_task(self, line): 349 parts = line.strip().split(' ') 350 return len(parts) > 2 and \ 351 parts[2] in EXEC_VERBS and \ 352 self._match_date(parts[0]) and \ 353 self._match_time(parts[1]) 354 355 def _match_start(self, line): 356 parts = line.strip().split(' ') 357 return len(parts) > 2 and \ 358 parts[2] == START and \ 359 self._match_date(parts[0]) and \ 360 self._match_time(parts[1]) and \ 361 SPREAD_FILE in parts[3] 362 363 def _match_operation(self, line): 364 parts = line.strip().split(' ') 365 return len(parts) > 2 and \ 366 parts[2] in OPERATIONS and \ 367 self._match_date(parts[0]) and \ 368 self._match_time(parts[1]) 369 370 def _match_result(self, line): 371 parts = line.strip().split(' ') 372 return len(parts) > 2 and \ 373 parts[2] in RESULTS and \ 374 self._match_date(parts[0]) and \ 375 self._match_time(parts[1]) 376 377 def _get_detail(self, results=False, other_limit=None): 378 """ 379 This function is used to get the piece of log which is after the 380 info lines (error, debug, warning). The detail could also include 381 a limit of lines to tail the log and show the last lines. 382 It returns a Detail object included all the lines. 383 """ 384 385 # If the first line matches with a regular line, this means the detail 386 # has no output and has to be discarded 387 line = self.lines[self.iter] 388 if self._match_task(line) or self._match_info(line) or \ 389 self._match_operation(line) or self._match_result(line): 390 return None 391 392 detail=[] 393 initial_iter = self.iter 394 while self.iter < len(self.lines): 395 line = self._next_line() 396 if self._match_task(line) or self._match_info(line) or \ 397 self._match_operation(line) or self._match_result(line): 398 # When the details is for results, then any match is ok to break 399 if results: 400 break 401 # When the details is no for results, then we need to check also 402 # the the last time was just a '.' 403 # The details for info (Error, Debug and Warning) always finish with 404 # ---- 405 # . 406 # 407 # As the iter is already pointing to the next line since 'line = self._next_line()' 408 # to access the previous line of the current one it is needed to do 'self.iter-2' 409 elif self.lines[self.iter-2].strip() == '.': 410 break 411 412 detail.append(line) 413 414 # When the details is for results, if the detail line does not start 415 # with " - ", then it is time to break 416 if results and not line.startswith(" - "): 417 break 418 else: 419 detail.append(line) 420 421 # We leave the iter in the last time in case the log has finished 422 if not self.iter == len(self.lines): 423 self.iter = self.iter - 1 424 if not other_limit: 425 other_limit = self.lines_limit 426 427 return Detail(other_limit, detail) 428 429 def _get_info(self, line): 430 """ 431 Get the Info object for the error, debug and warning lines including 432 the details for this 433 """ 434 parts = line.strip().split(' ') 435 if len(parts) < 3: 436 return None 437 date = parts[0] 438 time = parts[1] 439 info_type = parts[2] 440 441 verb = None 442 task = None 443 if info_type == WARN_TYPE: 444 info_type = info_type.split(':')[0] 445 verb = None 446 task = None 447 extra = ' '.join(parts[3:]) 448 elif info_type == ERROR_TYPE: 449 verb = parts[3] 450 task = parts[4] 451 extra = None 452 elif info_type == DEBUG_TYPE: 453 verb = None 454 task = parts[5] 455 extra = None 456 else: 457 print('log-parser: detail type not recognized: {}'.format(info_type)) 458 459 detail = self._get_detail(results=False) 460 return Info(info_type, verb, task, extra, date, time, detail, line) 461 462 def _get_result(self, line): 463 """ Get the Result object including the details for the result """ 464 parts = line.strip().split(' ') 465 if len(parts) < 3: 466 return None 467 date = parts[0] 468 time = parts[1] 469 result_type = parts[2] 470 level = parts[3].split(':')[0] 471 number = parts[-1] 472 473 stage = None 474 detail = None 475 if result_type == FAILED_TYPE: 476 if level in FAILED_LEVELS: 477 stage = parts[4].split(':')[0] 478 detail = self._get_detail(results=True, other_limit=-1) 479 480 return Result(result_type, level, stage, number.strip(), date, time, detail, 481 line) 482 483 def _get_action(self, line): 484 """ 485 Get the Action object for lines preparing, executing and restoring 486 """ 487 parts = line.strip().split(' ') 488 if len(parts) < 3: 489 return None 490 date = parts[0] 491 time = parts[1] 492 verb = parts[2] 493 task = parts[3] 494 return Action(verb, task.split('...')[0], date, time, line) 495 496 def _get_operation(self, line): 497 """ Get the Operation object for lines rebooting, allocating, etc """ 498 parts = line.strip().split(' ') 499 if len(parts) < 3: 500 return None 501 date = parts[0] 502 time = parts[1] 503 verb = parts[2] 504 task = None 505 extra = ' '.join(parts[3:]) 506 return Operation(verb, task, extra, date, time, line) 507 508 509 def _make_parser(): 510 # type: () -> argparse.ArgumentParser 511 parser = argparse.ArgumentParser( 512 description=""" 513 Parse the spread log and generates a file with a standardized output. It also 514 allows to filter the output by type and define the number of lines to show 515 for the error/debug/warning output. 516 """ 517 ) 518 parser.add_argument( 519 "-c", 520 "--cut", 521 type=int, 522 default=1000, 523 help="maximum number of lines for logs on errors and debug sections", 524 ) 525 parser.add_argument( 526 "-f", 527 "--format", 528 type=str, 529 default="json", 530 choices=['json'], 531 help="format for the output", 532 ) 533 parser.add_argument( 534 "-pd", 535 "--print-details", 536 type=str, 537 default=NONE, 538 choices=[ALL, ERROR, ERROR_DEBUG, OPERATION, ACTION, INFO, NONE], 539 help="Filter which info to print", 540 ) 541 parser.add_argument( 542 "-pr", 543 "--print-results", 544 type=str, 545 default=NONE, 546 choices=[ALL, FAILED, ABORTED, SUCCESSFUL, NONE], 547 help="Filter which results to print", 548 ) 549 parser.add_argument( 550 "-o", 551 "--output", 552 default="spread-results.json", 553 type=str, 554 help="output file to save the result", 555 ) 556 parser.add_argument( 557 "--store-setup", 558 action="store_true", 559 help="will save all the text before the spread run is started", 560 ) 561 parser.add_argument( 562 "logpath", metavar="PATH", help="path to the log to be analyzed" 563 ) 564 return parser 565 566 567 def main(): 568 # type: () -> None 569 parser = _make_parser() 570 args = parser.parse_args() 571 572 if len(args.logpath) == 0: 573 parser.print_usage() 574 parser.exit(0) 575 576 reader = LogReader(args.logpath, args.format, args.cut, args.store_setup) 577 if not reader.check_log_exists(): 578 print("log-parser: log not found") 579 sys.exit(1) 580 581 reader.read_spread_log() 582 583 if args.output: 584 reader.export_log(args.output) 585 586 reader.print_log(args.print_details, args.print_results) 587 588 589 if __name__ == "__main__": 590 main()