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