blob: c2f4f7c28fb56e386d08737232f3571817b589ae [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 collections
import json
import logging
import os
import subprocess
import sys
from common import chromium_utils
from common import env
from slave import cipd
from slave import gce
from slave import infra_platform
LOGGER = logging.getLogger('logdog_bootstrap')
class NotBootstrapped(Exception):
pass
class BootstrapError(Exception):
pass
# CIPD tag for LogDog Butler/Annotee to use.
_STABLE_CIPD_TAG = 'git_revision:c6b17d5aa4fa6396c5f971248120e0e624c21fb3'
# 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', (
'service_host', 'viewer_host', 'max_buffer_age',
'butler', 'butler_relpath', 'annotee', 'annotee_relpath',
'credential_path', 'streamserver'))
# An infra_platform cascading configuration for the supported architectures.
_PLATFORM_CONFIG = {
# All systems.
(): {
'service_host': 'services-dot-luci-logdog.appspot.com',
'viewer_host': 'luci-logdog.appspot.com',
'max_buffer_age': '30s',
},
# Linux
('linux',): {
'credential_path': ('/creds/service_accounts/'
'service-account-luci-logdog-publisher.json'),
'streamserver': 'unix',
'butler_relpath': 'logdog_butler',
'annotee_relpath': 'logdog_annotee',
},
('linux', 32): {
'butler': 'infra/tools/luci/logdog/butler/linux-386',
'annotee': 'infra/tools/luci/logdog/annotee/linux-386',
},
('linux', 64): {
'butler': 'infra/tools/luci/logdog/butler/linux-amd64',
'annotee': 'infra/tools/luci/logdog/annotee/linux-amd64',
},
# Mac
('mac',): {
'credential_path': ('/creds/service_accounts/'
'service-account-luci-logdog-publisher.json'),
'streamserver': 'unix',
'butler_relpath': 'logdog_butler',
'annotee_relpath': 'logdog_annotee',
},
('mac', 64): {
'butler': 'infra/tools/luci/logdog/butler/mac-amd64',
'annotee': 'infra/tools/luci/logdog/annotee/mac-amd64',
},
# Windows
('win',): {
'credential_path': ('c:\\creds\\service_accounts\\'
'service-account-luci-logdog-publisher.json'),
'streamserver': 'net.pipe',
'butler_relpath': 'logdog_butler.exe',
'annotee_relpath': 'logdog_annotee.exe',
},
('win', 32): {
'butler': 'infra/tools/luci/logdog/butler/windows-386',
'annotee': 'infra/tools/luci/logdog/annotee/windows-386',
},
('win', 64): {
'butler': 'infra/tools/luci/logdog/butler/windows-amd64',
'annotee': 'infra/tools/luci/logdog/annotee/windows-amd64',
},
}
# Params are parameters for this specific master/builder configuration.
#
# Loaded by '_get_params'.
Params = collections.namedtuple('Params', (
'project', 'cipd_tag', 'mastername', 'buildername', 'buildnumber',
))
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 _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).iteritems()):
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_CIPD_TAG,
}
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.')
return Params(
project=project,
cipd_tag=builder_map['cipd_tag'],
mastername=mastername,
buildername=buildername,
buildnumber=buildnumber,
)
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.
"""
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 BootstrapError('Could not find service account credentials. '
'Tried: %s' % (credential_path,))
def _install_cipd(path, *binaries):
"""Returns (list): The paths to the binaries.
This method bootstraps CIPD in "path", installing the packages specified
by "binaries".
Args:
path (str): The CIPD installation root.
binaries (CipdBinary): The set of CIPD binaries to install.
"""
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 b in binaries:
cmd += ['-P', '%s@%s' % (b.package.name, b.package.version)]
pmap[b.package.name] = os.path.join(path, b.relpath)
try:
_check_call(cmd)
except subprocess.CalledProcessError:
LOGGER.exception('Failed to install LogDog CIPD packages: %s', binaries)
raise BootstrapError('Failed to install CIPD packages.')
# Resolve installed binaries.
return tuple(pmap[b.package.name] for b in binaries)
def _build_prefix(params):
"""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:
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))
return 'bb/%s/%s/%s' % (mastername, buildername, buildnumber)
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.
"""
# 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', None) 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 = _build_prefix(params)
LOGGER.debug('Using log stream prefix: [%s]', prefix)
def var(title, v, dflt):
v = v or dflt
if not v:
raise NotBootstrapped('No value for [%s]' % (title,))
return v
# Install our Butler/Annotee packages from CIPD.
cipd_path = os.path.join(basedir, '.recipe_cipd')
butler, annotee = _install_cipd(cipd_path,
# butler
cipd.CipdBinary(
package=cipd.CipdPackage(name=plat.butler, version=params.cipd_tag),
relpath=plat.butler_relpath,
),
# annotee
cipd.CipdBinary(
package=cipd.CipdPackage(name=plat.annotee, version=params.cipd_tag),
relpath=plat.annotee_relpath,
),
)
butler = var('butler', opts.logdog_butler_path, butler)
if not os.path.isfile(butler):
raise NotBootstrapped('Invalid Butler path: %s' % (butler,))
annotee = var('annotee', opts.logdog_annotee_path, annotee)
if not os.path.isfile(annotee):
raise NotBootstrapped('Invalid Annotee path: %s' % (annotee,))
service_host = var('service host', opts.logdog_service_host,
plat.service_host)
viewer_host = var('viewer host', opts.logdog_viewer_host, plat.viewer_host)
# Determine LogDog verbosity.
if opts.logdog_verbose == 0:
log_level = 'warning'
elif opts.logdog_verbose == 1:
log_level = 'info'
else:
log_level = 'debug'
service_account_json = _get_service_account_json(opts, plat.credential_path)
# Generate our Butler stream server URI.
streamserver_uri = _get_streamserver_uri(rt, 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.
cmd = [
butler,
'-log-level', log_level,
'-project', params.project,
'-prefix', prefix,
'-output', 'logdog,host="%s"' % (service_host,),
]
if service_account_json:
cmd += ['-service-account-json', service_account_json]
if plat.max_buffer_age:
cmd += ['-output-max-buffer-age', plat.max_buffer_age]
cmd += [
'run',
'-stdout', 'tee=stdout',
'-stderr', 'tee=stderr',
'-streamserver-uri', streamserver_uri,
'--',
]
# Annotee Command.
cmd += [
annotee,
'-log-level', log_level,
'-project', params.project,
'-butler-stream-server', streamserver_uri,
'-logdog-host', viewer_host,
'-annotate', 'tee',
'-name-base', 'recipes',
'-print-summary',
'-tee',
'-json-args-path', cmd_json,
'-result-path', bootstrap_result_path,
]
return BootstrapState(cmd, bootstrap_result_path)
class BootstrapState(object):
def __init__(self, cmd, bootstrap_result_path):
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 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-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-service-host',
help='Override the LogDog service host, used by Butler for registration.')
parser.add_argument('--logdog-viewer-host',
help='Override the LogDog viewer host, used by Annotee to build URLs.')