| 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 | 
|  | 
|  |