| # Copyright 2015 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """API for the bisect recipe module. |
| |
| This API is meant to enable the bisect recipe to bisect any chromium-supported |
| platform for any test that can be run via buildbot, perf or otherwise. |
| """ |
| |
| import contextlib |
| |
| from recipe_engine import recipe_api |
| from . import bisector |
| from . import depot_config |
| from . import revision_state |
| from . import local_bisect |
| |
| BISECT_CONFIG_FILE = 'tools/auto_bisect/bisect.cfg' |
| |
| |
| class AutoBisectStagingApi(recipe_api.RecipeApi): |
| """A module for bisect specific functions.""" |
| |
| # Number of seconds to wait between polls for test results. |
| POLLING_INTERVAL = 60 |
| # GS bucket to use for communicating results and job state between bisector |
| # and tester bots. |
| BUCKET = 'chrome-perf' |
| # Directory within the above bucket to store results. |
| RESULTS_GS_DIR = 'bisect-results' |
| GS_RESULTS_URL = 'gs://%s/%s/' % (BUCKET, RESULTS_GS_DIR) |
| # Email to send on try jobs (for build requests) since git try will not |
| # necessarily rely on a local checkout for that information. |
| BOT_EMAIL = 'chrome_bot@chromium.org' |
| SERVICE_ACCOUNT = 'chromium-bisect' |
| |
| def __init__(self, *args, **kwargs): |
| super(AutoBisectStagingApi, self).__init__(*args, **kwargs) |
| self.override_poll_interval = None |
| self.bot_db = None |
| # Repo for triggering build jobs. |
| self.svn_repo_url = 'svn://svn.chromium.org/chrome-try/try-perf' |
| # The variable below are set and used for the internal bisects. |
| self.buildurl_gs_prefix = None |
| self.internal_bisect = False |
| self.builder_bot = None |
| self.full_deploy_script = None |
| |
| # Keep track of working directory (which contains the checkout). |
| # None means "default value". |
| self._working_dir = None |
| |
| @contextlib.contextmanager |
| def noop_context_manager(_api): |
| """This is a context manager that doesn't do anything. |
| |
| It is used for a placeholder for code that should be wrapped around ever |
| test execution of a bisect iteration or full build. Replace it with a |
| real context manager (one that accepts an api argument) to do something |
| useful. |
| """ |
| yield |
| |
| # build_context_mgr is run for each overall bisect run (i.e., once). |
| self.build_context_mgr = noop_context_manager |
| # test_context_mgr is run for each iteration of the bisect. |
| self.test_context_mgr = noop_context_manager |
| |
| @property |
| def working_dir(self): |
| if not self._working_dir: |
| self._working_dir = self.m.chromium_checkout.get_checkout_dir({}) |
| return self._working_dir or self.m.path['start_dir'] |
| |
| def create_bisector(self, bisect_config_dict, dummy_mode=False, **flags): |
| """Passes the api and the config dictionary to the Bisector constructor. |
| |
| For details about the keys in the bisect config dictionary go to: |
| http://chromium.org/developers/speed-infra/perf-try-bots-bisect-bots/config |
| |
| Args: |
| bisect_config_dict (dict): Contains the configuration for the bisect job. |
| dummy_mode (bool): In dummy mode we prevent the bisector for expanding |
| the revision range at construction, to avoid the need for lots of step |
| data when performing certain tests (such as results output). |
| |
| Returns: |
| An instance of bisector.Bisector. |
| """ |
| self.override_poll_interval = bisect_config_dict.get('poll_sleep') |
| return bisector.Bisector(self, bisect_config_dict, |
| revision_state.RevisionState, |
| init_revisions=not dummy_mode, **flags) |
| |
| def set_platform_gs_prefix(self, gs_url): |
| """Sets GS path for the platform.""" |
| self.buildurl_gs_prefix = gs_url # pragma: no cover |
| |
| def set_builder_bot(self, builder_bot): |
| """Sets builder name for building binaries.""" |
| self.builder_bot = builder_bot # pragma: no cover |
| |
| def set_internal(self): |
| """Sets bisector as internal only to process android-chrome.""" |
| self.internal_bisect = True # pragma: no cover |
| |
| def set_additional_depot_info(self, depot_info): |
| """Adds additional depot info to the global depot variables.""" |
| depot_config.add_addition_depot_into(depot_info) # pragma: no cover |
| |
| def set_deploy_script(self, path): # pragma: no cover |
| """Sets apk deployment script path for android-chrome.""" |
| self.full_deploy_script = path |
| |
| def set_svn_repo(self, svn_repo_url): # pragma: no cover |
| """Sets SVN repo url for triggering build jobs.""" |
| self.svn_repo_url = svn_repo_url |
| |
| def gsutil_file_exists(self, path, **kwargs): |
| """Returns True if a file exists at the given GS path.""" |
| try: |
| self.m.gsutil( ['ls', path], **kwargs) |
| except self.m.step.StepFailure: |
| # A step failure here simply means that the file does not exist, and |
| # should not be treated as an error. |
| self.m.step.active_result.presentation.status = self.m.step.SUCCESS |
| return False |
| return True |
| |
| def query_revision_info(self, revision): |
| """Gathers information on a particular revision. |
| |
| Args: |
| revision (str): A git commit hash. |
| |
| Returns: |
| A dict with the keys "author", "email", "date", "subject" and "body", |
| as output by fetch_revision_info.py. |
| """ |
| step_name = 'Reading culprit cl information.' |
| # Use gitiles to get commit information. |
| if revision.depot_name == 'android-chrome': # pragma: no cover |
| commit_url = depot_config.DEPOT_DEPS_NAME[revision.depot_name]['url'] |
| return self._commit_info(revision.commit_hash, commit_url, step_name) |
| result = self.m.python( |
| step_name, |
| self.resource('fetch_revision_info.py'), |
| [revision.commit_hash, revision.depot_name], |
| stdout=self.m.json.output(), |
| step_test_data=lambda: self._test_data['cl_info'][revision.commit_hash], |
| ) |
| return result.stdout |
| |
| def _commit_info(self, commit_hash, url, step_name=None): # pragma: no cover |
| """Fetches information about a given commit. |
| |
| Args: |
| commit_hash (str): A git commit hash. |
| url (str): The URL of a repository, e.g. |
| "https://chromium.googlesource.com/chromium/src". |
| step_name (str): Optional step name. |
| |
| Returns: |
| A dict with commit information. |
| """ |
| try: |
| step_name = step_name or 'gitiles commit info: %s' % commit_hash |
| commit_info = self.m.gitiles.commit_log( |
| url, commit_hash, step_name=step_name) |
| if commit_info: |
| message = commit_info.get('message', '').splitlines() |
| subject = message[0] |
| body = '\n'.join(message[1:]) |
| return { |
| 'author': commit_info['author']['name'], |
| 'email': commit_info['author']['email'], |
| 'subject': subject, |
| 'body': body, |
| 'date': commit_info['committer']['time'], |
| } |
| return None |
| except self.m.step.StepFailure: # pragma: no cover |
| self.surface_result('BAD_REV') |
| raise |
| |
| def run_local_test_run(self, test_config_params, |
| skip_download=False, **kwargs): |
| """Starts a test run on the same machine. |
| |
| This is for the merged director/tester flow. |
| """ |
| if self.m.platform.is_win: # pragma: no cover |
| self.m.chromium.taskkill() |
| |
| if skip_download: # pragma: no cover |
| update_step = None |
| else: |
| update_step = self._SyncRevisionToTest(test_config_params) |
| return self.start_test_run_for_bisect( |
| update_step, self.bot_db, test_config_params, |
| skip_download=skip_download, **kwargs) |
| |
| def ensure_checkout(self, *args, **kwargs): |
| kwargs.setdefault('disable_syntax_validation', True) |
| with self.m.context(cwd=(self.m.context.cwd or self.working_dir)): |
| return self.m.bot_update.ensure_checkout(*args, **kwargs) |
| |
| def _SyncRevisionToTest(self, test_config_params): |
| if not self.internal_bisect: |
| self.m.gclient.c.revisions.update( |
| test_config_params['deps_revision_overrides']) |
| return self.ensure_checkout( |
| root_solution_revision=test_config_params['revision']) |
| else: # pragma: no cover |
| return self._SyncRevisionsForAndroidChrome( |
| test_config_params['revision_ladder']) |
| |
| def _SyncRevisionsForAndroidChrome(self, revision_ladder): # pragma: no cover |
| """Syncs android-chrome and chromium repos to particular revision.""" |
| revisions = [] |
| for d, r in revision_ladder.iteritems(): |
| revisions.append('%s@%s' % (depot_config.DEPOT_DEPS_NAME[d]['src'], r)) |
| params = ['sync', '--verbose', '--nohooks', '--force', |
| '--delete_unversioned_trees'] |
| for revision in revisions: |
| params.extend(['--revision', revision]) |
| self.m.gclient('sync %s' % '-'.join(revisions), params) |
| return None |
| |
| def start_test_run_for_bisect(self, update_step, bot_db, test_config_params, |
| skip_download=False, **kwargs): |
| mastername = self.m.properties.get('mastername') |
| buildername = self.m.properties.get('buildername') |
| bot_config = bot_db.get_bot_config(mastername, buildername) |
| build_archive_url = test_config_params['parent_build_archive_url'] |
| if not skip_download: |
| if self.m.chromium.c.TARGET_PLATFORM == 'android': |
| # The best way to ensure the old build directory is not used is to |
| # remove it. |
| build_dir = self.m.chromium.c.build_dir.join( |
| self.m.chromium.c.build_config_fs) |
| self.m.file.rmtree('build directory', build_dir) |
| |
| # The way android builders on tryserver.chromium.perf are archived is |
| # different from builders on chromium.perf. In order to support both |
| # forms of archives, we added this temporary hack until builders are |
| # fixed. See http://crbug.com/535218. |
| zip_dir = self.m.path.join(self.m.path['checkout'], 'full-build-linux') |
| if self.m.path.exists(zip_dir): # pragma: no cover |
| self.m.file.rmtree('full-build-linux directory', zip_dir) |
| |
| gs_bucket = 'gs://%s/' % bot_config['bucket'] |
| archive_path = build_archive_url[len(gs_bucket):] |
| self.m.chromium_android.download_build( |
| bucket=bot_config['bucket'], |
| path=archive_path) |
| |
| # The way android builders on tryserver.chromium.perf are archived is |
| # different from builders on chromium.perf. In order to support both |
| # forms of archives, we added this temporary hack until builders are |
| # fixed. See http://crbug.com/535218. |
| if self.m.path.exists(zip_dir): # pragma: no cover |
| self.m.python.inline( |
| 'moving full-build-linux to out/Release', |
| """ |
| import shutil |
| import sys |
| shutil.move(sys.argv[1], sys.argv[2]) |
| """, |
| args=[zip_dir, build_dir]) |
| else: |
| self.m.chromium_tests.download_and_unzip_build( |
| mastername, buildername, update_step, bot_db, |
| build_archive_url=build_archive_url, |
| build_revision=test_config_params['parent_got_revision'], |
| override_bot_type='tester') |
| |
| tests = [ |
| self.m.chromium_tests.steps.BisectTestStaging( |
| test_config_params, **kwargs)] |
| |
| if not tests: # pragma: no cover |
| return |
| self.m.chromium_swarming.configure_swarming( |
| 'chromium', precommit=False, mastername=mastername) |
| test_runner = self.m.chromium_tests.create_test_runner(tests) |
| |
| with self.m.chromium_tests.wrap_chromium_tests(bot_config, tests): |
| with self.test_context_mgr(self.m): |
| if self.m.chromium.c.TARGET_PLATFORM == 'android' and not skip_download: |
| deploy_apks = [] |
| deploy_args = [] |
| if self.internal_bisect: # pragma: no cover |
| deploy_args = list(bot_config['deploy_args']) |
| deploy_apks = bot_config.get('deploy_apks') |
| if deploy_apks: |
| deploy_args.extend([ |
| '--apks', |
| ','.join(str(self.m.chromium_android.apk_path(apk)) |
| for apk in deploy_apks)]) |
| else: |
| if bot_config.get('webview'): |
| deploy_apks = ['SystemWebView.apk', 'SystemWebViewShell.apk'] |
| else: |
| deploy_apks = ['ChromePublic.apk'] |
| self.deploy_apk_on_device( |
| self.full_deploy_script, deploy_apks, deploy_args) |
| test_runner() |
| return tests[0].run_results |
| |
| def deploy_apk_on_device(self, deploy_script, deploy_apks, deploy_args): |
| """Installs apk on the android device.""" |
| if deploy_script: # pragma: no cover |
| self.full_deploy_on_device(deploy_script, deploy_args) |
| else: |
| for apk in deploy_apks: |
| self.m.chromium_android.adb_install_apk(apk) |
| |
| def full_deploy_on_device(self, deploy_script, args=None): # pragma: no cover |
| """Install android-chrome apk on device.""" |
| full_deploy_flags = [ |
| '-v', |
| '--blacklist-file', self.m.chromium_android.blacklist_file, |
| '--perfbot', |
| '--release', |
| '--adb-path', self.m.adb.adb_path(), |
| ] |
| if args: |
| full_deploy_flags += args |
| self.m.python( |
| 'Deploy on Device', |
| deploy_script, |
| full_deploy_flags, |
| infra_step=True, |
| env=self.m.chromium.get_env()) |
| |
| def start_try_job(self, api, update_step=None, bot_db=None, **kwargs): |
| """Starts a recipe bisect job, perf test run, or legacy bisect run. |
| |
| This function is an entry point for: |
| 1. A legacy bisect job run (in this case, there will be a patch |
| with a bisect config file). |
| 2. A recipe bisect job run (in this case, there will be a property |
| called bisect_config which contains the config parameters). |
| 3. A single test run for a recipe bisect job (there will be a |
| bisect_config property but it won't contain good/bad revisions). |
| 4. A perf try job run. |
| |
| Args: |
| api: The recipe api object. |
| update_step: Extra update_step to, used for some job types. |
| bot_db: A BotConfigAndTestDB object, used for some job types. |
| kwargs: Args to use only for legacy bisect. |
| """ |
| flags = {} |
| if kwargs.get('do_not_nest_wait_for_revision'): |
| flags['do_not_nest_wait_for_revision'] = kwargs.pop( |
| 'do_not_nest_wait_for_revision') |
| if bot_db is None: # pragma: no cover |
| self.bot_db = api.chromium_tests.create_bot_db_from_master_dict('', None) |
| else: |
| self.bot_db = bot_db |
| |
| with self.m.context(cwd=self.working_dir): |
| with self.m.context(cwd=self.m.path['checkout']): |
| affected_files = self.m.chromium_checkout.get_files_affected_by_patch() |
| with self.build_context_mgr(self.m): |
| if BISECT_CONFIG_FILE in affected_files: |
| api.step('***LEGACY BISECT HAS BEEN DEPRECATED***', []) |
| api.step.active_result.presentation.status = api.step.FAILURE |
| return |
| elif api.properties.get('bisect_config'): |
| # We can distinguish between a config for a full bisect vs a single |
| # test by checking for the presence of the good_revision key. |
| if api.properties.get('bisect_config').get('good_revision'): |
| api.step('***BISECT***', []) |
| with api.m.step.nest('Clearing results directory'): |
| results_dir = self.m.path['bisect_results'] |
| self.m.file.rmtree('old results directory', results_dir) |
| self.m.file.ensure_directory('new results directory', results_dir) |
| local_bisect.perform_bisect(self, **flags) |
| else: |
| api.step('***SINGLE TEST (deprecated)***', []) |
| self.start_test_run_for_bisect(update_step, self.bot_db, |
| api.properties) |
| else: |
| api.step('***PERF TRYJOB***', []) |
| if self.internal_bisect: # pragma: no cover |
| self.m.perf_try_staging.set_internal() |
| |
| self.m.perf_try_staging.start_perf_try_job( |
| api, affected_files, update_step, self.bot_db) |
| |
| def stat_compare(self, values_a, values_b, metric, |
| output_format='chartjson', **kwargs): |
| """Compares samples using catapult's statistics implementation. |
| |
| Args: |
| values_a, values_b: lists of paths to the json files containing the values |
| produced by the test. |
| metric: the name of the metric as sent by dashboard. |
| output_format: 'chartjson', 'valueset' or 'buildbot' |
| |
| Returns: |
| a dict containing 'result' which may be True, False or 'needMoreData', as |
| well as details about each sample ('debug_values', 'mean' and 'std_dev'). |
| |
| """ |
| values_a = self._make_copies(values_a) |
| values_b = self._make_copies(values_b) |
| |
| args = [','.join(values_a), |
| ','.join(values_b), |
| metric, |
| '--' + output_format] |
| |
| script = self.working_dir.join('catapult', 'tracing', 'bin', |
| 'compare_samples') |
| return self.m.python( |
| 'Compare samples', |
| script=script, |
| args=args, |
| stdout=self.m.json.output(), |
| **kwargs).stdout |
| |
| def _make_copies(self, files): |
| """Creates a copy of each file making sure to close the file handle. |
| |
| The reason why we need this is to allow compare_samples to open the files. |
| An issue with this is suspected to cause http://crbug.com/659908 |
| |
| Args: |
| files: A list of strings or Path objects. |
| |
| Returns: |
| A list of paths as strings. |
| """ |
| copy_paths = [] |
| for path in files: |
| new_path = str(path) + '.copy' |
| if not self._test_data.enabled: # pragma: no cover |
| with open(new_path, 'w') as f: |
| f.write(open(str(path)).read()) |
| copy_paths.append(new_path) |
| return copy_paths |