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()