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