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 |