1#!/usr/bin/env python
2# Copyright (c) 2013 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Runs all the buildbot steps for ChromeDriver except for update/compile."""
7
8import bisect
9import csv
10import datetime
11import glob
12import json
13import optparse
14import os
15import platform as platform_module
16import re
17import shutil
18import StringIO
19import sys
20import tempfile
21import time
22import urllib2
23
24_THIS_DIR = os.path.abspath(os.path.dirname(__file__))
25GS_CHROMEDRIVER_BUCKET = 'gs://chromedriver'
26GS_CHROMEDRIVER_DATA_BUCKET = 'gs://chromedriver-data'
27GS_CHROMEDRIVER_RELEASE_URL = 'http://chromedriver.storage.googleapis.com'
28GS_CONTINUOUS_URL = GS_CHROMEDRIVER_DATA_BUCKET + '/continuous'
29GS_PREBUILTS_URL = GS_CHROMEDRIVER_DATA_BUCKET + '/prebuilts'
30GS_SERVER_LOGS_URL = GS_CHROMEDRIVER_DATA_BUCKET + '/server_logs'
31SERVER_LOGS_LINK = (
32    'http://chromedriver-data.storage.googleapis.com/server_logs')
33TEST_LOG_FORMAT = '%s_log.json'
34GS_GIT_LOG_URL = (
35    'https://chromium.googlesource.com/chromium/src/+/%s?format=json')
36GS_SEARCH_PATTERN = (
37    r'Cr-Commit-Position: refs/heads/master@{#(\d+)}')
38CR_REV_URL = 'https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/%s'
39
40SCRIPT_DIR = os.path.join(_THIS_DIR, os.pardir, os.pardir, os.pardir, os.pardir,
41                          os.pardir, os.pardir, os.pardir, 'scripts')
42SITE_CONFIG_DIR = os.path.join(_THIS_DIR, os.pardir, os.pardir, os.pardir,
43                               os.pardir, os.pardir, os.pardir, os.pardir,
44                               'site_config')
45sys.path.append(SCRIPT_DIR)
46sys.path.append(SITE_CONFIG_DIR)
47
48import archive
49import chrome_paths
50from slave import gsutil_download
51from slave import slave_utils
52import util
53
54
55def _ArchivePrebuilts(revision):
56  """Uploads the prebuilts to google storage."""
57  util.MarkBuildStepStart('archive prebuilts')
58  zip_path = util.Zip(os.path.join(chrome_paths.GetBuildDir(['chromedriver']),
59                                   'chromedriver'))
60  if slave_utils.GSUtilCopy(
61      zip_path,
62      '%s/%s' % (GS_PREBUILTS_URL, 'r%s.zip' % revision)):
63    util.MarkBuildStepError()
64
65
66def _ArchiveServerLogs():
67  """Uploads chromedriver server logs to google storage."""
68  util.MarkBuildStepStart('archive chromedriver server logs')
69  for server_log in glob.glob(os.path.join(tempfile.gettempdir(),
70                                           'chromedriver_*')):
71    base_name = os.path.basename(server_log)
72    util.AddLink(base_name, '%s/%s' % (SERVER_LOGS_LINK, base_name))
73    slave_utils.GSUtilCopy(
74        server_log,
75        '%s/%s' % (GS_SERVER_LOGS_URL, base_name),
76        mimetype='text/plain')
77
78
79def _DownloadPrebuilts():
80  """Downloads the most recent prebuilts from google storage."""
81  util.MarkBuildStepStart('Download latest chromedriver')
82
83  zip_path = os.path.join(util.MakeTempDir(), 'build.zip')
84  if gsutil_download.DownloadLatestFile(GS_PREBUILTS_URL,
85                                        GS_PREBUILTS_URL + '/r',
86                                        zip_path):
87    util.MarkBuildStepError()
88
89  util.Unzip(zip_path, chrome_paths.GetBuildDir(['host_forwarder']))
90
91
92def _GetTestResultsLog(platform):
93  """Gets the test results log for the given platform.
94
95  Args:
96    platform: The platform that the test results log is for.
97
98  Returns:
99    A dictionary where the keys are SVN revisions and the values are booleans
100    indicating whether the tests passed.
101  """
102  temp_log = tempfile.mkstemp()[1]
103  log_name = TEST_LOG_FORMAT % platform
104  result = slave_utils.GSUtilDownloadFile(
105      '%s/%s' % (GS_CHROMEDRIVER_DATA_BUCKET, log_name), temp_log)
106  if result:
107    return {}
108  with open(temp_log, 'rb') as log_file:
109    json_dict = json.load(log_file)
110  # Workaround for json encoding dictionary keys as strings.
111  return dict([(int(v[0]), v[1]) for v in json_dict.items()])
112
113
114def _PutTestResultsLog(platform, test_results_log):
115  """Pushes the given test results log to google storage."""
116  temp_dir = util.MakeTempDir()
117  log_name = TEST_LOG_FORMAT % platform
118  log_path = os.path.join(temp_dir, log_name)
119  with open(log_path, 'wb') as log_file:
120    json.dump(test_results_log, log_file)
121  if slave_utils.GSUtilCopyFile(log_path, GS_CHROMEDRIVER_DATA_BUCKET):
122    raise Exception('Failed to upload test results log to google storage')
123
124
125def _UpdateTestResultsLog(platform, revision, passed):
126  """Updates the test results log for the given platform.
127
128  Args:
129    platform: The platform name.
130    revision: The SVN revision number.
131    passed: Boolean indicating whether the tests passed at this revision.
132  """
133  assert isinstance(revision, int), 'The revision must be an integer'
134  log = _GetTestResultsLog(platform)
135  if len(log) > 500:
136    del log[min(log.keys())]
137  assert revision not in log, 'Results already exist for revision %s' % revision
138  log[revision] = bool(passed)
139  _PutTestResultsLog(platform, log)
140
141
142def _GetVersion():
143  """Get the current chromedriver version."""
144  with open(os.path.join(_THIS_DIR, 'VERSION'), 'r') as f:
145    return f.read().strip()
146
147
148def _GetSupportedChromeVersions():
149  """Get the minimum and maximum supported Chrome versions.
150
151  Returns:
152    A tuple of the form (min_version, max_version).
153  """
154  # Minimum supported Chrome version is embedded as:
155  # const int kMinimumSupportedChromeVersion[] = {27, 0, 1453, 0};
156  with open(os.path.join(_THIS_DIR, 'chrome', 'version.cc'), 'r') as f:
157    lines = f.readlines()
158    chrome_min_version_line = [
159        x for x in lines if 'kMinimumSupportedChromeVersion' in x]
160  chrome_min_version = chrome_min_version_line[0].split('{')[1].split(',')[0]
161  with open(os.path.join(chrome_paths.GetSrc(), 'chrome', 'VERSION'), 'r') as f:
162    chrome_max_version = f.readlines()[0].split('=')[1].strip()
163  return (chrome_min_version, chrome_max_version)
164
165
166def _RevisionState(test_results_log, revision):
167  """Check the state of tests at a given SVN revision.
168
169  Considers tests as having passed at a revision if they passed at revisons both
170  before and after.
171
172  Args:
173    test_results_log: A test results log dictionary from _GetTestResultsLog().
174    revision: The revision to check at.
175
176  Returns:
177    'passed', 'failed', or 'unknown'
178  """
179  assert isinstance(revision, int), 'The revision must be an integer'
180  keys = sorted(test_results_log.keys())
181  # Return passed if the exact revision passed on Android.
182  if revision in test_results_log:
183    return 'passed' if test_results_log[revision] else 'failed'
184  # Tests were not run on this exact revision on Android.
185  index = bisect.bisect_right(keys, revision)
186  # Tests have not yet run on Android at or above this revision.
187  if index == len(test_results_log):
188    return 'unknown'
189  # No log exists for any prior revision, assume it failed.
190  if index == 0:
191    return 'failed'
192  # Return passed if the revisions on both sides passed.
193  if test_results_log[keys[index]] and test_results_log[keys[index - 1]]:
194    return 'passed'
195  return 'failed'
196
197
198def _ArchiveGoodBuild(platform, revision):
199  """Archive chromedriver binary if the build is green."""
200  assert platform != 'android'
201  util.MarkBuildStepStart('archive build')
202
203  server_name = 'chromedriver'
204  if util.IsWindows():
205    server_name += '.exe'
206  zip_path = util.Zip(os.path.join(chrome_paths.GetBuildDir([server_name]),
207                                   server_name))
208
209  build_name = 'chromedriver_%s_%s.%s.zip' % (
210      platform, _GetVersion(), revision)
211  build_url = '%s/%s' % (GS_CONTINUOUS_URL, build_name)
212  if slave_utils.GSUtilCopy(zip_path, build_url):
213    util.MarkBuildStepError()
214
215  (latest_fd, latest_file) = tempfile.mkstemp()
216  os.write(latest_fd, build_name)
217  os.close(latest_fd)
218  latest_url = '%s/latest_%s' % (GS_CONTINUOUS_URL, platform)
219  if slave_utils.GSUtilCopy(latest_file, latest_url, mimetype='text/plain'):
220    util.MarkBuildStepError()
221  os.remove(latest_file)
222
223
224def _WasReleased(version, platform):
225  """Check if the specified version is released for the given platform."""
226  result, _ = slave_utils.GSUtilListBucket(
227      '%s/%s/chromedriver_%s.zip' % (GS_CHROMEDRIVER_BUCKET, version, platform),
228      [])
229  return result == 0
230
231
232def _MaybeRelease(platform):
233  """Releases a release candidate if conditions are right."""
234  assert platform != 'android'
235
236  version = _GetVersion()
237
238  # Check if the current version has already been released.
239  if _WasReleased(version, platform):
240    return
241
242  # Fetch Android test results.
243  android_test_results = _GetTestResultsLog('android')
244
245  # Fetch release candidates.
246  result, output = slave_utils.GSUtilListBucket(
247      '%s/chromedriver_%s_%s*' % (
248          GS_CONTINUOUS_URL, platform, version),
249      [])
250  assert result == 0 and output, 'No release candidates found'
251  candidate_pattern = re.compile(
252      r'.*/chromedriver_%s_%s\.(\d+)\.zip$' % (platform, version))
253  candidates = []
254  for line in output.strip().split('\n'):
255    result = candidate_pattern.match(line)
256    if not result:
257      print 'Ignored line "%s"' % line
258      continue
259    candidates.append(int(result.group(1)))
260
261  # Release the latest candidate build that passed Android, if any.
262  # In this way, if a hot fix is needed, we can delete the release from
263  # the chromedriver bucket instead of bumping up the release version number.
264  candidates.sort(reverse=True)
265  for revision in candidates:
266    android_result = _RevisionState(android_test_results, revision)
267    if android_result == 'failed':
268      print 'Android tests did not pass at revision', revision
269    elif android_result == 'passed':
270      print 'Android tests passed at revision', revision
271      candidate = 'chromedriver_%s_%s.%s.zip' % (platform, version, revision)
272      _Release('%s/%s' % (GS_CONTINUOUS_URL, candidate), version, platform)
273      break
274    else:
275      print 'Android tests have not run at a revision as recent as', revision
276
277
278def _Release(build, version, platform):
279  """Releases the given candidate build."""
280  release_name = 'chromedriver_%s.zip' % platform
281  util.MarkBuildStepStart('releasing %s' % release_name)
282  temp_dir = util.MakeTempDir()
283  slave_utils.GSUtilCopy(build, temp_dir)
284  zip_path = os.path.join(temp_dir, os.path.basename(build))
285
286  if util.IsLinux():
287    util.Unzip(zip_path, temp_dir)
288    server_path = os.path.join(temp_dir, 'chromedriver')
289    util.RunCommand(['strip', server_path])
290    zip_path = util.Zip(server_path)
291
292  slave_utils.GSUtilCopy(
293      zip_path, '%s/%s/%s' % (GS_CHROMEDRIVER_BUCKET, version, release_name))
294
295  _MaybeUploadReleaseNotes(version)
296  _MaybeUpdateLatestRelease(version)
297
298
299def _GetWebPageContent(url):
300  """Return the content of the web page specified by the given url."""
301  return urllib2.urlopen(url).read()
302
303
304def _MaybeUploadReleaseNotes(version):
305  """Upload release notes if conditions are right."""
306  # Check if the current version has already been released.
307  notes_name = 'notes.txt'
308  notes_url = '%s/%s/%s' % (GS_CHROMEDRIVER_BUCKET, version, notes_name)
309  prev_version = '.'.join([version.split('.')[0],
310                           str(int(version.split('.')[1]) - 1)])
311  prev_notes_url = '%s/%s/%s' % (
312      GS_CHROMEDRIVER_BUCKET, prev_version, notes_name)
313
314  result, _ = slave_utils.GSUtilListBucket(notes_url, [])
315  if result == 0:
316    return
317
318  fixed_issues = []
319  query = ('https://code.google.com/p/chromedriver/issues/csv?'
320           'q=status%3AToBeReleased&colspec=ID%20Summary')
321  issues = StringIO.StringIO(_GetWebPageContent(query).split('\n', 1)[1])
322  for issue in csv.reader(issues):
323    if not issue:
324      continue
325    issue_id = issue[0]
326    desc = issue[1]
327    labels = issue[2]
328    fixed_issues += ['Resolved issue %s: %s [%s]' % (issue_id, desc, labels)]
329
330  old_notes = ''
331  temp_notes_fname = tempfile.mkstemp()[1]
332  if not slave_utils.GSUtilDownloadFile(prev_notes_url, temp_notes_fname):
333    with open(temp_notes_fname, 'rb') as f:
334      old_notes = f.read()
335
336  new_notes = '----------ChromeDriver v%s (%s)----------\n%s\n%s\n\n%s' % (
337      version, datetime.date.today().isoformat(),
338      'Supports Chrome v%s-%s' % _GetSupportedChromeVersions(),
339      '\n'.join(fixed_issues),
340      old_notes)
341  with open(temp_notes_fname, 'w') as f:
342    f.write(new_notes)
343
344  if slave_utils.GSUtilCopy(temp_notes_fname, notes_url, mimetype='text/plain'):
345    util.MarkBuildStepError()
346
347
348def _MaybeUpdateLatestRelease(version):
349  """Update the file LATEST_RELEASE with the latest release version number."""
350  latest_release_fname = 'LATEST_RELEASE'
351  latest_release_url = '%s/%s' % (GS_CHROMEDRIVER_BUCKET, latest_release_fname)
352
353  # Check if LATEST_RELEASE is up-to-date.
354  latest_released_version = _GetWebPageContent(
355      '%s/%s' % (GS_CHROMEDRIVER_RELEASE_URL, latest_release_fname))
356  if version == latest_released_version:
357    return
358
359  # Check if chromedriver was released on all supported platforms.
360  supported_platforms = ['linux32', 'linux64', 'mac32', 'win32']
361  for platform in supported_platforms:
362    if not _WasReleased(version, platform):
363      return
364
365  util.MarkBuildStepStart('updating LATEST_RELEASE to %s' % version)
366
367  temp_latest_release_fname = tempfile.mkstemp()[1]
368  with open(temp_latest_release_fname, 'w') as f:
369    f.write(version)
370  if slave_utils.GSUtilCopy(temp_latest_release_fname, latest_release_url,
371                            mimetype='text/plain'):
372    util.MarkBuildStepError()
373
374
375def _CleanTmpDir():
376  tmp_dir = tempfile.gettempdir()
377  print 'cleaning temp directory:', tmp_dir
378  for file_name in os.listdir(tmp_dir):
379    file_path = os.path.join(tmp_dir, file_name)
380    if os.path.isdir(file_path):
381      print 'deleting sub-directory', file_path
382      shutil.rmtree(file_path, True)
383    if file_name.startswith('chromedriver_'):
384      print 'deleting file', file_path
385      os.remove(file_path)
386
387
388def _GetCommitPositionFromGitHash(snapshot_hashcode):
389  json_url = GS_GIT_LOG_URL % snapshot_hashcode
390  try:
391    response = urllib2.urlopen(json_url)
392  except urllib2.HTTPError as error:
393    util.PrintAndFlush('HTTP Error %d' % error.getcode())
394    return None
395  except urllib2.URLError as error:
396    util.PrintAndFlush('URL Error %s' % error.message)
397    return None
398  data = json.loads(response.read()[4:])
399  if 'message' in data:
400    message = data['message'].split('\n')
401    message = [line for line in message if line.strip()]
402    search_pattern = re.compile(GS_SEARCH_PATTERN)
403    result = search_pattern.search(message[len(message)-1])
404    if result:
405      return result.group(1)
406  util.PrintAndFlush('Failed to get svn revision number for %s' %
407                     snapshot_hashcode)
408  return None
409
410
411def _GetGitHashFromCommitPosition(commit_position):
412  json_url = CR_REV_URL % commit_position
413  try:
414    response = urllib2.urlopen(json_url)
415  except urllib2.HTTPError as error:
416    util.PrintAndFlush('HTTP Error %d' % error.getcode())
417    return None
418  except urllib2.URLError as error:
419    util.PrintAndFlush('URL Error %s' % error.message)
420    return None
421  data = json.loads(response.read())
422  if 'git_sha' in data:
423    return data['git_sha']
424  util.PrintAndFlush('Failed to get git hash for %s' % commit_position)
425  return None
426
427
428def _WaitForLatestSnapshot(revision):
429  util.MarkBuildStepStart('wait_for_snapshot')
430  def _IsRevisionNumber(revision):
431    if isinstance(revision, int):
432      return True
433    else:
434      return revision.isdigit()
435  while True:
436    snapshot_revision = archive.GetLatestSnapshotVersion()
437    if not _IsRevisionNumber(snapshot_revision):
438      snapshot_revision = _GetCommitPositionFromGitHash(snapshot_revision)
439    if revision is not None and snapshot_revision is not None:
440      if int(snapshot_revision) >= int(revision):
441        break
442      util.PrintAndFlush('Waiting for snapshot >= %s, found %s' %
443                         (revision, snapshot_revision))
444    time.sleep(60)
445  util.PrintAndFlush('Got snapshot revision %s' % snapshot_revision)
446
447
448def _AddToolsToPath(platform_name):
449  """Add some tools like Ant and Java to PATH for testing steps to use."""
450  paths = []
451  error_message = ''
452  if platform_name == 'win32':
453    paths = [
454        # Path to Ant and Java, required for the java acceptance tests.
455        'C:\\Program Files (x86)\\Java\\ant\\bin',
456        'C:\\Program Files (x86)\\Java\\jre\\bin',
457    ]
458    error_message = ('Java test steps will fail as expected and '
459                     'they can be ignored.\n'
460                     'Ant, Java or others might not be installed on bot.\n'
461                     'Please refer to page "WATERFALL" on site '
462                     'go/chromedriver.')
463  if paths:
464    util.MarkBuildStepStart('Add tools to PATH')
465    path_missing = False
466    for path in paths:
467      if not os.path.isdir(path) or not os.listdir(path):
468        print 'Directory "%s" is not found or empty.' % path
469        path_missing = True
470    if path_missing:
471      print error_message
472      util.MarkBuildStepError()
473      return
474    os.environ['PATH'] += os.pathsep + os.pathsep.join(paths)
475
476
477def main():
478  parser = optparse.OptionParser()
479  parser.add_option(
480      '', '--android-packages',
481      help=('Comma separated list of application package names, '
482            'if running tests on Android.'))
483  parser.add_option(
484      '-r', '--revision', help='Chromium revision')
485  parser.add_option(
486      '', '--update-log', action='store_true',
487      help='Update the test results log (only applicable to Android)')
488  options, _ = parser.parse_args()
489
490  bitness = '32'
491  if util.IsLinux() and platform_module.architecture()[0] == '64bit':
492    bitness = '64'
493  platform = '%s%s' % (util.GetPlatformName(), bitness)
494  if options.android_packages:
495    platform = 'android'
496
497  _CleanTmpDir()
498
499  if not options.revision:
500    commit_position = None
501  elif options.revision.isdigit():
502    commit_position = options.revision
503  else:
504    commit_position = _GetCommitPositionFromGitHash(options.revision)
505
506  if platform == 'android':
507    if not options.revision and options.update_log:
508      parser.error('Must supply a --revision with --update-log')
509    _DownloadPrebuilts()
510  else:
511    if not options.revision:
512      parser.error('Must supply a --revision')
513    if platform == 'linux64':
514      _ArchivePrebuilts(commit_position)
515    _WaitForLatestSnapshot(commit_position)
516
517  _AddToolsToPath(platform)
518
519  cmd = [
520      sys.executable,
521      os.path.join(_THIS_DIR, 'test', 'run_all_tests.py'),
522  ]
523  if platform == 'android':
524    cmd.append('--android-packages=' + options.android_packages)
525
526  passed = (util.RunCommand(cmd) == 0)
527
528  _ArchiveServerLogs()
529
530  if platform == 'android':
531    if options.update_log:
532      util.MarkBuildStepStart('update test result log')
533      _UpdateTestResultsLog(platform, commit_position, passed)
534  elif passed:
535    _ArchiveGoodBuild(platform, commit_position)
536    _MaybeRelease(platform)
537
538  if not passed:
539    # Make sure the build is red if there is some uncaught exception during
540    # running run_all_tests.py.
541    util.MarkBuildStepStart('run_all_tests.py')
542    util.MarkBuildStepError()
543
544  # Add a "cleanup" step so that errors from runtest.py or bb_device_steps.py
545  # (which invoke this script) are kept in thier own build step.
546  util.MarkBuildStepStart('cleanup')
547
548
549if __name__ == '__main__':
550  main()
551