blob: 498fa25e53b6c602e9a80060145bfe960a76b7c2 [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.
"""This package houses all subcommands for the recipe engine.
See implementation_details.md for the expectations of the modules in this
directory.
"""
import argparse
import errno
import logging
import os
import pkgutil
import sys
from builtins import zip
if sys.version_info >= (3, 5): # we're running python > 3.5
OS_WALK = os.walk
else:
# From vpython
from scandir import walk as OS_WALK
# pylint: disable=wrong-import-position
from .. import simple_cfg
from ..recipe_deps import RecipeDeps
from ..recipe_module_importer import RecipeModuleImporter
LOG = logging.getLogger(__name__)
# This incantation finds all loadable submodules of ourself. The
# `prefix=__name__` bit is so that these modules get loaded with the correct
# import names, i.e.
#
# recipe_engine.internal.commands.<submodule>
#
# If omitted, then these submodules can get double loaded as both:
#
# <submodule> AND
# recipe_engine.internal.commands.<submodule>
#
# Which can both interfere with the global python module namespace, and lead to
# strange errors when doing type assertions (since all data in these modules
# will be loaded under two different names; classes will fail isinstance checks
# even though they are "the same").
_COMMANDS = [
loader.find_module(module_name).load_module(module_name)
for (loader, module_name, _) in pkgutil.walk_packages(
__path__, prefix=__name__+'.')
if '.' not in module_name[len(__name__)+1:]
]
# Order all commands by an optional __cmd_priority__ field, and then by module
# name.
_COMMANDS.sort(
key=lambda mod: (
not hasattr(mod, '__cmd_priority__'), # modules defining priority first
getattr(mod, '__cmd_priority__', None), # actual priority
mod.__name__ # name
))
# Now actually set these commands on ourself so that 'mock' works correctly.
#
# This is needed to allow some tests (though it may be worth adjusting these
# tests later to not need this. Just delete this function and see which tests
# fail to find the dependencies on this behavior).
def _patch_our_attrs():
self = sys.modules[__name__]
self.__all__ = [mod.__name__[len(__name__)+1:] for mod in _COMMANDS]
for modname, mod in zip(self.__all__, _COMMANDS):
setattr(self, modname, mod)
_patch_our_attrs()
def _check_recipes_cfg_consistency(recipe_deps):
"""Checks all recipe.cfg files for the loaded recipe_deps and logs
inconsistent dependencies.
Args:
recipe_deps (RecipeDeps) - The loaded+fetched recipe deps
for the current run.
"""
actual = recipe_deps.main_repo.simple_cfg.deps
# For every repo we loaded
for repo_name in actual:
required_deps = recipe_deps.repos[repo_name].simple_cfg.deps
for req_repo_name, req_spec in required_deps.items():
# If this depends on something we didn't load, log an error.
if req_repo_name not in actual:
LOG.error(
'%r depends on %r, but your recipes.cfg is missing an '
'entry for this.', repo_name, req_repo_name)
continue
actual_spec = actual[req_repo_name]
if req_spec.revision == actual_spec.revision:
# They match, it's all good.
continue
LOG.warn(
'recipes.cfg depends on %r @ %s, but %r depends on version %s.',
req_repo_name, actual_spec.revision, repo_name, req_spec.revision)
def _cleanup_pyc(recipe_deps):
"""Removes any .pyc files from the recipes/recipe_module directories.
Args:
* recipe_deps (RecipeDeps) - The loaded recipe dependencies.
"""
for repo in recipe_deps.repos.values():
for to_walk in (repo.recipes_dir, repo.modules_dir):
for root, _dirs, files in OS_WALK(to_walk):
for fname in files:
if not fname.endswith('.pyc'):
continue
try:
to_clean = os.path.join(root, fname)
LOG.info('cleaning %r', to_clean)
os.unlink(to_clean)
except OSError as ex:
# If multiple things are cleaning pyc's at the same time this can
# race. Fortunately we only care that SOMETHING deleted the pyc :)
if ex.errno != errno.ENOENT:
raise
def _common_post_process(args):
logging.basicConfig()
if args.log_level:
logging.getLogger().setLevel(args.log_level)
elif args.verbose > 1:
logging.getLogger().setLevel(logging.DEBUG)
elif args.verbose > 0:
logging.getLogger().setLevel(logging.INFO)
if args.pid_file:
try:
with open(args.pid_file, 'w') as pid_file:
pid_file.write('%d\n' % os.getpid())
except Exception:
logging.exception("unable to write pidfile")
args.recipe_deps = RecipeDeps.create(
args.main_repo_path,
{} if args.minimal_recipe_deps else args.repo_override,
args.proto_override,
minimal_protoc = args.minimal_recipe_deps,
)
_check_recipes_cfg_consistency(args.recipe_deps)
# Allows:
# import RECIPE_MODULES.repo_name.module_name.submodule
sys.meta_path = [RecipeModuleImporter(args.recipe_deps)] + sys.meta_path
_cleanup_pyc(args.recipe_deps)
# Remove flags that subcommands shouldn't use; everything from this point on
# should ONLY use args.recipe_deps.
del args.main_repo_path
del args.verbose
del args.repo_override
del args.proto_override
def _add_common_args(parser):
class _RepoOverrideAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
tokens = values.split('=', 2)
if len(tokens) != 2:
raise ValueError('Override must have the form: repo=path')
repo_name, path = tokens
override_dict = getattr(namespace, self.dest)
if repo_name in override_dict:
raise ValueError('An override is already defined for [%s] (%s)' % (
repo_name, override_dict[repo_name]))
path = os.path.abspath(os.path.expanduser(path))
if not os.path.isdir(path):
raise ValueError('Override path [%s] is not a directory' % (path,))
override_dict[repo_name] = path
def _package_to_main_repo(value):
try:
value = os.path.abspath(value)
except Exception as ex: # pylint: disable=broad-except
parser.error(
'--package %r could not be converted to absolute path: %r' % (
value, ex,))
recipes_cfg_rel = simple_cfg.RECIPES_CFG_LOCATION_REL
if not value.endswith(recipes_cfg_rel):
parser.error('--package must end with %r.' % (recipes_cfg_rel,))
# We know the arg ends with 'infra/config/recipes.cfg', so chop those
# elements off the path to get the path to the recipe repo root.
for _ in simple_cfg.RECIPES_CFG_LOCATION_TOKS:
value = os.path.dirname(value)
return value
def _to_log_level(val):
mapping = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
}
if val not in mapping:
parser.error('expect --log-level to be one of %s; got %r.' % (
list(mapping), val))
return mapping[val]
# TODO(iannucci): change --package to --repo-path and avoid having recipes.py
# pass the path to the recipes.cfg. This is preferable because the location of
# recipes.cfg MUST be discovered for recipe dependencies; the RepoSpec
# protobuf doesn't specify where the recipes.cfg is in the dependency repos
# (nor can it, even if it was dynamic; this would be a nightmare to maintain,
# and the autoroller would need to discover it automatically ANYWAY. If we
# allow it to be relocatable, the engine needs to be able to discover it, in
# which case the minimal information is still 'repo root').
parser.add_argument(
'--package',
dest='main_repo_path', type=_package_to_main_repo, required=True,
help='Path to recipes.cfg of the recipe repo to operate on.')
group = parser.add_mutually_exclusive_group()
group.add_argument(
'--verbose', '-v', action='count', default=0,
help='Increase logging verbosity. Mutually exclusive with --log-level.')
group.add_argument(
'--log-level', dest='log_level', type=_to_log_level,
help=(
'Set the log level of this command. Accepted values: [DEBUG, INFO, '
'WARNING, ERROR]. Mutually exclusive with --verbose.'
))
parser.add_argument('-O', '--repo-override', metavar='ID=PATH',
action=_RepoOverrideAction, default={},
help='Override a repo repository path with a local one.')
parser.add_argument('--pid-file', metavar='PATH',
help=(
'Absolute path to a file where the engine should write its pid. '
'Path must be absolute and not exist.'))
def _proto_override_abspath(value):
try:
value = os.path.abspath(value)
except Exception as ex: # pylint: disable=broad-except
parser.error(
'--proto-override %r could not be converted to absolute path: %r' % (
value, ex,))
return value
# Override the location of the folder containing the `PB` module. This should
# only be used for recipe bundles, so we don't bother giving it a shortform
# option, and suppress the option's help to avoid confusing users.
parser.add_argument(
'--proto-override', type=_proto_override_abspath, help=argparse.SUPPRESS)
parser.set_defaults(
# minimal_recipe_deps is an option for some subcommands to let them:
# * ignore any recipe deps overrides
# * skip protoc compilation
# This is used for subcommands like manual_roll which explicitly operate
# on the recipes.cfg and nothing else.
minimal_recipe_deps=False,
postprocess_func=(lambda error, args: None),
)
def parse_and_run():
"""Parses the command line and runs the chosen subcommand.
Returns the command's return value (either int or None, suitable as input to
`os._exit`).
"""
parser = argparse.ArgumentParser(
description='Interact with the recipe system.')
_add_common_args(parser)
subp = parser.add_subparsers(dest='command', required=True)
for module in _COMMANDS:
description = module.__doc__
helplines = []
for line in description.splitlines():
line = line.strip()
if not line:
break
helplines.append(line)
module.add_arguments(subp.add_parser(
module.__name__.split('.')[-1], # use module's short name
formatter_class=argparse.RawDescriptionHelpFormatter,
help=' '.join(helplines),
description=description,
))
args = parser.parse_args()
_common_post_process(args)
args.postprocess_func(parser.error, args)
try:
return args.func(args)
finally:
# Any file-like objects directly attached to args need to be closed
# explicitly here because otherwise main.py will do an os._exit and any
# buffered data in these files could be lost.
for value in vars(args).values():
if hasattr(value, 'close'):
value.close()