| #!/usr/bin/env python |
| # Copyright 2016 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. |
| |
| import argparse |
| import collections |
| import contextlib |
| import copy |
| import itertools |
| import json |
| import logging |
| import os |
| import subprocess |
| import sys |
| import tempfile |
| import urllib |
| |
| |
| # Install Infra build environment. |
| BUILD_ROOT = os.path.dirname(os.path.dirname(os.path.dirname( |
| os.path.abspath(__file__)))) |
| sys.path.insert(0, os.path.join(BUILD_ROOT, 'scripts')) |
| |
| # calling common.env.Install() globally causes annotated_run recipes to |
| # crash because it installs the system python lib path(s) to PYTHONPATH, |
| # which don't mix well with the bundled python we use. Doing it this way |
| # will allow us to load what we need, without polluting PYTHONPATH permanently. |
| import common.env |
| with common.env.GetInfraPythonPath().Enter(): |
| import httplib2 |
| from oauth2client import gce |
| from oauth2client.client import GoogleCredentials |
| |
| from common import annotator |
| from common import chromium_utils |
| from slave import cipd |
| from slave import infra_platform |
| from slave import logdog_bootstrap |
| from slave import monitoring_utils |
| from slave import robust_tempdir |
| from slave import update_scripts |
| |
| |
| # BuildBot root directory: /b |
| BUILDBOT_ROOT = os.path.abspath(os.path.dirname(BUILD_ROOT)) |
| |
| |
| LOGGER = logging.getLogger('remote_run') |
| |
| |
| # Masters that are running "canary" run. |
| _CANARY_MASTERS = set(( |
| 'chromium.infra.cron', |
| 'internal.infra', |
| 'internal.infra.cron', |
| |
| # Volunteered by bpastene@ as a generically representative waterfall that is |
| # not a big deal if it breaks. |
| 'chromium.swarm', |
| )) |
| |
| # The name of the recipe engine CIPD package. |
| _RECIPES_PY_CIPD_PACKAGE = 'infra/recipes-py' |
| |
| # _CIPD_PINS is a mapping of master name to pinned CIPD package version to use |
| # for that master. |
| CipdPins = collections.namedtuple('CipdPins', ('recipes',)) |
| |
| # Stable CIPD pin set. |
| _STABLE_CIPD_PINS = CipdPins( |
| recipes='git_revision:6eaacf24833ebd2565177157d368da33780fced9') |
| |
| # Canary CIPD pin set. |
| _CANARY_CIPD_PINS = CipdPins( |
| recipes='git_revision:6eaacf24833ebd2565177157d368da33780fced9') |
| |
| |
| def _ensure_directory(*path): |
| path = os.path.join(*path) |
| if not os.path.isdir(path): |
| os.makedirs(path) |
| return path |
| |
| |
| def _try_cleanup(src, cleanup_dir): |
| """Stages the "src" file or directory for removal via "cleanup_dir". |
| |
| The "cleanup_dir" is a BuildBot-provided facility that deletes files |
| in between builds. Moving files into this directory completes instantly. As |
| opposed to deleting in "remote_run" or a lower layer, deletions via |
| "cleanup_dir" happen in between builds, meaning that the overhead and |
| expense aren't subject to I/O timeout and don't affect actual build times. |
| |
| NOTE: We rely on "cleanup_dir" to be on the same filesystem as the source |
| directories, which is the case for BuildBot builds. |
| """ |
| if not os.path.isdir(cleanup_dir): |
| LOGGER.warning('Cleanup directory does not exist: %r', cleanup_dir) |
| return |
| |
| base = os.path.basename(src) |
| target_dir = tempfile.mkdtemp(prefix=base, dir=cleanup_dir) |
| dst = os.path.join(target_dir, base) |
| |
| LOGGER.info('Moving file to cleanup directory %r => %r', src, dst) |
| try: |
| os.rename(src, dst) |
| except Exception: |
| LOGGER.exception('Failed to cleanup path %r', src) |
| |
| |
| def _get_is_canary(mastername): |
| return mastername in _CANARY_MASTERS |
| |
| |
| def find_python(): |
| if sys.platform == 'win32': |
| candidates = ['python.exe', 'python.bat'] |
| else: |
| candidates = ['python'] |
| |
| path_env = os.environ.get('PATH', '') |
| for base in path_env.split(os.pathsep): |
| for c in candidates: |
| path = os.path.join(base, c) |
| if os.path.isfile(path) and os.access(path, os.F_OK|os.X_OK): |
| return os.path.abspath(path) |
| |
| LOGGER.warning('Could not find Python in PATH: %r', path_env) |
| return sys.executable |
| |
| |
| def all_cipd_manifests(): |
| """Generator which yields all CIPD ensure manifests (canary, staging, prod). |
| |
| Each manifest is represented by a list of cipd.CipdPackage instances. |
| """ |
| # All CIPD packages are in top-level platform config. |
| for pins in (_STABLE_CIPD_PINS, _CANARY_CIPD_PINS): |
| yield [ |
| cipd.CipdPackage(name=_RECIPES_PY_CIPD_PACKAGE, version=pins.recipes), |
| ] |
| |
| |
| def set_recipe_runtime_properties(stream, args, properties): |
| """Looks at mastername/buildername in $properties and contacts |
| `luci-migration.appspot.com` to see if LUCI is prod for this builder yet. |
| |
| Args: |
| * stream - an StructuredAnnotationStream that this function will use to |
| present a 'LUCI Migration' step (and display generated migration |
| properties). |
| * args - the optparse/argparse result object containing the logdog_bootstrap |
| arguments. |
| * properties (in/out dictionary) - the recipe properties. |
| |
| Modifies `properties` to contain `$recipe_engine/runtime`. |
| """ |
| ret = { |
| 'is_experimental': False, |
| 'is_luci': False, |
| } |
| migration = {'status': 'error'} |
| try: |
| cred_path = logdog_bootstrap.get_config( |
| args, properties).service_account_path |
| master = properties['mastername'] |
| builder = properties['buildername'] |
| |
| # piggyback on logdog's service account; this needs to run everywhere that |
| # logdog does, and since it's migration-buildbot-only code, this seems like |
| # a reasonable compromise to make this rollout as quick as possible. |
| scopes = ['https://www.googleapis.com/auth/userinfo.email'] |
| if cred_path == logdog_bootstrap.GCE_CREDENTIALS: |
| cred = gce.AppAssertionCredentials(scopes) |
| else: |
| cred = GoogleCredentials.from_stream(cred_path).create_scoped(scopes) |
| |
| http = httplib2.Http() |
| cred.authorize(http) |
| url = ('https://luci-migration.appspot.com/masters/%s/builders/%s/' |
| '?format=json') |
| resp, body = http.request( |
| url % (urllib.quote(master), urllib.quote(builder))) |
| if resp.status == 200: |
| ret['is_experimental'] = json.loads(body)['luci_is_prod'] |
| migration['status'] = 'ok' |
| else: |
| migration['status'] = 'bad_status' |
| migration['code'] = resp.status |
| except Exception as ex: |
| migration['error'] = str(ex) |
| |
| properties['$recipe_engine/runtime'] = ret |
| properties['luci_migration'] = migration |
| with stream.step('LUCI Migration') as st: |
| st.set_build_property('$recipe_engine/runtime', json.dumps(ret)) |
| st.set_build_property('luci_migration', json.dumps(migration)) |
| |
| |
| def _call(cmd, **kwargs): |
| LOGGER.info('Executing command: %s', cmd) |
| exit_code = subprocess.call(cmd, **kwargs) |
| LOGGER.info('Command %s finished with exit code %d.', cmd, exit_code) |
| return exit_code |
| |
| |
| def _cleanup_old_layouts(buildbot_build_dir, cleanup_dir, cache_dir, |
| properties=None): |
| properties = properties or {} |
| |
| cleanup_paths = [ |
| # All remote_run instances no longer use "//build/slave/cache_dir" or |
| # "//build/slave/goma_cache", preferring to use cache directories |
| # instead (see "infra_paths" recipe module). |
| os.path.join(BUILD_ROOT, 'slave', 'cache_dir'), |
| os.path.join(BUILD_ROOT, 'slave', 'goma_cache'), |
| |
| # "bot_update" uses "build.dead" as a way to automatically purge |
| # directories. While this is being handled differently, existing |
| # "build.dead" directories currently exist under "[CACHE]/b/build.dead" |
| # due to the previous logic. |
| os.path.join(cache_dir, 'b', 'build.dead'), |
| ] |
| |
| # Make switching to remote_run easier: we do not use buildbot workdir, |
| # and it takes disk space leading to out of disk errors. |
| buildbot_workdir = properties.get('workdir') |
| if buildbot_workdir and os.path.isdir(buildbot_workdir): |
| try: |
| buildbot_workdir = os.path.realpath(buildbot_workdir) |
| buildbot_build_dir = os.path.realpath(buildbot_build_dir) |
| if buildbot_build_dir.startswith(buildbot_workdir): |
| buildbot_workdir = buildbot_build_dir |
| |
| # Buildbot workdir is usually used as current working directory, |
| # so do not remove it, but delete all of the contents. Deleting |
| # current working directory of a running process may cause |
| # confusing errors. |
| cleanup_paths.extend(os.path.join(buildbot_workdir, x) |
| for x in os.listdir(buildbot_workdir)) |
| except Exception: |
| # It's preferred that we keep going rather than fail the build |
| # on optional cleanup. |
| LOGGER.exception('Buildbot workdir cleanup failed: %s', buildbot_workdir) |
| |
| # If we have a 'git' cache directory from a previous Kitchen run, we |
| # should delete that in favor of the 'git_cache' cache directory. |
| cleanup_paths.append(os.path.join(cache_dir, 'git')) |
| |
| # We want to delete "<cache>/builder" from the Kitchen run. |
| cleanup_paths.append(os.path.join(cache_dir, 'builder')) |
| |
| cleanup_paths = [p for p in cleanup_paths if os.path.exists(p)] |
| if cleanup_paths: |
| LOGGER.info('Cleaning up %d old layout path(s)...', len(cleanup_paths)) |
| |
| # We remove files by moving them to the cleanup directory. This causes them |
| # to be deleted in between builds. |
| for path in cleanup_paths: |
| LOGGER.info('Removing path from previous layout: %r', path) |
| _try_cleanup(path, cleanup_dir) |
| |
| |
| def _exec_recipe(args, rt, stream, basedir, buildbot_build_dir, cleanup_dir, |
| cache_dir): |
| tempdir = rt.tempdir(basedir) |
| LOGGER.info('Using temporary directory: %r.', tempdir) |
| |
| build_data_dir = rt.tempdir(basedir) |
| LOGGER.info('Using build data directory: %r.', build_data_dir) |
| |
| # Construct our properties. |
| properties = copy.copy(args.factory_properties) |
| properties.update(args.build_properties) |
| |
| # Determine our CIPD pins. |
| # |
| # If a property includes "remote_run_canary", we will explicitly use canary |
| # pins. This can be done by manually submitting a build to the waterfall. |
| mastername = properties.get('mastername') |
| buildername = properties.get('buildername') |
| is_canary = (_get_is_canary(mastername) or |
| 'remote_run_canary' in properties or args.canary) |
| pins = _STABLE_CIPD_PINS if not is_canary else _CANARY_CIPD_PINS |
| |
| # Augment our input properties... |
| def set_property(key, value): |
| properties[key] = value |
| print '@@@SET_BUILD_PROPERTY@%s@%s@@@' % (key, json.dumps(value)) |
| |
| set_property('build_data_dir', build_data_dir) |
| set_property('builder_id', 'master.%s:%s' % (mastername, buildername)) |
| if 'buildnumber' in properties: |
| set_property( |
| 'build_id', |
| 'buildbot/{mastername}/{buildername}/{buildnumber}'.format( |
| **properties)) |
| |
| # path_config property defines what paths a build uses for checkout, git |
| # cache, goma cache, etc. |
| # |
| # TODO(dnj or phajdan): Rename "kitchen" path config to "remote_run_legacy". |
| # "kitchen" was never correct, and incorrectly implies that Kitchen is |
| # somehow involved int his path config. |
| properties['path_config'] = 'kitchen' |
| properties['bot_id'] = properties['slavename'] |
| |
| # Set our cleanup directory to be "build.dead" so that BuildBot manages it. |
| properties['$recipe_engine/path'] = { |
| 'cleanup_dir': cleanup_dir, |
| } |
| |
| set_recipe_runtime_properties(stream, args, properties) |
| |
| monitoring_utils.write_build_monitoring_event(build_data_dir, properties) |
| |
| # Ensure that the CIPD client and base tooling is installed and available on |
| # PATH. |
| from slave import cipd_bootstrap_v2 |
| track = cipd_bootstrap_v2.PROD |
| prefix_paths = cipd_bootstrap_v2.high_level_ensure_cipd_client( |
| basedir, mastername, track=track) |
| |
| properties['$recipe_engine/step'] = {'prefix_path': prefix_paths} |
| LOGGER.info('Using properties: %r', properties) |
| |
| # Cleanup data from old builds. |
| _cleanup_old_layouts( |
| buildbot_build_dir, cleanup_dir, cache_dir, |
| properties=properties) |
| |
| properties_file = os.path.join(tempdir, 'remote_run_properties.json') |
| with open(properties_file, 'w') as f: |
| json.dump(properties, f) |
| |
| cipd_path = os.path.join(basedir, '.remote_run_cipd') |
| |
| cipd_bootstrap_v2.install_cipd_packages(cipd_path, |
| cipd.CipdPackage(_RECIPES_PY_CIPD_PACKAGE, pins.recipes)) |
| |
| engine_args = [] |
| |
| recipe_result_path = os.path.join(tempdir, 'recipe_result.json') |
| recipe_cmd = [ |
| find_python(), |
| os.path.join(cipd_path, 'recipes.py'),] + engine_args + [ |
| '--verbose', |
| 'remote', |
| '--repository', args.repository, |
| '--workdir', os.path.join(tempdir, 'rw'), |
| ] |
| if args.revision: |
| recipe_cmd.extend(['--revision', args.revision]) |
| recipe_cmd.extend([ |
| '--',] + ( |
| engine_args) + [ |
| '--verbose', |
| 'run', |
| '--properties-file', properties_file, |
| '--workdir', os.path.join(tempdir, 'w'), |
| '--output-result-json', recipe_result_path, |
| properties.get('recipe') or args.recipe, |
| ]) |
| # If we bootstrap through logdog, the recipe command line gets written |
| # to a temporary file and does not appear in the log. |
| LOGGER.info('Recipe command line: %r', recipe_cmd) |
| |
| environ = os.environ.copy() |
| environ['VPYTHON_CLEAR_PYTHONPATH'] = '1' |
| |
| # Default to return code != 0 is for the benefit of buildbot, which uses |
| # return code to decide if a step failed or not. |
| recipe_return_code = 1 |
| try: |
| bs = logdog_bootstrap.bootstrap(rt, args, basedir, tempdir, properties, |
| recipe_cmd) |
| |
| LOGGER.info('Bootstrapping through LogDog: %s', bs.cmd) |
| bs.annotate(stream) |
| _ = _call(bs.cmd, env=environ) |
| recipe_return_code = bs.get_result() |
| except logdog_bootstrap.NotBootstrapped: |
| LOGGER.info('Not using LogDog. Invoking `recipes.py` directly.') |
| recipe_return_code = _call(recipe_cmd, env=environ) |
| |
| # Try to open recipe result JSON. Any failure will result in an exception |
| # and an infra failure. |
| with open(recipe_result_path) as f: |
| return_value = json.load(f) |
| |
| f = return_value.get('failure') |
| if f is not None and not f.get('step_failure'): |
| # The recipe engine used to return -1, which got interpreted as 255 |
| # by os.exit in python, since process exit codes are a single byte. |
| recipe_return_code = 255 |
| |
| return recipe_return_code |
| |
| |
| def _main_impl(argv, stream): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--repository', required=True, |
| help='URL of a git repository to fetch.') |
| parser.add_argument('--revision', |
| help='Git commit hash to check out.') |
| parser.add_argument('--recipe', required=True, |
| help='Name of the recipe to run') |
| parser.add_argument('--build-properties-gz', dest='build_properties', |
| type=chromium_utils.convert_gz_json_type, default={}, |
| help='Build properties in b64 gz JSON format') |
| parser.add_argument('--factory-properties-gz', dest='factory_properties', |
| type=chromium_utils.convert_gz_json_type, default={}, |
| help='factory properties in b64 gz JSON format') |
| parser.add_argument('--leak', action='store_true', |
| help='Refrain from cleaning up generated artifacts.') |
| parser.add_argument('--canary', action='store_true', |
| help='Force use of canary configuration.') |
| parser.add_argument('--verbose', action='store_true') |
| |
| group = parser.add_argument_group('LogDog Bootstrap') |
| logdog_bootstrap.add_arguments(group) |
| |
| args = parser.parse_args(argv[1:]) |
| |
| buildbot_build_dir = os.path.abspath(os.getcwd()) |
| try: |
| basedir = chromium_utils.FindUpward(buildbot_build_dir, 'b') |
| except chromium_utils.PathNotFound as e: |
| LOGGER.warn(e) |
| # Use base directory inside system temporary directory - if we use slave |
| # one (cwd), the paths get too long. Recipes which need different paths |
| # or persistent directories should do so explicitly. |
| basedir = tempfile.gettempdir() |
| |
| # "/b/c" as a cache directory. |
| cache_dir = os.path.join(BUILDBOT_ROOT, 'c') |
| |
| # BuildBot automatically purges "build.dead", and recipe engine uses this as |
| # its cleanup directory (see "infra_paths" recipe module). Make sure that it |
| # exists, and retain it so that we can use it to perform "annotated_run" to |
| # "remote_run" path cleanup. |
| cleanup_dir = os.path.join( |
| os.path.dirname(buildbot_build_dir), 'build.dead') |
| |
| # Cleanup system and temporary directories. |
| from slave import cleanup_temp |
| try: |
| # Note that this will delete "cleanup_dir", so we will need to |
| # recreate it afterwards. |
| cleanup_temp.Cleanup(b_dir=basedir) |
| except cleanup_temp.FullDriveException: |
| LOGGER.error('Buildslave disk is full! Please contact the trooper.') |
| |
| # Our cleanup failed because the disk is full! Do a best-effort cleanup in |
| # hopes that the next run, we can get farther than this. |
| _ensure_directory(cleanup_dir) |
| _cleanup_old_layouts(buildbot_build_dir, cleanup_dir, cache_dir) |
| raise |
| |
| # Ensure that "cleanup_dir" exists; our recipes expect this to be the case. |
| _ensure_directory(cleanup_dir) |
| |
| # Choose a tempdir prefix. If we have no active subdir, we will use a prefix |
| # of "rr". If we have an active subdir, we will use "rs/<subdir>". This way, |
| # there will be no tempdir collisions between combinations of the two |
| # sitautions. |
| active_subdir = chromium_utils.GetActiveSubdir() |
| if active_subdir: |
| prefix = os.path.join('rs', str(active_subdir)) |
| else: |
| prefix = 'rr' |
| |
| with robust_tempdir.RobustTempdir(prefix, leak=args.leak) as rt: |
| # Explicitly clean up possibly leaked temporary directories |
| # from previous runs. |
| rt.cleanup(basedir) |
| return _exec_recipe(args, rt, stream, basedir, buildbot_build_dir, |
| cleanup_dir, cache_dir) |
| |
| |
| def main(argv, stream, passthrough=False): |
| # We always want everything to be unbuffered so that we can see the logs on |
| # buildbot/logdog as soon as they're available. |
| os.environ['PYTHONUNBUFFERED'] = '1' |
| |
| exc_info = None |
| try: |
| return _main_impl(argv, stream) |
| except Exception: |
| exc_info = sys.exc_info() |
| |
| # Report on the "remote_run" execution. If an exception (infra failure) |
| # occurred, raise it so that the build and the step turn purple. |
| with stream.step('remote_run_result') as s: |
| if passthrough: |
| s.step_text('(passthrough)') |
| if exc_info is not None: |
| raise exc_info[0], exc_info[1], exc_info[2] |
| |
| |
| def shell_main(argv): |
| logging.basicConfig( |
| level=(logging.DEBUG if '--verbose' in argv else logging.INFO)) |
| |
| if update_scripts.update_scripts(): |
| # Re-execute with the updated remote_run.py. |
| return _call([sys.executable] + argv) |
| |
| stream = annotator.StructuredAnnotationStream() |
| return main(argv, stream) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(shell_main(sys.argv)) |