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 48 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
59 convenience functions defined: | 59 convenience functions defined: |
60 * last_step - Returns the last step that ran or None | 60 * last_step - Returns the last step that ran or None |
61 * nth_step(n) - Returns the N'th step that ran or None | 61 * nth_step(n) - Returns the N'th step that ran or None |
62 | 62 |
63 'failed' is a boolean representing if the build is in a 'failed' state. | 63 'failed' is a boolean representing if the build is in a 'failed' state. |
64 """ | 64 """ |
65 | 65 |
66 import collections | 66 import collections |
67 import json | 67 import json |
68 import logging | 68 import logging |
69 import argparse | |
dnj
2017/04/27 17:14:01
nit: order
iannucci
2017/04/29 15:45:59
Done.
| |
69 import os | 70 import os |
70 import sys | 71 import sys |
71 import traceback | 72 import traceback |
72 | 73 |
73 from . import loader | 74 from . import loader |
74 from . import recipe_api | 75 from . import recipe_api |
75 from . import recipe_test_api | 76 from . import recipe_test_api |
76 from . import types | 77 from . import types |
77 from . import util | 78 from . import util |
79 | |
80 from . import env | |
dnj
2017/04/27 17:14:01
Shouldn't this be merged above?
iannucci
2017/04/29 15:45:59
I like to keep the env import line separate becaus
| |
81 | |
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 |
88 # TODO(dnj): Replace "properties" with a generic runtime instance. This instance | 95 # 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 | 96 # will be used to seed recipe clients and expanded to include managed runtime |
(...skipping 337 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
427 results.append( | 434 results.append( |
428 loader._invoke_with_properties( | 435 loader._invoke_with_properties( |
429 run_recipe, properties, recipe_script.PROPERTIES, | 436 run_recipe, properties, recipe_script.PROPERTIES, |
430 properties.keys())) | 437 properties.keys())) |
431 except TypeError as e: | 438 except TypeError as e: |
432 raise TypeError( | 439 raise TypeError( |
433 "Got %r while trying to call recipe %s with properties %r" % ( | 440 "Got %r while trying to call recipe %s with properties %r" % ( |
434 e, recipe, properties)) | 441 e, recipe, properties)) |
435 | 442 |
436 return results | 443 return results |
444 | |
445 | |
446 def add_subparser(parser): | |
447 def properties_file_type(filename): | |
448 with (sys.stdin if filename == '-' else open(filename)) as f: | |
449 obj = json.load(f) | |
450 if not isinstance(obj, dict): | |
451 raise argparse.ArgumentTypeError( | |
452 'must contain a JSON object, i.e. `{}`.') | |
453 return obj | |
454 | |
455 def parse_prop(prop): | |
456 key, val = prop.split('=', 1) | |
457 try: | |
458 val = json.loads(val) | |
459 except (ValueError, SyntaxError): | |
460 pass # If a value couldn't be evaluated, keep the string version | |
461 return {key: val} | |
462 | |
463 def properties_type(value): | |
464 obj = json.loads(value) | |
465 if not isinstance(obj, dict): | |
466 raise argparse.ArgumentTypeError('must contain a JSON object, i.e. `{}`.') | |
467 return obj | |
468 | |
469 run_p = parser.add_parser( | |
470 'run', | |
471 description='Run a recipe locally') | |
472 | |
473 run_p.add_argument( | |
474 '--workdir', | |
475 type=os.path.abspath, | |
476 help='The working directory of recipe execution') | |
477 run_p.add_argument( | |
478 '--output-result-json', | |
479 type=os.path.abspath, | |
480 help='The file to write the JSON serialized returned value \ | |
481 of the recipe to') | |
482 run_p.add_argument( | |
483 '--timestamps', | |
484 action='store_true', | |
485 help='If true, emit CURRENT_TIMESTAMP annotations. ' | |
486 'Default: false. ' | |
487 'CURRENT_TIMESTAMP annotation has one parameter, current time in ' | |
488 'Unix timestamp format. ' | |
489 'CURRENT_TIMESTAMP annotation will be printed at the beginning and ' | |
490 'end of the annotation stream and also immediately before each ' | |
491 'STEP_STARTED and STEP_CLOSED annotations.', | |
492 ) | |
493 prop_group = run_p.add_mutually_exclusive_group() | |
494 prop_group.add_argument( | |
495 '--properties-file', | |
496 dest='properties', | |
497 type=properties_file_type, | |
498 help=('A file containing a json blob of properties. ' | |
499 'Pass "-" to read from stdin')) | |
500 prop_group.add_argument( | |
501 '--properties', | |
502 type=properties_type, | |
503 help='A json string containing the properties') | |
504 | |
505 run_p.add_argument( | |
506 'recipe', | |
507 help='The recipe to execute') | |
508 run_p.add_argument( | |
509 'props', | |
510 nargs=argparse.REMAINDER, | |
511 type=parse_prop, | |
512 help='A list of property pairs; e.g. mastername=chromium.linux ' | |
513 'issue=12345. The property value will be decoded as JSON, but if ' | |
514 'this decoding fails the value will be interpreted as a string.') | |
515 | |
516 run_p.set_defaults(command='run', properties={}, func=main) | |
517 | |
dnj
2017/04/27 17:14:01
nit: two spaces (here and elsewhere)
iannucci
2017/04/29 15:45:59
Done.
| |
518 def handle_recipe_return(recipe_result, result_filename, stream_engine, | |
519 engine_flags): | |
520 if engine_flags and engine_flags.use_result_proto: | |
521 return new_handle_recipe_return( | |
522 recipe_result, result_filename, stream_engine) | |
523 | |
524 if 'recipe_result' in recipe_result.result: | |
525 result_string = json.dumps( | |
526 recipe_result.result['recipe_result'], indent=2) | |
527 if result_filename: | |
528 with open(result_filename, 'w') as f: | |
529 f.write(result_string) | |
530 with stream_engine.make_step_stream('recipe result') as s: | |
531 with s.new_log_stream('result') as l: | |
532 l.write_split(result_string) | |
533 | |
534 if 'traceback' in recipe_result.result: | |
535 with stream_engine.make_step_stream('Uncaught Exception') as s: | |
536 with s.new_log_stream('exception') as l: | |
537 for line in recipe_result.result['traceback']: | |
538 l.write_line(line) | |
539 | |
540 if 'reason' in recipe_result.result: | |
541 with stream_engine.make_step_stream('Failure reason') as s: | |
542 with s.new_log_stream('reason') as l: | |
543 for line in recipe_result.result['reason'].splitlines(): | |
544 l.write_line(line) | |
545 | |
546 if 'status_code' in recipe_result.result: | |
547 return recipe_result.result['status_code'] | |
548 else: | |
549 return 0 | |
550 | |
551 def new_handle_recipe_return(result, result_filename, stream_engine): | |
552 if result_filename: | |
553 with open(result_filename, 'w') as fil: | |
554 fil.write(jsonpb.MessageToJson( | |
555 result, including_default_value_fields=True)) | |
556 | |
557 if result.json_result: | |
558 with stream_engine.make_step_stream('recipe result') as s: | |
559 with s.new_log_stream('result') as l: | |
560 l.write_split(result.json_result) | |
561 | |
562 if result.HasField('failure'): | |
563 f = result.failure | |
564 if f.HasField('exception'): | |
565 with stream_engine.make_step_stream('Uncaught Exception') as s: | |
566 s.add_step_text(f.human_reason) | |
567 with s.new_log_stream('exception') as l: | |
568 for line in f.exception.traceback: | |
569 l.write_line(line) | |
570 # TODO(martiniss): Remove this code once calling code handles these states | |
571 elif f.HasField('timeout'): | |
572 with stream_engine.make_step_stream('Step Timed Out') as s: | |
573 with s.new_log_stream('timeout_s') as l: | |
574 l.write_line(f.timeout.timeout_s) | |
575 elif f.HasField('step_data'): | |
576 with stream_engine.make_step_stream('Invalid Step Data Access') as s: | |
577 with s.new_log_stream('step') as l: | |
578 l.write_line(f.step_data.step) | |
579 | |
580 with stream_engine.make_step_stream('Failure reason') as s: | |
581 with s.new_log_stream('reason') as l: | |
582 l.write_split(f.human_reason) | |
583 | |
584 return 1 | |
585 | |
586 return 0 | |
587 | |
588 | |
589 # Map of arguments_pb2.Property "value" oneof conversion functions. | |
590 # | |
591 # The fields here should be kept in sync with the "value" oneof field names in | |
592 # the arguments_pb2.Arguments.Property protobuf message. | |
593 _OP_PROPERTY_CONV = { | |
594 's': lambda prop: prop.s, | |
595 'int': lambda prop: prop.int, | |
596 'uint': lambda prop: prop.uint, | |
597 'd': lambda prop: prop.d, | |
598 'b': lambda prop: prop.b, | |
599 'data': lambda prop: prop.data, | |
600 'map': lambda prop: _op_properties_to_dict(prop.map.property), | |
601 'list': lambda prop: [_op_property_value(v) for v in prop.list.property], | |
602 } | |
603 | |
604 def _op_property_value(prop): | |
605 """Returns the Python-converted value of an arguments_pb2.Property. | |
606 | |
607 Args: | |
608 prop (arguments_pb2.Property): property to convert. | |
609 Returns: The converted value. | |
610 Raises: | |
611 ValueError: If 'prop' is incomplete or invalid. | |
612 """ | |
613 typ = prop.WhichOneof('value') | |
614 conv = _OP_PROPERTY_CONV.get(typ) | |
615 if not conv: | |
616 raise ValueError('Unknown property field [%s]' % (typ,)) | |
617 return conv(prop) | |
618 | |
619 | |
620 def _op_properties_to_dict(pmap): | |
621 """Creates a properties dictionary from an arguments_pb2.PropertyMap entry. | |
622 | |
623 Args: | |
624 pmap (arguments_pb2.PropertyMap): Map to convert to dictionary form. | |
625 Returns (dict): A dictionary derived from the properties in 'pmap'. | |
626 """ | |
627 return dict((k, _op_property_value(pmap[k])) for k in pmap) | |
628 | |
629 | |
630 def main(package_deps, args): | |
631 from recipe_engine import step_runner | |
632 from recipe_engine import stream | |
633 from recipe_engine import stream_logdog | |
634 | |
635 config_file = args.package | |
636 | |
637 if args.props: | |
638 for p in args.props: | |
639 args.properties.update(p) | |
640 | |
641 def get_properties_from_operational_args(op_args): | |
642 if not op_args.properties.property: | |
643 return None | |
644 return _op_properties_to_dict(op_args.properties.property) | |
645 | |
646 op_args = args.operational_args | |
647 op_properties = get_properties_from_operational_args(op_args) | |
648 if args.properties and op_properties: | |
649 raise ValueError( | |
650 'Got operational args properties as well as CLI properties.') | |
651 | |
652 properties = op_properties | |
653 if not properties: | |
654 properties = args.properties | |
655 | |
656 properties['recipe'] = args.recipe | |
657 | |
658 properties = util.strip_unicode(properties) | |
659 | |
660 os.environ['PYTHONUNBUFFERED'] = '1' | |
661 os.environ['PYTHONIOENCODING'] = 'UTF-8' | |
662 | |
663 universe_view = loader.UniverseView( | |
664 loader.RecipeUniverse( | |
665 package_deps, config_file), package_deps.root_package) | |
666 | |
667 # TODO(iannucci): this is horrible; why do we want to set a workdir anyway? | |
668 # Shouldn't the caller of recipes just CD somewhere if they want a different | |
669 # workdir? | |
670 workdir = (args.workdir or | |
671 os.path.join(SCRIPT_PATH, os.path.pardir, 'workdir')) | |
672 logging.info('Using %s as work directory' % workdir) | |
673 if not os.path.exists(workdir): | |
674 os.makedirs(workdir) | |
675 | |
676 old_cwd = os.getcwd() | |
677 os.chdir(workdir) | |
678 | |
679 # Construct our stream engines. We may want to share stream events with more | |
680 # than one StreamEngine implementation, so we will accumulate them in a | |
681 # "stream_engines" list and compose them into a MultiStreamEngine. | |
682 def build_annotation_stream_engine(): | |
683 return stream.AnnotatorStreamEngine( | |
684 sys.stdout, | |
685 emit_timestamps=(args.timestamps or | |
686 op_args.annotation_flags.emit_timestamp)) | |
687 | |
688 stream_engines = [] | |
689 if op_args.logdog.streamserver_uri: | |
690 logging.debug('Using LogDog with parameters: [%s]', op_args.logdog) | |
691 stream_engines.append(stream_logdog.StreamEngine( | |
692 streamserver_uri=op_args.logdog.streamserver_uri, | |
693 name_base=(op_args.logdog.name_base or None), | |
694 dump_path=op_args.logdog.final_annotation_dump_path, | |
695 )) | |
696 | |
697 # If we're teeing, also fold in a standard annotation stream engine. | |
698 if op_args.logdog.tee: | |
699 stream_engines.append(build_annotation_stream_engine()) | |
700 else: | |
701 # Not using LogDog; use a standard annotation stream engine. | |
702 stream_engines.append(build_annotation_stream_engine()) | |
703 multi_stream_engine = stream.MultiStreamEngine.create(*stream_engines) | |
704 | |
705 emit_initial_properties = op_args.annotation_flags.emit_initial_properties | |
706 engine_flags = op_args.engine_flags | |
707 | |
708 # Have a top-level set of invariants to enforce StreamEngine expectations. | |
709 with stream.StreamEngineInvariants.wrap(multi_stream_engine) as stream_engine: | |
710 try: | |
711 ret = run_steps( | |
712 properties, stream_engine, | |
713 step_runner.SubprocessStepRunner(stream_engine, engine_flags), | |
714 universe_view, engine_flags=engine_flags, | |
715 emit_initial_properties=emit_initial_properties) | |
716 finally: | |
717 os.chdir(old_cwd) | |
718 | |
719 return handle_recipe_return( | |
720 ret, args.output_result_json, stream_engine, engine_flags) | |
OLD | NEW |