blob: c0d7d05a249cf843659407479bf025610f5aedcf [file] [log] [blame]
#!/usr/bin/env python
# Copyright (c) 2014 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.
"""Run a command and report its results in machine-readable format."""
import collections
import optparse
import os
import pickle
import pprint
import socket
import subprocess
import sys
import traceback
buildbot_path = os.path.abspath(os.path.join(os.path.dirname(__file__),
os.pardir))
sys.path.append(os.path.join(buildbot_path))
from site_config import slave_hosts_cfg
class BaseCommandResults(object):
"""Base class for CommandResults classes."""
# We print this string before and after the important output from the command.
# This makes it easy to ignore output from SSH, shells, etc.
BOOKEND_STR = '@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@'
def encode(self):
"""Convert the results into a machine-readable string.
Returns:
A hex-encoded string, bookended by BOOKEND_STR for easy parsing.
"""
raise NotImplementedError()
@staticmethod
def decode(results_str):
"""Convert a machine-readable string into a CommandResults instance.
Args:
results_str: string; output from "run" or one of its siblings.
Returns:
A dictionary of results.
"""
decoded_dict = pickle.loads(
results_str.split(BaseCommandResults.BOOKEND_STR)[1].decode('hex'))
errors = []
# First, try to interpret the dict as SingleCommandResults.
try:
# This will fail unless decoded_dict has the following set of keys:
# ('returncode', 'stdout', 'stderr')
return SingleCommandResults(**decoded_dict)
except TypeError:
errors.append(traceback.format_exc())
# Next, try to interpret the dict as MultiCommandResults.
try:
results_dict = {}
for (slavename, results) in decoded_dict.iteritems():
results_dict[slavename] = BaseCommandResults.decode(results)
return MultiCommandResults(results_dict)
except Exception:
errors.append(traceback.format_exc())
raise Exception('Unable to decode CommandResults from dict:\n\n%s\n%s'
% ('\n'.join(errors), decoded_dict))
def print_results(self, pretty=False):
"""Print the results of a command.
Args:
pretty: bool; whether or not to print in human-readable format.
"""
if pretty:
print pprint.pformat(self.__dict__)
else:
print self.encode()
class SingleCommandResults(collections.namedtuple('CommandResults_tuple',
'stdout, stderr, returncode'),
BaseCommandResults):
"""Results for a single command. Properties: stdout, stderr, and returncode"""
def encode(self):
"""Convert the results into a machine-readable string.
Returns:
A hex-encoded string, bookended by BOOKEND_STR for easy parsing.
"""
return (BaseCommandResults.BOOKEND_STR +
pickle.dumps(self.__dict__).encode('hex') +
BaseCommandResults.BOOKEND_STR)
@staticmethod
def make(stdout='', stderr='', returncode=1):
"""Create CommandResults for a command.
Args:
stdout: string; stdout from a command.
stderr: string; stderr from a command.
returncode: string; return code of a command.
"""
return SingleCommandResults(stdout=stdout,
stderr=stderr,
returncode=returncode)
@property
def __dict__(self):
"""Return a dictionary representation of this CommandResults instance.
Since collections.NamedTuple.__dict__ returns an OrderedDict, we have to
create this wrapper to get a normal dict.
"""
return dict(self._asdict())
class MultiCommandResults(BaseCommandResults):
"""Encapsulates CommandResults for multiple buildslaves or hosts.
MultiCommandResults can form tree structures whose leaves are instances of
SingleComamandResults and interior nodes are instances of MultiCommandResults:
MultiCommandResults({
'remote_slave_host_name': MultiCommandResults({
'slave_name': SingleCommandResults,
'slave_name2': SingleCommandResults,
}),
'local_slave_name': SingleCommandResults,
})
"""
def __init__(self, results):
"""Instantiate the MultiCommandResults.
Args:
results: dict whose keys are slavenames or slave host names and values
are instances of a BaseCommandResults subclass.
"""
super(MultiCommandResults, self).__init__()
self._dict = {}
for (slavename, result) in results.iteritems():
if not issubclass(result.__class__, BaseCommandResults):
raise ValueError('%s is not a subclass of BaseCommandResults.'
% result.__class__)
self._dict[slavename] = result
def __getitem__(self, key):
return self._dict[key]
def __iter__(self):
return self._dict.__iter__()
def __len__(self):
return self._dict.__len__()
def iteritems(self):
return self._dict.iteritems()
def iterkeys(self):
return self._dict.iterkeys()
def encode(self):
"""Convert the results into a machine-readable string.
Returns:
A hex-encoded string, bookended by BOOKEND_STR for easy parsing.
"""
encoded_dict = dict([(key, value.encode())
for (key, value) in self._dict.iteritems()])
return (BaseCommandResults.BOOKEND_STR +
pickle.dumps(encoded_dict).encode('hex') +
BaseCommandResults.BOOKEND_STR)
@property
def __dict__(self):
return dict([(key, value.__dict__)
for (key, value) in self._dict.iteritems()])
class ResolvableCommandElement(object):
"""Base class for elements of commands which have different string values
depending on the properties of the host."""
def resolve(self, slave_host_name):
"""Resolve this ResolvableCommandElement as appropriate.
Args:
slave_host_name: string; name of the slave host.
Returns:
string whose value depends on the given slave_host_name in some way.
"""
raise NotImplementedError
class ResolvablePath(ResolvableCommandElement):
"""Represents a path."""
def __init__(self, *path_elems):
"""Instantiate this ResolvablePath.
Args:
path_elems: strings or ResolvableCommandElements which will be joined to
form a path.
"""
super(ResolvablePath, self).__init__()
self._path_elems = list(path_elems)
def resolve(self, slave_host_name):
"""Resolve this ResolvablePath as appropriate.
Args:
slave_host_name: string; name of the slave host.
Returns:
string whose value depends on the given slave_host_name in some way.
"""
host_data = slave_hosts_cfg.get_slave_host_config(slave_host_name)
fixed_path_elems = _fixup_cmd(self._path_elems, slave_host_name)
return host_data.path_module.join(*fixed_path_elems)
def _fixup_cmd(cmd, slave_host_name):
"""Resolve the command into a list of strings.
Args:
cmd: list containing strings or ResolvableCommandElements.
slave_host_name: string; the name of the relevant slave host machine.
"""
new_cmd = []
for elem in cmd:
if isinstance(elem, ResolvableCommandElement):
resolved_elem = elem.resolve(slave_host_name)
new_cmd.append(resolved_elem)
else:
new_cmd.append(elem)
return new_cmd
def _launch_cmd(cmd, cwd=None):
"""Launch the given command. Non-blocking.
Args:
cmd: list of strings; command to run.
cwd: working directory in which to run the process. Defaults to the root
of the buildbot checkout containing this file.
Returns:
subprocess.Popen instance.
"""
if not cwd:
cwd = buildbot_path
return subprocess.Popen(cmd, shell=False, cwd=cwd, stderr=subprocess.PIPE,
stdout=subprocess.PIPE)
def _get_result(popen):
"""Get the results from a running process. Blocks until the process completes.
Args:
popen: subprocess.Popen instance.
Returns:
CommandResults instance, decoded from the results of the process.
"""
stdout, stderr = popen.communicate()
try:
return BaseCommandResults.decode(stdout)
except Exception:
pass
return SingleCommandResults.make(stdout=stdout,
stderr=stderr,
returncode=popen.returncode)
def run(cmd):
"""Run the command, block until it completes, and return a results dictionary.
Args:
cmd: string or list of strings; the command to run.
Returns:
CommandResults instance, decoded from the results of the command.
"""
try:
proc = _launch_cmd(cmd)
except OSError as e:
return SingleCommandResults.make(stderr=str(e))
return _get_result(proc)
def run_on_local_slaves(cmd):
"""Run the command on each local buildslave, blocking until completion.
Args:
cmd: list of strings; the command to run.
Returns:
MultiCommandResults instance containing the results of the command on each
of the local slaves.
"""
slave_host = slave_hosts_cfg.get_slave_host_config(socket.gethostname())
slaves = slave_host.slaves
results = {}
procs = []
for (slave, _) in slaves:
try:
proc = _launch_cmd(cmd, cwd=os.path.join(buildbot_path, slave,
'buildbot'))
procs.append((slave, proc))
except OSError as e:
results[slave] = SingleCommandResults.make(stderr=str(e))
for slavename, proc in procs:
results[slavename] = _get_result(proc)
return MultiCommandResults(results)
def _launch_on_remote_host(slave_host_name, cmd):
"""Launch the command on a remote slave host machine. Non-blocking.
Args:
slave_host_name: string; name of the slave host machine.
cmd: list of strings; command to run.
Returns:
subprocess.Popen instance.
"""
host = slave_hosts_cfg.SLAVE_HOSTS[slave_host_name]
login_cmd = host.login_cmd
if not login_cmd:
raise ValueError('%s does not have a remote login procedure defined in '
'slave_hosts_cfg.py.' % slave_host_name)
path_to_buildbot = host.path_module.join(*host.path_to_buildbot)
path_to_run_cmd = host.path_module.join(path_to_buildbot, 'scripts',
'run_cmd.py')
return _launch_cmd(login_cmd + ['python', path_to_run_cmd] +
_fixup_cmd(cmd, slave_host_name))
def run_on_remote_host(slave_host_name, cmd):
"""Run a command on a remote slave host machine, blocking until completion.
Args:
slave_host_name: string; name of the slave host machine.
cmd: list of strings or ResolvableCommandElements; the command to run.
Returns:
CommandResults instance containing the results of the command.
"""
proc = _launch_on_remote_host(slave_host_name, cmd)
return _get_result(proc)
def _get_remote_slaves_cmd(cmd):
"""Build a command which runs the command on all slaves on a remote host.
Args:
cmd: list of strings or ResolvableCommandElements; the command to run.
Returns:
list of strings or ResolvableCommandElements; a command which results in
the given command being run on all of the slaves on the remote host.
"""
return ['python', ResolvablePath('scripts', 'run_on_local_slaves.py')] + cmd
def run_on_remote_slaves(slave_host_name, cmd):
"""Run a command on each buildslave on a remote slave host machine, blocking
until completion.
Args:
slave_host_name: string; name of the slave host machine.
cmd: list of strings or ResolvableCommandElements; the command to run.
Returns:
MultiCommandResults instance with results from each slave on the remote
host.
"""
proc = _launch_on_remote_host(slave_host_name, _get_remote_slaves_cmd(cmd))
return _get_result(proc)
def run_on_all_slave_hosts(cmd):
"""Run the given command on all slave hosts, blocking until all complete.
Args:
cmd: list of strings or ResolvableCommandElements; the command to run.
Returns:
MultiCommandResults instance with results from each remote slave host.
"""
results = {}
procs = []
for hostname in slave_hosts_cfg.SLAVE_HOSTS.iterkeys():
if not slave_hosts_cfg.SLAVE_HOSTS[hostname].remote_access:
continue
if not slave_hosts_cfg.SLAVE_HOSTS[hostname].login_cmd:
results.update({
hostname: SingleCommandResults.make(stderr='No procedure for login.'),
})
else:
procs.append((hostname, _launch_on_remote_host(hostname, cmd)))
for slavename, proc in procs:
results[slavename] = _get_result(proc)
return MultiCommandResults(results)
def run_on_all_slaves_on_all_hosts(cmd):
"""Run the given command on all slaves on all hosts. Blocks until completion.
Args:
cmd: list of strings or ResolvableCommandElements; the command to run.
Returns:
MultiCommandResults instance with results from each slave on each remote
slave host.
"""
return run_on_all_slave_hosts(_get_remote_slaves_cmd(cmd))
def parse_args(positional_args=None):
"""Common argument parser for scripts using this module.
Args:
positional_args: optional list of strings; extra positional arguments to
the script.
"""
parser = optparse.OptionParser()
parser.disable_interspersed_args()
parser.add_option('-p', '--pretty', action='store_true', dest='pretty',
help='Print output in a human-readable form.')
# Fixup the usage message to include the positional args.
cmd = 'cmd'
all_positional_args = (positional_args or []) + [cmd]
usage = parser.get_usage().rstrip()
for arg in all_positional_args:
usage += ' ' + arg
parser.set_usage(usage)
options, args = parser.parse_args()
# Set positional arguments.
for positional_arg in positional_args or []:
try:
setattr(options, positional_arg, args[0])
except IndexError:
parser.print_usage()
sys.exit(1)
args = args[1:]
# Everything else is part of the command to run.
try:
setattr(options, cmd, args)
except IndexError:
parser.print_usage()
sys.exit(1)
return options
if '__main__' == __name__:
parsed_args = parse_args()
run(parsed_args.cmd).print_results(pretty=parsed_args.pretty)