|  | #!/usr/bin/env python | 
|  | # Copyright 2018 The Chromium Authors. All rights reserved. | 
|  | # Use of this source code is governed by a BSD-style license that can be | 
|  | # found in the LICENSE file. | 
|  | """Custom swarming base trigger class. | 
|  |  | 
|  | This base class consolidates custom swarming triggering logic, to allow one bot | 
|  | to conceptually span multiple Swarming configurations, while lumping all trigger | 
|  | calls under one logical step.  It also gives the subclasses the ability to | 
|  | define their own logic for pruning the configurations they want to trigger | 
|  | jobs on and what configurations to use. | 
|  |  | 
|  | See trigger_multiple_dimensions.py for an example of how to use this base class. | 
|  |  | 
|  | """ | 
|  |  | 
|  | import argparse | 
|  | import copy | 
|  | import json | 
|  | import os | 
|  | import random | 
|  | import subprocess | 
|  | import sys | 
|  | import tempfile | 
|  | import urllib | 
|  |  | 
|  |  | 
|  | SRC_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath( | 
|  | __file__)))) | 
|  |  | 
|  | SWARMING_PY = os.path.join(SRC_DIR, 'tools', 'swarming_client', 'swarming.py') | 
|  |  | 
|  | def strip_unicode(obj): | 
|  | """Recursively re-encodes strings as utf-8 inside |obj|. Returns the result. | 
|  | """ | 
|  | if isinstance(obj, unicode): | 
|  | return obj.encode('utf-8', 'replace') | 
|  | if isinstance(obj, list): | 
|  | return list(map(strip_unicode, obj)) | 
|  |  | 
|  | if isinstance(obj, dict): | 
|  | new_obj = type(obj)( | 
|  | (strip_unicode(k), strip_unicode(v)) for k, v in obj.iteritems() ) | 
|  | return new_obj | 
|  | return obj | 
|  |  | 
|  |  | 
|  | class BaseTestTriggerer(object): | 
|  | def __init__(self): | 
|  | self._bot_configs = None | 
|  | self._bot_statuses = [] | 
|  | self._total_bots = 0 | 
|  |  | 
|  |  | 
|  | def modify_args(self, all_args, bot_index, shard_index, total_shards, | 
|  | temp_file): | 
|  | """Modifies the given argument list. | 
|  |  | 
|  | Specifically, it does the following: | 
|  | * Adds a --dump_json argument, to read in the results of the | 
|  | individual trigger command. | 
|  | * Adds the dimensions associated with the bot config at the given index. | 
|  | * If the number of shards is greater than one, adds --env | 
|  | arguments to set the GTEST_SHARD_INDEX and GTEST_TOTAL_SHARDS | 
|  | environment variables to _shard_index_ and _total_shards_, | 
|  | respectively. | 
|  |  | 
|  | The arguments are structured like this: | 
|  | <args to swarming.py trigger> -- <args to bot running isolate> | 
|  | This means we have to add arguments to specific locations in the argument | 
|  | list, to either affect the trigger command, or what the bot runs. | 
|  |  | 
|  | """ | 
|  | bot_args = ['--dump-json', temp_file] | 
|  | if total_shards > 1: | 
|  | bot_args.append('--env') | 
|  | bot_args.append('GTEST_SHARD_INDEX') | 
|  | bot_args.append(str(shard_index)) | 
|  | bot_args.append('--env') | 
|  | bot_args.append('GTEST_TOTAL_SHARDS') | 
|  | bot_args.append(str(total_shards)) | 
|  | for key, val in sorted(self._bot_configs[bot_index].iteritems()): | 
|  | bot_args.append('--dimension') | 
|  | bot_args.append(key) | 
|  | bot_args.append(val) | 
|  | if '--' in all_args: | 
|  | dash_ind = all_args.index('--') | 
|  | additional_args = all_args[:dash_ind] + bot_args + all_args[dash_ind:] | 
|  | else: | 
|  | additional_args = all_args + bot_args | 
|  | return self.append_additional_args(additional_args, shard_index) | 
|  |  | 
|  | def append_additional_args(self, args, shard_index): | 
|  | """ Gives subclasses ability to append additional args if necessary | 
|  |  | 
|  | Base class just returns given args.""" | 
|  | del shard_index # unused | 
|  | return args | 
|  |  | 
|  | def parse_bot_configs(self, args): | 
|  | try: | 
|  | self._bot_configs = strip_unicode(json.loads( | 
|  | args.multiple_trigger_configs)) | 
|  | except Exception as e: | 
|  | raise ValueError('Error while parsing JSON from bot config string %s: %s' | 
|  | % (args.multiple_trigger_configs, str(e))) | 
|  | # Validate the input. | 
|  | if not isinstance(self._bot_configs, list): | 
|  | raise ValueError('Bot configurations must be a list, were: %s' % | 
|  | args.multiple_trigger_configs) | 
|  | if len(self._bot_configs) < 1: | 
|  | raise ValueError('Bot configuration list must have at least one entry') | 
|  | if not all(isinstance(entry, dict) for entry in self._bot_configs): | 
|  | raise ValueError('Bot configurations must all be dictionaries') | 
|  |  | 
|  | # TODO(eyaich): Move the stateless logic that is specific to querying | 
|  | # swarming to its own object to make trigger logic more clear. | 
|  | def query_swarming(self, api, query_args, verbose, | 
|  | limit='0', | 
|  | server='chromium-swarm.appspot.com', | 
|  | service_account=None): | 
|  | try: | 
|  | temp_file = self.make_temp_file(prefix='base_trigger_dimensions', | 
|  | suffix='.json') | 
|  | encoded_args = urllib.urlencode(query_args) | 
|  | args =['query', | 
|  | '-S', | 
|  | server, | 
|  | '--limit', | 
|  | limit, | 
|  | '--json', | 
|  | temp_file] | 
|  | # Add in service account auth if present | 
|  | if service_account: | 
|  | args.append('--auth-service-account-json') | 
|  | args.append(service_account) | 
|  | # Append the query at the end | 
|  | args.append(('%s?%s' % (api, encoded_args))) | 
|  | ret = self.run_swarming(args, verbose) | 
|  | if ret: | 
|  | raise Exception('Error running swarming.py') | 
|  | return self.read_encoded_json_from_temp_file(temp_file) | 
|  | finally: | 
|  | self.delete_temp_file(temp_file) | 
|  |  | 
|  | def query_swarming_for_bot_configs(self, verbose): | 
|  | # Query Swarming to figure out which bots are available. | 
|  | for config in self._bot_configs: | 
|  | values = [] | 
|  | for key, value in sorted(config.iteritems()): | 
|  | values.append(('dimensions', '%s:%s' % (key, value))) | 
|  | # Ignore dead and quarantined bots. | 
|  | values.append(('is_dead', 'FALSE')) | 
|  | values.append(('quarantined', 'FALSE')) | 
|  |  | 
|  | query_result = self.query_swarming('bots/count', values, verbose) | 
|  | # Summarize number of available bots per configuration. | 
|  | count = int(query_result['count']) | 
|  | # Be robust against errors in computation. | 
|  | available = max(0, count - int(query_result['busy'])) | 
|  | self._bot_statuses.append({'total': count, 'available': available}) | 
|  | if verbose: | 
|  | idx = len(self._bot_statuses) - 1 | 
|  | print 'Bot config %d: %s' % (idx, str(self._bot_statuses[idx])) | 
|  | # Sum up the total count of all bots. | 
|  | self._total_bots = sum(x['total'] for x in self._bot_statuses) | 
|  | if verbose: | 
|  | print 'Total bots: %d' % (self._total_bots) | 
|  |  | 
|  | def remove_swarming_dimension(self, args, dimension): | 
|  | for i in xrange(len(args)): | 
|  | if args[i] == '--dimension' and args[i+1] == dimension: | 
|  | return args[:i] + args[i+3:] | 
|  | return args | 
|  |  | 
|  | def make_temp_file(self, prefix=None, suffix=None): | 
|  | # This trick of closing the file handle is needed on Windows in order to | 
|  | # make the file writeable. | 
|  | h, temp_file = tempfile.mkstemp(prefix=prefix, suffix=suffix) | 
|  | os.close(h) | 
|  | return temp_file | 
|  |  | 
|  | def delete_temp_file(self, temp_file): | 
|  | os.remove(temp_file) | 
|  |  | 
|  | def read_json_from_temp_file(self, temp_file): | 
|  | with open(temp_file) as f: | 
|  | return json.load(f) | 
|  |  | 
|  | def read_encoded_json_from_temp_file(self, temp_file): | 
|  | return strip_unicode(self.read_json_from_temp_file(temp_file)) | 
|  |  | 
|  | def write_json_to_file(self, merged_json, output_file): | 
|  | with open(output_file, 'w') as f: | 
|  | json.dump(merged_json, f) | 
|  |  | 
|  | def run_swarming(self, args, verbose): | 
|  | if verbose: | 
|  | print 'Running Swarming with args:' | 
|  | print str(args) | 
|  | return subprocess.call([sys.executable, SWARMING_PY] + args) | 
|  |  | 
|  | def prune_test_specific_configs(self, args, verbose): | 
|  | # Ability for base class to further prune configs to | 
|  | # run tests on. | 
|  | pass | 
|  |  | 
|  | def select_config_indices(self, args, verbose): | 
|  | # Main implementation for base class to determine what | 
|  | # configs to trigger jobs on from self._bot_configs. | 
|  | # Returns a list of indices into the self._bot_configs and | 
|  | # len(args.shards) == len(selected_indices). | 
|  | pass | 
|  |  | 
|  | def trigger_tasks(self, args, remaining): | 
|  | """Triggers tasks for each bot. | 
|  |  | 
|  | Args: | 
|  | args: Parsed arguments which we need to use. | 
|  | remaining: The remainder of the arguments, which should be passed to | 
|  | swarming.py calls. | 
|  |  | 
|  | Returns: | 
|  | Exit code for the script. | 
|  | """ | 
|  | verbose = args.multiple_dimension_script_verbose | 
|  | self.parse_bot_configs(args) | 
|  | # Prunes config list to the exact set of configurations to trigger jobs on. | 
|  | # This logic is specific to the base class if they want to prune list | 
|  | # further. | 
|  | self.prune_test_specific_configs(args, verbose) | 
|  |  | 
|  | # In the remaining arguments, find the Swarming dimensions that are | 
|  | # specified by the bot configs and remove them, because for each shard, | 
|  | # we're going to select one of the bot configs and put all of its Swarming | 
|  | # dimensions on the command line. | 
|  | filtered_remaining_args = copy.deepcopy(remaining) | 
|  | for config in self._bot_configs: | 
|  | for k in config.iterkeys(): | 
|  | filtered_remaining_args = self.remove_swarming_dimension( | 
|  | filtered_remaining_args, k) | 
|  |  | 
|  | merged_json = {} | 
|  |  | 
|  | # Choose selected configs for this run of the test suite. | 
|  | selected_configs = self.select_config_indices(args, verbose) | 
|  | for i in xrange(args.shards): | 
|  | # For each shard that we're going to distribute, do the following: | 
|  | # 1. Pick which bot configuration to use. | 
|  | # 2. Insert that bot configuration's dimensions as command line | 
|  | #    arguments, and invoke "swarming.py trigger". | 
|  | bot_index = selected_configs[i] | 
|  | # Holds the results of the swarming.py trigger call. | 
|  | try: | 
|  | json_temp = self.make_temp_file(prefix='base_trigger_dimensions', | 
|  | suffix='.json') | 
|  | args_to_pass = self.modify_args(filtered_remaining_args, bot_index, i, | 
|  | args.shards, json_temp) | 
|  | ret = self.run_swarming(args_to_pass, verbose) | 
|  | if ret: | 
|  | sys.stderr.write('Failed to trigger a task, aborting\n') | 
|  | return ret | 
|  | result_json = self.read_json_from_temp_file(json_temp) | 
|  | if i == 0: | 
|  | # Copy the entire JSON -- in particular, the "request" | 
|  | # dictionary -- from shard 0. "swarming.py collect" uses | 
|  | # some keys from this dictionary, in particular related to | 
|  | # expiration. It also contains useful debugging information. | 
|  | merged_json = copy.deepcopy(result_json) | 
|  | # However, reset the "tasks" entry to an empty dictionary, | 
|  | # which will be handled specially. | 
|  | merged_json['tasks'] = {} | 
|  | for k, v in result_json['tasks'].items(): | 
|  | v['shard_index'] = i | 
|  | merged_json['tasks'][k + ':%d:%d' % (i, args.shards)] = v | 
|  | finally: | 
|  | self.delete_temp_file(json_temp) | 
|  | self.write_json_to_file(merged_json, args.dump_json) | 
|  | return 0 | 
|  |  | 
|  | @staticmethod | 
|  | def setup_parser_contract(parser): | 
|  | parser.add_argument('--multiple-trigger-configs', type=str, required=False, | 
|  | help='The Swarming configurations to trigger tasks on, ' | 
|  | 'in the form of a JSON array of dictionaries (these are' | 
|  | ' Swarming dimension_sets). At least one entry is' | 
|  | 'required if you dont override parse_bot_configs') | 
|  | parser.add_argument('--multiple-dimension-script-verbose', type=bool, | 
|  | default=False, help='Turn on verbose logging') | 
|  | parser.add_argument('--dump-json', required=True, | 
|  | help='(Swarming Trigger Script API) Where to dump the' | 
|  | ' resulting json which indicates which tasks were' | 
|  | ' triggered for which shards.') | 
|  | parser.add_argument('--shards', type=int, default=1, | 
|  | help='How many shards to trigger. Duplicated from the' | 
|  | ' `swarming.py trigger` command.') | 
|  | return parser | 
|  |  |