blob: 41dba0aa5520767c3c85cc761ce8e9a031c43901 [file] [log] [blame]
# Copyright (c) 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Implements LogDog Bootstrapping support.
"""
import argparse
import collections
import json
import logging
import os
import subprocess
import sys
from common import annotator
from common import chromium_utils
from common import env
from slave import cipd
from slave import cipd_bootstrap_v2
from slave import gce
from slave import infra_platform
LOGGER = logging.getLogger('logdog_bootstrap')
# Magic credential "path" to indicate that we're using GCE credentials instead
# of a service account json file.
GCE_CREDENTIALS = ':gce'
class NotBootstrapped(Exception):
pass
class BootstrapError(Exception):
pass
# Path to the "cipd.py" library.
_CIPD_PY_PATH = os.path.join(env.Build, 'scripts', 'slave', 'cipd.py')
# CIPD tag for LogDog Butler/Annotee to use.
#
# Verify full package set with:
# $ cipd resolve infra/tools/luci/logdog/butler/ -version ${TAG}
# $ cipd resolve infra/tools/luci/logdog/annotee/ -version ${TAG}
_STABLE_CIPD_TAG = 'git_revision:910b0131071156dce831a84150623d2b9ead62cd'
_CANARY_CIPD_TAG = 'git_revision:57211f43708aa2c91027d1f90f6cdfb639925fb4'
_CIPD_TAG_MAP = {
'$stable': _STABLE_CIPD_TAG,
'$canary': _CANARY_CIPD_TAG,
}
# Map between CIPD versions and LogDog API version strings. This can be updated
# if API-based decisions are needed.
#
# If a CIPD tag is explicitly mentioned here, the associated API will be used.
# Otherwise, the "_STABLE_CIPD_TAG" API will be used.
#
# As CIPD tags rotate, old API versions and their respective logic can be
# removed from this code.
_CIPD_TAG_API_MAP = {
_STABLE_CIPD_TAG: 4,
_CANARY_CIPD_TAG: 4,
}
# Platform is the set of platform-specific LogDog bootstrapping
# configuration parameters. Platform is loaded by cascading the _PLATFORM_CONFIG
# against the current running platform.
#
# See _get_streamserver_uri for "streamserver" parameter details.
#
# Loaded by '_get_platform'.
Platform = collections.namedtuple('Platform', (
'host', 'max_buffer_age', 'butler', 'annotee', 'credential_path',
'streamserver'))
# An infra_platform cascading configuration for the supported architectures.
_PLATFORM_CONFIG = {
# All systems.
(): {
'host': 'logs.chromium.org',
'max_buffer_age': '30s',
'butler': 'infra/tools/luci/logdog/butler/${platform}',
'annotee': 'infra/tools/luci/logdog/annotee/${platform}',
},
# Linux
('linux',): {
'credential_path': ('/creds/service_accounts/'
'service-account-luci-logdog-publisher.json'),
'streamserver': 'unix',
},
# Mac
('mac',): {
'credential_path': ('/creds/service_accounts/'
'service-account-luci-logdog-publisher.json'),
'streamserver': 'unix',
},
# Windows
('win',): {
'credential_path': ('c:\\creds\\service_accounts\\'
'service-account-luci-logdog-publisher.json'),
'streamserver': 'net.pipe',
},
}
# Params are parameters for this specific master/builder configuration.
#
# Loaded by '_get_params'.
Params = collections.namedtuple('Params', (
'project', 'cipd_tag', 'api', 'mastername', 'buildername', 'buildnumber',
'generation',
))
# LogDog bootstrapping configuration.
Config = collections.namedtuple('Config', (
'params', 'plat', 'host', 'prefix', 'tags',
'service_account_path',
))
def _check_call(cmd, **kwargs):
LOGGER.debug('Executing command: %s', cmd)
subprocess.check_call(cmd, **kwargs)
def _get_platform():
"""Returns (Platform): The constructed Platform object.
Raises:
TypeError: if a required configuration key/parameter is not available.
"""
return Platform(**infra_platform.cascade_config(_PLATFORM_CONFIG))
def all_cipd_manifests():
"""Generator which yields all CIPD ensure manifests (canary, staging, prod).
Each manifest is represented by a list of cipd.CipdPackage instances.
"""
# All CIPD packages are in top-level platform config.
pcfg = infra_platform.cascade_config(_PLATFORM_CONFIG, plat=())
for version in (_STABLE_CIPD_TAG, _CANARY_CIPD_TAG):
yield [
cipd.CipdPackage(name=name, version=version)
for name in (pcfg['butler'], pcfg['annotee'])
]
def _load_params_dict(mastername):
"""Returns (dict or None): The parameters for the specified master.
The parameters are loaded by locating the 'logdog-params.pyl' file for the
currently-executing waterfall. If found, it will be parsed and the waterfall's
parameters will be loaded from it.
If no parameter file could be found, or no parameters are defined for the
specified waterfall, None will be returned.
Args:
mastername (str): The name of the master whose parameters will be loaded.
Raises:
NotBootstrapped: If the parameters dictionary could not be loaded.
"""
# Identify the directory where the master is located.
try:
master_dir = chromium_utils.MasterPath(mastername)
except LookupError as e:
LOGGER.warning('Unable to find directory for master [%s] (%s)',
mastername, e)
raise NotBootstrapped('No master directory')
# 'master_dir' is:
# <build_dir>/masters/<master_name>
#
# We want to look up:
# <build_dir>/scripts/slave/logdog-params.pyl
#
params_path = os.path.join(master_dir, os.pardir, os.pardir, 'scripts',
'slave', 'logdog-params.pyl')
if not os.path.isfile(params_path):
LOGGER.warning('No LogDog parameters at: [%s]', params_path)
raise NotBootstrapped('No parameters file.')
# Load and parse our parameters.
LOGGER.debug('Loading LogDog parameters from: [%s]', params_path)
with open(params_path, 'r') as fd:
params_data = fd.read()
try:
params = eval(params_data)
assert isinstance(params, dict)
except SyntaxError as e:
LOGGER.error('Failed to parse params from [%s]: %s', params_path, e)
raise NotBootstrapped('Invalid parameters file.')
except AssertionError:
LOGGER.error('Params parsed to non-dictionary (%s)', type(params).__name__)
raise NotBootstrapped('Parameters file does not contain a dictionary.')
return params
def _get_params(properties):
"""Returns (Params): Parameters for the given properties.
The parameters are loaded by locating the 'logdog-params.pyl' file for the
currently-executing waterfall. If found, it will be parsed and the waterfall's
parameters will be loaded from it.
If no parameter file could be found, or no parameters are defined for the
specified waterfall, None will be returned.
Args:
properties (dict): Build property dictionary.
Raises:
NotBootstrapped: If parameters could not be built, or if this master/builder
is disabled.
"""
# Extract our required properties.
props = tuple(properties.get(f) for f in (
'mastername', 'buildername', 'buildnumber'))
if not all(props):
LOGGER.warning('Missing mastername/buildername/buildnumber properties.')
raise NotBootstrapped('Insufficient properties.')
mastername, buildername, buildnumber = props
# Find our project name and master config.
project = None
for project, masters in sorted(_load_params_dict(mastername).items()):
master_config = masters.get(mastername)
if master_config is not None:
break
else:
LOGGER.info('No master config found for [%s].', mastername)
raise NotBootstrapped('No master config.')
# Get builder config map, allowing overrides if one is defined for either the
# specific builder or all builders ('*').
builder_map = {
'enabled': True,
'cipd_tag': '$stable',
'generation': None,
}
for bn in (buildername, '*'):
bn_map = master_config.get(bn)
if bn_map is not None:
builder_map.update(bn_map)
break
# If our builder is not enabled, we are done.
if not builder_map['enabled']:
LOGGER.info('LogDog is disabled for master / builder [%s / %s].',
mastername, buildername)
raise NotBootstrapped('LogDog is disabled.')
# Resolve our CIPD tag.
cipd_tag = builder_map['cipd_tag']
cipd_tag = _CIPD_TAG_MAP.get(cipd_tag, cipd_tag)
# Determine our API version.
api = _CIPD_TAG_API_MAP.get(cipd_tag, _CIPD_TAG_API_MAP[_STABLE_CIPD_TAG])
return Params(
project=project,
cipd_tag=cipd_tag,
api=api,
mastername=mastername,
buildername=buildername,
buildnumber=buildnumber,
generation=builder_map['generation'],
)
def _get_streamserver_uri(rt, typ):
"""Returns (str): The Butler StreamServer URI.
Args:
rt (RobustTempdir): context for temporary directories.
typ (str): The type of URI to generate. One of: ['unix'].
Raises:
BootstrapError: 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.
#
# Bots run out of "/b/build", so this will form a path starting at
# "/b/build/.recipe_runtime/tmp-<random>/butler.sock", which is well below
# the socket name size limit.
#
# We don't drop this in "/tmp" because several build scripts assume
# ownership of that directory and blindly clear it as part of cleanup, and
# this socket is too important to risk.
sockdir = rt.tempdir(env.Build)
uri = 'unix:%s' % (os.path.join(sockdir, 'butler.sock'),)
if len(uri) > 104:
raise BootstrapError('Generated URI exceeds UNIX domain socket '
'name size: %s' % (uri,))
return uri
elif typ == 'net.pipe':
return 'net.pipe:LUCILogDogButler'
else:
raise BootstrapError('No streamserver URI generator.')
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.
Raises:
|BootstrapError| if no credentials could be found.
"""
if opts.logdog_debug_out_file:
return None
path = opts.logdog_service_account_json
if path:
return path
if gce.Authenticator.is_gce():
LOGGER.info('Running on GCE. No credentials necessary.')
return GCE_CREDENTIALS
if os.path.isfile(credential_path):
return credential_path
raise BootstrapError('Could not find service account credentials. '
'Tried: %s' % (credential_path,))
def _build_prefix(params):
"""Constructs a LogDog stream prefix and tags from the supplied properties.
The returned prefix is of the form:
bb/<mastername>/<buildername>/<buildnumber>
Any path-incompatible characters will be flattened to underscores.
Returns (prefix, tags):
prefix (str): the LogDog stream prefix.
tags (dict): A dict of LogDog tags to add.
"""
def normalize(s):
parts = []
for ch in str(s):
if ch.isalnum() or ch in ':_-.':
parts.append(ch)
else:
parts.append('_')
if not parts[0].isalnum():
parts.insert(0, 's_')
return ''.join(parts)
mastername, buildername, buildnumber = (normalize(p) for p in (
params.mastername, params.buildername, params.buildnumber))
parts = ['bb', mastername, buildername]
if params.generation:
parts += [params.generation]
parts += [buildnumber]
prefix = '/'.join(str(x) for x in parts)
viewer_url = (
'https://luci-milo.appspot.com/buildbot/%(mastername)s/%(buildername)s/'
'%(buildnumber)d' % params._asdict())
tags = collections.OrderedDict((
('buildbot.master', mastername),
('buildbot.builder', buildername),
('buildbot.buildnumber', str(buildnumber)),
('logdog.viewer_url', viewer_url),
))
return prefix, tags
def _make_butler_output(opts, _cfg):
"""Returns a Butler output string.
"""
if opts.logdog_debug_out_file:
return 'file,path="%s"' % (opts.logdog_debug_out_file,)
return 'logdog'
def _prune_arg(l, key, extra=0):
"""Removes list entry "key" and "extra" additional entries, if present.
Args:
l (list): The list to prune.
key (object): The list entry to identify and remove.
extra (int): Additional entries after key to prune, if found.
"""
try:
idx = l.index(key)
args = l[idx:idx+extra+1]
del(l[idx:idx+extra+1])
return args
except ValueError:
return None
def get_config(opts, properties):
"""Returns (Config): the LogDog bootstrap configuration.
This probes the supplied options and properties and resolves the full
bootstrap configuration from them.
Raises:
NotBootstrapped: If the environment is not configured to be bootstrapped.
"""
if opts.logdog_disable:
raise NotBootstrapped('LogDog explicitly disabled (--disable-logdog).')
# If we have LOGDOG_STREAM_PREFIX defined, we are already bootstrapped. Don't
# start a new instance.
#
# LOGDOG_STREAM_PREFIX is set by the Butler when it bootstraps a process, so
# it should be set for all child processes of the initial bootstrap.
if os.environ.get('LOGDOG_STREAM_PREFIX') is not None:
raise NotBootstrapped(
'LOGDOG_STREAM_PREFIX in enviornment, refusing to nest bootstraps.')
# Load our bootstrap parameters based on our master/builder.
params = _get_params(properties)
# Get our platform configuration. This will fail if any fields are missing.
plat = _get_platform()
# Determine LogDog prefix.
prefix, tags = _build_prefix(params)
host = opts.logdog_host or plat.host
if not host:
raise BootstrapError('No host is defined')
# Generate our service account path.
service_account_path = _get_service_account_json(opts, plat.credential_path)
return Config(
params=params,
plat=plat,
host=host,
prefix=prefix,
tags=tags,
service_account_path=service_account_path,
)
def bootstrap(rt, opts, basedir, tempdir, 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 (RobustTempdir): context for temporary directories.
opts (argparse.Namespace): Command-line options.
basedir (str): The base (non-temporary) recipe directory.
tempdir (str): The path to the session temporary directory.
properties (dict): Build properties.
cmd (list): The recipe runner command list to bootstrap.
Returns (BootstrapState): The populated bootstrap state.
Raises:
NotBootstrapped: if the recipe engine was not executed because the
LogDog bootstrap requirements are not available.
BootstrapError: if there was an error bootstrapping the recipe runner
through LogDog.
"""
# Load bootstrap configuration (may raise NotBootstrapped).
cfg = get_config(opts, properties)
# Determine LogDog prefix.
LOGGER.debug('Using log stream prefix: [%s]', cfg.prefix)
# Install our Butler/Annotee packages from CIPD.
cipd_path = os.path.join(basedir, '.recipe_cipd')
packages = (
# Butler
cipd.CipdPackage(
name=cfg.plat.butler,
version=cfg.params.cipd_tag),
# Annotee
cipd.CipdPackage(
name=cfg.plat.annotee,
version=cfg.params.cipd_tag),
)
try:
cipd_bootstrap_v2.install_cipd_packages(cipd_path, *packages)
except Exception:
LOGGER.exception('Failed to install LogDog CIPD packages: %s', packages)
raise BootstrapError('Failed to install CIPD packages.')
def cipd_bin(base):
return os.path.join(cipd_path, base + infra_platform.exe_suffix())
def var(title, v, dflt):
v = v or dflt
if not v:
raise NotBootstrapped('No value for [%s]' % (title,))
return v
butler = var('butler', opts.logdog_butler_path, cipd_bin('logdog_butler'))
if not os.path.isfile(butler):
raise NotBootstrapped('Invalid Butler path: %s' % (butler,))
annotee = var('annotee', opts.logdog_annotee_path, cipd_bin('logdog_annotee'))
if not os.path.isfile(annotee):
raise NotBootstrapped('Invalid Annotee path: %s' % (annotee,))
# Determine LogDog verbosity.
if opts.logdog_verbose == 0:
log_level = 'warning'
elif opts.logdog_verbose == 1:
log_level = 'info'
else:
log_level = 'debug'
# Generate our Butler stream server URI.
streamserver_uri = _get_streamserver_uri(rt, cfg.plat.streamserver)
# If we are using file sentinel-based bootstrap error detection, enable.
bootstrap_result_path = os.path.join(tempdir, 'bootstrap_result.json')
# 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(tempdir, 'logdog_annotee_cmd.json')
with open(cmd_json, 'w') as fd:
json.dump(cmd, fd)
# Butler Command, global options.
butler_args = [
butler,
'-log-level', log_level,
'-project', cfg.params.project,
'-prefix', cfg.prefix,
'-coordinator-host', cfg.host,
'-output', _make_butler_output(opts, cfg),
]
for k, v in cfg.tags.iteritems():
if v:
k = '%s=%s' % (k, v)
butler_args += ['-tag', k]
if cfg.service_account_path:
butler_args += ['-service-account-json', cfg.service_account_path]
if cfg.plat.max_buffer_age:
butler_args += ['-output-max-buffer-age', cfg.plat.max_buffer_age]
butler_args += ['-io-keepalive-stderr', '5m']
# Butler: subcommand run.
butler_run_args = [
'-stdout', 'tee=stdout',
'-stderr', 'tee=stderr',
'-streamserver-uri', streamserver_uri,
]
# Annotee Command.
annotee_args = [
annotee,
'-log-level', log_level,
'-name-base', 'recipes',
'-print-summary',
'-tee', 'annotations',
'-json-args-path', cmd_json,
'-result-path', bootstrap_result_path,
]
# API transformation switch. Please prune as API versions become
# unused.
#
# NOTE: Please update the above comment as new API versions and translation
# functions are added.
start_api = cur_api = max(_CIPD_TAG_API_MAP.itervalues())
# Assert that we've hit the target "params.api".
assert cur_api == cfg.params.api, 'Failed to transform API %s => %s' % (
start_api, cfg.params.api)
cmd = butler_args + ['run'] + butler_run_args + ['--'] + annotee_args
return BootstrapState(cfg, cmd, bootstrap_result_path)
def get_annotation_url(cfg):
"""Returns (str): LogDog stream URL for the configured annotation stream.
Args:
cfg (Config): The bootstrap config.
"""
return 'logdog://%(host)s/%(project)s/%(prefix)s/+/recipes/annotations' % {
'host': cfg.host,
'project': cfg.params.project,
'prefix': cfg.prefix,
}
def annotate(cfg, stream):
"""Writes LogDog bootstrap annotations to an annotation stream.
Args:
stream (annotator.StructuredAnnotationStream): The annotation stream to
write to.
"""
annotation_url = get_annotation_url(cfg)
with stream.step('LogDog Bootstrap') as st:
st.set_build_property('logdog_project', json.dumps(cfg.params.project))
st.set_build_property('logdog_prefix', json.dumps(cfg.prefix))
st.set_build_property('log_location', json.dumps(annotation_url))
class BootstrapState(object):
def __init__(self, cfg, cmd, bootstrap_result_path):
self._cfg = cfg
self._cmd = cmd
self._bootstrap_result_path = bootstrap_result_path
@property
def cmd(self):
"""Returns (list): The Butler-bootstrapped command."""
return self._cmd[:]
def get_result(self):
"""Retrieves and returns the return code of the bootstrapped process.
Returns (int): The bootstrapped process' return code.
Raises:
BootstrapError: If the bootstrapped process didn't even run.
"""
try:
with open(self._bootstrap_result_path) as fd:
result = json.load(fd)
except (IOError, ValueError) as e:
raise BootstrapError('Failed to open bootstrap result file [%s]: %s' % (
self._bootstrap_result_path, e))
try:
return result['return_code']
except KeyError as e:
raise BootstrapError('Invalid bootstrap result file [%s]: %s' % (
self._bootstrap_result_path, e))
def annotate(self, stream):
"""Writes LogDog bootstrap annotations to an annotation stream.
Args:
stream (annotator.StructuredAnnotationStream): The annotation stream to
write to.
"""
annotate(self._cfg, stream)
def _argparse_type_trinary(v):
v = v.lower()
if v == '':
return None
if v in ('true', 't', 'yes', 'y', '1'):
return True
if v in ('false', 'f', 'no', 'n', '0'):
return False
raise argparse.ArgumentTypeError('%r is not a valid trinary value' % (v,))
def add_arguments(parser):
parser.add_argument('--logdog-verbose',
action='count', default=0,
help='Increase LogDog verbosity. This can be specified multiple times.')
parser.add_argument('--logdog-disable', action='store_true',
help='Disable LogDog bootstrapping, even if otherwise configured.')
parser.add_argument('--logdog-butler-path',
help='Path to the LogDog Butler. If empty, one will be probed/downloaded '
'from CIPD.')
parser.add_argument('--logdog-annotee-path',
help='Path to the LogDog Annotee. If empty, one will be '
'probed/downloaded from CIPD.')
parser.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.')
parser.add_argument('--logdog-host',
help='Override the LogDog host.')
parser.add_argument('--logdog-output-service',
help='If specified, use <service>-dot-<host> for output service '
'configuration.')
parser.add_argument('--logdog-debug-out-file',
help='(Debug) Write logs to this text protobuf file instead of a live '
'service.')