OLD | NEW |
1 # Copyright 2016 The LUCI Authors. All rights reserved. | 1 # Copyright 2016 The LUCI Authors. All rights reserved. |
2 # Use of this source code is governed under the Apache License, Version 2.0 | 2 # Use of this source code is governed under the Apache License, Version 2.0 |
3 # that can be found in the LICENSE file. | 3 # that can be found in the LICENSE file. |
4 | 4 |
5 """Entry point for fully-annotated builds. | 5 """Entry point for fully-annotated builds. |
6 | 6 |
7 This script is part of the effort to move all builds to annotator-based | 7 This script is part of the effort to move all builds to annotator-based |
8 systems. Any builder configured to use the AnnotatorFactory.BaseFactory() | 8 systems. Any builder configured to use the AnnotatorFactory.BaseFactory() |
9 found in scripts/master/factory/annotator_factory.py executes a single | 9 found in scripts/master/factory/annotator_factory.py executes a single |
10 AddAnnotatedScript step. That step (found in annotator_commands.py) calls | 10 AddAnnotatedScript step. That step (found in annotator_commands.py) calls |
(...skipping 57 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
68 import logging | 68 import logging |
69 import os | 69 import os |
70 import sys | 70 import sys |
71 import traceback | 71 import traceback |
72 | 72 |
73 from . import loader | 73 from . import loader |
74 from . import recipe_api | 74 from . import recipe_api |
75 from . import recipe_test_api | 75 from . import recipe_test_api |
76 from . import types | 76 from . import types |
77 from . import util | 77 from . import util |
| 78 |
| 79 from . import env |
| 80 |
| 81 import argparse # this is vendored |
| 82 import subprocess42 |
| 83 |
78 from . import result_pb2 | 84 from . import result_pb2 |
79 import subprocess42 | 85 |
| 86 from google.protobuf import json_format as jsonpb |
80 | 87 |
81 | 88 |
82 SCRIPT_PATH = os.path.dirname(os.path.abspath(__file__)) | 89 SCRIPT_PATH = os.path.dirname(os.path.abspath(__file__)) |
83 | 90 |
84 | 91 |
85 # TODO(martiniss): Remove this | 92 # TODO(martiniss): Remove this |
86 RecipeResult = collections.namedtuple('RecipeResult', 'result') | 93 RecipeResult = collections.namedtuple('RecipeResult', 'result') |
87 | 94 |
| 95 |
88 # TODO(dnj): Replace "properties" with a generic runtime instance. This instance | 96 # TODO(dnj): Replace "properties" with a generic runtime instance. This instance |
89 # will be used to seed recipe clients and expanded to include managed runtime | 97 # will be used to seed recipe clients and expanded to include managed runtime |
90 # entities. | 98 # entities. |
91 def run_steps(properties, stream_engine, step_runner, universe_view, | 99 def run_steps(properties, stream_engine, step_runner, universe_view, |
92 engine_flags=None, emit_initial_properties=False): | 100 engine_flags=None, emit_initial_properties=False): |
93 """Runs a recipe (given by the 'recipe' property). | 101 """Runs a recipe (given by the 'recipe' property). |
94 | 102 |
95 Args: | 103 Args: |
96 properties: a dictionary of properties to pass to the recipe. The | 104 properties: a dictionary of properties to pass to the recipe. The |
97 'recipe' property defines which recipe to actually run. | 105 'recipe' property defines which recipe to actually run. |
(...skipping 71 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
169 # Run the steps emitted by a recipe via the engine, emitting annotations | 177 # Run the steps emitted by a recipe via the engine, emitting annotations |
170 # into |stream| along the way. | 178 # into |stream| along the way. |
171 return engine.run(recipe_script, api, properties) | 179 return engine.run(recipe_script, api, properties) |
172 | 180 |
173 | 181 |
174 # Return value of run_steps and RecipeEngine.run. Just a container for the | 182 # Return value of run_steps and RecipeEngine.run. Just a container for the |
175 # literal return value of the recipe. | 183 # literal return value of the recipe. |
176 # TODO(martiniss): remove this | 184 # TODO(martiniss): remove this |
177 RecipeResult = collections.namedtuple('RecipeResult', 'result') | 185 RecipeResult = collections.namedtuple('RecipeResult', 'result') |
178 | 186 |
| 187 |
179 class RecipeEngine(object): | 188 class RecipeEngine(object): |
180 """ | 189 """ |
181 Knows how to execute steps emitted by a recipe, holds global state such as | 190 Knows how to execute steps emitted by a recipe, holds global state such as |
182 step history and build properties. Each recipe module API has a reference to | 191 step history and build properties. Each recipe module API has a reference to |
183 this object. | 192 this object. |
184 | 193 |
185 Recipe modules that are aware of the engine: | 194 Recipe modules that are aware of the engine: |
186 * properties - uses engine.properties. | 195 * properties - uses engine.properties. |
187 * step - uses engine.create_step(...), and previous_step_result. | 196 * step - uses engine.create_step(...), and previous_step_result. |
188 """ | 197 """ |
(...skipping 238 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
427 results.append( | 436 results.append( |
428 loader._invoke_with_properties( | 437 loader._invoke_with_properties( |
429 run_recipe, properties, recipe_script.PROPERTIES, | 438 run_recipe, properties, recipe_script.PROPERTIES, |
430 properties.keys())) | 439 properties.keys())) |
431 except TypeError as e: | 440 except TypeError as e: |
432 raise TypeError( | 441 raise TypeError( |
433 "Got %r while trying to call recipe %s with properties %r" % ( | 442 "Got %r while trying to call recipe %s with properties %r" % ( |
434 e, recipe, properties)) | 443 e, recipe, properties)) |
435 | 444 |
436 return results | 445 return results |
| 446 |
| 447 |
| 448 def add_subparser(parser): |
| 449 def properties_file_type(filename): |
| 450 with (sys.stdin if filename == '-' else open(filename)) as f: |
| 451 obj = json.load(f) |
| 452 if not isinstance(obj, dict): |
| 453 raise argparse.ArgumentTypeError( |
| 454 'must contain a JSON object, i.e. `{}`.') |
| 455 return obj |
| 456 |
| 457 def parse_prop(prop): |
| 458 key, val = prop.split('=', 1) |
| 459 try: |
| 460 val = json.loads(val) |
| 461 except (ValueError, SyntaxError): |
| 462 pass # If a value couldn't be evaluated, keep the string version |
| 463 return {key: val} |
| 464 |
| 465 def properties_type(value): |
| 466 obj = json.loads(value) |
| 467 if not isinstance(obj, dict): |
| 468 raise argparse.ArgumentTypeError('must contain a JSON object, i.e. `{}`.') |
| 469 return obj |
| 470 |
| 471 run_p = parser.add_parser( |
| 472 'run', |
| 473 description='Run a recipe locally') |
| 474 |
| 475 run_p.add_argument( |
| 476 '--workdir', |
| 477 type=os.path.abspath, |
| 478 help='The working directory of recipe execution') |
| 479 run_p.add_argument( |
| 480 '--output-result-json', |
| 481 type=os.path.abspath, |
| 482 help='The file to write the JSON serialized returned value \ |
| 483 of the recipe to') |
| 484 run_p.add_argument( |
| 485 '--timestamps', |
| 486 action='store_true', |
| 487 help='If true, emit CURRENT_TIMESTAMP annotations. ' |
| 488 'Default: false. ' |
| 489 'CURRENT_TIMESTAMP annotation has one parameter, current time in ' |
| 490 'Unix timestamp format. ' |
| 491 'CURRENT_TIMESTAMP annotation will be printed at the beginning and ' |
| 492 'end of the annotation stream and also immediately before each ' |
| 493 'STEP_STARTED and STEP_CLOSED annotations.', |
| 494 ) |
| 495 prop_group = run_p.add_mutually_exclusive_group() |
| 496 prop_group.add_argument( |
| 497 '--properties-file', |
| 498 dest='properties', |
| 499 type=properties_file_type, |
| 500 help=('A file containing a json blob of properties. ' |
| 501 'Pass "-" to read from stdin')) |
| 502 prop_group.add_argument( |
| 503 '--properties', |
| 504 type=properties_type, |
| 505 help='A json string containing the properties') |
| 506 |
| 507 run_p.add_argument( |
| 508 'recipe', |
| 509 help='The recipe to execute') |
| 510 run_p.add_argument( |
| 511 'props', |
| 512 nargs=argparse.REMAINDER, |
| 513 type=parse_prop, |
| 514 help='A list of property pairs; e.g. mastername=chromium.linux ' |
| 515 'issue=12345. The property value will be decoded as JSON, but if ' |
| 516 'this decoding fails the value will be interpreted as a string.') |
| 517 |
| 518 run_p.set_defaults(command='run', properties={}, func=main) |
| 519 |
| 520 |
| 521 def handle_recipe_return(recipe_result, result_filename, stream_engine, |
| 522 engine_flags): |
| 523 if engine_flags and engine_flags.use_result_proto: |
| 524 return new_handle_recipe_return( |
| 525 recipe_result, result_filename, stream_engine) |
| 526 |
| 527 if 'recipe_result' in recipe_result.result: |
| 528 result_string = json.dumps( |
| 529 recipe_result.result['recipe_result'], indent=2) |
| 530 if result_filename: |
| 531 with open(result_filename, 'w') as f: |
| 532 f.write(result_string) |
| 533 with stream_engine.make_step_stream('recipe result') as s: |
| 534 with s.new_log_stream('result') as l: |
| 535 l.write_split(result_string) |
| 536 |
| 537 if 'traceback' in recipe_result.result: |
| 538 with stream_engine.make_step_stream('Uncaught Exception') as s: |
| 539 with s.new_log_stream('exception') as l: |
| 540 for line in recipe_result.result['traceback']: |
| 541 l.write_line(line) |
| 542 |
| 543 if 'reason' in recipe_result.result: |
| 544 with stream_engine.make_step_stream('Failure reason') as s: |
| 545 with s.new_log_stream('reason') as l: |
| 546 for line in recipe_result.result['reason'].splitlines(): |
| 547 l.write_line(line) |
| 548 |
| 549 if 'status_code' in recipe_result.result: |
| 550 return recipe_result.result['status_code'] |
| 551 else: |
| 552 return 0 |
| 553 |
| 554 |
| 555 def new_handle_recipe_return(result, result_filename, stream_engine): |
| 556 if result_filename: |
| 557 with open(result_filename, 'w') as fil: |
| 558 fil.write(jsonpb.MessageToJson( |
| 559 result, including_default_value_fields=True)) |
| 560 |
| 561 if result.json_result: |
| 562 with stream_engine.make_step_stream('recipe result') as s: |
| 563 with s.new_log_stream('result') as l: |
| 564 l.write_split(result.json_result) |
| 565 |
| 566 if result.HasField('failure'): |
| 567 f = result.failure |
| 568 if f.HasField('exception'): |
| 569 with stream_engine.make_step_stream('Uncaught Exception') as s: |
| 570 s.add_step_text(f.human_reason) |
| 571 with s.new_log_stream('exception') as l: |
| 572 for line in f.exception.traceback: |
| 573 l.write_line(line) |
| 574 # TODO(martiniss): Remove this code once calling code handles these states |
| 575 elif f.HasField('timeout'): |
| 576 with stream_engine.make_step_stream('Step Timed Out') as s: |
| 577 with s.new_log_stream('timeout_s') as l: |
| 578 l.write_line(f.timeout.timeout_s) |
| 579 elif f.HasField('step_data'): |
| 580 with stream_engine.make_step_stream('Invalid Step Data Access') as s: |
| 581 with s.new_log_stream('step') as l: |
| 582 l.write_line(f.step_data.step) |
| 583 |
| 584 with stream_engine.make_step_stream('Failure reason') as s: |
| 585 with s.new_log_stream('reason') as l: |
| 586 l.write_split(f.human_reason) |
| 587 |
| 588 return 1 |
| 589 |
| 590 return 0 |
| 591 |
| 592 |
| 593 # Map of arguments_pb2.Property "value" oneof conversion functions. |
| 594 # |
| 595 # The fields here should be kept in sync with the "value" oneof field names in |
| 596 # the arguments_pb2.Arguments.Property protobuf message. |
| 597 _OP_PROPERTY_CONV = { |
| 598 's': lambda prop: prop.s, |
| 599 'int': lambda prop: prop.int, |
| 600 'uint': lambda prop: prop.uint, |
| 601 'd': lambda prop: prop.d, |
| 602 'b': lambda prop: prop.b, |
| 603 'data': lambda prop: prop.data, |
| 604 'map': lambda prop: _op_properties_to_dict(prop.map.property), |
| 605 'list': lambda prop: [_op_property_value(v) for v in prop.list.property], |
| 606 } |
| 607 |
| 608 |
| 609 def _op_property_value(prop): |
| 610 """Returns the Python-converted value of an arguments_pb2.Property. |
| 611 |
| 612 Args: |
| 613 prop (arguments_pb2.Property): property to convert. |
| 614 Returns: The converted value. |
| 615 Raises: |
| 616 ValueError: If 'prop' is incomplete or invalid. |
| 617 """ |
| 618 typ = prop.WhichOneof('value') |
| 619 conv = _OP_PROPERTY_CONV.get(typ) |
| 620 if not conv: |
| 621 raise ValueError('Unknown property field [%s]' % (typ,)) |
| 622 return conv(prop) |
| 623 |
| 624 |
| 625 def _op_properties_to_dict(pmap): |
| 626 """Creates a properties dictionary from an arguments_pb2.PropertyMap entry. |
| 627 |
| 628 Args: |
| 629 pmap (arguments_pb2.PropertyMap): Map to convert to dictionary form. |
| 630 Returns (dict): A dictionary derived from the properties in 'pmap'. |
| 631 """ |
| 632 return dict((k, _op_property_value(pmap[k])) for k in pmap) |
| 633 |
| 634 |
| 635 def main(package_deps, args): |
| 636 from recipe_engine import step_runner |
| 637 from recipe_engine import stream |
| 638 from recipe_engine import stream_logdog |
| 639 |
| 640 config_file = args.package |
| 641 |
| 642 if args.props: |
| 643 for p in args.props: |
| 644 args.properties.update(p) |
| 645 |
| 646 def get_properties_from_operational_args(op_args): |
| 647 if not op_args.properties.property: |
| 648 return None |
| 649 return _op_properties_to_dict(op_args.properties.property) |
| 650 |
| 651 op_args = args.operational_args |
| 652 op_properties = get_properties_from_operational_args(op_args) |
| 653 if args.properties and op_properties: |
| 654 raise ValueError( |
| 655 'Got operational args properties as well as CLI properties.') |
| 656 |
| 657 properties = op_properties |
| 658 if not properties: |
| 659 properties = args.properties |
| 660 |
| 661 properties['recipe'] = args.recipe |
| 662 |
| 663 properties = util.strip_unicode(properties) |
| 664 |
| 665 os.environ['PYTHONUNBUFFERED'] = '1' |
| 666 os.environ['PYTHONIOENCODING'] = 'UTF-8' |
| 667 |
| 668 universe_view = loader.UniverseView( |
| 669 loader.RecipeUniverse( |
| 670 package_deps, config_file), package_deps.root_package) |
| 671 |
| 672 # TODO(iannucci): this is horrible; why do we want to set a workdir anyway? |
| 673 # Shouldn't the caller of recipes just CD somewhere if they want a different |
| 674 # workdir? |
| 675 workdir = (args.workdir or |
| 676 os.path.join(SCRIPT_PATH, os.path.pardir, 'workdir')) |
| 677 logging.info('Using %s as work directory' % workdir) |
| 678 if not os.path.exists(workdir): |
| 679 os.makedirs(workdir) |
| 680 |
| 681 old_cwd = os.getcwd() |
| 682 os.chdir(workdir) |
| 683 |
| 684 # Construct our stream engines. We may want to share stream events with more |
| 685 # than one StreamEngine implementation, so we will accumulate them in a |
| 686 # "stream_engines" list and compose them into a MultiStreamEngine. |
| 687 def build_annotation_stream_engine(): |
| 688 return stream.AnnotatorStreamEngine( |
| 689 sys.stdout, |
| 690 emit_timestamps=(args.timestamps or |
| 691 op_args.annotation_flags.emit_timestamp)) |
| 692 |
| 693 stream_engines = [] |
| 694 if op_args.logdog.streamserver_uri: |
| 695 logging.debug('Using LogDog with parameters: [%s]', op_args.logdog) |
| 696 stream_engines.append(stream_logdog.StreamEngine( |
| 697 streamserver_uri=op_args.logdog.streamserver_uri, |
| 698 name_base=(op_args.logdog.name_base or None), |
| 699 dump_path=op_args.logdog.final_annotation_dump_path, |
| 700 )) |
| 701 |
| 702 # If we're teeing, also fold in a standard annotation stream engine. |
| 703 if op_args.logdog.tee: |
| 704 stream_engines.append(build_annotation_stream_engine()) |
| 705 else: |
| 706 # Not using LogDog; use a standard annotation stream engine. |
| 707 stream_engines.append(build_annotation_stream_engine()) |
| 708 multi_stream_engine = stream.MultiStreamEngine.create(*stream_engines) |
| 709 |
| 710 emit_initial_properties = op_args.annotation_flags.emit_initial_properties |
| 711 engine_flags = op_args.engine_flags |
| 712 |
| 713 # Have a top-level set of invariants to enforce StreamEngine expectations. |
| 714 with stream.StreamEngineInvariants.wrap(multi_stream_engine) as stream_engine: |
| 715 try: |
| 716 ret = run_steps( |
| 717 properties, stream_engine, |
| 718 step_runner.SubprocessStepRunner(stream_engine, engine_flags), |
| 719 universe_view, engine_flags=engine_flags, |
| 720 emit_initial_properties=emit_initial_properties) |
| 721 finally: |
| 722 os.chdir(old_cwd) |
| 723 |
| 724 return handle_recipe_return( |
| 725 ret, args.output_result_json, stream_engine, engine_flags) |
OLD | NEW |