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/buildbot/chromium.perf.fyi.json b/testing/buildbot/chromium.perf.fyi.json
index b24daa1..c4ccecd 100644
--- a/testing/buildbot/chromium.perf.fyi.json
+++ b/testing/buildbot/chromium.perf.fyi.json
@@ -553,14 +553,15 @@
           "expiration": 36000,
           "hard_timeout": 10800,
           "ignore_task_failure": false,
-          "io_timeout": 3600
+          "io_timeout": 3600,
+          "shards": 2
         },
         "trigger_script": {
           "args": [
-            "--bot-id=swarm846-c4",
-            "--bot-id=swarm847-c4"
+            "--multiple-trigger-configs",
+            "[{\"id\": \"swarm846-c4\", \"pool\": \"Chrome-perf-fyi\"}, {\"id\": \"swarm847-c4\", \"pool\": \"Chrome-perf-fyi\"}]"
           ],
-          "script": "//tools/perf/perf_device_trigger.py"
+          "script": "//testing/trigger_scripts/perf_device_trigger.py"
         }
       }
     ]
diff --git a/testing/scripts/run_performance_tests.py b/testing/scripts/run_performance_tests.py
index b2db0962..d90e379 100755
--- a/testing/scripts/run_performance_tests.py
+++ b/testing/scripts/run_performance_tests.py
@@ -70,7 +70,6 @@
       '--isolated-script-test-filter', type=str, required=False)
   parser.add_argument('--xvfb', help='Start xvfb.', action='store_true')
   parser.add_argument('--output-format', action='append')
