| Index: scripts/slave/annotated_run.py
|
| diff --git a/scripts/slave/annotated_run.py b/scripts/slave/annotated_run.py
|
| index 724ed9d02a068e0fae2362f7a642db1f3fc61901..174bfd373dd85ce13cb00ffb24a669dbd1cc353d 100755
|
| --- a/scripts/slave/annotated_run.py
|
| +++ b/scripts/slave/annotated_run.py
|
| @@ -26,10 +26,31 @@ 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,
|
| +)
|
| +
|
| +# Whitelist of {master}=>[{builder}|WHITELIST_ALL] whitelisting specific masters
|
| +# and builders for experimental LogDog/Annotee export.
|
| +LOGDOG_WHITELIST_MASTER_BUILDERS = {
|
| +}
|
| +
|
| +# Sentinel value that, if present in master config, matches all builders
|
| +# underneath that master.
|
| +WHITELIST_ALL = '*'
|
| +
|
| +# Configuration for a Pub/Sub topic.
|
| +PubSubConfig = collections.namedtuple('PubSubConfig', ('project', 'topic'))
|
|
|
| # RecipeRuntime will probe this for values.
|
| # - First, (system, platform)
|
| @@ -37,11 +58,21 @@ LOGGER = logging.getLogger('annotated_run')
|
| # - Finally, (),
|
| PLATFORM_CONFIG = {
|
| # All systems.
|
| - (): {},
|
| + (): {
|
| + 'logdog_pubsub': PubSubConfig(
|
| + project='luci-logdog',
|
| + topic='chrome-infra-beta',
|
| + ),
|
| + },
|
|
|
| # Linux
|
| ('Linux',): {
|
| 'run_cmd': ['/opt/infra-python/run.py'],
|
| + 'credential_paths': (
|
| + # XXX: Get this right?
|
| + '/opt/infra/service_accounts',
|
| + ),
|
| + 'logdog_butler_streamserver_gen': lambda d: os.path.join(d, 'butler.sock'),
|
| },
|
|
|
| # Mac OSX
|
| @@ -61,6 +92,9 @@ PLATFORM_CONFIG = {
|
| # the recipe engine.
|
| Config = collections.namedtuple('Config', (
|
| 'run_cmd',
|
| + 'logdog_pubsub',
|
| + 'logdog_butler_streamserver_gen',
|
| + 'credential_paths',
|
| ))
|
|
|
|
|
| @@ -83,6 +117,10 @@ def get_config():
|
| # Construct runtime configuration.
|
| return Config(
|
| run_cmd=platform_config.get('run_cmd'),
|
| + logdog_pubsub=platform_config.get('logdog_pubsub'),
|
| + logdog_butler_streamserver_gen=platform_config.get(
|
| + 'logdog_butler_streamserver_gen'),
|
| + credential_paths=platform_config.get('credential_paths', ()),
|
| )
|
|
|
|
|
| @@ -144,6 +182,256 @@ def recipe_tempdir(root=None, leak=False):
|
| LOGGER.warning('(--leak) Leaking temporary directory [%s].', basedir)
|
|
|
|
|
| +class LogDogNotBootstrapped(Exception):
|
| + pass
|
| +
|
| +
|
| +class LogDogBootstrapError(Exception):
|
| + pass
|
| +
|
| +
|
| +def is_executable(path):
|
| + return os.path.isfile(path) and os.access(path, os.X_OK)
|
| +
|
| +
|
| +def ensure_directory(*path):
|
| + path = os.path.join(*path)
|
| + if not os.path.isdir(path):
|
| + os.makedirs(path)
|
| + return path
|
| +
|
| +
|
| +def _run_command(cmd, **kwargs):
|
| + dry_run = kwargs.pop('dry_run', False)
|
| +
|
| + LOGGER.debug('Executing command: %s', cmd)
|
| + if dry_run:
|
| + LOGGER.info('(Dry Run) Not executing command.')
|
| + return 0, ''
|
| + proc = subprocess.Popen(cmd, stderr=subprocess.STDOUT)
|
| + stdout, _ = proc.communicate()
|
| +
|
| + LOGGER.debug('Process [%s] returned [%d] with output:\n%s',
|
| + cmd, proc.returncode, stdout)
|
| + return proc.returncode, stdout
|
| +
|
| +
|
| +def _check_command(*args, **kwargs):
|
| + rv, stdout = _run_command(args, **kwargs)
|
| + if rv != 0:
|
| + raise ValueError('Process exited with non-zero return code (%d)' % (rv,))
|
| + return stdout
|
| +
|
| +
|
| +class RecipeRuntime(object):
|
| + """RecipeRuntime is the platform-specific runtime enviornment.
|
| +
|
| + The runtime is loaded with a set of read-only attributes that are a
|
| + combination of plaetform and runtime values used in the setup and execution of
|
| + the recipe engine.
|
| + """
|
| +
|
| + _SENTINEL = object()
|
| +
|
| + def __init__(self, **kwargs):
|
| + self._attrs = kwargs
|
| +
|
| + @classmethod
|
| + @contextlib.contextmanager
|
| + def enter(cls, leak, **kw):
|
| + """Enters the annotated_run environment.
|
| +
|
| + This creates a temporary directory for this annotation run that is
|
| + automatically cleaned up. It returns a RecipeRuntime object containing a
|
| + combination of the supplied keyword arguments and the platform-specific
|
| + configuration.
|
| +
|
| + Args:
|
| + leak (bool): If true, don't clean up the temporary directory on exit.
|
| + kw (dict): Key/value pairs to add as attributes to the RecipeRuntime.
|
| + """
|
| + # Build our platform attributes.
|
| + p = (platform.system(), platform.processor())
|
| + attrs = {}
|
| + for i in xrange(len(p)+1):
|
| + attrs.update(PLATFORM_CONFIG.get(p[:i], {}))
|
| + attrs.update(kw)
|
| +
|
| + basedir = ensure_directory(os.getcwd(), '.recipe_runtime')
|
| + try:
|
| + tdir = tempfile.mkdtemp(dir=basedir)
|
| + LOGGER.debug('Using temporary directory [%s].', tdir)
|
| +
|
| + attrs['workdir'] = tdir
|
| + yield cls(**attrs)
|
| + finally:
|
| + if basedir and os.path.isdir(basedir):
|
| + if not leak:
|
| + LOGGER.debug('Cleaning up temporary directory [%s].', basedir)
|
| + try:
|
| + # TODO(pgervais): use infra_libs.rmtree instead.
|
| + shutil.rmtree(basedir)
|
| + except Exception:
|
| + LOGGER.exception('Failed to clean up temporary directory [%s].',
|
| + basedir)
|
| + else:
|
| + LOGGER.warning('(--leak) Leaking temporary directory [%s].', basedir)
|
| +
|
| + def __getattr__(self, key):
|
| + # Class methods/variables.
|
| + value = getattr(super(RecipeRuntime, self), key, self._SENTINEL)
|
| + if value is not self._SENTINEL:
|
| + return value
|
| +
|
| + value = getattr(self, 'get')(key, self._SENTINEL)
|
| + if value is not self._SENTINEL:
|
| + return value
|
| + raise KeyError(key)
|
| +
|
| + def get(self, key, default=None):
|
| + value = self._attrs.get(key, self._SENTINEL)
|
| + if value is not self._SENTINEL:
|
| + return value
|
| + return default
|
| +
|
| + def __str__(self):
|
| + return str(self._attrs)
|
| +
|
| +
|
| +def _get_service_account_json(opts, credential_paths):
|
| + """Returns (str/None): If specified, the path to the service account JSON.
|
| +
|
| + This method probes the local environemnt 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.get('service_account_json')
|
| + if path:
|
| + return path
|
| +
|
| + if gce.Authenticator.is_gce():
|
| + LOGGER.info('Running on GCE. No credentials necessary.')
|
| + return None
|
| +
|
| + for credential_path in credential_paths:
|
| + candidate = os.path.join(credential_path, 'logdog_service_account.json')
|
| + if os.path.isfile(candidate):
|
| + return candidate
|
| +
|
| + raise LogDogBootstrapError('Could not find service account credentials. '
|
| + 'Tried: %s' % (credential_paths,))
|
| +
|
| +
|
| +def _logdog_bootstrap(tempdir, config, opts, 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:
|
| + config (Config): Recipe runtime configuration.
|
| + opts (argparse.Namespace): Command-line options.
|
| + 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')
|
| + butler, annotee = opts.logdog_butler_path, opts.logdog_annotee_path
|
| +
|
| + if not is_executable(annotee):
|
| + raise LogDogNotBootstrapped('Annotee is not executable: %s' % (annotee,))
|
| + if not is_executable(butler):
|
| + raise LogDogNotBootstrapped('Butler is not executable: %s' % (butler,))
|
| +
|
| + # Determine LogDog verbosity.
|
| + logdog_verbose = []
|
| + if opts.logdog_verbose == 0:
|
| + pass
|
| + elif opts.logdog_verbose == 1:
|
| + logdog_verbose.extend('-log_level=info')
|
| + else:
|
| + logdog_verbose.extend('-log_level=debug')
|
| +
|
| + service_account_args = []
|
| + service_account_json = _get_service_account_json(
|
| + opts, config.credential_paths)
|
| + if service_account_json:
|
| + service_account_args += ['-service-account-json', service_account_json]
|
| +
|
| + streamserver_uri_gen = config.logdog_butler_streamserver_gen
|
| + if not streamserver_uri_gen:
|
| + raise LogDogBootstrapError('No streamserver URI generator.')
|
| + streamserver_uri = streamserver_uri_gen(tempdir)
|
| +
|
| + # Dump Annotee command to JSON.
|
| + cmd_json = os.path.join(bootstrap_dir, 'annotee_cmd.json')
|
| + with open(cmd_json, 'w') as fd:
|
| + json.dump(cmd, fd)
|
| +
|
| + cmd = [
|
| + # Butler Command.
|
| + butler,
|
| + ] + logdog_verbose + service_account_args + [
|
| + '-output', 'gcps,project="%s",topic="%s"' % (config.logdog_pubsub.project,
|
| + config.logdog_pubsub.topic),
|
| + 'run',
|
| + '-streamserver-uri', streamserver_uri,
|
| + '--',
|
| +
|
| + # Annotee Command.
|
| + annotee,
|
| + ] + logdog_verbose + [
|
| + '-json-args-path', cmd_json,
|
| + ]
|
| + rv, _ = _run_command(cmd, dry_run=opts.dry_run)
|
| + if rv in LOGDOG_ERROR_RETURNCODES:
|
| + raise LogDogBootstrapError('LogDog Error (%d)' % (rv,))
|
| + return rv
|
| +
|
| +
|
| +def _assert_logdog_whitelisted(mastername, buildername):
|
| + """Asserts that the runtime environment is whitelisted for LogDog bootstrap.
|
| +
|
| + Args:
|
| + mastername (str): The master name string.
|
| + buildername (str): The builder name.
|
| + Raises:
|
| + LogDogNotBootstrapped: if the runtime is not whitelisted.
|
| + """
|
| + if not all((mastername, buildername)):
|
| + raise LogDogNotBootstrapped('Required mastername/buildername is not set.')
|
| +
|
| + # 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
|
| + raise LogDogNotBootstrapped('Master %s, builder %s is not whitelisted.' % (
|
| + mastername, buildername))
|
| +
|
| +
|
| def get_recipe_properties(workdir, build_properties,
|
| use_factory_properties_from_disk):
|
| """Constructs the recipe's properties from buildbot's properties.
|
| @@ -291,6 +579,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('-V', '--logdog-verbose',
|
| + action='count', default=0,
|
| + help='Increase LogDog verbosity. This can be specified multiple times.')
|
| + group.add_argument('-f', '--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)
|
|
|
|
|
| @@ -477,7 +781,21 @@ def main(argv):
|
| properties['recipe'],
|
| ]
|
|
|
| - status, _ = _run_command(cmd, dry_run=opts.dry_run)
|
| + status = None
|
| + try:
|
| + if not opts.logdog_force:
|
| + _assert_logdog_whitelisted(config.mastername, config.buildername)
|
| + status = _logdog_bootstrap(tdir, config, opts, 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:
|
| + LOGGER.exception('Exception while bootstrapping LogDog.')
|
| + finally:
|
| + if status is None:
|
| + LOGGER.info('Not using LogDog. Invoking `annotated_run.py` directly.')
|
| + status, _ = _run_command(cmd, dry_run=opts.dry_run)
|
|
|
| return status
|
|
|
|
|