blob: 962f5148ced9f246e3b6f0f1a7fee481b23938c9 [file] [log] [blame]
# Copyright 2019 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""Create a hermetically runnable recipe bundle (no git operations on startup).
Requires a git version >= 2.13+
This is done by packaging all the repos in RecipeDeps into a folder and then
generating an entrypoint script with `-O` override flags to this folder.
The general principle is that the input to bundle is:
* The loaded RecipeDeps (derived from the main repo's recipes.cfg file). This
is all the files on disk.
* files tagged with the `recipes` gitattribute value (see
`git help gitattributes`).
And the output is:
* a runnable folder for the named repo
Some things that we'd want to do to make this better:
* Allow this to fetch lazily from gitiles (no git clones)
* will be necessary to support HUGE repos like chromium/src
* Allow this to target a specific subset of runnable recipes (maybe)
* prune down to ONLY the modules which are required to run those particular
recipes.
* this may be more trouble than it's worth
Included files
By default, bundle will include all recipes/ and recipe_modules/ files in your
repo, plus the `recipes.cfg` file, and excluding all json expectation files.
Recipe bundle also uses the standard `gitattributes` mechanism for tagging files
within the repo, and will also include these files when generating the bundle.
In particular, it looks for files tagged with the string `recipes`. As an
example, you could put this in a `.gitattributes` file in your repo:
```
*.py recipes
*_test.py -recipes
```
That would include all .py files, but exclude all _test.py files. See the page
`git help gitattributes`
For more information on how gitattributes work.
"""
import logging
import ntpath
import os
import posixpath
import shutil
import stat
import sys
from collections import defaultdict
from gevent import subprocess
from ... import simple_cfg
from ...recipe_deps import RecipeRepo, RecipeDeps
LOGGER = logging.getLogger(__name__)
GIT = 'git.bat' if sys.platform == 'win32' else 'git'
def _check(obj, typ):
if not isinstance(obj, typ):
msg = '%r was %s, expected %s' % (obj, type(obj).__name__, typ.__name__)
LOGGER.debug(msg)
raise TypeError(msg)
def _prepare_destination(destination):
_check(destination, str)
destination = os.path.abspath(destination)
LOGGER.info('prepping destination %s', destination)
if os.path.exists(destination):
if os.listdir(destination):
LOGGER.fatal(
'directory %s exists and is non-empty! The directory must be empty or'
' missing to use it as a bundle target.', destination)
sys.exit(1)
else:
os.makedirs(destination)
return destination
def export_repo(repo, destination):
"""Copies all the recipe-relevant files for the repo to the given
destination.
Args:
* repo (RecipeRepo) - The repo to export.
* destination (str) - The absolute path we're exporting to (we'll export to
a subfolder equal to `repo.name`).
"""
_check(repo, RecipeRepo)
_check(destination, str)
bundle_dst = os.path.join(destination, repo.name)
reldir = repo.simple_cfg.recipes_path
if reldir:
reldir += '/'
args = [
GIT, '-C', repo.path, 'ls-files', '--',
':(attr:recipes)', # anything tagged for recipes
simple_cfg.RECIPES_CFG_LOCATION_REL, # always grab recipes.cfg
'%srecipes/**' % reldir, # all the recipes stuff
'%srecipe_modules/**' % reldir, # all the recipe_modules stuff
'%srecipe_proto/**.proto' % reldir, # all the protos in recipe_proto
# And exclude all the json expectations
':(exclude)%s**/*.expected/*.json' % reldir,
]
LOGGER.info('enumerating all recipe files: %r', args)
to_copy = subprocess.check_output(args).decode('utf-8').splitlines()
copy_map = defaultdict(set)
for i in to_copy:
if posixpath.sep != os.path.sep:
i = i.replace(posixpath.sep, os.path.sep)
while i:
i, tail = os.path.split(i)
base = os.path.join(repo.path, i) if i else repo.path
copy_map[base].add(tail)
def _ignore_fn(base, items):
return set(items) - copy_map[base]
shutil.copytree(repo.path, bundle_dst, ignore=_ignore_fn)
def export_protos(recipe_deps, destination):
"""Exports the compiled protos for the bundle.
The engine initialization process has already built all protos and made them
importable as `PB`. We rely on `PB.__path__` because this allows the
`--proto-override` flag to work.
Args:
* recipe_deps (RecipeDeps) - All loaded dependency repos.
* destination (str) - The absolute path we're exporting to (we'll export to
subfolder `_pb3/PB`).
"""
shutil.copytree(
os.path.join(recipe_deps.recipe_deps_path, '_pb3', 'PB'),
os.path.join(destination, '_pb3', 'PB'),
ignore=lambda _base, names: [n for n in names if n.endswith('.pyc')],
)
# pylint: disable=line-too-long
TEMPLATE3_SH = """#!/usr/bin/env bash
exec vpython3 -u ${BASH_SOURCE[0]%/*}/recipe_engine/recipe_engine/main.py
""".strip()
TEMPLATE3_BAT = """@echo off
call vpython3.bat -u "%~dp0\\recipe_engine\\recipe_engine\\main.py"
""".strip()
# pylint: enable=line-too-long
def prep_recipes_py(recipe_deps, destination):
"""Prepares `recipes` and `recipes.bat` entrypoint scripts at the given
destination.
Args:
* recipe_deps (RecipeDeps) - All loaded dependency repos.
* destination (str) - The absolute path we're writing the scripts at.
"""
_check(recipe_deps, RecipeDeps)
_check(destination, str)
overrides = list(recipe_deps.repos)
overrides.remove(recipe_deps.main_repo_id)
LOGGER.info('prepping recipes.py for %s', recipe_deps.main_repo.name)
sh_joiner = ' \\\n'
sh_header = TEMPLATE3_SH + sh_joiner + sh_joiner.join([
' --package %s' %
posixpath.join('${BASH_SOURCE[0]%%/*}/%s' % recipe_deps.main_repo.name, *
simple_cfg.RECIPES_CFG_LOCATION_TOKS),
' --proto-override ${BASH_SOURCE[0]%/*}/_pb3',
] + [
' -O %s=${BASH_SOURCE[0]%%/*}/%s' % (repo_name, repo_name)
for repo_name in overrides
]) + sh_joiner
bat_joiner = ' ^\n'
bat_header = TEMPLATE3_BAT + bat_joiner + bat_joiner.join([
' --package %s' %
ntpath.join('"%%~dp0\\%s"' % recipe_deps.main_repo.name, *simple_cfg
.RECIPES_CFG_LOCATION_TOKS), ' --proto-override "%~dp0\\_pb3"'
] + [' -O %s=%%~dp0/%s' % (repo_name, repo_name)
for repo_name in overrides]) + bat_joiner
files = {
"recipes": '"$@"',
"recipes.bat": '%*',
"luciexe": 'luciexe "$@"',
"luciexe.bat": 'luciexe %*',
}
for fname, runline in files.items():
isbat = fname.endswith('.bat')
header = bat_header if isbat else sh_header
newline = '\r\n' if isbat else '\n'
script = os.path.join(destination, fname)
with open(script, 'w', newline=newline, encoding='utf-8') as fil:
fil.write(header)
fil.write(f' {runline}\n')
if not isbat:
os.chmod(script, os.stat(script).st_mode | stat.S_IXUSR)
def main(args):
destination = _prepare_destination(args.destination)
for repo in args.recipe_deps.repos.values():
export_repo(repo, destination)
export_protos(args.recipe_deps, destination)
prep_recipes_py(args.recipe_deps, destination)
LOGGER.info('done!')