1#!/bin/sh 2# Copyright 2019 The LUCI Authors. All rights reserved. 3# Use of this source code is governed under the Apache License, Version 2.0 4# that can be found in the LICENSE file. 5 6# We want to run python in unbuffered mode; however shebangs on linux grab the 7# entire rest of the shebang line as a single argument, leading to errors like: 8# 9# /usr/bin/env: 'python -u': No such file or directory 10# 11# This little shell hack is a triple-quoted noop in python, but in sh it 12# evaluates to re-exec'ing this script in unbuffered mode. 13# pylint: disable=pointless-string-statement 14''''exec python -u -- "$0" ${1+"$@"} # ''' 15# vi: syntax=python 16"""Bootstrap script to clone and forward to the recipe engine tool. 17 18******************* 19** DO NOT MODIFY ** 20******************* 21 22This is a copy of https://chromium.googlesource.com/infra/luci/recipes-py/+/master/recipes.py. 23To fix bugs, fix in the googlesource repo then run the autoroller. 24""" 25 26# pylint: disable=wrong-import-position 27import argparse 28import json 29import logging 30import os 31import subprocess 32import sys 33import urlparse 34 35from collections import namedtuple 36 37# The dependency entry for the recipe_engine in the client repo's recipes.cfg 38# 39# url (str) - the url to the engine repo we want to use. 40# revision (str) - the git revision for the engine to get. 41# branch (str) - the branch to fetch for the engine as an absolute ref (e.g. 42# refs/heads/master) 43EngineDep = namedtuple('EngineDep', 'url revision branch') 44 45 46class MalformedRecipesCfg(Exception): 47 48 def __init__(self, msg, path): 49 full_message = 'malformed recipes.cfg: %s: %r' % (msg, path) 50 super(MalformedRecipesCfg, self).__init__(full_message) 51 52 53def parse(repo_root, recipes_cfg_path): 54 """Parse is a lightweight a recipes.cfg file parser. 55 56 Args: 57 repo_root (str) - native path to the root of the repo we're trying to run 58 recipes for. 59 recipes_cfg_path (str) - native path to the recipes.cfg file to process. 60 61 Returns (as tuple): 62 engine_dep (EngineDep|None): The recipe_engine dependency, or None, if the 63 current repo IS the recipe_engine. 64 recipes_path (str) - native path to where the recipes live inside of the 65 current repo (i.e. the folder containing `recipes/` and/or 66 `recipe_modules`) 67 """ 68 with open(recipes_cfg_path, 'rU') as fh: 69 pb = json.load(fh) 70 71 try: 72 if pb['api_version'] != 2: 73 raise MalformedRecipesCfg('unknown version %d' % pb['api_version'], 74 recipes_cfg_path) 75 76 # If we're running ./recipes.py from the recipe_engine repo itself, then 77 # return None to signal that there's no EngineDep. 78 repo_name = pb.get('repo_name') 79 if not repo_name: 80 repo_name = pb['project_id'] 81 if repo_name == 'recipe_engine': 82 return None, pb.get('recipes_path', '') 83 84 engine = pb['deps']['recipe_engine'] 85 86 if 'url' not in engine: 87 raise MalformedRecipesCfg( 88 'Required field "url" in dependency "recipe_engine" not found', 89 recipes_cfg_path) 90 91 engine.setdefault('revision', '') 92 engine.setdefault('branch', 'refs/heads/master') 93 recipes_path = pb.get('recipes_path', '') 94 95 # TODO(iannucci): only support absolute refs 96 if not engine['branch'].startswith('refs/'): 97 engine['branch'] = 'refs/heads/' + engine['branch'] 98 99 recipes_path = os.path.join(repo_root, 100 recipes_path.replace('/', os.path.sep)) 101 return EngineDep(**engine), recipes_path 102 except KeyError as ex: 103 raise MalformedRecipesCfg(ex.message, recipes_cfg_path) 104 105 106_BAT = '.bat' if sys.platform.startswith(('win', 'cygwin')) else '' 107GIT = 'git' + _BAT 108VPYTHON = 'vpython' + _BAT 109CIPD = 'cipd' + _BAT 110REQUIRED_BINARIES = {GIT, VPYTHON, CIPD} 111 112 113def _is_executable(path): 114 return os.path.isfile(path) and os.access(path, os.X_OK) 115 116 117# TODO: Use shutil.which once we switch to Python3. 118def _is_on_path(basename): 119 for path in os.environ['PATH'].split(os.pathsep): 120 full_path = os.path.join(path, basename) 121 if _is_executable(full_path): 122 return True 123 return False 124 125 126def _subprocess_call(argv, **kwargs): 127 logging.info('Running %r', argv) 128 return subprocess.call(argv, **kwargs) 129 130 131def _git_check_call(argv, **kwargs): 132 argv = [GIT] + argv 133 logging.info('Running %r', argv) 134 subprocess.check_call(argv, **kwargs) 135 136 137def _git_output(argv, **kwargs): 138 argv = [GIT] + argv 139 logging.info('Running %r', argv) 140 return subprocess.check_output(argv, **kwargs) 141 142 143def parse_args(argv): 144 """This extracts a subset of the arguments that this bootstrap script cares 145 about. Currently this consists of: 146 * an override for the recipe engine in the form of `-O recipe_engine=/path` 147 * the --package option. 148 """ 149 PREFIX = 'recipe_engine=' 150 151 p = argparse.ArgumentParser(add_help=False) 152 p.add_argument('-O', '--project-override', action='append') 153 p.add_argument('--package', type=os.path.abspath) 154 args, _ = p.parse_known_args(argv) 155 for override in args.project_override or (): 156 if override.startswith(PREFIX): 157 return override[len(PREFIX):], args.package 158 return None, args.package 159 160 161def checkout_engine(engine_path, repo_root, recipes_cfg_path): 162 dep, recipes_path = parse(repo_root, recipes_cfg_path) 163 if dep is None: 164 # we're running from the engine repo already! 165 return os.path.join(repo_root, recipes_path) 166 167 url = dep.url 168 169 if not engine_path and url.startswith('file://'): 170 engine_path = urlparse.urlparse(url).path 171 172 if not engine_path: 173 revision = dep.revision 174 branch = dep.branch 175 176 # Ensure that we have the recipe engine cloned. 177 engine_path = os.path.join(recipes_path, '.recipe_deps', 'recipe_engine') 178 179 with open(os.devnull, 'w') as NUL: 180 # Note: this logic mirrors the logic in recipe_engine/fetch.py 181 _git_check_call(['init', engine_path], stdout=NUL) 182 183 try: 184 _git_check_call(['rev-parse', '--verify', 185 '%s^{commit}' % revision], 186 cwd=engine_path, 187 stdout=NUL, 188 stderr=NUL) 189 except subprocess.CalledProcessError: 190 _git_check_call(['fetch', url, branch], 191 cwd=engine_path, 192 stdout=NUL, 193 stderr=NUL) 194 195 try: 196 _git_check_call(['diff', '--quiet', revision], cwd=engine_path) 197 except subprocess.CalledProcessError: 198 _git_check_call(['reset', '-q', '--hard', revision], cwd=engine_path) 199 200 # If the engine has refactored/moved modules we need to clean all .pyc files 201 # or things will get squirrely. 202 _git_check_call(['clean', '-qxf'], cwd=engine_path) 203 204 return engine_path 205 206 207def main(): 208 for required_binary in REQUIRED_BINARIES: 209 if not _is_on_path(required_binary): 210 return 'Required binary is not found on PATH: %s' % required_binary 211 212 if '--verbose' in sys.argv: 213 logging.getLogger().setLevel(logging.INFO) 214 215 args = sys.argv[1:] 216 engine_override, recipes_cfg_path = parse_args(args) 217 218 if recipes_cfg_path: 219 # calculate repo_root from recipes_cfg_path 220 repo_root = os.path.dirname( 221 os.path.dirname(os.path.dirname(recipes_cfg_path))) 222 else: 223 # find repo_root with git and calculate recipes_cfg_path 224 repo_root = ( 225 _git_output(['rev-parse', '--show-toplevel'], 226 cwd=os.path.abspath(os.path.dirname(__file__))).strip()) 227 repo_root = os.path.abspath(repo_root) 228 recipes_cfg_path = os.path.join(repo_root, 'infra', 'config', 'recipes.cfg') 229 args = ['--package', recipes_cfg_path] + args 230 231 engine_path = checkout_engine(engine_override, repo_root, recipes_cfg_path) 232 233 try: 234 return _subprocess_call( 235 [VPYTHON, '-u', 236 os.path.join(engine_path, 'recipe_engine', 'main.py')] + args) 237 except KeyboardInterrupt: 238 return 1 239 240 241if __name__ == '__main__': 242 sys.exit(main()) 243