1#!/usr/bin/env python 2 3# Copyright 2016 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 EXCEPT IN THE PER-REPO CONFIGURATION SECTION BELOW. ** 11*********************************************************************** 12 13This is a copy of https://github.com/luci/recipes-py/blob/master/doc/recipes.py. 14To fix bugs, fix in the github repo then copy it back to here and fix the 15PER-REPO CONFIGURATION section to look like this one. 16""" 17 18import os 19 20# IMPORTANT: Do not alter the header or footer line for the 21# "PER-REPO CONFIGURATION" section below, or the autoroller will not be able 22# to automatically update this file! All lines between the header and footer 23# lines will be retained verbatim by the autoroller. 24 25#### PER-REPO CONFIGURATION (editable) #### 26# The root of the repository relative to the directory of this file. 27REPO_ROOT = os.path.join(os.pardir, os.pardir) 28# The path of the recipes.cfg file relative to the root of the repository. 29RECIPES_CFG = os.path.join('infra', 'config', 'recipes.cfg') 30#### END PER-REPO CONFIGURATION #### 31 32BOOTSTRAP_VERSION = 1 33 34import argparse 35import ast 36import json 37import logging 38import random 39import re 40import subprocess 41import sys 42import time 43import traceback 44import urlparse 45 46from cStringIO import StringIO 47 48 49def parse(repo_root, recipes_cfg_path): 50 """Parse is transitional code which parses a recipes.cfg file as either jsonpb 51 or as textpb. 52 53 Args: 54 repo_root (str) - native path to the root of the repo we're trying to run 55 recipes for. 56 recipes_cfg_path (str) - native path to the recipes.cfg file to process. 57 58 Returns (as tuple): 59 engine_url (str) - the url to the engine repo we want to use. 60 engine_revision (str) - the git revision for the engine to get. 61 engine_subpath (str) - the subdirectory in the engine repo we should use to 62 find it's recipes.py entrypoint. This is here for completeness, but will 63 essentially always be empty. It would be used if the recipes-py repo was 64 merged as a subdirectory of some other repo and you depended on that 65 subdirectory. 66 recipes_path (str) - native path to where the recipes live inside of the 67 current repo (i.e. the folder containing `recipes/` and/or 68 `recipe_modules`) 69 """ 70 with open(recipes_cfg_path, 'rU') as fh: 71 data = fh.read() 72 73 if data.lstrip().startswith('{'): 74 pb = json.loads(data) 75 engine = next( 76 (d for d in pb['deps'] if d['project_id'] == 'recipe_engine'), None) 77 if engine is None: 78 raise ValueError('could not find recipe_engine dep in %r' 79 % recipes_cfg_path) 80 engine_url = engine['url'] 81 engine_revision = engine.get('revision', '') 82 engine_subpath = engine.get('path_override', '') 83 recipes_path = pb.get('recipes_path', '') 84 else: 85 def get_unique(things): 86 if len(things) == 1: 87 return things[0] 88 elif len(things) == 0: 89 raise ValueError("Expected to get one thing, but dinna get none.") 90 else: 91 logging.warn('Expected to get one thing, but got a bunch: %s\n%s' % 92 (things, traceback.format_stack())) 93 return things[0] 94 95 protobuf = parse_textpb(StringIO(data)) 96 97 engine_buf = get_unique([ 98 b for b in protobuf.get('deps', []) 99 if b.get('project_id') == ['recipe_engine'] ]) 100 engine_url = get_unique(engine_buf['url']) 101 engine_revision = get_unique(engine_buf.get('revision', [''])) 102 engine_subpath = (get_unique(engine_buf.get('path_override', [''])) 103 .replace('/', os.path.sep)) 104 recipes_path = get_unique(protobuf.get('recipes_path', [''])) 105 106 recipes_path = os.path.join(repo_root, recipes_path.replace('/', os.path.sep)) 107 return engine_url, engine_revision, engine_subpath, recipes_path 108 109 110def parse_textpb(fh): 111 """Parse the protobuf text format just well enough to understand recipes.cfg. 112 113 We don't use the protobuf library because we want to be as self-contained 114 as possible in this bootstrap, so it can be simply vendored into a client 115 repo. 116 117 We assume all fields are repeated since we don't have a proto spec to work 118 with. 119 120 Args: 121 fh: a filehandle containing the text format protobuf. 122 Returns: 123 A recursive dictionary of lists. 124 """ 125 def parse_atom(field, text): 126 if text == 'true': 127 return True 128 if text == 'false': 129 return False 130 131 # repo_type is an enum. Since it does not have quotes, 132 # invoking literal_eval would fail. 133 if field == 'repo_type': 134 return text 135 136 return ast.literal_eval(text) 137 138 ret = {} 139 for line in fh: 140 line = line.strip() 141 m = re.match(r'(\w+)\s*:\s*(.*)', line) 142 if m: 143 ret.setdefault(m.group(1), []).append(parse_atom(m.group(1), m.group(2))) 144 continue 145 146 m = re.match(r'(\w+)\s*{', line) 147 if m: 148 subparse = parse_textpb(fh) 149 ret.setdefault(m.group(1), []).append(subparse) 150 continue 151 152 if line == '}': 153 return ret 154 if line == '': 155 continue 156 157 raise ValueError('Could not understand line: <%s>' % line) 158 159 return ret 160 161 162def _subprocess_call(argv, **kwargs): 163 logging.info('Running %r', argv) 164 return subprocess.call(argv, **kwargs) 165 166 167def _subprocess_check_call(argv, **kwargs): 168 logging.info('Running %r', argv) 169 subprocess.check_call(argv, **kwargs) 170 171 172def find_engine_override(argv): 173 """Since the bootstrap process attempts to defer all logic to the recipes-py 174 repo, we need to be aware if the user is overriding the recipe_engine 175 dependency. This looks for and returns the overridden recipe_engine path, if 176 any, or None if the user didn't override it.""" 177 PREFIX = 'recipe_engine=' 178 179 p = argparse.ArgumentParser() 180 p.add_argument('-O', '--project-override', action='append') 181 args, _ = p.parse_known_args(argv) 182 for override in args.project_override or (): 183 if override.startswith(PREFIX): 184 return override[len(PREFIX):] 185 return None 186 187 188def main(): 189 if '--verbose' in sys.argv: 190 logging.getLogger().setLevel(logging.INFO) 191 192 if REPO_ROOT is None or RECIPES_CFG is None: 193 logging.error( 194 'In order to use this script, please copy it to your repo and ' 195 'replace the REPO_ROOT and RECIPES_CFG values with approprite paths.') 196 sys.exit(1) 197 198 if sys.platform.startswith(('win', 'cygwin')): 199 git = 'git.bat' 200 else: 201 git = 'git' 202 203 # Find the repository and config file to operate on. 204 repo_root = os.path.abspath( 205 os.path.join(os.path.dirname(__file__), REPO_ROOT)) 206 recipes_cfg_path = os.path.join(repo_root, RECIPES_CFG) 207 208 engine_url, engine_revision, engine_subpath, recipes_path = parse( 209 repo_root, recipes_cfg_path) 210 211 engine_path = find_engine_override(sys.argv[1:]) 212 if not engine_path and engine_url.startswith('file://'): 213 engine_path = urlparse.urlparse(engine_url).path 214 215 if not engine_path: 216 deps_path = os.path.join(recipes_path, '.recipe_deps') 217 # Ensure that we have the recipe engine cloned. 218 engine_root_path = os.path.join(deps_path, 'recipe_engine') 219 engine_path = os.path.join(engine_root_path, engine_subpath) 220 def ensure_engine(): 221 if not os.path.exists(deps_path): 222 os.makedirs(deps_path) 223 if not os.path.exists(engine_root_path): 224 _subprocess_check_call([git, 'clone', engine_url, engine_root_path]) 225 226 needs_fetch = _subprocess_call( 227 [git, 'rev-parse', '--verify', '%s^{commit}' % engine_revision], 228 cwd=engine_root_path, stdout=open(os.devnull, 'w')) 229 if needs_fetch: 230 _subprocess_check_call([git, 'fetch'], cwd=engine_root_path) 231 _subprocess_check_call( 232 [git, 'checkout', '--quiet', engine_revision], cwd=engine_root_path) 233 234 try: 235 ensure_engine() 236 except subprocess.CalledProcessError: 237 logging.exception('ensure_engine failed') 238 239 # Retry errors. 240 time.sleep(random.uniform(2,5)) 241 ensure_engine() 242 243 args = ['--package', recipes_cfg_path] + sys.argv[1:] 244 return _subprocess_call([ 245 sys.executable, '-u', 246 os.path.join(engine_path, 'recipes.py')] + args) 247 248if __name__ == '__main__': 249 sys.exit(main()) 250