| OLD | NEW |
| 1 #!/usr/bin/env python | 1 # Copyright (c) 2015 The Chromium Authors. All rights reserved. |
| 2 # Copyright (c) 2013 The Chromium Authors. All rights reserved. | |
| 3 # Use of this source code is governed by a BSD-style license that can be | 2 # Use of this source code is governed by a BSD-style license that can be |
| 4 # found in the LICENSE file. | 3 # found in the LICENSE file. |
| 5 | 4 |
| 6 """Contains generating and parsing systems of the Chromium Buildbot Annotator. | 5 """Contains the parsing system of the Chromium Buildbot Annotator.""" |
| 7 | 6 |
| 8 When executed as a script, this reads step name / command pairs from a file and | |
| 9 executes those lines while annotating the output. The input is json: | |
| 10 | |
| 11 [{"name": "step_name", "cmd": ["command", "arg1", "arg2"]}, | |
| 12 {"name": "step_name2", "cmd": ["command2", "arg1"]}] | |
| 13 | |
| 14 """ | |
| 15 | |
| 16 import calendar | |
| 17 import contextlib | |
| 18 import datetime | |
| 19 import json | |
| 20 import optparse | |
| 21 import os | 7 import os |
| 22 import subprocess | |
| 23 import sys | 8 import sys |
| 24 import threading | |
| 25 import traceback | 9 import traceback |
| 26 | 10 |
| 27 | |
| 28 # These are maps of annotation key -> number of expected arguments. | 11 # These are maps of annotation key -> number of expected arguments. |
| 29 STEP_ANNOTATIONS = { | 12 STEP_ANNOTATIONS = { |
| 30 'SET_BUILD_PROPERTY': 2, | 13 'SET_BUILD_PROPERTY': 2, |
| 31 'STEP_CLEAR': 0, | 14 'STEP_CLEAR': 0, |
| 32 'STEP_EXCEPTION': 0, | 15 'STEP_EXCEPTION': 0, |
| 33 'STEP_FAILURE': 0, | 16 'STEP_FAILURE': 0, |
| 34 'STEP_LINK': 2, | 17 'STEP_LINK': 2, |
| 35 'STEP_LOG_END': 1, | 18 'STEP_LOG_END': 1, |
| 36 'STEP_LOG_END_PERF': 2, | 19 'STEP_LOG_END_PERF': 2, |
| 37 'STEP_LOG_LINE': 2, | 20 'STEP_LOG_LINE': 2, |
| (...skipping 147 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 185 | 168 |
| 186 logname = logname.replace('/', '/') | 169 logname = logname.replace('/', '/') |
| 187 | 170 |
| 188 for line in lines: | 171 for line in lines: |
| 189 self.step_log_line(logname, line) | 172 self.step_log_line(logname, line) |
| 190 if perf: | 173 if perf: |
| 191 self.step_log_end_perf(logname, perf) | 174 self.step_log_end_perf(logname, perf) |
| 192 else: | 175 else: |
| 193 self.step_log_end(logname) | 176 self.step_log_end(logname) |
| 194 | 177 |
| 178 |
| 195 class StepControlCommands(AnnotationPrinter): | 179 class StepControlCommands(AnnotationPrinter): |
| 196 """Subclass holding step control commands. Intended to be subclassed. | 180 """Subclass holding step control commands. Intended to be subclassed. |
| 197 | 181 |
| 198 This is subclassed out so callers in StructuredAnnotationStep can't call | 182 This is subclassed out so callers in StructuredAnnotationStep can't call |
| 199 step_started() or step_closed(). | 183 step_started() or step_closed(). |
| 200 """ | 184 """ |
| 201 ANNOTATIONS = CONTROL_ANNOTATIONS | 185 ANNOTATIONS = CONTROL_ANNOTATIONS |
| 202 | 186 |
| 203 | 187 |
| 204 class StructuredAnnotationStep(StepCommands): | 188 class StructuredAnnotationStep(StepCommands, StepControlCommands): |
| 205 """Helper class to provide context for a step.""" | 189 """Helper class to provide context for a step.""" |
| 206 | 190 |
| 207 def __init__(self, annotation_stream, *args, **kwargs): | 191 def __init__(self, annotation_stream, *args, **kwargs): |
| 208 self.annotation_stream = annotation_stream | 192 self.annotation_stream = annotation_stream |
| 209 super(StructuredAnnotationStep, self).__init__(*args, **kwargs) | 193 super(StructuredAnnotationStep, self).__init__(*args, **kwargs) |
| 210 self.control = StepControlCommands(self.stream, self.flush_before) | 194 self.control = StepControlCommands(self.stream, self.flush_before) |
| 211 self.emitted_logs = set() | 195 self.emitted_logs = set() |
| 212 | 196 |
| 213 | 197 |
| 214 def __enter__(self): | 198 def __enter__(self): |
| (...skipping 19 matching lines...) Expand all Loading... |
| 234 self.write_log_lines('exception', filter(None, trace_lines)) | 218 self.write_log_lines('exception', filter(None, trace_lines)) |
| 235 self.step_exception() | 219 self.step_exception() |
| 236 | 220 |
| 237 def step_ended(self): | 221 def step_ended(self): |
| 238 self.annotation_stream.step_cursor(self.annotation_stream.current_step) | 222 self.annotation_stream.step_cursor(self.annotation_stream.current_step) |
| 239 self.control.step_closed() | 223 self.control.step_closed() |
| 240 self.annotation_stream.current_step = '' | 224 self.annotation_stream.current_step = '' |
| 241 | 225 |
| 242 return True | 226 return True |
| 243 | 227 |
| 244 class AdvancedAnnotationStep(StepCommands, StepControlCommands): | |
| 245 """Holds additional step functions for finer step control. | |
| 246 | 228 |
| 247 Most users will want to use StructuredAnnotationSteps generated from a | 229 class StructuredAnnotationStream(AnnotationPrinter): |
| 248 StructuredAnnotationStream as these handle state automatically. | |
| 249 """ | |
| 250 | |
| 251 def __init__(self, *args, **kwargs): | |
| 252 super(AdvancedAnnotationStep, self).__init__(*args, **kwargs) | |
| 253 | |
| 254 | |
| 255 class AdvancedAnnotationStream(AnnotationPrinter): | |
| 256 """Holds individual annotation generating functions for streams. | |
| 257 | |
| 258 Most callers should use StructuredAnnotationStream to simplify coding and | |
| 259 avoid errors. For the rare cases where StructuredAnnotationStream is | |
| 260 insufficient (parallel step execution), the individual functions are exposed | |
| 261 here. | |
| 262 """ | |
| 263 ANNOTATIONS = STREAM_ANNOTATIONS | |
| 264 | |
| 265 | |
| 266 class StructuredAnnotationStream(AdvancedAnnotationStream): | |
| 267 """Provides an interface to handle an annotated build. | 230 """Provides an interface to handle an annotated build. |
| 268 | 231 |
| 269 StructuredAnnotationStream handles most of the step setup and closure calls | 232 StructuredAnnotationStream handles most of the step setup and closure calls |
| 270 for you. All you have to do is execute your code within the steps and set any | 233 for you. All you have to do is execute your code within the steps and set any |
| 271 failures or warnings that come up. You may optionally provide a list of steps | 234 failures or warnings that come up. You may optionally provide a list of steps |
| 272 to seed before execution. | 235 to seed before execution. |
| 273 | 236 |
| 274 Usage: | 237 Usage: |
| 275 | 238 |
| 276 stream = StructuredAnnotationStream() | 239 stream = StructuredAnnotationStream() |
| 277 with stream.step('compile') as s: | 240 with stream.step('compile') as s: |
| 278 # do something | 241 # do something |
| 279 if error: | 242 if error: |
| 280 s.step_failure() | 243 s.step_failure() |
| 281 with stream.step('test') as s: | 244 with stream.step('test') as s: |
| 282 # do something | 245 # do something |
| 283 if warnings: | 246 if warnings: |
| 284 s.step_warnings() | 247 s.step_warnings() |
| 285 """ | 248 """ |
| 249 ANNOTATIONS = STREAM_ANNOTATIONS |
| 286 | 250 |
| 287 def __init__(self, stream=sys.stdout, | 251 def __init__(self, stream=sys.stdout, |
| 288 flush_before=sys.stderr, | 252 flush_before=sys.stderr, |
| 289 seed_steps=None): # pylint: disable=W0613 | 253 seed_steps=None): # pylint: disable=W0613 |
| 290 super(StructuredAnnotationStream, self).__init__(stream=stream, | 254 super(StructuredAnnotationStream, self).__init__(stream=stream, |
| 291 flush_before=flush_before) | 255 flush_before=flush_before) |
| 292 self.current_step = '' | 256 self.current_step = '' |
| 293 | 257 |
| 294 def step(self, name): | 258 def step(self, name): |
| 295 """Provide a context with which to execute a step.""" | 259 """Provide a context with which to execute a step.""" |
| (...skipping 54 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 350 args = [] | 314 args = [] |
| 351 | 315 |
| 352 fn = getattr(callback_implementor, cmd, None) | 316 fn = getattr(callback_implementor, cmd, None) |
| 353 if fn is None: | 317 if fn is None: |
| 354 raise Exception('"%s" does not implement "%s"' | 318 raise Exception('"%s" does not implement "%s"' |
| 355 % (callback_implementor, cmd)) | 319 % (callback_implementor, cmd)) |
| 356 | 320 |
| 357 fn(*args) | 321 fn(*args) |
| 358 | 322 |
| 359 | 323 |
| 360 def _merge_envs(original, override): | |
| 361 """Merges two environments. | |
| 362 | |
| 363 Returns a new environment dict with entries from |override| overwriting | |
| 364 corresponding entries in |original|. Keys whose value is None will completely | |
| 365 remove the environment variable. Values can contain %(KEY)s strings, which | |
| 366 will be substituted with the values from the original (useful for amending, as | |
| 367 opposed to overwriting, variables like PATH). | |
| 368 """ | |
| 369 result = original.copy() | |
| 370 if not override: | |
| 371 return result | |
| 372 for k, v in override.items(): | |
| 373 if v is None: | |
| 374 if k in result: | |
| 375 del result[k] | |
| 376 else: | |
| 377 result[str(k)] = str(v) % original | |
| 378 return result | |
| 379 | |
| 380 | |
| 381 def _validate_step(step): | |
| 382 """Validates parameters of the step. | |
| 383 Returns None if it's OK, error message if not. | |
| 384 """ | |
| 385 for req in ['cmd', 'name']: | |
| 386 if req not in step: | |
| 387 return 'missing \'%s\' parameter' % (req,) | |
| 388 if 'cwd' in step and not os.path.isabs(step['cwd']): | |
| 389 return '\'cwd\' should be an absolute path' | |
| 390 return None | |
| 391 | |
| 392 | |
| 393 def print_step(step, env, stream): | 324 def print_step(step, env, stream): |
| 394 """Prints the step command and relevant metadata. | 325 """Prints the step command and relevant metadata. |
| 395 | 326 |
| 396 Intended to be similar to the information that Buildbot prints at the | 327 Intended to be similar to the information that Buildbot prints at the |
| 397 beginning of each non-annotator step. | 328 beginning of each non-annotator step. |
| 398 """ | 329 """ |
| 399 step_info_lines = [] | 330 step_info_lines = [] |
| 400 step_info_lines.append(' '.join(step['cmd'])) | 331 step_info_lines.append(' '.join(step['cmd'])) |
| 401 step_info_lines.append('in dir %s:' % (step['cwd'] or os.getcwd())) | 332 step_info_lines.append('in dir %s:' % (step['cwd'] or os.getcwd())) |
| 402 for key, value in sorted(step.items()): | 333 for key, value in sorted(step.items()): |
| 403 if value is not None: | 334 if value is not None: |
| 404 if callable(value): | 335 if callable(value): |
| 405 # This prevents functions from showing up as: | 336 # This prevents functions from showing up as: |
| 406 # '<function foo at 0x7f523ec7a410>' | 337 # '<function foo at 0x7f523ec7a410>' |
| 407 # which is tricky to test. | 338 # which is tricky to test. |
| 408 value = value.__name__+'(...)' | 339 value = value.__name__+'(...)' |
| 409 step_info_lines.append(' %s: %s' % (key, value)) | 340 step_info_lines.append(' %s: %s' % (key, value)) |
| 410 step_info_lines.append('full environment:') | 341 step_info_lines.append('full environment:') |
| 411 for key, value in sorted(env.items()): | 342 for key, value in sorted(env.items()): |
| 412 step_info_lines.append(' %s: %s' % (key, value)) | 343 step_info_lines.append(' %s: %s' % (key, value)) |
| 413 step_info_lines.append('') | 344 step_info_lines.append('') |
| 414 stream.emit('\n'.join(step_info_lines)) | 345 stream.emit('\n'.join(step_info_lines)) |
| 415 | |
| 416 | |
| 417 @contextlib.contextmanager | |
| 418 def modify_lookup_path(path): | |
| 419 """Places the specified path into os.environ. | |
| 420 | |
| 421 Necessary because subprocess.Popen uses os.environ to perform lookup on the | |
| 422 supplied command, and only uses the |env| kwarg for modifying the environment | |
| 423 of the child process. | |
| 424 """ | |
| 425 saved_path = os.environ['PATH'] | |
| 426 try: | |
| 427 if path is not None: | |
| 428 os.environ['PATH'] = path | |
| 429 yield | |
| 430 finally: | |
| 431 os.environ['PATH'] = saved_path | |
| 432 | |
| 433 | |
| 434 def normalizeChange(change): | |
| 435 assert isinstance(change, dict), 'Change is not a dict' | |
| 436 change = change.copy() | |
| 437 | |
| 438 # Convert when_timestamp to UNIX timestamp. | |
| 439 when = change.get('when_timestamp') | |
| 440 if isinstance(when, datetime.datetime): | |
| 441 when = calendar.timegm(when.utctimetuple()) | |
| 442 change['when_timestamp'] = when | |
| 443 | |
| 444 return change | |
| 445 | |
| 446 | |
| 447 def triggerBuilds(step, trigger_specs): | |
| 448 assert trigger_specs is not None | |
| 449 for trig in trigger_specs: | |
| 450 builder_name = trig.get('builder_name') | |
| 451 if not builder_name: | |
| 452 raise ValueError('Trigger spec: builder_name is not set') | |
| 453 | |
| 454 changes = trig.get('buildbot_changes', []) | |
| 455 assert isinstance(changes, list), 'buildbot_changes must be a list' | |
| 456 changes = map(normalizeChange, changes) | |
| 457 | |
| 458 step.step_trigger(json.dumps({ | |
| 459 'builderNames': [builder_name], | |
| 460 'bucket': trig.get('bucket'), | |
| 461 'changes': changes, | |
| 462 'properties': trig.get('properties'), | |
| 463 }, sort_keys=True)) | |
| 464 | |
| 465 | |
| 466 def run_step(stream, name, cmd, | |
| 467 cwd=None, env=None, | |
| 468 allow_subannotations=False, | |
| 469 trigger_specs=None, | |
| 470 **kwargs): | |
| 471 """Runs a single step. | |
| 472 | |
| 473 Context: | |
| 474 stream: StructuredAnnotationStream to use to emit step | |
| 475 | |
| 476 Step parameters: | |
| 477 name: name of the step, will appear in buildbots waterfall | |
| 478 cmd: command to run, list of one or more strings | |
| 479 cwd: absolute path to working directory for the command | |
| 480 env: dict with overrides for environment variables | |
| 481 allow_subannotations: if True, lets the step emit its own annotations | |
| 482 trigger_specs: a list of trigger specifications, which are dict with keys: | |
| 483 properties: a dict of properties. | |
| 484 Buildbot requires buildername property. | |
| 485 | |
| 486 Known kwargs: | |
| 487 stdout: Path to a file to put step stdout into. If used, stdout won't appear | |
| 488 in annotator's stdout (and |allow_subannotations| is ignored). | |
| 489 stderr: Path to a file to put step stderr into. If used, stderr won't appear | |
| 490 in annotator's stderr. | |
| 491 stdin: Path to a file to read step stdin from. | |
| 492 | |
| 493 Returns the returncode of the step. | |
| 494 """ | |
| 495 if isinstance(cmd, basestring): | |
| 496 cmd = (cmd,) | |
| 497 cmd = map(str, cmd) | |
| 498 | |
| 499 # For error reporting. | |
| 500 step_dict = kwargs.copy() | |
| 501 step_dict.update({ | |
| 502 'name': name, | |
| 503 'cmd': cmd, | |
| 504 'cwd': cwd, | |
| 505 'env': env, | |
| 506 'allow_subannotations': allow_subannotations, | |
| 507 }) | |
| 508 step_env = _merge_envs(os.environ, env) | |
| 509 | |
| 510 step_annotation = stream.step(name) | |
| 511 step_annotation.step_started() | |
| 512 | |
| 513 print_step(step_dict, step_env, stream) | |
| 514 returncode = 0 | |
| 515 if cmd: | |
| 516 try: | |
| 517 # Open file handles for IO redirection based on file names in step_dict. | |
| 518 fhandles = { | |
| 519 'stdout': subprocess.PIPE, | |
| 520 'stderr': subprocess.PIPE, | |
| 521 'stdin': None, | |
| 522 } | |
| 523 for key in fhandles: | |
| 524 if key in step_dict: | |
| 525 fhandles[key] = open(step_dict[key], | |
| 526 'rb' if key == 'stdin' else 'wb') | |
| 527 | |
| 528 if sys.platform.startswith('win'): | |
| 529 # Windows has a bad habit of opening a dialog when a console program | |
| 530 # crashes, rather than just letting it crash. Therefore, when a program | |
| 531 # crashes on Windows, we don't find out until the build step times out. | |
| 532 # This code prevents the dialog from appearing, so that we find out | |
| 533 # immediately and don't waste time waiting for a user to close the | |
| 534 # dialog. | |
| 535 import ctypes | |
| 536 # SetErrorMode(SEM_NOGPFAULTERRORBOX). For more information, see: | |
| 537 # https://msdn.microsoft.com/en-us/library/windows/desktop/ms680621.aspx | |
| 538 ctypes.windll.kernel32.SetErrorMode(0x0002) | |
| 539 # CREATE_NO_WINDOW. For more information, see: | |
| 540 # https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863.aspx | |
| 541 creationflags = 0x8000000 | |
| 542 else: | |
| 543 creationflags = 0 | |
| 544 | |
| 545 with modify_lookup_path(step_env.get('PATH')): | |
| 546 proc = subprocess.Popen( | |
| 547 cmd, | |
| 548 env=step_env, | |
| 549 cwd=cwd, | |
| 550 universal_newlines=True, | |
| 551 creationflags=creationflags, | |
| 552 **fhandles) | |
| 553 | |
| 554 # Safe to close file handles now that subprocess has inherited them. | |
| 555 for handle in fhandles.itervalues(): | |
| 556 if isinstance(handle, file): | |
| 557 handle.close() | |
| 558 | |
| 559 outlock = threading.Lock() | |
| 560 def filter_lines(lock, allow_subannotations, inhandle, outhandle): | |
| 561 while True: | |
| 562 line = inhandle.readline() | |
| 563 if not line: | |
| 564 break | |
| 565 lock.acquire() | |
| 566 try: | |
| 567 if not allow_subannotations and line.startswith('@@@'): | |
| 568 outhandle.write('!') | |
| 569 outhandle.write(line) | |
| 570 outhandle.flush() | |
| 571 finally: | |
| 572 lock.release() | |
| 573 | |
| 574 # Pump piped stdio through filter_lines. IO going to files on disk is | |
| 575 # not filtered. | |
| 576 threads = [] | |
| 577 for key in ('stdout', 'stderr'): | |
| 578 if fhandles[key] == subprocess.PIPE: | |
| 579 inhandle = getattr(proc, key) | |
| 580 outhandle = getattr(sys, key) | |
| 581 threads.append(threading.Thread( | |
| 582 target=filter_lines, | |
| 583 args=(outlock, allow_subannotations, inhandle, outhandle))) | |
| 584 | |
| 585 for th in threads: | |
| 586 th.start() | |
| 587 proc.wait() | |
| 588 for th in threads: | |
| 589 th.join() | |
| 590 returncode = proc.returncode | |
| 591 except OSError: | |
| 592 # File wasn't found, error will be reported to stream when the exception | |
| 593 # crosses the context manager. | |
| 594 step_annotation.step_exception_occured(*sys.exc_info()) | |
| 595 raise | |
| 596 | |
| 597 # TODO(martiniss) move logic into own module? | |
| 598 if trigger_specs: | |
| 599 triggerBuilds(step_annotation, trigger_specs) | |
| 600 | |
| 601 return step_annotation, returncode | |
| 602 | |
| 603 def update_build_failure(failure, retcode, **_kwargs): | |
| 604 """Potentially moves failure from False to True, depending on returncode of | |
| 605 the run step and the step's configuration. | |
| 606 | |
| 607 can_fail_build: A boolean indicating that a bad retcode for this step should | |
| 608 be intepreted as a build failure. | |
| 609 | |
| 610 Returns new value for failure. | |
| 611 | |
| 612 Called externally from annotated_run, which is why it's a separate function. | |
| 613 """ | |
| 614 # TODO(iannucci): Allow step to specify "OK" return values besides 0? | |
| 615 return failure or retcode | |
| 616 | |
| 617 def run_steps(steps, build_failure): | |
| 618 for step in steps: | |
| 619 error = _validate_step(step) | |
| 620 if error: | |
| 621 print 'Invalid step - %s\n%s' % (error, json.dumps(step, indent=2)) | |
| 622 sys.exit(1) | |
| 623 | |
| 624 stream = StructuredAnnotationStream() | |
| 625 ret_codes = [] | |
| 626 build_failure = False | |
| 627 prev_annotation = None | |
| 628 for step in steps: | |
| 629 if build_failure and not step.get('always_run', False): | |
| 630 ret = None | |
| 631 else: | |
| 632 prev_annotation, ret = run_step(stream, **step) | |
| 633 stream = prev_annotation.annotation_stream | |
| 634 if ret > 0: | |
| 635 stream.step_cursor(stream.current_step) | |
| 636 stream.emit('step returned non-zero exit code: %d' % ret) | |
| 637 prev_annotation.step_failure() | |
| 638 | |
| 639 prev_annotation.step_ended() | |
| 640 build_failure = update_build_failure(build_failure, ret) | |
| 641 ret_codes.append(ret) | |
| 642 if prev_annotation: | |
| 643 prev_annotation.step_ended() | |
| 644 return build_failure, ret_codes | |
| 645 | |
| 646 | |
| 647 def main(): | |
| 648 usage = '%s <command list file or - for stdin>' % sys.argv[0] | |
| 649 parser = optparse.OptionParser(usage=usage) | |
| 650 _, args = parser.parse_args() | |
| 651 if not args: | |
| 652 parser.error('Must specify an input filename.') | |
| 653 if len(args) > 1: | |
| 654 parser.error('Too many arguments specified.') | |
| 655 | |
| 656 steps = [] | |
| 657 | |
| 658 def force_list_str(lst): | |
| 659 ret = [] | |
| 660 for v in lst: | |
| 661 if isinstance(v, basestring): | |
| 662 v = str(v) | |
| 663 elif isinstance(v, list): | |
| 664 v = force_list_str(v) | |
| 665 elif isinstance(v, dict): | |
| 666 v = force_dict_strs(v) | |
| 667 ret.append(v) | |
| 668 return ret | |
| 669 | |
| 670 def force_dict_strs(obj): | |
| 671 ret = {} | |
| 672 for k, v in obj.iteritems(): | |
| 673 if isinstance(v, basestring): | |
| 674 v = str(v) | |
| 675 elif isinstance(v, list): | |
| 676 v = force_list_str(v) | |
| 677 elif isinstance(v, dict): | |
| 678 v = force_dict_strs(v) | |
| 679 ret[str(k)] = v | |
| 680 return ret | |
| 681 | |
| 682 if args[0] == '-': | |
| 683 steps.extend(json.load(sys.stdin, object_hook=force_dict_strs)) | |
| 684 else: | |
| 685 with open(args[0], 'rb') as f: | |
| 686 steps.extend(json.load(f, object_hook=force_dict_strs)) | |
| 687 | |
| 688 return 1 if run_steps(steps, False)[0] else 0 | |
| 689 | |
| 690 | |
| 691 if __name__ == '__main__': | |
| 692 sys.exit(main()) | |
| OLD | NEW |