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