Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(940)

Unified Diff: scripts/slave/annotated_run.py

Issue 1501663002: annotated_run.py: Add LogDog bootstrapping. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/build
Patch Set: Comments. Created 4 years, 11 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
« no previous file with comments | « no previous file | scripts/slave/cipd.py » ('j') | no next file with comments »
Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
Index: scripts/slave/annotated_run.py
diff --git a/scripts/slave/annotated_run.py b/scripts/slave/annotated_run.py
index d5bfd101ce26de0ba26dc5d26f813ebde43d1a31..04261217440221470910cc1da1887e1e269b80d6 100755
--- a/scripts/slave/annotated_run.py
+++ b/scripts/slave/annotated_run.py
@@ -26,10 +26,43 @@ from common import annotator
from common import chromium_utils
from common import env
from common import master_cfg_utils
+from slave import gce
# Logging instance.
LOGGER = logging.getLogger('annotated_run')
+# Return codes used by Butler/Annotee to indicate their failure (as opposed to
+# a forwarded return code from the underlying process).
+LOGDOG_ERROR_RETURNCODES = (
+ # Butler runtime error.
+ 250,
+ # Annotee runtime error.
+ 251,
+)
+
+# Sentinel value that, if present in master config, matches all builders
+# underneath that master.
+WHITELIST_ALL = '*'
+
+# Whitelist of {master}=>[{builder}|WHITELIST_ALL] whitelisting specific masters
+# and builders for experimental LogDog/Annotee export.
+LOGDOG_WHITELIST_MASTER_BUILDERS = {
+}
+
+# Configuration for a Pub/Sub topic.
+PubSubConfig = collections.namedtuple('PubSubConfig', ('project', 'topic'))
+
+# LogDogPlatform is the set of platform-specific LogDog bootstrapping
+# configuration parameters.
+#
+# See _logdog_get_streamserver_uri for "streamserver" parameter details.
+LogDogPlatform = collections.namedtuple('LogDogPlatform', (
+ 'butler', 'annotee', 'credential_path', 'streamserver'))
+
+# A CIPD binary description, including the package name, version, and relative
+# path of the binary within the package.
+CipdBinary = collections.namedtuple('CipdBinary',
+ ('package', 'version', 'relpath'))
# RecipeRuntime will probe this for values.
# - First, (system, platform)
@@ -37,11 +70,25 @@ LOGGER = logging.getLogger('annotated_run')
# - Finally, (),
PLATFORM_CONFIG = {
# All systems.
- (): {},
+ (): {
+ 'logdog_pubsub': PubSubConfig(
+ project='luci-logdog',
+ topic='logs',
+ ),
+ },
# Linux
('Linux',): {
'run_cmd': ['/opt/infra-python/run.py'],
+ 'logdog_platform': LogDogPlatform(
+ butler=CipdBinary('infra/tools/luci/logdog/butler/linux-amd64',
+ 'latest', 'logdog_butler'),
+ annotee=CipdBinary('infra/tools/luci/logdog/annotee/linux-amd64',
+ 'latest', 'logdog_annotee'),
+ credential_path=(
+ '/creds/service_accounts/service-account-luci-logdog-pubsub.json'),
+ streamserver='unix',
+ ),
},
# Mac OSX
@@ -61,9 +108,71 @@ PLATFORM_CONFIG = {
# the recipe engine.
Config = collections.namedtuple('Config', (
'run_cmd',
+ 'logdog_pubsub',
+ 'logdog_platform',
))
+class Runtime(object):
+ """Runtime is the runtime context of the recipe execution.
+
+ It is a ContextManager that tracks generated files and cleans them up at
+ exit.
+ """
+
+ def __init__(self, leak=False):
+ self._tempdirs = []
+ self._leak = leak
+
+ def cleanup(self, path):
+ self._tempdirs.append(path)
+
+ def tempdir(self, base=None):
+ """Creates a temporary recipe-local working directory and yields it.
+
+ This creates a temporary directory for this annotation run. Directory
+ cleanup is appended to the supplied Runtime.
+
+ This creates two levels of directory:
+ <base>/.recipe_runtime
+ <base>/.recipe_runtime/tmpFOO
+
+ On termination, the entire "<base>/.recipe_runtime" directory is deleted,
+ removing the subdirectory created by this instance as well as cleaning up
+ any other temporary subdirectories leaked by previous executions.
+
+ Args:
+ rt (Runtime): Process-wide runtime.
+ base (str/None): The directory under which the tempdir should be created.
+ If None, the default temporary directory root will be used.
+ """
+ base = base or tempfile.gettempdir()
+ basedir = ensure_directory(base, '.recipe_runtime')
+ self.cleanup(basedir)
+ tdir = tempfile.mkdtemp(dir=basedir)
+ return tdir
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, _et, _ev, _tb):
+ self.close()
+
+ def close(self):
+ if self._leak:
+ LOGGER.warning('(--leak) Leaking temporary paths: %s', self._tempdirs)
+ else:
+ for path in reversed(self._tempdirs):
+ try:
+ if os.path.isdir(path):
+ LOGGER.debug('Cleaning up temporary directory [%s].', path)
+ chromium_utils.RemoveDirectory(path)
+ except BaseException:
+ LOGGER.exception('Failed to clean up temporary directory [%s].',
+ path)
+ del(self._tempdirs[:])
+
+
def get_config():
"""Returns (Config): The constructed Config object.
@@ -83,6 +192,8 @@ def get_config():
# Construct runtime configuration.
return Config(
run_cmd=platform_config.get('run_cmd'),
+ logdog_pubsub=platform_config.get('logdog_pubsub'),
+ logdog_platform=platform_config.get('logdog_platform'),
)
@@ -93,6 +204,29 @@ def ensure_directory(*path):
return path
+def _logdog_get_streamserver_uri(rt, typ):
+ """Returns (str): The Butler StreamServer URI.
+
+ Args:
+ rt (Runtime): Process-wide runtime.
+ typ (str): The type of URI to generate. One of: ['unix'].
+ Raises:
+ LogDogBootstrapError: if |typ| is not a known type.
+ """
+ if typ == 'unix':
+ # We have to use a custom temporary directory here. This is due to the path
+ # length limitation on UNIX domain sockets, which is generally 104-108
+ # characters. We can't make that assumption about our standard recipe
+ # temporary directory.
+ sockdir = rt.tempdir()
+ uri = 'unix:%s' % (os.path.join(sockdir, 'butler.sock'),)
+ if len(uri) > 104:
+ raise LogDogBootstrapError('Generated URI exceeds UNIX domain socket '
+ 'name size: %s' % (uri,))
+ return uri
+ raise LogDogBootstrapError('No streamserver URI generator.')
+
+
def _run_command(cmd, **kwargs):
if kwargs.pop('dry_run', False):
LOGGER.info('(Dry Run) Would have executed command: %s', cmd)
@@ -115,33 +249,255 @@ def _check_command(cmd, **kwargs):
return stdout
-@contextlib.contextmanager
-def recipe_tempdir(root=None, leak=False):
- """Creates a temporary recipe-local working directory and yields it.
+class LogDogNotBootstrapped(Exception):
+ pass
+
+
+class LogDogBootstrapError(Exception):
+ pass
+
+
+def ensure_directory(*path):
+ path = os.path.join(*path)
+ if not os.path.isdir(path):
+ os.makedirs(path)
+ return path
+
+
+def _get_service_account_json(opts, credential_path):
+ """Returns (str/None): If specified, the path to the service account JSON.
+
+ This method probes the local environment and returns a (possibly empty) list
+ of arguments to add to the Butler command line for authentication.
+
+ If we're running on a GCE instance, no arguments will be returned, as GCE
+ service account is implicitly authenticated. If we're running on Baremetal,
+ a path to those credentials will be returned.
+
+ Args:
+ rt (RecipeRuntime): The runtime environment.
+ Raises:
+ |LogDogBootstrapError| if no credentials could be found.
+ """
+ path = opts.logdog_service_account_json
+ if path:
+ return path
+
+ if gce.Authenticator.is_gce():
+ LOGGER.info('Running on GCE. No credentials necessary.')
+ return None
+
+ if os.path.isfile(credential_path):
+ return credential_path
+
+ raise LogDogBootstrapError('Could not find service account credentials. '
+ 'Tried: %s' % (credential_path,))
+
- This creates a temporary directory for this annotation run that is
- automatically cleaned up. It returns the directory.
+def _logdog_install_cipd(path, *packages):
+ """Returns (list): The paths to the binaries in each of the packages.
+
+ This method bootstraps CIPD in "path", installing the packages specified
+ by "packages" and returning the paths to their binaries.
Args:
- root (str/None): If not None, the root directory. Otherwise, |os.cwd| will
- be used.
- leak (bool): If true, don't clean up the temporary directory on exit.
+ path (str): The CIPD installation root.
+ packages (CipdBinary): The set of CIPD binary packages to install.
"""
- basedir = ensure_directory((root or os.getcwd()), '.recipe_runtime')
+ verbosity = 0
+ level = logging.getLogger().level
+ if level <= logging.INFO:
+ verbosity += 1
+ if level <= logging.DEBUG:
+ verbosity += 1
+
+ packages_path = os.path.join(path, 'packages.json')
+ pmap = {}
+ cmd = [
+ sys.executable,
+ os.path.join(env.Build, 'scripts', 'slave', 'cipd.py'),
+ '--dest-directory', path,
+ '--json-output', packages_path,
+ ] + (['--verbose'] * verbosity)
+ for p in packages:
+ cmd += ['-P', '%s@%s' % (p.package, p.version)]
+ pmap[p.package] = os.path.join(path, p.relpath)
+
try:
- tdir = tempfile.mkdtemp(dir=basedir)
- yield tdir
- finally:
- if basedir and os.path.isdir(basedir):
- if not leak:
- LOGGER.debug('Cleaning up temporary directory [%s].', basedir)
- try:
- chromium_utils.RemoveDirectory(basedir)
- except Exception:
- LOGGER.exception('Failed to clean up temporary directory [%s].',
- basedir)
+ _check_command(cmd)
+ except subprocess.CalledProcessError:
+ LOGGER.exception('Failed to install LogDog CIPD packages.')
+ raise LogDogBootstrapError()
+
+ # Resolve installed packages.
+ return tuple(pmap[p.package] for p in packages)
+
+
+def _build_logdog_prefix(properties):
+ """Constructs a LogDog stream prefix from the supplied properties.
+
+ The returned prefix is of the form:
+ bb/<mastername>/<buildername>/<buildnumber>
+
+ Any path-incompatible characters will be flattened to underscores.
+ """
+ def normalize(s):
+ parts = []
+ for ch in str(s):
+ if ch.isalnum() or ch in ':_-.':
+ parts.append(ch)
else:
- LOGGER.warning('(--leak) Leaking temporary directory [%s].', basedir)
+ parts.append('_')
+ if not parts[0].isalnum():
+ parts.insert(0, 's_')
+ return ''.join(parts)
+
+ components = {}
+ for f in ('mastername', 'buildername', 'buildnumber'):
+ prop = properties.get(f)
+ if not prop:
+ raise LogDogBootstrapError('Missing build property [%s].' % (f,))
+ components[f] = normalize(properties.get(f))
+ return 'bb/%(mastername)s/%(buildername)s/%(buildnumber)s' % components
+
+
+def _logdog_bootstrap(rt, opts, tempdir, config, properties, cmd):
+ """Executes the recipe engine, bootstrapping it through LogDog/Annotee.
+
+ This method executes the recipe engine, bootstrapping it through
+ LogDog/Annotee so its output and annotations are streamed to LogDog. The
+ bootstrap is configured to tee the annotations through STDOUT/STDERR so they
+ will still be sent to BuildBot.
+
+ The overall setup here is:
+ [annotated_run.py] => [logdog_butler] => [logdog_annotee] => [recipes.py]
+
+ Args:
+ rt (Runtime): Process-wide runtime.
+ opts (argparse.Namespace): Command-line options.
+ tempdir (str): The path to the session temporary directory.
+ config (Config): Recipe runtime configuration.
+ properties (dict): Build properties.
+ cmd (list): The recipe runner command list to bootstrap.
+
+ Returns (int): The return code of the recipe runner process.
+
+ Raises:
+ LogDogNotBootstrapped: if the recipe engine was not executed because the
+ LogDog bootstrap requirements are not available.
+ LogDogBootstrapError: if there was an error bootstrapping the recipe runner
+ through LogDog.
+ """
+ bootstrap_dir = ensure_directory(tempdir, 'logdog_bootstrap')
+
+ plat = config.logdog_platform
+ if not plat:
+ raise LogDogNotBootstrapped('LogDog platform is not configured.')
+
+ # Determine LogDog prefix.
+ prefix = _build_logdog_prefix(properties)
+
+ # TODO(dnj): Consider moving this to a permanent directory on the bot so we
+ # don't CIPD-refresh each time.
+ cipd_path = os.path.join(bootstrap_dir, 'cipd')
+ butler, annotee = _logdog_install_cipd(cipd_path, plat.butler, plat.annotee)
+ if opts.logdog_butler_path:
+ butler = opts.logdog_butler_path
+ if opts.logdog_annotee_path:
+ annotee = opts.logdog_annotee_path
+
+ if not config.logdog_pubsub:
+ raise LogDogNotBootstrapped('No Pub/Sub configured.')
+ if not config.logdog_pubsub.project:
+ raise LogDogNotBootstrapped('No Pub/Sub project configured.')
+ if not config.logdog_pubsub.topic:
+ raise LogDogNotBootstrapped('No Pub/Sub topic configured.')
+
+ # Determine LogDog verbosity.
+ logdog_verbose = []
+ if opts.logdog_verbose == 0:
+ pass
+ elif opts.logdog_verbose == 1:
+ logdog_verbose.append('-log_level=info')
+ else:
+ logdog_verbose.append('-log_level=debug')
+
+ service_account_args = []
+ service_account_json = _get_service_account_json(opts, plat.credential_path)
+ if service_account_json:
+ service_account_args += ['-service-account-json', service_account_json]
+
+ # Generate our Butler stream server URI.
+ streamserver_uri = _logdog_get_streamserver_uri(rt, plat.streamserver)
+
+ # Dump the bootstrapped Annotee command to JSON for Annotee to load.
+ #
+ # Annotee can run accept bootstrap parameters through either JSON or
+ # command-line, but using JSON effectively steps around any sort of command-
+ # line length limits such as those experienced on Windows.
+ cmd_json = os.path.join(bootstrap_dir, 'annotee_cmd.json')
+ with open(cmd_json, 'w') as fd:
+ json.dump(cmd, fd)
+
+ # Butler Command.
+ cmd = [
+ butler,
+ '-prefix', prefix,
+ '-output', 'pubsub,project="%(project)s",topic="%(topic)s"' % (
+ config.logdog_pubsub._asdict()),
+ ]
+ cmd += logdog_verbose
+ cmd += service_account_args
+ cmd += [
+ 'run',
+ '-stdout', 'tee=stdout',
+ '-stderr', 'tee=stderr',
+ '-streamserver-uri', streamserver_uri,
+ '--',
+ ]
+
+ # Annotee Command.
+ cmd += [
+ annotee,
+ '-butler-stream-server', streamserver_uri,
+ '-annotate', 'tee',
+ '-name-base', 'recipes',
+ '-print-summary',
+ '-tee',
+ '-json-args-path', cmd_json,
+ ]
+ cmd += logdog_verbose
+
+ rv, _ = _run_command(cmd, dry_run=opts.dry_run)
+ if rv in LOGDOG_ERROR_RETURNCODES:
+ raise LogDogBootstrapError('LogDog Error (%d)' % (rv,))
+ return rv
+
+
+def _should_run_logdog(properties):
+ """Returns (bool): True if LogDog should be used for this run.
+
+ Args:
+ properties (dict): The factory properties for this recipe run.
+ """
+ mastername = properties.get('mastername')
+ buildername = properties.get('buildername')
+ if not all((mastername, buildername)):
+ LOGGER.warning('Required mastername/buildername is not set.')
+ return False
+
+ # Key on mastername.
+ bdict = LOGDOG_WHITELIST_MASTER_BUILDERS.get(mastername)
+ if bdict is not None:
+ # Key on buildername.
+ if WHITELIST_ALL in bdict or buildername in bdict:
+ LOGGER.info('Whitelisted master %s, builder %s.',
+ mastername, buildername)
+ return True
+
+ LOGGER.info('Master %s, builder %s is not whitelisted for LogDog.',
+ mastername, buildername)
+ return False
def get_recipe_properties(workdir, build_properties,
@@ -297,6 +653,22 @@ def get_args(argv):
action='store_true', default=False,
help='use factory properties loaded from disk on the slave')
+ group = parser.add_argument_group('LogDog Bootstrap')
+ group.add_argument('--logdog-verbose',
+ action='count', default=0,
+ help='Increase LogDog verbosity. This can be specified multiple times.')
+ group.add_argument('--logdog-force', action='store_true',
+ help='Force LogDog bootstrapping, even if the system is not configured.')
+ group.add_argument('--logdog-butler-path',
+ help='Path to the LogDog Butler. If empty, one will be probed/downloaded '
+ 'from CIPD.')
+ group.add_argument('--logdog-annotee-path',
+ help='Path to the LogDog Annotee. If empty, one will be '
+ 'probed/downloaded from CIPD.')
+ group.add_argument('--logdog-service-account-json',
+ help='Path to the service account JSON. If one is not provided, the '
+ 'local system credentials will be used.')
+
return parser.parse_args(argv)
@@ -311,7 +683,7 @@ def update_scripts():
gclient_name = 'gclient'
if sys.platform.startswith('win'):
gclient_name += '.bat'
- gclient_path = os.path.join(env.Build, '..', 'depot_tools',
+ gclient_path = os.path.join(env.Build, os.pardir, 'depot_tools',
gclient_name)
gclient_cmd = [gclient_path, 'sync', '--force', '--verbose', '--jobs=2']
try:
@@ -424,6 +796,51 @@ def write_monitoring_event(config, datadir, build_properties):
LOGGER.warning("Failed to send monitoring event.", exc_info=True)
+def _exec_recipe(rt, opts, tdir, config, properties):
+ # Find out if the recipe we intend to run is in build_internal's recipes. If
+ # so, use recipes.py from there, otherwise use the one from build.
+ recipe_file = properties['recipe'].replace('/', os.path.sep) + '.py'
+
+ # Use the standard recipe runner unless the recipes are explicitly in the
+ # "build_limited" repository.
+ recipe_runner = os.path.join(env.Build,
+ 'scripts', 'slave', 'recipes.py')
+ if env.BuildInternal:
+ build_limited = os.path.join(env.BuildInternal, 'scripts', 'slave')
+ if os.path.exists(os.path.join(build_limited, 'recipes', recipe_file)):
+ recipe_runner = os.path.join(build_limited, 'recipes.py')
+
+ # Dump properties to JSON and build recipe command.
+ props_file = os.path.join(tdir, 'recipe_properties.json')
+ with open(props_file, 'w') as fh:
+ json.dump(properties, fh)
+
+ cmd = [
+ sys.executable, '-u', recipe_runner,
+ 'run',
+ '--workdir=%s' % os.getcwd(),
+ '--properties-file=%s' % props_file,
+ properties['recipe'],
+ ]
+
+ status = None
+ try:
+ if opts.logdog_force or _should_run_logdog(properties):
+ status = _logdog_bootstrap(rt, opts, tdir, config, properties, cmd)
+ except LogDogNotBootstrapped as e:
+ LOGGER.info('Not bootstrapped: %s', e.message)
+ except LogDogBootstrapError as e:
+ LOGGER.warning('Could not bootstrap LogDog: %s', e.message)
+ except Exception as e:
+ LOGGER.exception('Exception while bootstrapping LogDog.')
+ finally:
+ if status is None:
+ LOGGER.info('Not using LogDog. Invoking `recipes.py` directly.')
+ status, _ = _run_command(cmd, dry_run=opts.dry_run)
+
+ return status
+
+
def main(argv):
opts = get_args(argv)
@@ -436,7 +853,8 @@ def main(argv):
clean_old_recipe_engine()
# Enter our runtime environment.
- with recipe_tempdir(leak=opts.leak) as tdir:
+ with Runtime(leak=opts.leak) as rt:
+ tdir = rt.tempdir(os.getcwd())
LOGGER.debug('Using temporary directory: [%s].', tdir)
# Load factory properties and configuration.
@@ -450,19 +868,6 @@ def main(argv):
config = get_config()
LOGGER.debug('Loaded runtime configuration: %s', config)
- # Find out if the recipe we intend to run is in build_internal's recipes. If
- # so, use recipes.py from there, otherwise use the one from build.
- recipe_file = properties['recipe'].replace('/', os.path.sep) + '.py'
-
- # Use the standard recipe runner unless the recipes are explicitly in the
- # "build_limited" repository.
- recipe_runner = os.path.join(env.Build,
- 'scripts', 'slave', 'recipes.py')
- if env.BuildInternal:
- build_limited = os.path.join(env.BuildInternal, 'scripts', 'slave')
- if os.path.exists(os.path.join(build_limited, 'recipes', recipe_file)):
- recipe_runner = os.path.join(build_limited, 'recipes.py')
-
# Setup monitoring directory and send a monitoring event.
build_data_dir = ensure_directory(tdir, 'build_data')
properties['build_data_dir'] = build_data_dir
@@ -470,21 +875,8 @@ def main(argv):
# Write our annotated_run.py monitoring event.
write_monitoring_event(config, build_data_dir, properties)
- # Dump properties to JSON and build recipe command.
- props_file = os.path.join(tdir, 'recipe_properties.json')
- with open(props_file, 'w') as fh:
- json.dump(properties, fh)
- cmd = [
- sys.executable, '-u', recipe_runner,
- 'run',
- '--workdir=%s' % os.getcwd(),
- '--properties-file=%s' % props_file,
- properties['recipe'],
- ]
-
- status, _ = _run_command(cmd, dry_run=opts.dry_run)
-
- return status
+ # Execute our recipe.
+ return _exec_recipe(rt, opts, tdir, config, properties)
def shell_main(argv):
« no previous file with comments | « no previous file | scripts/slave/cipd.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698