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: 'python3 -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 python3 -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/+/main/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 errno 29import json 30import logging 31import os 32import subprocess 33import sys 34 35from collections import namedtuple 36from io import open # pylint: disable=redefined-builtin 37 38try: 39 import urllib.parse as urlparse 40except ImportError: 41 import urlparse 42 43# The dependency entry for the recipe_engine in the client repo's recipes.cfg 44# 45# url (str) - the url to the engine repo we want to use. 46# revision (str) - the git revision for the engine to get. 47# branch (str) - the branch to fetch for the engine as an absolute ref (e.g. 48# refs/heads/main) 49EngineDep = namedtuple('EngineDep', 'url revision branch') 50 51 52class MalformedRecipesCfg(Exception): 53 54 def __init__(self, msg, path): 55 full_message = 'malformed recipes.cfg: %s: %r' % (msg, path) 56 super(MalformedRecipesCfg, self).__init__(full_message) 57 58 59def parse(repo_root, recipes_cfg_path): 60 """Parse is a lightweight a recipes.cfg file parser. 61 62 Args: 63 repo_root (str) - native path to the root of the repo we're trying to run 64 recipes for. 65 recipes_cfg_path (str) - native path to the recipes.cfg file to process. 66 67 Returns (as tuple): 68 engine_dep (EngineDep|None): The recipe_engine dependency, or None, if the 69 current repo IS the recipe_engine. 70 recipes_path (str) - native path to where the recipes live inside of the 71 current repo (i.e. the folder containing `recipes/` and/or 72 `recipe_modules`) 73 """ 74 with open(recipes_cfg_path, 'r') as fh: 75 pb = json.load(fh) 76 77 try: 78 if pb['api_version'] != 2: 79 raise MalformedRecipesCfg('unknown version %d' % pb['api_version'], 80 recipes_cfg_path) 81 82 # If we're running ./recipes.py from the recipe_engine repo itself, then 83 # return None to signal that there's no EngineDep. 84 repo_name = pb.get('repo_name') 85 if not repo_name: 86 repo_name = pb['project_id'] 87 if repo_name == 'recipe_engine': 88 return None, pb.get('recipes_path', '') 89 90 engine = pb['deps']['recipe_engine'] 91 92 if 'url' not in engine: 93 raise MalformedRecipesCfg( 94 'Required field "url" in dependency "recipe_engine" not found', 95 recipes_cfg_path) 96 97 engine.setdefault('revision', '') 98 engine.setdefault('branch', 'refs/heads/main') 99 recipes_path = pb.get('recipes_path', '') 100 101 # TODO(iannucci): only support absolute refs 102 if not engine['branch'].startswith('refs/'): 103 engine['branch'] = 'refs/heads/' + engine['branch'] 104 105 recipes_path = os.path.join(repo_root, 106 recipes_path.replace('/', os.path.sep)) 107 return EngineDep(**engine), recipes_path 108 except KeyError as ex: 109 raise MalformedRecipesCfg(str(ex), recipes_cfg_path) 110 111 112IS_WIN = sys.platform.startswith(('win', 'cygwin')) 113 114_BAT = '.bat' if IS_WIN else '' 115GIT = 'git' + _BAT 116VPYTHON = ('vpython' + 117 ('3' if os.getenv('RECIPES_USE_PY3') == 'true' else '') + 118 _BAT) 119CIPD = 'cipd' + _BAT 120REQUIRED_BINARIES = {GIT, VPYTHON, CIPD} 121 122 123def _is_executable(path): 124 return os.path.isfile(path) and os.access(path, os.X_OK) 125 126 127# TODO: Use shutil.which once we switch to Python3. 128def _is_on_path(basename): 129 for path in os.environ['PATH'].split(os.pathsep): 130 full_path = os.path.join(path, basename) 131 if _is_executable(full_path): 132 return True 133 return False 134 135 136def _subprocess_call(argv, **kwargs): 137 logging.info('Running %r', argv) 138 return subprocess.call(argv, **kwargs) 139 140 141def _git_check_call(argv, **kwargs): 142 argv = [GIT] + argv 143 logging.info('Running %r', argv) 144 subprocess.check_call(argv, **kwargs) 145 146 147def _git_output(argv, **kwargs): 148 argv = [GIT] + argv 149 logging.info('Running %r', argv) 150 return subprocess.check_output(argv, **kwargs) 151 152 153def parse_args(argv): 154 """This extracts a subset of the arguments that this bootstrap script cares 155 about. Currently this consists of: 156 * an override for the recipe engine in the form of `-O recipe_engine=/path` 157 * the --package option. 158 """ 159 PREFIX = 'recipe_engine=' 160 161 p = argparse.ArgumentParser(add_help=False) 162 p.add_argument('-O', '--project-override', action='append') 163 p.add_argument('--package', type=os.path.abspath) 164 args, _ = p.parse_known_args(argv) 165 for override in args.project_override or (): 166 if override.startswith(PREFIX): 167 return override[len(PREFIX):], args.package 168 return None, args.package 169 170 171def checkout_engine(engine_path, repo_root, recipes_cfg_path): 172 dep, recipes_path = parse(repo_root, recipes_cfg_path) 173 if dep is None: 174 # we're running from the engine repo already! 175 return os.path.join(repo_root, recipes_path) 176 177 url = dep.url 178 179 if not engine_path and url.startswith('file://'): 180 engine_path = urlparse.urlparse(url).path 181 182 if not engine_path: 183 revision = dep.revision 184 branch = dep.branch 185 186 # Ensure that we have the recipe engine cloned. 187 engine_path = os.path.join(recipes_path, '.recipe_deps', 'recipe_engine') 188 189 with open(os.devnull, 'w') as NUL: 190 # Note: this logic mirrors the logic in recipe_engine/fetch.py 191 _git_check_call(['init', engine_path], stdout=NUL) 192 193 try: 194 _git_check_call(['rev-parse', '--verify', 195 '%s^{commit}' % revision], 196 cwd=engine_path, 197 stdout=NUL, 198 stderr=NUL) 199 except subprocess.CalledProcessError: 200 _git_check_call(['fetch', url, branch], 201 cwd=engine_path, 202 stdout=NUL, 203 stderr=NUL) 204 205 try: 206 _git_check_call(['diff', '--quiet', revision], cwd=engine_path) 207 except subprocess.CalledProcessError: 208 index_lock = os.path.join(engine_path, '.git', 'index.lock') 209 try: 210 os.remove(index_lock) 211 except OSError as exc: 212 if exc.errno != errno.ENOENT: 213 logging.warn('failed to remove %r, reset will fail: %s', index_lock, 214 exc) 215 _git_check_call(['reset', '-q', '--hard', revision], cwd=engine_path) 216 217 # If the engine has refactored/moved modules we need to clean all .pyc files 218 # or things will get squirrely. 219 _git_check_call(['clean', '-qxf'], cwd=engine_path) 220 221 return engine_path 222 223 224def main(): 225 for required_binary in REQUIRED_BINARIES: 226 if not _is_on_path(required_binary): 227 return 'Required binary is not found on PATH: %s' % required_binary 228 229 if '--verbose' in sys.argv: 230 logging.getLogger().setLevel(logging.INFO) 231 232 args = sys.argv[1:] 233 engine_override, recipes_cfg_path = parse_args(args) 234 235 if recipes_cfg_path: 236 # calculate repo_root from recipes_cfg_path 237 repo_root = os.path.dirname( 238 os.path.dirname(os.path.dirname(recipes_cfg_path))) 239 else: 240 # find repo_root with git and calculate recipes_cfg_path 241 repo_root = ( 242 _git_output(['rev-parse', '--show-toplevel'], 243 cwd=os.path.abspath(os.path.dirname(__file__))).strip()) 244 repo_root = os.path.abspath(repo_root).decode() 245 recipes_cfg_path = os.path.join(repo_root, 'infra', 'config', 'recipes.cfg') 246 args = ['--package', recipes_cfg_path] + args 247 engine_path = checkout_engine(engine_override, repo_root, recipes_cfg_path) 248 249 argv = ( 250 [VPYTHON, '-u', 251 os.path.join(engine_path, 'recipe_engine', 'main.py')] + args) 252 253 if IS_WIN: 254 # No real 'exec' on windows; set these signals to ignore so that they 255 # propagate to our children but we still wait for the child process to quit. 256 import signal 257 signal.signal(signal.SIGBREAK, signal.SIG_IGN) 258 signal.signal(signal.SIGINT, signal.SIG_IGN) 259 signal.signal(signal.SIGTERM, signal.SIG_IGN) 260 return _subprocess_call(argv) 261 else: 262 os.execvp(argv[0], argv) 263 264 265if __name__ == '__main__': 266 sys.exit(main()) 267