• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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