github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/testgrid/conformance/upload_e2e.py (about)

     1  #!/usr/bin/env python
     2  
     3  # Copyright 2018 The Kubernetes Authors.
     4  #
     5  # Licensed under the Apache License, Version 2.0 (the "License");
     6  # you may not use this file except in compliance with the License.
     7  # You may obtain a copy of the License at
     8  #
     9  #     http://www.apache.org/licenses/LICENSE-2.0
    10  #
    11  # Unless required by applicable law or agreed to in writing, software
    12  # distributed under the License is distributed on an "AS IS" BASIS,
    13  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    14  # See the License for the specific language governing permissions and
    15  # limitations under the License.
    16  
    17  # This script parses conformance test output to produce testgrid entries
    18  #
    19  # Assumptions:
    20  # - there is one log file and one JUnit file (true for current conformance tests..)
    21  # - the log file contains ginkgo's output (true for kubetest and sonobuoy..)
    22  # - the ginkgo output will give us start / end time, and overall success
    23  #
    24  # - the start timestamp is suitable as a testgrid ID (unique, monotonic)
    25  #
    26  # - the test ran in the current year unless --year is provided
    27  # - the timestamps are parsed on a machine with the same local time (zone)
    28  #   settings as the machine that produced the logs
    29  #
    30  # The log file is the source of truth for metadata, the JUnit will be consumed
    31  # by testgrid / gubernator for individual test case results
    32  #
    33  # Usage: see README.md
    34  
    35  
    36  import re
    37  import sys
    38  import time
    39  import datetime
    40  import argparse
    41  import json
    42  import subprocess
    43  from os import path
    44  import glob
    45  import atexit
    46  
    47  
    48  # logs often contain ANSI escape sequences
    49  # https://stackoverflow.com/a/14693789
    50  ANSI_ESCAPE_RE = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]')
    51  
    52  
    53  # NOTE e2e logs use go's time.StampMilli ("Jan _2 15:04:05.000")
    54  # Example log line with a timestamp:
    55  # Jan 26 06:38:46.284: INFO: Running AfterSuite actions on all node
    56  # the third ':' separates the date from the rest
    57  E2E_LOG_TIMESTAMP_RE = re.compile(r'(... .\d \d\d:\d\d:\d\d\.\d\d\d):.*')
    58  
    59  # Ginkgo gives a line like the following at the end of successful runs:
    60  # SUCCESS! -- 123 Passed | 0 Failed | 0 Pending | 587 Skipped PASS
    61  # we match this to detect overall success
    62  E2E_LOG_SUCCESS_RE = re.compile(r'SUCCESS! -- .* PASS')
    63  
    64  
    65  def log_line_strip_escape_sequences(line):
    66      return ANSI_ESCAPE_RE.sub('', line)
    67  
    68  
    69  def parse_e2e_log_line_timestamp(line, year):
    70      """parses a ginkgo e2e log line for the leading timestamp
    71  
    72      Args:
    73          line (str) - the log line
    74          year (str) - 'YYYY'
    75  
    76      Returns:
    77          timestamp (datetime.datetime) or None
    78      """
    79      match = E2E_LOG_TIMESTAMP_RE.match(line)
    80      if match is None:
    81          return None
    82      # note we add year to the timestamp because the actual timestamp doesn't
    83      # contain one and we want a datetime object...
    84      timestamp = year+' '+match.group(1)
    85      return datetime.datetime.strptime(timestamp, '%Y %b %d %H:%M:%S.%f')
    86  
    87  
    88  def parse_e2e_logfile(file_handle, year):
    89      """parse e2e logfile at path, assuming the log is from year
    90  
    91      Args:
    92          file_handle (file): the log file, iterated for lines
    93          year (str): YYYY year logfile is from
    94  
    95      Returns:
    96          started (datetime.datetime), finished (datetime.datetime), passed (boolean)
    97      """
    98      started = finished = None
    99      passed = False
   100      for line in file_handle:
   101          line = log_line_strip_escape_sequences(line)
   102          # try to get a timestamp from each line, keep the first one as
   103          # start time, and the last one as finish time
   104          timestamp = parse_e2e_log_line_timestamp(line, year)
   105          if timestamp:
   106              if started:
   107                  finished = timestamp
   108              else:
   109                  started = timestamp
   110          # if we found the ginkgo success line then the run passed
   111          is_success = E2E_LOG_SUCCESS_RE.match(line)
   112          if is_success:
   113              passed = True
   114      return started, finished, passed
   115  
   116  
   117  def datetime_to_unix(datetime_obj):
   118      """convert datetime.datetime to unix timestamp"""
   119      return int(time.mktime(datetime_obj.timetuple()))
   120  
   121  
   122  def testgrid_started_json_contents(start_time):
   123      """returns the string contents of a testgrid started.json file
   124  
   125      Args:
   126          start_time (datetime.datetime)
   127  
   128      Returns:
   129          contents (str)
   130      """
   131      started = datetime_to_unix(start_time)
   132      return json.dumps({
   133          'timestamp': started
   134      })
   135  
   136  
   137  def testgrid_finished_json_contents(finish_time, passed, metadata):
   138      """returns the string contents of a testgrid finished.json file
   139  
   140      Args:
   141          finish_time (datetime.datetime)
   142          passed (bool)
   143          metadata (str)
   144  
   145      Returns:
   146          contents (str)
   147      """
   148      finished = datetime_to_unix(finish_time)
   149      result = 'SUCCESS' if passed else 'FAILURE'
   150      if metadata:
   151          testdata = json.loads(metadata)
   152          return json.dumps({
   153              'timestamp': finished,
   154              'result': result,
   155              'metadata': testdata
   156          })
   157      return json.dumps({
   158          'timestamp': finished,
   159          'result': result
   160      })
   161  
   162  
   163  def upload_string(gcs_path, text, dry):
   164      """Uploads text to gcs_path if dry is False, otherwise just prints"""
   165      cmd = ['gsutil', '-q', '-h', 'Content-Type:text/plain', 'cp', '-', gcs_path]
   166      print >>sys.stderr, 'Run:', cmd, 'stdin=%s' % text
   167      if dry:
   168          return
   169      proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
   170      proc.communicate(input=text)
   171      if proc.returncode != 0:
   172          raise RuntimeError(
   173              "Failed to upload with exit code: %d" % proc.returncode)
   174  
   175  
   176  def upload_file(gcs_path, file_path, dry):
   177      """Uploads file at file_path to gcs_path if dry is False, otherwise just prints"""
   178      cmd = ['gsutil', '-q', '-h', 'Content-Type:text/plain',
   179             'cp', file_path, gcs_path]
   180      print >>sys.stderr, 'Run:', cmd
   181      if dry:
   182          return
   183      proc = subprocess.Popen(cmd)
   184      proc.communicate()
   185      if proc.returncode != 0:
   186          raise RuntimeError(
   187              'Failed to upload with exit code: %d' % proc.returncode)
   188  
   189  
   190  def get_current_account(dry_run):
   191      """gets the currently active gcp account by shelling out to gcloud"""
   192      cmd = ['gcloud', 'auth', 'list',
   193             '--filter=status:ACTIVE', '--format=value(account)']
   194      print >>sys.stderr, 'Run:', cmd
   195      if dry_run:
   196          return ""
   197      return subprocess.check_output(cmd).strip('\n')
   198  
   199  
   200  def set_current_account(account, dry_run):
   201      """sets the currently active gcp account by shelling out to gcloud"""
   202      cmd = ['gcloud', 'config', 'set', 'core/account', account]
   203      print >>sys.stderr, 'Run:', cmd
   204      if dry_run:
   205          return
   206      return subprocess.check_call(cmd)
   207  
   208  
   209  def activate_service_account(key_file, dry_run):
   210      """activates a gcp service account by shelling out to gcloud"""
   211      cmd = ['gcloud', 'auth', 'activate-service-account', '--key-file='+key_file]
   212      print >>sys.stderr, 'Run:', cmd
   213      if dry_run:
   214          return
   215      subprocess.check_call(cmd)
   216  
   217  
   218  def revoke_current_account(dry_run):
   219      """logs out of the currently active gcp account by shelling out to gcloud"""
   220      cmd = ['gcloud', 'auth', 'revoke']
   221      print >>sys.stderr, 'Run:', cmd
   222      if dry_run:
   223          return
   224      return subprocess.check_call(cmd)
   225  
   226  
   227  def parse_args(cli_args=None):
   228      if cli_args is None:
   229          cli_args = sys.argv[1:]
   230      parser = argparse.ArgumentParser()
   231      parser.add_argument(
   232          '--bucket',
   233          help=('GCS bucket to upload the results to,'
   234                ' of the form \'gs://foo/bar\''),
   235          required=True,
   236      )
   237      parser.add_argument(
   238          '--year',
   239          help=('the year in which the log is from, defaults to the current year.'
   240                ' format: YYYY'),
   241          default=str(datetime.datetime.now().year),
   242      )
   243      parser.add_argument(
   244          '--junit',
   245          help='path or glob expression to the junit xml results file(s)',
   246          required=True,
   247      )
   248      parser.add_argument(
   249          '--log',
   250          help='path to the test log file, should contain the ginkgo output',
   251          required=True,
   252      )
   253      parser.add_argument(
   254          '--dry-run',
   255          help='if set, do not actually upload anything, only print actions',
   256          required=False,
   257          action='store_true',
   258      )
   259      parser.add_argument(
   260          '--metadata',
   261          help='dictionary of additional key-value pairs that can be displayed to the user.',
   262          required=False,
   263          default=str(),
   264      )
   265      parser.add_argument(
   266          '--key-file',
   267          help='path to GCP service account key file, which will be activated before '
   268          'uploading if provided, the account will be revoked and the active account reset '
   269          'on exit',
   270          required=False,
   271      )
   272      return parser.parse_args(args=cli_args)
   273  
   274  
   275  def main(cli_args):
   276      args = parse_args(cli_args)
   277  
   278      # optionally activate a service account with upload credentials
   279      if args.key_file:
   280          # grab the currently active account if any, and if there is one
   281          # register a handler to set it active again on exit
   282          current_account = get_current_account(args.dry_run)
   283          if current_account:
   284              atexit.register(
   285                  lambda: set_current_account(current_account, args.dry_run)
   286              )
   287          # login to the service account and register a handler to logout before exit
   288          # NOTE: atexit handlers are called in LIFO order
   289          activate_service_account(args.key_file, args.dry_run)
   290          atexit.register(lambda: revoke_current_account(args.dry_run))
   291  
   292      # find the matching junit files, there should be at least one for a useful
   293      # testgrid entry
   294      junits = glob.glob(args.junit)
   295      if not junits:
   296          print 'No matching JUnit files found!'
   297          sys.exit(-1)
   298  
   299      # parse the e2e.log for start time, finish time, and success
   300      with open(args.log) as file_handle:
   301          started, finished, passed = parse_e2e_logfile(file_handle, args.year)
   302  
   303      # convert parsed results to testgrid json metadata blobs
   304      started_json = testgrid_started_json_contents(started)
   305      finished_json = testgrid_finished_json_contents(
   306          finished, passed, args.metadata)
   307  
   308      # use timestamp as build ID
   309      gcs_dir = args.bucket + '/' + str(datetime_to_unix(started))
   310  
   311      # upload metadata, log, junit to testgrid
   312      print 'Uploading entry to: %s' % gcs_dir
   313      upload_string(gcs_dir+'/started.json', started_json, args.dry_run)
   314      upload_string(gcs_dir+'/finished.json', finished_json, args.dry_run)
   315      upload_file(gcs_dir+'/build-log.txt', args.log, args.dry_run)
   316      for junit_file in junits:
   317          upload_file(gcs_dir+'/artifacts/' +
   318                      path.basename(junit_file), junit_file, args.dry_run)
   319      print 'Done.'
   320  
   321  
   322  if __name__ == '__main__':
   323      main(sys.argv[1:])