blob: 05ca9416479925f41ffe4acfaad35305f3cded8c [file] [log] [blame]
Rob Mohr97be9922019-10-15 11:32:43 -07001#!/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#
recipe-roller87641362021-09-17 09:03:38 +00009# /usr/bin/env: 'python3 -u': No such file or directory
Rob Mohr97be9922019-10-15 11:32:43 -070010#
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
recipe-roller87641362021-09-17 09:03:38 +000014''''exec python3 -u -- "$0" ${1+"$@"} # '''
Rob Mohr97be9922019-10-15 11:32:43 -070015"""Bootstrap script to clone and forward to the recipe engine tool.
16
17*******************
18** DO NOT MODIFY **
19*******************
20
recipe-roller9da75272021-05-11 17:52:46 +000021This is a copy of https://chromium.googlesource.com/infra/luci/recipes-py/+/main/recipes.py.
Rob Mohr97be9922019-10-15 11:32:43 -070022To fix bugs, fix in the googlesource repo then run the autoroller.
23"""
24
25# pylint: disable=wrong-import-position
26import argparse
recipe-roller67346332020-07-02 18:01:40 +000027import errno
Rob Mohr97be9922019-10-15 11:32:43 -070028import json
29import logging
30import os
recipe-rollerdf996192023-05-04 21:58:22 +000031import shutil
Rob Mohr97be9922019-10-15 11:32:43 -070032import subprocess
33import sys
Rob Mohr97be9922019-10-15 11:32:43 -070034
recipe-rollerdf996192023-05-04 21:58:22 +000035import urllib.parse as urlparse
Rob Mohr97be9922019-10-15 11:32:43 -070036
recipe-rollerdf996192023-05-04 21:58:22 +000037from collections import namedtuple
38
recipe-roller75c7d4f2020-12-10 00:01:16 +000039
Rob Mohr97be9922019-10-15 11:32:43 -070040# 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.
recipe-roller9da75272021-05-11 17:52:46 +000045# refs/heads/main)
Rob Mohr97be9922019-10-15 11:32:43 -070046EngineDep = namedtuple('EngineDep', 'url revision branch')
47
48
49class MalformedRecipesCfg(Exception):
recipe-roller4d9f0f12020-09-23 21:01:11 +000050
51 def __init__(self, msg, path):
recipe-rollerdf996192023-05-04 21:58:22 +000052 full_message = f'malformed recipes.cfg: {msg}: {path!r}'
53 super().__init__(full_message)
Rob Mohr97be9922019-10-15 11:32:43 -070054
55
56def parse(repo_root, recipes_cfg_path):
recipe-roller4d9f0f12020-09-23 21:01:11 +000057 """Parse is a lightweight a recipes.cfg file parser.
Rob Mohr97be9922019-10-15 11:32:43 -070058
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 """
recipe-rollerdf996192023-05-04 21:58:22 +000071 with open(recipes_cfg_path, 'r', encoding='utf-8') as file:
72 recipes_cfg = json.load(file)
Rob Mohr97be9922019-10-15 11:32:43 -070073
recipe-roller4d9f0f12020-09-23 21:01:11 +000074 try:
recipe-rollerdf996192023-05-04 21:58:22 +000075 if (version := recipes_cfg['api_version']) != 2:
76 raise MalformedRecipesCfg(f'unknown version {version}', recipes_cfg_path)
Rob Mohr97be9922019-10-15 11:32:43 -070077
recipe-roller4d9f0f12020-09-23 21:01:11 +000078 # If we're running ./recipes.py from the recipe_engine repo itself, then
79 # return None to signal that there's no EngineDep.
recipe-rollerdf996192023-05-04 21:58:22 +000080 repo_name = recipes_cfg.get('repo_name')
recipe-roller4d9f0f12020-09-23 21:01:11 +000081 if not repo_name:
recipe-rollerdf996192023-05-04 21:58:22 +000082 repo_name = recipes_cfg['project_id']
recipe-roller4d9f0f12020-09-23 21:01:11 +000083 if repo_name == 'recipe_engine':
recipe-rollerdf996192023-05-04 21:58:22 +000084 return None, recipes_cfg.get('recipes_path', '')
Rob Mohr97be9922019-10-15 11:32:43 -070085
recipe-rollerdf996192023-05-04 21:58:22 +000086 engine = recipes_cfg['deps']['recipe_engine']
Rob Mohr97be9922019-10-15 11:32:43 -070087
recipe-roller4d9f0f12020-09-23 21:01:11 +000088 if 'url' not in engine:
89 raise MalformedRecipesCfg(
90 'Required field "url" in dependency "recipe_engine" not found',
91 recipes_cfg_path)
Rob Mohr97be9922019-10-15 11:32:43 -070092
recipe-roller4d9f0f12020-09-23 21:01:11 +000093 engine.setdefault('revision', '')
recipe-roller9da75272021-05-11 17:52:46 +000094 engine.setdefault('branch', 'refs/heads/main')
recipe-rollerdf996192023-05-04 21:58:22 +000095 recipes_path = recipes_cfg.get('recipes_path', '')
Rob Mohr97be9922019-10-15 11:32:43 -070096
recipe-roller4d9f0f12020-09-23 21:01:11 +000097 # TODO(iannucci): only support absolute refs
98 if not engine['branch'].startswith('refs/'):
99 engine['branch'] = 'refs/heads/' + engine['branch']
Rob Mohr97be9922019-10-15 11:32:43 -0700100
recipe-roller4d9f0f12020-09-23 21:01:11 +0000101 recipes_path = os.path.join(repo_root,
102 recipes_path.replace('/', os.path.sep))
recipe-rollerdf996192023-05-04 21:58:22 +0000103 return EngineDep(**engine), recipes_path
recipe-roller4d9f0f12020-09-23 21:01:11 +0000104 except KeyError as ex:
recipe-rollerdf996192023-05-04 21:58:22 +0000105 raise MalformedRecipesCfg(str(ex), recipes_cfg_path) from ex
Rob Mohr97be9922019-10-15 11:32:43 -0700106
107
recipe-roller1e8c8b12020-09-14 15:01:29 +0000108IS_WIN = sys.platform.startswith(('win', 'cygwin'))
109
110_BAT = '.bat' if IS_WIN else ''
Rob Mohr97be9922019-10-15 11:32:43 -0700111GIT = 'git' + _BAT
Rob Mohr97be9922019-10-15 11:32:43 -0700112CIPD = 'cipd' + _BAT
recipe-roller0063a142022-07-25 14:49:19 +0000113REQUIRED_BINARIES = {GIT, CIPD}
Rob Mohr97be9922019-10-15 11:32:43 -0700114
115
116def _is_executable(path):
recipe-roller4d9f0f12020-09-23 21:01:11 +0000117 return os.path.isfile(path) and os.access(path, os.X_OK)
Rob Mohr97be9922019-10-15 11:32:43 -0700118
119
Rob Mohr97be9922019-10-15 11:32:43 -0700120def _subprocess_call(argv, **kwargs):
recipe-roller4d9f0f12020-09-23 21:01:11 +0000121 logging.info('Running %r', argv)
122 return subprocess.call(argv, **kwargs)
Rob Mohr97be9922019-10-15 11:32:43 -0700123
124
125def _git_check_call(argv, **kwargs):
recipe-roller4d9f0f12020-09-23 21:01:11 +0000126 argv = [GIT] + argv
127 logging.info('Running %r', argv)
128 subprocess.check_call(argv, **kwargs)
Rob Mohr97be9922019-10-15 11:32:43 -0700129
130
131def _git_output(argv, **kwargs):
recipe-roller4d9f0f12020-09-23 21:01:11 +0000132 argv = [GIT] + argv
133 logging.info('Running %r', argv)
134 return subprocess.check_output(argv, **kwargs)
Rob Mohr97be9922019-10-15 11:32:43 -0700135
136
137def parse_args(argv):
recipe-roller4d9f0f12020-09-23 21:01:11 +0000138 """This extracts a subset of the arguments that this bootstrap script cares
Rob Mohr97be9922019-10-15 11:32:43 -0700139 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 """
recipe-rollerdf996192023-05-04 21:58:22 +0000143 override_prefix = 'recipe_engine='
Rob Mohr97be9922019-10-15 11:32:43 -0700144
recipe-rollerdf996192023-05-04 21:58:22 +0000145 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)
recipe-roller4d9f0f12020-09-23 21:01:11 +0000149 for override in args.project_override or ():
recipe-rollerdf996192023-05-04 21:58:22 +0000150 if override.startswith(override_prefix):
151 return override[len(override_prefix):], args.package
recipe-roller4d9f0f12020-09-23 21:01:11 +0000152 return None, args.package
Rob Mohr97be9922019-10-15 11:32:43 -0700153
154
155def checkout_engine(engine_path, repo_root, recipes_cfg_path):
recipe-roller0063a142022-07-25 14:49:19 +0000156 """Checks out the recipe_engine repo pinned in recipes.cfg.
157
recipe-rollerdf996192023-05-04 21:58:22 +0000158 Returns the path to the recipe engine repo.
recipe-roller0063a142022-07-25 14:49:19 +0000159 """
recipe-rollerdf996192023-05-04 21:58:22 +0000160 dep, recipes_path = parse(repo_root, recipes_cfg_path)
recipe-roller4d9f0f12020-09-23 21:01:11 +0000161 if dep is None:
162 # we're running from the engine repo already!
recipe-rollerdf996192023-05-04 21:58:22 +0000163 return os.path.join(repo_root, recipes_path)
Rob Mohr97be9922019-10-15 11:32:43 -0700164
recipe-roller4d9f0f12020-09-23 21:01:11 +0000165 url = dep.url
Rob Mohr97be9922019-10-15 11:32:43 -0700166
recipe-roller4d9f0f12020-09-23 21:01:11 +0000167 if not engine_path and url.startswith('file://'):
168 engine_path = urlparse.urlparse(url).path
Rob Mohr97be9922019-10-15 11:32:43 -0700169
recipe-roller4d9f0f12020-09-23 21:01:11 +0000170 if not engine_path:
171 revision = dep.revision
172 branch = dep.branch
Rob Mohr97be9922019-10-15 11:32:43 -0700173
recipe-roller4d9f0f12020-09-23 21:01:11 +0000174 # Ensure that we have the recipe engine cloned.
175 engine_path = os.path.join(recipes_path, '.recipe_deps', 'recipe_engine')
Rob Mohr97be9922019-10-15 11:32:43 -0700176
recipe-rollerdf996192023-05-04 21:58:22 +0000177 # Note: this logic mirrors the logic in recipe_engine/fetch.py
178 _git_check_call(['init', engine_path], stdout=subprocess.DEVNULL)
Rob Mohr97be9922019-10-15 11:32:43 -0700179
recipe-rollerdf996192023-05-04 21:58:22 +0000180 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)
Rob Mohr97be9922019-10-15 11:32:43 -0700189
recipe-roller4d9f0f12020-09-23 21:01:11 +0000190 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:
recipe-roller767059d2020-10-01 15:01:55 +0000197 if exc.errno != errno.ENOENT:
recipe-rollerdf996192023-05-04 21:58:22 +0000198 logging.warning('failed to remove %r, reset will fail: %s',
199 index_lock, exc)
recipe-roller4d9f0f12020-09-23 21:01:11 +0000200 _git_check_call(['reset', '-q', '--hard', revision], cwd=engine_path)
Rob Mohr97be9922019-10-15 11:32:43 -0700201
recipe-roller4d9f0f12020-09-23 21:01:11 +0000202 # 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)
Rob Mohr97be9922019-10-15 11:32:43 -0700205
recipe-rollerdf996192023-05-04 21:58:22 +0000206 return engine_path
Rob Mohr97be9922019-10-15 11:32:43 -0700207
208
209def main():
recipe-roller4d9f0f12020-09-23 21:01:11 +0000210 for required_binary in REQUIRED_BINARIES:
recipe-rollerdf996192023-05-04 21:58:22 +0000211 if not shutil.which(required_binary):
212 return f'Required binary is not found on PATH: {required_binary}'
Rob Mohr97be9922019-10-15 11:32:43 -0700213
recipe-roller4d9f0f12020-09-23 21:01:11 +0000214 if '--verbose' in sys.argv:
215 logging.getLogger().setLevel(logging.INFO)
Rob Mohr97be9922019-10-15 11:32:43 -0700216
recipe-roller4d9f0f12020-09-23 21:01:11 +0000217 args = sys.argv[1:]
218 engine_override, recipes_cfg_path = parse_args(args)
Rob Mohr97be9922019-10-15 11:32:43 -0700219
recipe-roller4d9f0f12020-09-23 21:01:11 +0000220 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())
recipe-roller75c7d4f2020-12-10 00:01:16 +0000229 repo_root = os.path.abspath(repo_root).decode()
recipe-roller4d9f0f12020-09-23 21:01:11 +0000230 recipes_cfg_path = os.path.join(repo_root, 'infra', 'config', 'recipes.cfg')
231 args = ['--package', recipes_cfg_path] + args
recipe-rollerdf996192023-05-04 21:58:22 +0000232 engine_path = checkout_engine(engine_override, repo_root, recipes_cfg_path)
Rob Mohr97be9922019-10-15 11:32:43 -0700233
recipe-rollerdf996192023-05-04 21:58:22 +0000234 vpython = 'vpython3' + _BAT
235 if not shutil.which(vpython):
236 return f'Required binary is not found on PATH: {vpython}'
recipe-roller0063a142022-07-25 14:49:19 +0000237
recipe-roller89179bd2023-05-31 06:46:44 +0000238 # We unset PYTHONPATH here in case the user has conflicting environmental
239 # things we don't want them to leak through into the recipe_engine which
240 # manages its environment entirely via vpython.
241 #
242 # NOTE: os.unsetenv unhelpfully doesn't exist on all platforms until python3.9
243 # so we have to use the cutesy `pop` formulation below until then...
244 os.environ.pop("PYTHONPATH", None)
245
recipe-roller132b20b2023-06-21 21:41:17 +0000246 spec = '.vpython3'
247 debugger = os.environ.get('RECIPE_DEBUGGER', '')
248 if debugger.startswith('pycharm'):
249 spec = '.pycharm.vpython3'
250 elif debugger.startswith('vscode'):
251 spec = '.vscode.vpython3'
252
recipe-roller0063a142022-07-25 14:49:19 +0000253 argv = ([
recipe-roller132b20b2023-06-21 21:41:17 +0000254 vpython,
255 '-vpython-spec',
256 os.path.join(engine_path, spec),
257 '-u',
258 os.path.join(engine_path, 'recipe_engine', 'main.py'),
recipe-roller0063a142022-07-25 14:49:19 +0000259 ] + args)
recipe-roller1e8c8b12020-09-14 15:01:29 +0000260
recipe-roller4d9f0f12020-09-23 21:01:11 +0000261 if IS_WIN:
262 # No real 'exec' on windows; set these signals to ignore so that they
263 # propagate to our children but we still wait for the child process to quit.
recipe-rollerdf996192023-05-04 21:58:22 +0000264 import signal # pylint: disable=import-outside-toplevel
265 signal.signal(signal.SIGBREAK, signal.SIG_IGN) # pylint: disable=no-member
recipe-roller4d9f0f12020-09-23 21:01:11 +0000266 signal.signal(signal.SIGINT, signal.SIG_IGN)
267 signal.signal(signal.SIGTERM, signal.SIG_IGN)
268 return _subprocess_call(argv)
recipe-rollerdf996192023-05-04 21:58:22 +0000269
270 os.execvp(argv[0], argv)
271 return -1 # should never occur
Rob Mohr97be9922019-10-15 11:32:43 -0700272
273
274if __name__ == '__main__':
recipe-roller4d9f0f12020-09-23 21:01:11 +0000275 sys.exit(main())