Factoring out base class for trigger_multiple_dimensions.py.

This will be used by perf team for intercepting trigger call
to trigger on specific bot ids.

Bug: 758630
Cq-Include-Trybots: master.tryserver.chromium.android:android_optional_gpu_tests_rel;master.tryserver.chromium.linux:linux_optional_gpu_tests_rel;master.tryserver.chromium.mac:mac_optional_gpu_tests_rel;master.tryserver.chromium.win:win_optional_gpu_tests_rel
Change-Id: Ib5152748169ccd50d01dccc1f61979bcb967a481
Reviewed-on: https://chromium-review.googlesource.com/896365
Reviewed-by: Emily Hanley <[email protected]>
Reviewed-by: Ashley Enstad <[email protected]>
Reviewed-by: Kenneth Russell <[email protected]>
Commit-Queue: Emily Hanley <[email protected]>
Cr-Commit-Position: refs/heads/master@{#535001}
diff --git a/testing/trigger_scripts/base_test_triggerer.py b/testing/trigger_scripts/base_test_triggerer.py
new file mode 100755
index 0000000..98f19e0
--- /dev/null
+++ b/testing/trigger_scripts/base_test_triggerer.py
@@ -0,0 +1,287 @@
+#!/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.
+
+    """
+
+
+
+    assert '--' in all_args, (
+        'Malformed trigger command; -- argument expected but not found')
+    dash_ind = all_args.index('--')
+    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)
+    return self.append_additional_args(
+        all_args[:dash_ind] + bot_args + all_args[dash_ind:])
+
+  def append_additional_args(self, args):
+    """ Gives subclasses ability to append additional args if necessary
+
+    Base class just returns given get."""
+    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')
+
+  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_arg = urllib.urlencode(values)
+
+      temp_file = self.make_temp_file(prefix='base_trigger_dimensions',
+                                      suffix='.json')
+      try:
+        ret = self.run_swarming(['query',
+                                 '-S',
+                                 'chromium-swarm.appspot.com',
+                                 '--limit',
+                                 '0',
+                                 '--json',
+                                 temp_file,
+                                 ('bots/count?%s' % query_arg)],
+                                verbose)
+        if ret:
+          raise Exception('Error running swarming.py')
+        with open(temp_file) as fp:
+          query_result = strip_unicode(json.load(fp))
+        # 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]))
+      finally:
+        self.delete_temp_file(temp_file)
+    # 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 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
+
+
+  def setup_parser_contract(self, parser):
+    parser.add_argument('--multiple-trigger-configs', type=str, required=True,
+                        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 in this '
+                        'dictionary is required.')
+    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
+