| OLD | NEW |
| (Empty) |
| 1 #!/usr/bin/env python | |
| 2 # Copyright (c) 2014 The Chromium Authors. All rights reserved. | |
| 3 # Use of this source code is governed by a BSD-style license that can be | |
| 4 # found in the LICENSE file. | |
| 5 | |
| 6 | |
| 7 """Run a command and report its results in machine-readable format.""" | |
| 8 | |
| 9 | |
| 10 import collections | |
| 11 import optparse | |
| 12 import os | |
| 13 import pickle | |
| 14 import pprint | |
| 15 import socket | |
| 16 import subprocess | |
| 17 import sys | |
| 18 import traceback | |
| 19 | |
| 20 buildbot_path = os.path.abspath(os.path.join(os.path.dirname(__file__), | |
| 21 os.pardir)) | |
| 22 sys.path.append(os.path.join(buildbot_path)) | |
| 23 | |
| 24 from site_config import slave_hosts_cfg | |
| 25 | |
| 26 | |
| 27 class BaseCommandResults(object): | |
| 28 """Base class for CommandResults classes.""" | |
| 29 | |
| 30 # We print this string before and after the important output from the command. | |
| 31 # This makes it easy to ignore output from SSH, shells, etc. | |
| 32 BOOKEND_STR = '@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@' | |
| 33 | |
| 34 def encode(self): | |
| 35 """Convert the results into a machine-readable string. | |
| 36 | |
| 37 Returns: | |
| 38 A hex-encoded string, bookended by BOOKEND_STR for easy parsing. | |
| 39 """ | |
| 40 raise NotImplementedError() | |
| 41 | |
| 42 @staticmethod | |
| 43 def decode(results_str): | |
| 44 """Convert a machine-readable string into a CommandResults instance. | |
| 45 | |
| 46 Args: | |
| 47 results_str: string; output from "run" or one of its siblings. | |
| 48 Returns: | |
| 49 A dictionary of results. | |
| 50 """ | |
| 51 decoded_dict = pickle.loads( | |
| 52 results_str.split(BaseCommandResults.BOOKEND_STR)[1].decode('hex')) | |
| 53 errors = [] | |
| 54 # First, try to interpret the dict as SingleCommandResults. | |
| 55 try: | |
| 56 # This will fail unless decoded_dict has the following set of keys: | |
| 57 # ('returncode', 'stdout', 'stderr') | |
| 58 return SingleCommandResults(**decoded_dict) | |
| 59 except TypeError: | |
| 60 errors.append(traceback.format_exc()) | |
| 61 # Next, try to interpret the dict as MultiCommandResults. | |
| 62 try: | |
| 63 results_dict = {} | |
| 64 for (slavename, results) in decoded_dict.iteritems(): | |
| 65 results_dict[slavename] = BaseCommandResults.decode(results) | |
| 66 return MultiCommandResults(results_dict) | |
| 67 except Exception: | |
| 68 errors.append(traceback.format_exc()) | |
| 69 raise Exception('Unable to decode CommandResults from dict:\n\n%s\n%s' | |
| 70 % ('\n'.join(errors), decoded_dict)) | |
| 71 | |
| 72 def print_results(self, pretty=False): | |
| 73 """Print the results of a command. | |
| 74 | |
| 75 Args: | |
| 76 pretty: bool; whether or not to print in human-readable format. | |
| 77 """ | |
| 78 if pretty: | |
| 79 print pprint.pformat(self.__dict__) | |
| 80 else: | |
| 81 print self.encode() | |
| 82 | |
| 83 | |
| 84 class SingleCommandResults(collections.namedtuple('CommandResults_tuple', | |
| 85 'stdout, stderr, returncode'), | |
| 86 BaseCommandResults): | |
| 87 """Results for a single command. Properties: stdout, stderr, and returncode""" | |
| 88 | |
| 89 def encode(self): | |
| 90 """Convert the results into a machine-readable string. | |
| 91 | |
| 92 Returns: | |
| 93 A hex-encoded string, bookended by BOOKEND_STR for easy parsing. | |
| 94 """ | |
| 95 return (BaseCommandResults.BOOKEND_STR + | |
| 96 pickle.dumps(self.__dict__).encode('hex') + | |
| 97 BaseCommandResults.BOOKEND_STR) | |
| 98 | |
| 99 @staticmethod | |
| 100 def make(stdout='', stderr='', returncode=1): | |
| 101 """Create CommandResults for a command. | |
| 102 | |
| 103 Args: | |
| 104 stdout: string; stdout from a command. | |
| 105 stderr: string; stderr from a command. | |
| 106 returncode: string; return code of a command. | |
| 107 """ | |
| 108 return SingleCommandResults(stdout=stdout, | |
| 109 stderr=stderr, | |
| 110 returncode=returncode) | |
| 111 | |
| 112 @property | |
| 113 def __dict__(self): | |
| 114 """Return a dictionary representation of this CommandResults instance. | |
| 115 | |
| 116 Since collections.NamedTuple.__dict__ returns an OrderedDict, we have to | |
| 117 create this wrapper to get a normal dict. | |
| 118 """ | |
| 119 return dict(self._asdict()) | |
| 120 | |
| 121 | |
| 122 class MultiCommandResults(BaseCommandResults): | |
| 123 """Encapsulates CommandResults for multiple buildslaves or hosts. | |
| 124 | |
| 125 MultiCommandResults can form tree structures whose leaves are instances of | |
| 126 SingleComamandResults and interior nodes are instances of MultiCommandResults: | |
| 127 | |
| 128 MultiCommandResults({ | |
| 129 'remote_slave_host_name': MultiCommandResults({ | |
| 130 'slave_name': SingleCommandResults, | |
| 131 'slave_name2': SingleCommandResults, | |
| 132 }), | |
| 133 'local_slave_name': SingleCommandResults, | |
| 134 }) | |
| 135 """ | |
| 136 | |
| 137 def __init__(self, results): | |
| 138 """Instantiate the MultiCommandResults. | |
| 139 | |
| 140 Args: | |
| 141 results: dict whose keys are slavenames or slave host names and values | |
| 142 are instances of a BaseCommandResults subclass. | |
| 143 """ | |
| 144 super(MultiCommandResults, self).__init__() | |
| 145 self._dict = {} | |
| 146 for (slavename, result) in results.iteritems(): | |
| 147 if not issubclass(result.__class__, BaseCommandResults): | |
| 148 raise ValueError('%s is not a subclass of BaseCommandResults.' | |
| 149 % result.__class__) | |
| 150 self._dict[slavename] = result | |
| 151 | |
| 152 def __getitem__(self, key): | |
| 153 return self._dict[key] | |
| 154 | |
| 155 def __iter__(self): | |
| 156 return self._dict.__iter__() | |
| 157 | |
| 158 def __len__(self): | |
| 159 return self._dict.__len__() | |
| 160 | |
| 161 def iteritems(self): | |
| 162 return self._dict.iteritems() | |
| 163 | |
| 164 def iterkeys(self): | |
| 165 return self._dict.iterkeys() | |
| 166 | |
| 167 def encode(self): | |
| 168 """Convert the results into a machine-readable string. | |
| 169 | |
| 170 Returns: | |
| 171 A hex-encoded string, bookended by BOOKEND_STR for easy parsing. | |
| 172 """ | |
| 173 encoded_dict = dict([(key, value.encode()) | |
| 174 for (key, value) in self._dict.iteritems()]) | |
| 175 return (BaseCommandResults.BOOKEND_STR + | |
| 176 pickle.dumps(encoded_dict).encode('hex') + | |
| 177 BaseCommandResults.BOOKEND_STR) | |
| 178 | |
| 179 @property | |
| 180 def __dict__(self): | |
| 181 return dict([(key, value.__dict__) | |
| 182 for (key, value) in self._dict.iteritems()]) | |
| 183 | |
| 184 | |
| 185 class ResolvableCommandElement(object): | |
| 186 """Base class for elements of commands which have different string values | |
| 187 depending on the properties of the host.""" | |
| 188 | |
| 189 def resolve(self, slave_host_name): | |
| 190 """Resolve this ResolvableCommandElement as appropriate. | |
| 191 | |
| 192 Args: | |
| 193 slave_host_name: string; name of the slave host. | |
| 194 Returns: | |
| 195 string whose value depends on the given slave_host_name in some way. | |
| 196 """ | |
| 197 raise NotImplementedError | |
| 198 | |
| 199 | |
| 200 class ResolvablePath(ResolvableCommandElement): | |
| 201 """Represents a path.""" | |
| 202 | |
| 203 def __init__(self, *path_elems): | |
| 204 """Instantiate this ResolvablePath. | |
| 205 | |
| 206 Args: | |
| 207 path_elems: strings or ResolvableCommandElements which will be joined to | |
| 208 form a path. | |
| 209 """ | |
| 210 super(ResolvablePath, self).__init__() | |
| 211 self._path_elems = list(path_elems) | |
| 212 | |
| 213 def resolve(self, slave_host_name): | |
| 214 """Resolve this ResolvablePath as appropriate. | |
| 215 | |
| 216 Args: | |
| 217 slave_host_name: string; name of the slave host. | |
| 218 Returns: | |
| 219 string whose value depends on the given slave_host_name in some way. | |
| 220 """ | |
| 221 host_data = slave_hosts_cfg.get_slave_host_config(slave_host_name) | |
| 222 fixed_path_elems = _fixup_cmd(self._path_elems, slave_host_name) | |
| 223 return host_data.path_module.join(*fixed_path_elems) | |
| 224 | |
| 225 | |
| 226 def _fixup_cmd(cmd, slave_host_name): | |
| 227 """Resolve the command into a list of strings. | |
| 228 | |
| 229 Args: | |
| 230 cmd: list containing strings or ResolvableCommandElements. | |
| 231 slave_host_name: string; the name of the relevant slave host machine. | |
| 232 """ | |
| 233 new_cmd = [] | |
| 234 for elem in cmd: | |
| 235 if isinstance(elem, ResolvableCommandElement): | |
| 236 resolved_elem = elem.resolve(slave_host_name) | |
| 237 new_cmd.append(resolved_elem) | |
| 238 else: | |
| 239 new_cmd.append(elem) | |
| 240 return new_cmd | |
| 241 | |
| 242 | |
| 243 def _launch_cmd(cmd, cwd=None): | |
| 244 """Launch the given command. Non-blocking. | |
| 245 | |
| 246 Args: | |
| 247 cmd: list of strings; command to run. | |
| 248 cwd: working directory in which to run the process. Defaults to the root | |
| 249 of the buildbot checkout containing this file. | |
| 250 Returns: | |
| 251 subprocess.Popen instance. | |
| 252 """ | |
| 253 if not cwd: | |
| 254 cwd = buildbot_path | |
| 255 return subprocess.Popen(cmd, shell=False, cwd=cwd, stderr=subprocess.PIPE, | |
| 256 stdout=subprocess.PIPE) | |
| 257 | |
| 258 | |
| 259 def _get_result(popen): | |
| 260 """Get the results from a running process. Blocks until the process completes. | |
| 261 | |
| 262 Args: | |
| 263 popen: subprocess.Popen instance. | |
| 264 Returns: | |
| 265 CommandResults instance, decoded from the results of the process. | |
| 266 """ | |
| 267 stdout, stderr = popen.communicate() | |
| 268 try: | |
| 269 return BaseCommandResults.decode(stdout) | |
| 270 except Exception: | |
| 271 pass | |
| 272 return SingleCommandResults.make(stdout=stdout, | |
| 273 stderr=stderr, | |
| 274 returncode=popen.returncode) | |
| 275 | |
| 276 | |
| 277 def run(cmd): | |
| 278 """Run the command, block until it completes, and return a results dictionary. | |
| 279 | |
| 280 Args: | |
| 281 cmd: string or list of strings; the command to run. | |
| 282 Returns: | |
| 283 CommandResults instance, decoded from the results of the command. | |
| 284 """ | |
| 285 try: | |
| 286 proc = _launch_cmd(cmd) | |
| 287 except OSError as e: | |
| 288 return SingleCommandResults.make(stderr=str(e)) | |
| 289 return _get_result(proc) | |
| 290 | |
| 291 | |
| 292 def run_on_local_slaves(cmd): | |
| 293 """Run the command on each local buildslave, blocking until completion. | |
| 294 | |
| 295 Args: | |
| 296 cmd: list of strings; the command to run. | |
| 297 Returns: | |
| 298 MultiCommandResults instance containing the results of the command on each | |
| 299 of the local slaves. | |
| 300 """ | |
| 301 slave_host = slave_hosts_cfg.get_slave_host_config(socket.gethostname()) | |
| 302 slaves = slave_host.slaves | |
| 303 results = {} | |
| 304 procs = [] | |
| 305 for (slave, _) in slaves: | |
| 306 try: | |
| 307 proc = _launch_cmd(cmd, cwd=os.path.join(buildbot_path, slave, | |
| 308 'buildbot')) | |
| 309 procs.append((slave, proc)) | |
| 310 except OSError as e: | |
| 311 results[slave] = SingleCommandResults.make(stderr=str(e)) | |
| 312 | |
| 313 for slavename, proc in procs: | |
| 314 results[slavename] = _get_result(proc) | |
| 315 | |
| 316 return MultiCommandResults(results) | |
| 317 | |
| 318 | |
| 319 def _launch_on_remote_host(slave_host_name, cmd): | |
| 320 """Launch the command on a remote slave host machine. Non-blocking. | |
| 321 | |
| 322 Args: | |
| 323 slave_host_name: string; name of the slave host machine. | |
| 324 cmd: list of strings; command to run. | |
| 325 Returns: | |
| 326 subprocess.Popen instance. | |
| 327 """ | |
| 328 host = slave_hosts_cfg.SLAVE_HOSTS[slave_host_name] | |
| 329 login_cmd = host.login_cmd | |
| 330 if not login_cmd: | |
| 331 raise ValueError('%s does not have a remote login procedure defined in ' | |
| 332 'slave_hosts_cfg.py.' % slave_host_name) | |
| 333 path_to_buildbot = host.path_module.join(*host.path_to_buildbot) | |
| 334 path_to_run_cmd = host.path_module.join(path_to_buildbot, 'scripts', | |
| 335 'run_cmd.py') | |
| 336 return _launch_cmd(login_cmd + ['python', path_to_run_cmd] + | |
| 337 _fixup_cmd(cmd, slave_host_name)) | |
| 338 | |
| 339 | |
| 340 def run_on_remote_host(slave_host_name, cmd): | |
| 341 """Run a command on a remote slave host machine, blocking until completion. | |
| 342 | |
| 343 Args: | |
| 344 slave_host_name: string; name of the slave host machine. | |
| 345 cmd: list of strings or ResolvableCommandElements; the command to run. | |
| 346 Returns: | |
| 347 CommandResults instance containing the results of the command. | |
| 348 """ | |
| 349 proc = _launch_on_remote_host(slave_host_name, cmd) | |
| 350 return _get_result(proc) | |
| 351 | |
| 352 | |
| 353 def _get_remote_slaves_cmd(cmd): | |
| 354 """Build a command which runs the command on all slaves on a remote host. | |
| 355 | |
| 356 Args: | |
| 357 cmd: list of strings or ResolvableCommandElements; the command to run. | |
| 358 Returns: | |
| 359 list of strings or ResolvableCommandElements; a command which results in | |
| 360 the given command being run on all of the slaves on the remote host. | |
| 361 """ | |
| 362 return ['python', ResolvablePath('scripts', 'run_on_local_slaves.py')] + cmd | |
| 363 | |
| 364 | |
| 365 def run_on_remote_slaves(slave_host_name, cmd): | |
| 366 """Run a command on each buildslave on a remote slave host machine, blocking | |
| 367 until completion. | |
| 368 | |
| 369 Args: | |
| 370 slave_host_name: string; name of the slave host machine. | |
| 371 cmd: list of strings or ResolvableCommandElements; the command to run. | |
| 372 Returns: | |
| 373 MultiCommandResults instance with results from each slave on the remote | |
| 374 host. | |
| 375 """ | |
| 376 proc = _launch_on_remote_host(slave_host_name, _get_remote_slaves_cmd(cmd)) | |
| 377 return _get_result(proc) | |
| 378 | |
| 379 | |
| 380 def run_on_all_slave_hosts(cmd): | |
| 381 """Run the given command on all slave hosts, blocking until all complete. | |
| 382 | |
| 383 Args: | |
| 384 cmd: list of strings or ResolvableCommandElements; the command to run. | |
| 385 Returns: | |
| 386 MultiCommandResults instance with results from each remote slave host. | |
| 387 """ | |
| 388 results = {} | |
| 389 procs = [] | |
| 390 | |
| 391 for hostname in slave_hosts_cfg.SLAVE_HOSTS.iterkeys(): | |
| 392 if not slave_hosts_cfg.SLAVE_HOSTS[hostname].remote_access: | |
| 393 continue | |
| 394 if not slave_hosts_cfg.SLAVE_HOSTS[hostname].login_cmd: | |
| 395 results.update({ | |
| 396 hostname: SingleCommandResults.make(stderr='No procedure for login.'), | |
| 397 }) | |
| 398 else: | |
| 399 procs.append((hostname, _launch_on_remote_host(hostname, cmd))) | |
| 400 | |
| 401 for slavename, proc in procs: | |
| 402 results[slavename] = _get_result(proc) | |
| 403 | |
| 404 return MultiCommandResults(results) | |
| 405 | |
| 406 | |
| 407 def run_on_all_slaves_on_all_hosts(cmd): | |
| 408 """Run the given command on all slaves on all hosts. Blocks until completion. | |
| 409 | |
| 410 Args: | |
| 411 cmd: list of strings or ResolvableCommandElements; the command to run. | |
| 412 Returns: | |
| 413 MultiCommandResults instance with results from each slave on each remote | |
| 414 slave host. | |
| 415 """ | |
| 416 return run_on_all_slave_hosts(_get_remote_slaves_cmd(cmd)) | |
| 417 | |
| 418 | |
| 419 def parse_args(positional_args=None): | |
| 420 """Common argument parser for scripts using this module. | |
| 421 | |
| 422 Args: | |
| 423 positional_args: optional list of strings; extra positional arguments to | |
| 424 the script. | |
| 425 """ | |
| 426 parser = optparse.OptionParser() | |
| 427 parser.disable_interspersed_args() | |
| 428 parser.add_option('-p', '--pretty', action='store_true', dest='pretty', | |
| 429 help='Print output in a human-readable form.') | |
| 430 | |
| 431 # Fixup the usage message to include the positional args. | |
| 432 cmd = 'cmd' | |
| 433 all_positional_args = (positional_args or []) + [cmd] | |
| 434 usage = parser.get_usage().rstrip() | |
| 435 for arg in all_positional_args: | |
| 436 usage += ' ' + arg | |
| 437 parser.set_usage(usage) | |
| 438 | |
| 439 options, args = parser.parse_args() | |
| 440 | |
| 441 # Set positional arguments. | |
| 442 for positional_arg in positional_args or []: | |
| 443 try: | |
| 444 setattr(options, positional_arg, args[0]) | |
| 445 except IndexError: | |
| 446 parser.print_usage() | |
| 447 sys.exit(1) | |
| 448 args = args[1:] | |
| 449 | |
| 450 # Everything else is part of the command to run. | |
| 451 try: | |
| 452 setattr(options, cmd, args) | |
| 453 except IndexError: | |
| 454 parser.print_usage() | |
| 455 sys.exit(1) | |
| 456 return options | |
| 457 | |
| 458 | |
| 459 if '__main__' == __name__: | |
| 460 parsed_args = parse_args() | |
| 461 run(parsed_args.cmd).print_results(pretty=parsed_args.pretty) | |
| OLD | NEW |