-  parser.add_argument('--builder', required=True)
   parser.add_argument('--bot', required=True,
                       help='Bot ID to use to determine which tests to run. Will'
                            ' use //tools/perf/core/benchmark_sharding_map.json'
@@ -84,7 +83,7 @@
 
   with open(sharding_map_path()) as f:
     sharding_map = json.load(f)
-  sharding = sharding_map[args.builder][args.bot]['benchmarks']
+  sharding = sharding_map[args.bot]['benchmarks']
   return_code = 0
 
   for benchmark in sharding:
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
+
diff --git a/testing/trigger_scripts/perf_device_trigger.py b/testing/trigger_scripts/perf_device_trigger.py
new file mode 100755
index 0000000..86fcffe
--- /dev/null
+++ b/testing/trigger_scripts/perf_device_trigger.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python
+# Copyright 2017 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 triggering script.
+
+This script does custom swarming triggering logic, to enable device affinity
+for our bots, while lumping all trigger calls under one logical step.
+
+This script receives multiple machine configurations on the command line in the
+form of quoted strings. These strings are JSON dictionaries that represent
+entries in the "dimensions" array of the "swarming" dictionary in the
+src/testing/buildbot JSON files.
+
+Scripts inheriting must have roughly the same command line interface as
+swarming.py trigger. It modifies it in the following ways:
+ * Intercepts the dump-json argument, and creates its own by combining the
+   results from each trigger call.
+ * Scans through the multiple-trigger-configs dictionaries. For any key found,
+   deletes that dimension from the originally triggered task's dimensions. This
+   is what allows the Swarming dimensions to be replaced.  Must contain the
+   dimension "id" for the perf device affinity use case.
+ * On a per-shard basis, adds the Swarming dimensions chosen from the
+   multiple-trigger-configs list to the dimensions for the shard.
+
+This script is normally called from the swarming recipe module in tools/build.
+
+"""
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+
+import base_test_triggerer
+
+class PerfDeviceTriggerer(base_test_triggerer.BaseTestTriggerer):
+  def __init__(self):
+    super(PerfDeviceTriggerer, self).__init__()
+
+  def append_additional_args(self, args):
+    if 'id' in args:
+      # Adds the bot id as an argument to the test.
+      return args + ['--bot', args[args.index('id') + 1]]
+    else:
+      raise Exception('Id must be present for perf device triggering')
+
+  def select_config_indices(self, args, verbose):
+    # For perf we want to trigger a job for every valid config since
+    # each config represents exactly one bot in the perf swarming pool,
+    selected_configs = []
+    for i in xrange(args.shards):
+      selected_configs.append(i)
+    return selected_configs
+
+
+def main():
+  triggerer = PerfDeviceTriggerer()
+  # Setup args for common contract of base class
+  parser = triggerer.setup_parser_contract(
+      argparse.ArgumentParser(description=__doc__))
+  args, remaining = parser.parse_known_args()
+  return triggerer.trigger_tasks(args, remaining)
+
+if __name__ == '__main__':
+  sys.exit(main())
+
diff --git a/testing/trigger_scripts/perf_device_trigger_unittest.py b/testing/trigger_scripts/perf_device_trigger_unittest.py
new file mode 100755
index 0000000..f6eb576
--- /dev/null
+++ b/testing/trigger_scripts/perf_device_trigger_unittest.py
@@ -0,0 +1,146 @@
+#!/usr/bin/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.
+
+"""Tests for perf_device_trigger_unittest.py."""
+
+import unittest
+
+import perf_device_trigger
+
+class Args(object):
+  def __init__(self):
+    self.shards = 1
+    self.dump_json = ''
+    self.multiple_trigger_configs = []
+    self.multiple_dimension_script_verbose = False
+
+
+class FakeTriggerer(perf_device_trigger.PerfDeviceTriggerer):
+  def __init__(self, bot_configs):
+    super(FakeTriggerer, self).__init__()
+    self._bot_configs = bot_configs
+    self._bot_statuses = []
+    self._swarming_runs = []
+    self._files = {}
+    self._temp_file_id = 0
+
+  def set_files(self, files):
+    self._files = files
+
+  def make_temp_file(self, prefix=None, suffix=None):
+    result = prefix + str(self._temp_file_id) + suffix
+    self._temp_file_id += 1
+    return result
+
+  def delete_temp_file(self, temp_file):
+    pass
+
+  def read_json_from_temp_file(self, temp_file):
+    return self._files[temp_file]
+
+  def write_json_to_file(self, merged_json, output_file):
+    self._files[output_file] = merged_json
+
+  def parse_bot_configs(self, args):
+    pass
+
+  def run_swarming(self, args, verbose):
+    self._swarming_runs.append(args)
+
+
+PERF_BOT1 = {
+  'pool': 'Chrome-perf-fyi',
+  'id': 'build1'
+}
+
+PERF_BOT2 = {
+  'pool': 'Chrome-perf-fyi',
+  'id': 'build2'
+}
+
+class UnitTest(unittest.TestCase):
+  def basic_setup(self):
+    triggerer = FakeTriggerer(
+      [
+        PERF_BOT1,
+        PERF_BOT2
+      ]
+    )
+    # Note: the contents of these JSON files don't accurately reflect
+    # that produced by "swarming.py trigger". The unit tests only
+    # verify that shard 0's JSON is preserved.
+    triggerer.set_files({
+      'base_trigger_dimensions0.json': {
+        'base_task_name': 'webgl_conformance_tests',
+        'request': {
+          'expiration_secs': 3600,
+          'properties': {
+            'execution_timeout_secs': 3600,
+          },
+        },
+        'tasks': {
+          'webgl_conformance_tests on NVIDIA GPU on Windows': {
+            'task_id': 'f001',
+          },
+        },
+      },
+      'base_trigger_dimensions1.json': {
+        'tasks': {
+          'webgl_conformance_tests on NVIDIA GPU on Windows': {
+            'task_id': 'f002',
+          },
+        },
+      },
+    })
+    args = Args()
+    args.shards = 2
+    args.dump_json = 'output.json'
+    args.multiple_dimension_script_verbose = False
+    triggerer.trigger_tasks(
+      args,
+      [
+        'trigger',
+        '--dimension',
+        'pool',
+        'chrome-perf-fyi',
+        '--dimension',
+        'id',
+        'build1',
+        '--',
+        'benchmark1',
+      ])
+    return triggerer
+
+  def list_contains_sublist(self, main_list, sub_list):
+    return any(sub_list == main_list[offset:offset + len(sub_list)]
+               for offset in xrange(len(main_list) - (len(sub_list) - 1)))
+
+  def test_shard_env_vars_and_bot_id(self):
+    triggerer = self.basic_setup()
+    self.assertTrue(self.list_contains_sublist(
+      triggerer._swarming_runs[0], ['--bot', 'build1']))
+    self.assertTrue(self.list_contains_sublist(
+      triggerer._swarming_runs[1], ['--bot', 'build2']))
+    self.assertTrue(self.list_contains_sublist(
+      triggerer._swarming_runs[0], ['--env', 'GTEST_SHARD_INDEX', '0']))
+    self.assertTrue(self.list_contains_sublist(
+      triggerer._swarming_runs[1], ['--env', 'GTEST_SHARD_INDEX', '1']))
+    self.assertTrue(self.list_contains_sublist(
+      triggerer._swarming_runs[0], ['--env', 'GTEST_TOTAL_SHARDS', '2']))
+    self.assertTrue(self.list_contains_sublist(
+      triggerer._swarming_runs[1], ['--env', 'GTEST_TOTAL_SHARDS', '2']))
+
+  def test_json_merging(self):
+    triggerer = self.basic_setup()
+    self.assertTrue('output.json' in triggerer._files)
+    output_json = triggerer._files['output.json']
+    self.assertTrue('base_task_name' in output_json)
+    self.assertTrue('request' in output_json)
+    self.assertEqual(output_json['request']['expiration_secs'], 3600)
+    self.assertEqual(
+      output_json['request']['properties']['execution_timeout_secs'], 3600)
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/testing/trigger_scripts/trigger_multiple_dimensions.py b/testing/trigger_scripts/trigger_multiple_dimensions.py
index cb8723cf..c17984a3 100755
--- a/testing/trigger_scripts/trigger_multiple_dimensions.py
+++ b/testing/trigger_scripts/trigger_multiple_dimensions.py
@@ -64,135 +64,12 @@
 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')
+import base_test_triggerer
 
 
-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 MultiDimensionTestTriggerer(object):
+class MultiDimensionTestTriggerer(base_test_triggerer.BaseTestTriggerer):
   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 all_args[:dash_ind] + bot_args + all_args[dash_ind:]
-
-  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='trigger_multiple_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
+    super(MultiDimensionTestTriggerer, self).__init__()
 
   def choose_random_int(self, max_num):
     return random.randint(1, max_num)
@@ -234,112 +111,22 @@
       r -= status['total']
     raise Exception('Should not reach here')
 
-  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 select_config_indices(self, args, verbose):
+    selected_indices = []
+    for _ in xrange(args.shards):
+      selected_indices.append(self.pick_bot_configuration(verbose))
+    return selected_indices
 
-  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 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)
+  def prune_test_specific_configs(self, args, verbose):
     self.query_swarming_for_bot_configs(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 = {}
-
-    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 = self.pick_bot_configuration(verbose)
-      # Holds the results of the swarming.py trigger call.
-      try:
-        json_temp = self.make_temp_file(prefix='trigger_multiple_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 main():
-  parser = argparse.ArgumentParser(description=__doc__)
-  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.')
-
+  triggerer =  MultiDimensionTestTriggerer()
+  # setup args for common contract of base class
+  parser = triggerer.setup_parser_contract(
+      argparse.ArgumentParser(description=__doc__))
   args, remaining = parser.parse_known_args()
-
-  return MultiDimensionTestTriggerer().trigger_tasks(args, remaining)
+  return triggerer.trigger_tasks(args, remaining)
 
 
 if __name__ == '__main__':
diff --git a/testing/trigger_scripts/trigger_multiple_dimensions_unittest.py b/testing/trigger_scripts/trigger_multiple_dimensions_unittest.py
index a0a3769..1cd2a27 100755
--- a/testing/trigger_scripts/trigger_multiple_dimensions_unittest.py
+++ b/testing/trigger_scripts/trigger_multiple_dimensions_unittest.py
@@ -99,7 +99,7 @@
     # that produced by "swarming.py trigger". The unit tests only
     # verify that shard 0's JSON is preserved.
     triggerer.set_files({
-      'trigger_multiple_dimensions0.json': {
+      'base_trigger_dimensions0.json': {
         'base_task_name': 'webgl_conformance_tests',
         'request': {
           'expiration_secs': 3600,
@@ -113,7 +113,7 @@
           },
         },
       },
-      'trigger_multiple_dimensions1.json': {
+      'base_trigger_dimensions1.json': {
         'tasks': {
           'webgl_conformance_tests on NVIDIA GPU on Windows': {
             'task_id': 'f002',
@@ -145,7 +145,7 @@
 
   def list_contains_sublist(self, main_list, sub_list):
     return any(sub_list == main_list[offset:offset + len(sub_list)]
-               for offset in xrange(len(main_list) - len(sub_list)))
+               for offset in xrange(len(main_list) - (len(sub_list) - 1)))
 
   def shard_runs_on_os(self, triggerer, shard_index, os):
     return self.list_contains_sublist(triggerer._swarming_runs[shard_index],