Port Skia recipe to normal Python scripts, move to Skia repo

BUG=skia:4763
GOLD_TRYBOT_URL= https://gold.skia.org/search2?unt=true&query=source_type%3Dgm&master=false&issue=1703663002

Review URL: https://codereview.chromium.org/1703663002
diff --git a/PRESUBMIT.py b/PRESUBMIT.py
index 326caad..df2b573 100644
--- a/PRESUBMIT.py
+++ b/PRESUBMIT.py
@@ -85,6 +85,7 @@
       'R0201',  # Method could be a function.
       'E1003',  # Using class name in super.
       'W0613',  # Unused argument.
+      'W0105',  # String statement has no effect.
   )
   # Run Pylint on only the modified python files. Unfortunately it still runs
   # Pylint on the whole file instead of just the modified lines.
diff --git a/infra/bots/README.md b/infra/bots/README.md
new file mode 100644
index 0000000..9f184aa
--- /dev/null
+++ b/infra/bots/README.md
@@ -0,0 +1,18 @@
+Skia Buildbot Scripts
+=====================
+
+The scripts in this directory are ported from Skia's buildbot recipes and are
+intended to run as standalone Python scripts either locally or via Swarming.
+
+How to Run
+----------
+
+The scripts can be run by hand, eg:
+
+$ cd infra/bots
+$ python compile_skia.py Build-Ubuntu-GCC-x86_64-Debug ../../out
+
+Or, you can run the scripts via Swarming:
+
+$ isolate archive --isolate-server https://isolateserver.appspot.com/ -i infra/bots/compile_skia.isolate -s ../compile-skia.isolated --verbose --config-variable BUILDER_NAME=Build-Ubuntu-GCC-x86_64-Debug
+$ swarming.py run --swarming https://chromium-swarm.appspot.com --isolate-server https://isolateserver.appspot.com --dimension os Ubuntu --dimension pool Skia --task-name compile-skia --io-timeout=3600 --hard-timeout=3600 ../compile-skia.isolated
diff --git a/infra/bots/common.py b/infra/bots/common.py
new file mode 100644
index 0000000..9b96440
--- /dev/null
+++ b/infra/bots/common.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import os
+import subprocess
+import sys
+
+from flavor import android_flavor
+from flavor import chromeos_flavor
+from flavor import cmake_flavor
+from flavor import coverage_flavor
+from flavor import default_flavor
+from flavor import ios_flavor
+from flavor import valgrind_flavor
+from flavor import xsan_flavor
+
+
+CONFIG_COVERAGE = 'Coverage'
+CONFIG_DEBUG = 'Debug'
+CONFIG_RELEASE = 'Release'
+VALID_CONFIGS = (CONFIG_COVERAGE, CONFIG_DEBUG, CONFIG_RELEASE)
+
+GM_ACTUAL_FILENAME = 'actual-results.json'
+GM_EXPECTATIONS_FILENAME = 'expected-results.json'
+GM_IGNORE_TESTS_FILENAME = 'ignored-tests.txt'
+
+GS_GM_BUCKET = 'chromium-skia-gm'
+GS_SUMMARIES_BUCKET = 'chromium-skia-gm-summaries'
+
+SKIA_REPO = 'https://skia.googlesource.com/skia.git'
+INFRA_REPO = 'https://skia.googlesource.com/buildbot.git'
+
+SERVICE_ACCOUNT_FILE = 'service-account-skia.json'
+SERVICE_ACCOUNT_INTERNAL_FILE = 'service-account-skia-internal.json'
+
+
+def is_android(bot_cfg):
+  """Determine whether the given bot is an Android bot."""
+  return ('Android' in bot_cfg.get('extra_config', '') or
+          bot_cfg.get('os') == 'Android')
+
+def is_chromeos(bot_cfg):
+  return ('CrOS' in bot_cfg.get('extra_config', '') or
+          bot_cfg.get('os') == 'ChromeOS')
+
+def is_cmake(bot_cfg):
+  return 'CMake' in bot_cfg.get('extra_config', '')
+
+def is_ios(bot_cfg):
+  return ('iOS' in bot_cfg.get('extra_config', '') or
+          bot_cfg.get('os') == 'iOS')
+
+
+def is_valgrind(bot_cfg):
+  return 'Valgrind' in bot_cfg.get('extra_config', '')
+
+
+def is_xsan(bot_cfg):
+  return (bot_cfg.get('extra_config') == 'ASAN' or
+          bot_cfg.get('extra_config') == 'MSAN' or
+          bot_cfg.get('extra_config') == 'TSAN')
+
+
+class BotInfo(object):
+  def __init__(self, bot_name, slave_name, out_dir):
+    """Initialize the bot, given its name.
+
+    Assumes that CWD is the directory containing this file.
+    """
+    self.name = bot_name
+    self.slave_name = slave_name
+    self.skia_dir = os.path.abspath(os.path.join(
+        os.path.dirname(os.path.realpath(__file__)),
+        os.pardir, os.pardir))
+    os.chdir(self.skia_dir)
+    self.build_dir = os.path.abspath(os.path.join(self.skia_dir, os.pardir))
+    self.out_dir = out_dir
+    self.spec = self.get_bot_spec(bot_name)
+    self.configuration = self.spec['configuration']
+    self.default_env = {
+      'SKIA_OUT': self.out_dir,
+      'BUILDTYPE': self.configuration,
+      'PATH': os.environ['PATH'],
+    }
+    self.default_env.update(self.spec['env'])
+    self.build_targets = [str(t) for t in self.spec['build_targets']]
+    self.bot_cfg = self.spec['builder_cfg']
+    self.is_trybot = self.bot_cfg['is_trybot']
+    self.upload_dm_results = self.spec['upload_dm_results']
+    self.upload_perf_results = self.spec['upload_perf_results']
+    self.dm_flags = self.spec['dm_flags']
+    self.nanobench_flags = self.spec['nanobench_flags']
+    self._ccache = None
+    self._checked_for_ccache = False
+    self.flavor = self.get_flavor(self.bot_cfg)
+
+  @property
+  def ccache(self):
+    if not self._checked_for_ccache:
+      self._checked_for_ccache = True
+      if sys.platform != 'win32':
+        try:
+          result = subprocess.check_output(['which', 'ccache'])
+          self._ccache = result.rstrip()
+        except subprocess.CalledProcessError:
+          pass
+
+    return self._ccache
+
+  def get_bot_spec(self, bot_name):
+    """Retrieve the bot spec for this bot."""
+    sys.path.append(self.skia_dir)
+    from tools import buildbot_spec
+    return buildbot_spec.get_builder_spec(bot_name)
+
+  def get_flavor(self, bot_cfg):
+    """Return a flavor utils object specific to the given bot."""
+    if is_android(bot_cfg):
+      return android_flavor.AndroidFlavorUtils(self)
+    elif is_chromeos(bot_cfg):
+      return chromeos_flavor.ChromeOSFlavorUtils(self)
+    elif is_cmake(bot_cfg):
+      return cmake_flavor.CMakeFlavorUtils(self)
+    elif is_ios(bot_cfg):
+      return ios_flavor.iOSFlavorUtils(self)
+    elif is_valgrind(bot_cfg):
+      return valgrind_flavor.ValgrindFlavorUtils(self)
+    elif is_xsan(bot_cfg):
+      return xsan_flavor.XSanFlavorUtils(self)
+    elif bot_cfg.get('configuration') == CONFIG_COVERAGE:
+      return coverage_flavor.CoverageFlavorUtils(self)
+    else:
+      return default_flavor.DefaultFlavorUtils(self)
+
+  def run(self, cmd, env=None, cwd=None):
+    _env = {}
+    _env.update(self.default_env)
+    _env.update(env or {})
+    cwd = cwd or self.skia_dir
+    print '============'
+    print 'CMD: %s' % cmd
+    print 'CWD: %s' % cwd
+    print 'ENV: %s' % _env
+    print '============'
+    subprocess.check_call(cmd, env=_env, cwd=cwd)
diff --git a/infra/bots/compile_skia.isolate b/infra/bots/compile_skia.isolate
new file mode 100644
index 0000000..51168e0
--- /dev/null
+++ b/infra/bots/compile_skia.isolate
@@ -0,0 +1,10 @@
+{
+  'includes': [
+    'skia_repo.isolate',
+  ],
+  'variables': {
+    'command': [
+      'python', 'compile_skia.py', '<(BUILDER_NAME)', '${ISOLATED_OUTDIR}/out',
+    ],
+  },
+}
diff --git a/infra/bots/compile_skia.py b/infra/bots/compile_skia.py
new file mode 100644
index 0000000..b3b6251
--- /dev/null
+++ b/infra/bots/compile_skia.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import common
+import sys
+
+
+def main():
+  if len(sys.argv) != 3:
+    print >> sys.stderr, 'Usage: compile_skia.py <builder name> <out-dir>'
+    sys.exit(1)
+  bot = common.BotInfo(sys.argv[1], 'fake-slave', sys.argv[2])
+  for t in bot.build_targets:
+    bot.flavor.compile(t)
+
+
+if __name__ == '__main__':
+  main()
diff --git a/infra/bots/flavor/__init__.py b/infra/bots/flavor/__init__.py
new file mode 100644
index 0000000..78953f5
--- /dev/null
+++ b/infra/bots/flavor/__init__.py
@@ -0,0 +1,6 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
diff --git a/infra/bots/flavor/android_devices.py b/infra/bots/flavor/android_devices.py
new file mode 100644
index 0000000..37ceabe
--- /dev/null
+++ b/infra/bots/flavor/android_devices.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import collections
+import json
+
+
+DEFAULT_SDK_ROOT = '/home/chrome-bot/android-sdk-linux'
+MAC_SDK_ROOT = '/Users/chrome-bot/adt-bundle-mac-x86_64-20140702/sdk'
+MACMINI_SDK_ROOT = '/Users/chrome-bot/android-sdk-macosx'
+
+SlaveInfo = collections.namedtuple('SlaveInfo',
+                                   'serial android_sdk_root has_root')
+
+SLAVE_INFO = {
+  'skiabot-mac-10_8-compile-000':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-001':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-002':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-003':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-004':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-005':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-006':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-007':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-008':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-mac-10_8-compile-009':
+      SlaveInfo('noserial', MAC_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu15-androidone-001':
+      SlaveInfo('AG86044202A04GC', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu15-androidone-002':
+      SlaveInfo('AG8404EC06G02GC', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu15-androidone-003':
+      SlaveInfo('AG8404EC0688EGC', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-galaxys3-001':
+      SlaveInfo('4df713b8244a21cf', DEFAULT_SDK_ROOT, False),
+  'skiabot-shuttle-ubuntu12-galaxys3-002':
+      SlaveInfo('32309a56e9b3a09f', DEFAULT_SDK_ROOT, False),
+  'skiabot-shuttle-ubuntu12-galaxys4-001':
+      SlaveInfo('4d0032a5d8cb6125', MACMINI_SDK_ROOT, False),
+  'skiabot-shuttle-ubuntu12-galaxys4-002':
+      SlaveInfo('4d00353cd8ed61c3', MACMINI_SDK_ROOT, False),
+  'skiabot-shuttle-ubuntu12-nexus5-001':
+      SlaveInfo('03f61449437cc47b', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus5-002':
+      SlaveInfo('018dff3520c970f6', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu15-nexus6-001':
+      SlaveInfo('ZX1G22JJWS', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu15-nexus6-002':
+      SlaveInfo('ZX1G22JN35', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu15-nexus6-003':
+      SlaveInfo('ZX1G22JXXM', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus7-001':
+      SlaveInfo('015d210a13480604', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus7-002':
+      SlaveInfo('015d18848c280217', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus7-003':
+      SlaveInfo('015d16897c401e17', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus9-001':
+      SlaveInfo('HT43RJT00022', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus9-002':
+      SlaveInfo('HT4AEJT03112', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus9-003':
+      SlaveInfo('HT4ADJT03339', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus10-001':
+      SlaveInfo('R32C801B5LH', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexus10-003':
+      SlaveInfo('R32CB017X2L', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexusplayer-001':
+      SlaveInfo('D76C708B', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu12-nexusplayer-002':
+      SlaveInfo('8AB5139A', DEFAULT_SDK_ROOT, True),
+  'skiabot-shuttle-ubuntu15-nvidia-shield-001':
+      SlaveInfo('04217150066510000078', MACMINI_SDK_ROOT, False),
+  'skiabot-linux-housekeeper-003':
+      SlaveInfo('noserial', DEFAULT_SDK_ROOT, False),
+  'vm690-m3': SlaveInfo('noserial', MACMINI_SDK_ROOT, False),
+  'vm691-m3': SlaveInfo('noserial', MACMINI_SDK_ROOT, False),
+  'vm692-m3': SlaveInfo('noserial', MACMINI_SDK_ROOT, False),
+  'vm693-m3': SlaveInfo('noserial', MACMINI_SDK_ROOT, False),
+  'default':
+      SlaveInfo('noserial', DEFAULT_SDK_ROOT, False),
+}
+
+
+if __name__ == '__main__':
+  print json.dumps(SLAVE_INFO)  # pragma: no cover
+
diff --git a/infra/bots/flavor/android_flavor.py b/infra/bots/flavor/android_flavor.py
new file mode 100644
index 0000000..5a30079
--- /dev/null
+++ b/infra/bots/flavor/android_flavor.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import android_devices
+import default_flavor
+import os
+
+
+"""Android flavor utils, used for building for and running tests on Android."""
+
+
+class _ADBWrapper(object):
+  """Wrapper for ADB."""
+  def __init__(self, path_to_adb, serial, android_flavor):
+    self._adb = path_to_adb
+    self._serial = serial
+    self._wait_count = 0
+    self._android_flavor = android_flavor
+
+  def wait_for_device(self):
+    """Run 'adb wait-for-device'."""
+    self._wait_count += 1
+    cmd = [
+        os.path.join(self._android_flavor.android_bin, 'adb_wait_for_device'),
+        '-s', self._serial,
+    ]
+    self._android_flavor._bot_info.run(
+        cmd, env=self._android_flavor._default_env)
+
+  def maybe_wait_for_device(self):
+    """Run 'adb wait-for-device' if it hasn't already been run."""
+    if self._wait_count == 0:
+      self.wait_for_device()
+
+  def __call__(self, *args, **kwargs):
+    self.maybe_wait_for_device()
+    return self._android_flavor._bot_info.run(self._adb + args, **kwargs)
+
+
+class AndroidFlavorUtils(default_flavor.DefaultFlavorUtils):
+  def __init__(self, skia_api):
+    super(AndroidFlavorUtils, self).__init__(skia_api)
+    self.device = self._bot_info.spec['device_cfg']
+    slave_info = android_devices.SLAVE_INFO.get(
+        self._bot_info.slave_name,
+        android_devices.SLAVE_INFO['default'])
+    self.serial = slave_info.serial
+    self.android_bin = os.path.join(
+        self._bot_info.skia_dir, 'platform_tools', 'android', 'bin')
+    self._android_sdk_root = slave_info.android_sdk_root
+    self._adb = _ADBWrapper(
+        os.path.join(self._android_sdk_root, 'platform-tools', 'adb'),
+        self.serial,
+        self)
+    self._has_root = slave_info.has_root
+    self._default_env = {'ANDROID_SDK_ROOT': self._android_sdk_root,
+                         'ANDROID_HOME': self._android_sdk_root,
+                         'SKIA_ANDROID_VERBOSE_SETUP': '1'}
+
+  def step(self, name, cmd, env=None, **kwargs):
+    self._adb.maybe_wait_for_device()
+    args = [self.android_bin.join('android_run_skia'),
+            '--verbose',
+            '--logcat',
+            '-d', self.device,
+            '-s', self.serial,
+            '-t', self._bot_info.configuration,
+    ]
+    env = dict(env or {})
+    env.update(self._default_env)
+
+    return self._bot_info.run(self._bot_info.m.step, name=name, cmd=args + cmd,
+                              env=env, **kwargs)
+
+  def compile(self, target):
+    """Build the given target."""
+    env = dict(self._default_env)
+    ccache = self._bot_info.ccache
+    if ccache:
+      env['ANDROID_MAKE_CCACHE'] = ccache
+
+    cmd = [os.path.join(self.android_bin, 'android_ninja'), target,
+           '-d', self.device]
+    if 'Clang' in self._bot_info.name:
+      cmd.append('--clang')
+    self._bot_info.run(cmd, env=env)
+
+  def device_path_join(self, *args):
+    """Like os.path.join(), but for paths on a connected Android device."""
+    return '/'.join(args)
+
+  def device_path_exists(self, path):
+    """Like os.path.exists(), but for paths on a connected device."""
+    exists_str = 'FILE_EXISTS'
+    return exists_str in self._adb(
+        name='exists %s' % self._bot_info.m.path.basename(path),
+        serial=self.serial,
+        cmd=['shell', 'if', '[', '-e', path, '];',
+             'then', 'echo', exists_str + ';', 'fi'],
+        stdout=self._bot_info.m.raw_io.output(),
+        infra_step=True
+    ).stdout
+
+  def _remove_device_dir(self, path):
+    """Remove the directory on the device."""
+    self._adb(name='rmdir %s' % self._bot_info.m.path.basename(path),
+              serial=self.serial,
+              cmd=['shell', 'rm', '-r', path],
+              infra_step=True)
+    # Sometimes the removal fails silently. Verify that it worked.
+    if self.device_path_exists(path):
+      raise Exception('Failed to remove %s!' % path)  # pragma: no cover
+
+  def _create_device_dir(self, path):
+    """Create the directory on the device."""
+    self._adb(name='mkdir %s' % self._bot_info.m.path.basename(path),
+              serial=self.serial,
+              cmd=['shell', 'mkdir', '-p', path],
+              infra_step=True)
+
+  def copy_directory_contents_to_device(self, host_dir, device_dir):
+    """Like shutil.copytree(), but for copying to a connected device."""
+    self._bot_info.run(
+        self._bot_info.m.step,
+        name='push %s' % self._bot_info.m.path.basename(host_dir),
+        cmd=[self.android_bin.join('adb_push_if_needed'), '--verbose',
+             '-s', self.serial, host_dir, device_dir],
+        env=self._default_env,
+        infra_step=True)
+
+  def copy_directory_contents_to_host(self, device_dir, host_dir):
+    """Like shutil.copytree(), but for copying from a connected device."""
+    self._bot_info.run(
+        self._bot_info.m.step,
+        name='pull %s' % self._bot_info.m.path.basename(device_dir),
+        cmd=[self.android_bin.join('adb_pull_if_needed'), '--verbose',
+             '-s', self.serial, device_dir, host_dir],
+        env=self._default_env,
+        infra_step=True)
+
+  def copy_file_to_device(self, host_path, device_path):
+    """Like shutil.copyfile, but for copying to a connected device."""
+    self._adb(name='push %s' % self._bot_info.m.path.basename(host_path),
+              serial=self.serial,
+              cmd=['push', host_path, device_path],
+              infra_step=True)
+
+  def create_clean_device_dir(self, path):
+    """Like shutil.rmtree() + os.makedirs(), but on a connected device."""
+    self._remove_device_dir(path)
+    self._create_device_dir(path)
+
+  def install(self):
+    """Run device-specific installation steps."""
+    if self._has_root:
+      self._adb(name='adb root',
+                serial=self.serial,
+                cmd=['root'],
+                infra_step=True)
+      # Wait for the device to reconnect.
+      self._bot_info.run(
+          self._bot_info.m.step,
+          name='wait',
+          cmd=['sleep', '10'],
+          infra_step=True)
+      self._adb.wait_for_device()
+
+    # TODO(borenet): Set CPU scaling mode to 'performance'.
+    self._bot_info.run(self._bot_info.m.step,
+                       name='kill skia',
+                       cmd=[self.android_bin.join('android_kill_skia'),
+                            '--verbose', '-s', self.serial],
+                       env=self._default_env,
+                       infra_step=True)
+    if self._has_root:
+      self._adb(name='stop shell',
+                serial=self.serial,
+                cmd=['shell', 'stop'],
+                infra_step=True)
+
+    # Print out battery stats.
+    self._adb(name='starting battery stats',
+              serial=self.serial,
+              cmd=['shell', 'dumpsys', 'batteryproperties'],
+              infra_step=True)
+
+  def cleanup_steps(self):
+    """Run any device-specific cleanup steps."""
+    self._adb(name='final battery stats',
+              serial=self.serial,
+              cmd=['shell', 'dumpsys', 'batteryproperties'],
+              infra_step=True)
+    self._adb(name='reboot',
+              serial=self.serial,
+              cmd=['reboot'],
+              infra_step=True)
+    self._bot_info.run(
+        self._bot_info.m.step,
+        name='wait for reboot',
+        cmd=['sleep', '10'],
+        infra_step=True)
+    self._adb.wait_for_device()
+
+  def read_file_on_device(self, path, *args, **kwargs):
+    """Read the given file."""
+    return self._adb(name='read %s' % self._bot_info.m.path.basename(path),
+                     serial=self.serial,
+                     cmd=['shell', 'cat', path],
+                     stdout=self._bot_info.m.raw_io.output(),
+                     infra_step=True).stdout.rstrip()
+
+  def remove_file_on_device(self, path, *args, **kwargs):
+    """Delete the given file."""
+    return self._adb(name='rm %s' % self._bot_info.m.path.basename(path),
+                     serial=self.serial,
+                     cmd=['shell', 'rm', '-f', path],
+                     infra_step=True,
+                     *args,
+                     **kwargs)
+
+  def get_device_dirs(self):
+    """ Set the directories which will be used by the build steps."""
+    device_scratch_dir = self._adb(
+        name='get EXTERNAL_STORAGE dir',
+        serial=self.serial,
+        cmd=['shell', 'echo', '$EXTERNAL_STORAGE'],
+    )
+    prefix = self.device_path_join(device_scratch_dir, 'skiabot', 'skia_')
+    return default_flavor.DeviceDirs(
+        dm_dir=prefix + 'dm',
+        perf_data_dir=prefix + 'perf',
+        resource_dir=prefix + 'resources',
+        images_dir=prefix + 'images',
+        skp_dir=prefix + 'skp/skps',
+        tmp_dir=prefix + 'tmp_dir')
+
diff --git a/infra/bots/flavor/chromeos_flavor.py b/infra/bots/flavor/chromeos_flavor.py
new file mode 100644
index 0000000..8e82214
--- /dev/null
+++ b/infra/bots/flavor/chromeos_flavor.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import default_flavor
+import os
+import ssh_flavor
+
+
+"""Utils for building for and running tests on ChromeOS."""
+
+
+class ChromeOSFlavorUtils(ssh_flavor.SSHFlavorUtils):
+  def __init__(self, bot_info):
+    super(ChromeOSFlavorUtils, self).__init__(bot_info)
+    self.board = self._bot_info.spec['device_cfg']
+    self.device_root_dir = '/usr/local/skiabot'
+    self.device_bin_dir = self.device_path_join(self.device_root_dir, 'bin')
+
+  def step(self, name, cmd, **kwargs):
+    """Wrapper for the Step API; runs a step as appropriate for this flavor."""
+    local_path = self._bot_info.out_dir.join(
+      'config', 'chromeos-%s' % self.board,
+      self._bot_info.configuration, cmd[0])
+    remote_path = self.device_path_join(self.device_bin_dir, cmd[0])
+    self.copy_file_to_device(local_path, remote_path)
+    super(ChromeOSFlavorUtils, self).step(name=name,
+                                          cmd=[remote_path]+cmd[1:],
+                                          **kwargs)
+
+  def compile(self, target):
+    """Build the given target."""
+    cmd = [os.path.join(self._bot_info.skia_dir, 'platform_tools', 'chromeos',
+                        'bin', 'chromeos_make'),
+           '-d', self.board,
+           target]
+    self._bot_info.run(cmd)
+
+  def install(self):
+    """Run any device-specific installation steps."""
+    self.create_clean_device_dir(self.device_bin_dir)
+
+  def get_device_dirs(self):
+    """ Set the directories which will be used by the build steps."""
+    prefix = self.device_path_join(self.device_root_dir, 'skia_')
+    def join(suffix):
+      return ''.join((prefix, suffix))
+    return default_flavor.DeviceDirs(
+        dm_dir=join('dm_out'),  # 'dm' conflicts with the binary
+        perf_data_dir=join('perf'),
+        resource_dir=join('resources'),
+        images_dir=join('images'),
+        skp_dir=self.device_path_join(join('skp'), 'skps'),
+        tmp_dir=join('tmp_dir'))
+
diff --git a/infra/bots/flavor/cmake_flavor.py b/infra/bots/flavor/cmake_flavor.py
new file mode 100644
index 0000000..cfba2bb
--- /dev/null
+++ b/infra/bots/flavor/cmake_flavor.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import default_flavor
+import os
+
+
+"""CMake flavor utils, used for building Skia with CMake."""
+
+
+class CMakeFlavorUtils(default_flavor.DefaultFlavorUtils):
+  def compile(self, target):
+    """Build Skia with CMake.  Ignores `target`."""
+    cmake_build = os.path.join(self._bot_info.skia_dir, 'cmake', 'cmake_build')
+    self._bot_info.run([cmake_build, target])
diff --git a/infra/bots/flavor/coverage_flavor.py b/infra/bots/flavor/coverage_flavor.py
new file mode 100644
index 0000000..0a11d08
--- /dev/null
+++ b/infra/bots/flavor/coverage_flavor.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import default_flavor
+import os
+import subprocess
+import time
+
+
+"""Utils for running coverage tests."""
+
+
+class CoverageFlavorUtils(default_flavor.DefaultFlavorUtils):
+  def compile(self, target):
+    """Build the given target."""
+    cmd = [os.path.join(self._bot_info.skia_dir, 'tools',
+                        'llvm_coverage_build'),
+           target]
+    self._bot_info.run(cmd)
+
+  def step(self, cmd, **kwargs):
+    """Run the given step through coverage."""
+    # Slice out the 'key' and 'properties' arguments to be reused.
+    key = []
+    properties = []
+    current = None
+    for i in xrange(0, len(cmd)):
+      if isinstance(cmd[i], basestring) and cmd[i] == '--key':
+        current = key
+      elif isinstance(cmd[i], basestring) and cmd[i] == '--properties':
+        current = properties
+      elif isinstance(cmd[i], basestring) and cmd[i].startswith('--'):
+        current = None
+      if current is not None:
+        current.append(cmd[i])
+
+    results_dir = self._bot_info.out_dir.join('coverage_results')
+    self.create_clean_host_dir(results_dir)
+
+    # Run DM under coverage.
+    report_file_basename = '%s.cov' % self._bot_info.got_revision
+    report_file = os.path.join(results_dir, report_file_basename)
+    args = [
+        'python',
+        os.path.join(self._bot_info.skia_dir, 'tools', 'llvm_coverage_run.py'),
+    ] + cmd + ['--outResultsFile', report_file]
+    self._bot_info.run(args, **kwargs)
+
+    # Generate nanobench-style JSON output from the coverage report.
+    git_timestamp = subprocess.check_output(['git', 'log', '-n1',
+        self._bot_info.got_revision, '--format=%%ci']).rstrip()
+    nanobench_json = results_dir.join('nanobench_%s_%s.json' % (
+        self._bot_info.got_revision, git_timestamp))
+    line_by_line_basename = ('coverage_by_line_%s_%s.json' % (
+        self._bot_info.got_revision, git_timestamp))
+    line_by_line = results_dir.join(line_by_line_basename)
+    args = [
+        'python',
+        os.path.join(self._bot_info.skia_dir, 'tools',
+                     'parse_llvm_coverage.py'),
+        '--report', report_file, '--nanobench', nanobench_json,
+        '--linebyline', line_by_line]
+    args.extend(key)
+    args.extend(properties)
+    self._bot_info.run(args)
+
+    # Upload raw coverage data.
+    now = time.utcnow()
+    gs_json_path = '/'.join((
+        str(now.year).zfill(4), str(now.month).zfill(2),
+        str(now.day).zfill(2), str(now.hour).zfill(2),
+        self._bot_info.name,
+        str(self._bot_info.build_number)))
+    if self._bot_info.is_trybot:
+      gs_json_path = '/'.join(('trybot', gs_json_path,
+                               str(self._bot_info.issue)))
+
+    self._bot_info.gsutil_upload(
+        'upload raw coverage data',
+        source=report_file,
+        bucket='skia-infra',
+        dest='/'.join(('coverage-raw-v1', gs_json_path, report_file_basename)))
+
+    # Upload nanobench JSON data.
+    gsutil_path = self._bot_info.m.path['depot_tools'].join(
+        'third_party', 'gsutil', 'gsutil')
+    upload_args = [self._bot_info.name,
+                   self._bot_info.m.properties['buildnumber'],
+                   results_dir,
+                   self._bot_info.got_revision, gsutil_path]
+    if self._bot_info.is_trybot:
+      upload_args.append(self._bot_info.m.properties['issue'])
+    self._bot_info.run(
+        self._bot_info.m.python,
+        'upload nanobench coverage results',
+        script=self._bot_info.resource('upload_bench_results.py'),
+        args=upload_args,
+        cwd=self._bot_info.m.path['checkout'],
+        abort_on_failure=False,
+        infra_step=True)
+
+    # Upload line-by-line coverage data.
+    self._bot_info.gsutil_upload(
+        'upload line-by-line coverage data',
+        source=line_by_line,
+        bucket='skia-infra',
+        dest='/'.join(('coverage-json-v1', gs_json_path,
+                       line_by_line_basename)))
+
diff --git a/infra/bots/flavor/default_flavor.py b/infra/bots/flavor/default_flavor.py
new file mode 100644
index 0000000..5263073
--- /dev/null
+++ b/infra/bots/flavor/default_flavor.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+"""Default flavor utils class, used for desktop bots."""
+
+
+import os
+import shutil
+import sys
+
+
+class DeviceDirs(object):
+  def __init__(self,
+               dm_dir,
+               perf_data_dir,
+               resource_dir,
+               images_dir,
+               skp_dir,
+               tmp_dir):
+    self._dm_dir = dm_dir
+    self._perf_data_dir = perf_data_dir
+    self._resource_dir = resource_dir
+    self._images_dir = images_dir
+    self._skp_dir = skp_dir
+    self._tmp_dir = tmp_dir
+
+  @property
+  def dm_dir(self):
+    """Where DM writes."""
+    return self._dm_dir
+
+  @property
+  def perf_data_dir(self):
+    return self._perf_data_dir
+
+  @property
+  def resource_dir(self):
+    return self._resource_dir
+
+  @property
+  def images_dir(self):
+    return self._images_dir
+
+  @property
+  def skp_dir(self):
+    return self._skp_dir
+
+  @property
+  def tmp_dir(self):
+    return self._tmp_dir
+
+
+class DefaultFlavorUtils(object):
+  """Utilities to be used by build steps.
+
+  The methods in this class define how certain high-level functions should
+  work. Each build step flavor should correspond to a subclass of
+  DefaultFlavorUtils which may override any of these functions as appropriate
+  for that flavor.
+
+  For example, the AndroidFlavorUtils will override the functions for
+  copying files between the host and Android device, as well as the
+  'step' function, so that commands may be run through ADB.
+  """
+  def __init__(self, bot_info, *args, **kwargs):
+    self._bot_info = bot_info
+    self.chrome_path = os.path.join(os.path.expanduser('~'), 'src')
+
+  def step(self, cmd, **kwargs):
+    """Runs a step as appropriate for this flavor."""
+    path_to_app = self._bot_info.out_dir.join(
+        self._bot_info.configuration, cmd[0])
+    if (sys.platform == 'linux' and
+        'x86_64' in self._bot_info.bot_name and
+        not 'TSAN' in self._bot_info.bot_name):
+      new_cmd = ['catchsegv', path_to_app]
+    else:
+      new_cmd = [path_to_app]
+    new_cmd.extend(cmd[1:])
+    return self._bot_info.run(new_cmd, **kwargs)
+
+
+  def compile(self, target):
+    """Build the given target."""
+    # The CHROME_PATH environment variable is needed for bots that use
+    # toolchains downloaded by Chrome.
+    env = {'CHROME_PATH': self.chrome_path}
+    if sys.platform == 'win32':
+      make_cmd = ['python', 'make.py']
+    else:
+      make_cmd = ['make']
+    cmd = make_cmd + [target]
+    self._bot_info.run(cmd, env=env)
+
+  def device_path_join(self, *args):
+    """Like os.path.join(), but for paths on a connected device."""
+    return os.path.join(*args)
+
+  def device_path_exists(self, path):
+    """Like os.path.exists(), but for paths on a connected device."""
+    return os.path.exists(path, infra_step=True)  # pragma: no cover
+
+  def copy_directory_contents_to_device(self, host_dir, device_dir):
+    """Like shutil.copytree(), but for copying to a connected device."""
+    # For "normal" bots who don't have an attached device, we expect
+    # host_dir and device_dir to be the same.
+    if str(host_dir) != str(device_dir):
+      raise ValueError('For bots who do not have attached devices, copying '
+                       'from host to device is undefined and only allowed if '
+                       'host_path and device_path are the same (%s vs %s).' % (
+                       str(host_dir), str(device_dir)))  # pragma: no cover
+
+  def copy_directory_contents_to_host(self, device_dir, host_dir):
+    """Like shutil.copytree(), but for copying from a connected device."""
+    # For "normal" bots who don't have an attached device, we expect
+    # host_dir and device_dir to be the same.
+    if str(host_dir) != str(device_dir):
+      raise ValueError('For bots who do not have attached devices, copying '
+                       'from device to host is undefined and only allowed if '
+                       'host_path and device_path are the same (%s vs %s).' % (
+                       str(host_dir), str(device_dir)))  # pragma: no cover
+
+  def copy_file_to_device(self, host_path, device_path):
+    """Like shutil.copyfile, but for copying to a connected device."""
+    # For "normal" bots who don't have an attached device, we expect
+    # host_dir and device_dir to be the same.
+    if str(host_path) != str(device_path):  # pragma: no cover
+      raise ValueError('For bots who do not have attached devices, copying '
+                       'from host to device is undefined and only allowed if '
+                       'host_path and device_path are the same (%s vs %s).' % (
+                       str(host_path), str(device_path)))
+
+  def create_clean_device_dir(self, path):
+    """Like shutil.rmtree() + os.makedirs(), but on a connected device."""
+    self.create_clean_host_dir(path)
+
+  def create_clean_host_dir(self, path):
+    """Convenience function for creating a clean directory."""
+    shutil.rmtree(path)
+    os.makedirs(path)
+
+  def install(self):
+    """Run device-specific installation steps."""
+    pass
+
+  def cleanup_steps(self):
+    """Run any device-specific cleanup steps."""
+    pass
+
+  def get_device_dirs(self):
+    """ Set the directories which will be used by the build steps.
+
+    These refer to paths on the same device where the test executables will
+    run, for example, for Android bots these are paths on the Android device
+    itself. For desktop bots, these are just local paths.
+    """
+    join = lambda p: os.path.join(self._bot_info.build_dir, p)
+    return DeviceDirs(
+        dm_dir=join('dm'),
+        perf_data_dir=self._bot_info.perf_data_dir,
+        resource_dir=self._bot_info.resource_dir,
+        images_dir=join('images'),
+        skp_dir=self._bot_info.local_skp_dir,
+        tmp_dir=join('tmp'))
+
+  def __repr__(self):
+    return '<%s object>' % self.__class__.__name__  # pragma: no cover
diff --git a/infra/bots/flavor/ios_flavor.py b/infra/bots/flavor/ios_flavor.py
new file mode 100644
index 0000000..c2d8737
--- /dev/null
+++ b/infra/bots/flavor/ios_flavor.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import default_flavor
+import os
+import subprocess
+
+
+"""iOS flavor utils, used for building for and running tests on iOS."""
+
+
+class iOSFlavorUtils(default_flavor.DefaultFlavorUtils):
+  def __init__(self, bot_info):
+    super(iOSFlavorUtils, self).__init__(bot_info)
+    self.ios_bin = os.path.join(self._bot_info.skia_dir, 'platform_tools',
+                                'ios', 'bin')
+
+  def step(self, cmd, **kwargs):
+    args = [os.path.join(self.ios_bin, 'ios_run_skia')]
+
+    # Convert 'dm' and 'nanobench' from positional arguments
+    # to flags, which is what iOSShell expects to select which
+    # one is being run.
+    cmd = ["--" + c if c in ['dm', 'nanobench'] else c
+          for c in cmd]
+    return self._bot_info.run(args + cmd, **kwargs)
+
+  def compile(self, target):
+    """Build the given target."""
+    cmd = [os.path.join(self.ios_bin, 'ios_ninja')]
+    self._bot_info.run(cmd)
+
+  def device_path_join(self, *args):
+    """Like os.path.join(), but for paths on a connected iOS device."""
+    return '/'.join(args)
+
+  def device_path_exists(self, path):
+    """Like os.path.exists(), but for paths on a connected device."""
+    return self._bot_info.run(
+        [os.path.join(self.ios_bin, 'ios_path_exists'), path],
+    ) # pragma: no cover
+
+  def _remove_device_dir(self, path):
+    """Remove the directory on the device."""
+    return self._bot_info.run(
+        [os.path.join(self.ios_bin, 'ios_rm'), path],
+    )
+
+  def _create_device_dir(self, path):
+    """Create the directory on the device."""
+    return self._bot_info.run(
+        [os.path.join(self.ios_bin, 'ios_mkdir'), path],
+    )
+
+  def copy_directory_contents_to_device(self, host_dir, device_dir):
+    """Like shutil.copytree(), but for copying to a connected device."""
+    return self._bot_info.run([
+        os.path.join(self.ios_bin, 'ios_push_if_needed'),
+        host_dir, device_dir
+    ])
+
+  def copy_directory_contents_to_host(self, device_dir, host_dir):
+    """Like shutil.copytree(), but for copying from a connected device."""
+    return self._bot_info.run(
+        [os.path.join(self.ios_bin, 'ios_pull_if_needed'),
+             device_dir, host_dir],
+    )
+
+  def copy_file_to_device(self, host_path, device_path):
+    """Like shutil.copyfile, but for copying to a connected device."""
+    self._bot_info.run(
+        [os.path.join(self.ios_bin, 'ios_push_file'), host_path, device_path],
+    ) # pragma: no cover
+
+  def create_clean_device_dir(self, path):
+    """Like shutil.rmtree() + os.makedirs(), but on a connected device."""
+    self._remove_device_dir(path)
+    self._create_device_dir(path)
+
+  def install(self):
+    """Run device-specific installation steps."""
+    self._bot_info.run([os.path.join(self.ios_bin, 'ios_install')])
+
+  def cleanup_steps(self):
+    """Run any device-specific cleanup steps."""
+    self._bot_info.run([os.path.join(self.ios_bin, 'ios_restart')])
+    self._bot_info.run(['sleep', '20'])
+
+  def read_file_on_device(self, path):
+    """Read the given file."""
+    return subprocess.check_output(
+        [os.path.join(self.ios_bin, 'ios_cat_file'), path]).rstrip()
+
+  def remove_file_on_device(self, path):
+    """Remove the file on the device."""
+    return self._bot_info.run(
+        [os.path.join(self.ios_bin, 'ios_rm'), path],
+    )
+
+  def get_device_dirs(self):
+    """ Set the directories which will be used by the build steps."""
+    prefix = self.device_path_join('skiabot', 'skia_')
+    return default_flavor.DeviceDirs(
+        dm_dir=prefix + 'dm',
+        perf_data_dir=prefix + 'perf',
+        resource_dir=prefix + 'resources',
+        images_dir=prefix + 'images',
+        skp_dir=prefix + 'skp/skps',
+        tmp_dir=prefix + 'tmp_dir')
diff --git a/infra/bots/flavor/ssh_devices.py b/infra/bots/flavor/ssh_devices.py
new file mode 100644
index 0000000..f113fdb
--- /dev/null
+++ b/infra/bots/flavor/ssh_devices.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import collections
+import json
+
+
+DEFAULT_PORT = '22'
+DEFAULT_USER = 'chrome-bot'
+
+
+SlaveInfo = collections.namedtuple('SlaveInfo',
+                                   'ssh_user ssh_host ssh_port')
+
+SLAVE_INFO = {
+  'skiabot-shuttle-ubuntu12-003':
+      SlaveInfo('root', '192.168.1.123', DEFAULT_PORT),
+  'skiabot-shuttle-ubuntu12-004':
+      SlaveInfo('root', '192.168.1.134', DEFAULT_PORT),
+  'default':
+      SlaveInfo('nouser', 'noip', 'noport'),
+}
+
+
+if __name__ == '__main__':
+  print json.dumps(SLAVE_INFO)  # pragma: no cover
+
diff --git a/infra/bots/flavor/ssh_flavor.py b/infra/bots/flavor/ssh_flavor.py
new file mode 100644
index 0000000..07c383f
--- /dev/null
+++ b/infra/bots/flavor/ssh_flavor.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import default_flavor
+import os
+import posixpath
+import subprocess
+import ssh_devices
+
+
+"""Utils for running tests remotely over SSH."""
+
+
+class SSHFlavorUtils(default_flavor.DefaultFlavorUtils):
+  def __init__(self, *args, **kwargs):
+    super(SSHFlavorUtils, self).__init__(*args, **kwargs)
+    slave_info = ssh_devices.SLAVE_INFO.get(self._bot_info.slave_name,
+                                            ssh_devices.SLAVE_INFO['default'])
+    self._host = slave_info.ssh_host
+    self._port = slave_info.ssh_port
+    self._user = slave_info.ssh_user
+
+  @property
+  def host(self):
+    return self._host
+
+  @property
+  def port(self):
+    return self._port
+
+  @property
+  def user(self):
+    return self._user
+
+  def ssh(self, cmd, **kwargs):
+    """Run the given SSH command."""
+    ssh_cmd = ['ssh']
+    if self.port:
+      ssh_cmd.extend(['-p', self.port])
+    dest = self.host
+    if self.user:
+      dest = self.user + '@' + dest
+    ssh_cmd.append(dest)
+    ssh_cmd.extend(cmd)
+    return self._bot_info.run(ssh_cmd, **kwargs)
+
+  def step(self, *args, **kwargs):
+    """Run the given step over SSH."""
+    self.ssh(*args, **kwargs)
+
+  def device_path_join(self, *args):
+    """Like os.path.join(), but for paths on a remote machine."""
+    return posixpath.join(*args)
+
+  def device_path_exists(self, path):  # pragma: no cover
+    """Like os.path.exists(), but for paths on a remote device."""
+    try:
+      self.ssh(['test', '-e', path])
+      return True
+    except subprocess.CalledProcessError:
+      return False
+
+  def _remove_device_dir(self, path):
+    """Remove the directory on the device."""
+    self.ssh(['rm', '-rf', path])
+
+  def _create_device_dir(self, path):
+    """Create the directory on the device."""
+    self.ssh(['mkdir', '-p', path])
+
+  def create_clean_device_dir(self, path):
+    """Like shutil.rmtree() + os.makedirs(), but on a remote device."""
+    self._remove_device_dir(path)
+    self._create_device_dir(path)
+
+  def _make_scp_cmd(self, remote_path, recurse=True):
+    """Prepare an SCP command.
+
+    Returns a partial SCP command and an adjusted remote path.
+    """
+    cmd = ['scp']
+    if recurse:
+      cmd.append('-r')
+    if self.port:
+      cmd.extend(['-P', self.port])
+    adj_remote_path = self.host + ':' + remote_path
+    if self.user:
+      adj_remote_path = self.user + '@' + adj_remote_path
+    return cmd, adj_remote_path
+
+  def copy_directory_contents_to_device(self, host_dir, device_dir):
+    """Like shutil.copytree(), but for copying to a remote device."""
+    _, remote_path = self._make_scp_cmd(device_dir)
+    cmd = [os.path.join(self._bot_info.skia_dir, 'tools',
+                        'scp_dir_contents.sh'),
+           host_dir, remote_path]
+    self._bot_info.run(cmd)
+
+  def copy_directory_contents_to_host(self, device_dir, host_dir):
+    """Like shutil.copytree(), but for copying from a remote device."""
+    _, remote_path = self._make_scp_cmd(device_dir)
+    cmd = [os.path.join(self._bot_info.skia_dir, 'tools',
+                        'scp_dir_contents.sh'),
+           remote_path, host_dir]
+    self._bot_info.run(cmd)
+
+  def copy_file_to_device(self, host_path, device_path):
+    """Like shutil.copyfile, but for copying to a connected device."""
+    cmd, remote_path = self._make_scp_cmd(device_path, recurse=False)
+    cmd.extend([host_path, remote_path])
+    self._bot_info.run(cmd)
+
+  def read_file_on_device(self, path):
+    return self.ssh(['cat', path]).rstrip()
+
+  def remove_file_on_device(self, path):
+    """Delete the given file."""
+    return self.ssh(['rm', '-f', path])
diff --git a/infra/bots/flavor/valgrind_flavor.py b/infra/bots/flavor/valgrind_flavor.py
new file mode 100644
index 0000000..129a7c0
--- /dev/null
+++ b/infra/bots/flavor/valgrind_flavor.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+import default_flavor
+import os
+
+
+"""Utils for running under Valgrind."""
+
+
+class ValgrindFlavorUtils(default_flavor.DefaultFlavorUtils):
+  def __init__(self, *args, **kwargs):
+    super(ValgrindFlavorUtils, self).__init__(*args, **kwargs)
+    self._suppressions_file = os.path.join(self._bot_info.skia_dir,
+        'tools', 'valgrind.supp')
+
+  def step(self, name, cmd, **kwargs):
+    new_cmd = ['valgrind', '--gen-suppressions=all', '--leak-check=full',
+               '--track-origins=yes', '--error-exitcode=1', '--num-callers=40',
+               '--suppressions=%s' % self._suppressions_file]
+    path_to_app = os.path.join(self._bot_info.out_dir,
+                               self._bot_info.configuration, cmd[0])
+    new_cmd.append(path_to_app)
+    new_cmd.extend(cmd[1:])
+    return self._bot_info.run(new_cmd, **kwargs)
+
diff --git a/infra/bots/flavor/xsan_flavor.py b/infra/bots/flavor/xsan_flavor.py
new file mode 100644
index 0000000..5807be0
--- /dev/null
+++ b/infra/bots/flavor/xsan_flavor.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python
+#
+# Copyright 2016 Google Inc.
+#
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+"""Utils for running under *SAN"""
+
+
+import default_flavor
+import os
+
+
+class XSanFlavorUtils(default_flavor.DefaultFlavorUtils):
+  def __init__(self, *args, **kwargs):
+    super(XSanFlavorUtils, self).__init__(*args, **kwargs)
+    self._sanitizer = {
+      # We'd love to just pass 'address,undefined' and get all the checks, but
+      # we're not anywhere close to being able to do that.  Instead we start
+      # with a set of checks that we know pass or nearly pass.  See here for
+      # more information:
+      # http://clang.llvm.org/docs/UsersManual.html#controlling-code-generation
+      'ASAN': ('address,bool,function,integer-divide-by-zero,nonnull-attribute,'
+               'null,object-size,return,returns-nonnull-attribute,shift,'
+               'signed-integer-overflow,unreachable,vla-bound,vptr'),
+      # MSAN and TSAN can't run together with ASAN, so they're their own bots.
+      'MSAN': 'memory',
+      'TSAN': 'thread',
+    }[self._bot_info.bot_cfg['extra_config']]
+
+  def compile(self, target):
+    cmd = [os.path.join(self._bot_info.skia_dir, 'tools', 'xsan_build'),
+           self._sanitizer, target]
+    self._bot_info.run(cmd)
+
+  def step(self, cmd, env=None, **kwargs):
+    """Wrapper for the Step API; runs a step as appropriate for this flavor."""
+    lsan_suppressions = self._bot_info.skia_dir.join('tools', 'lsan.supp')
+    tsan_suppressions = self._bot_info.skia_dir.join('tools', 'tsan.supp')
+    ubsan_suppressions = self._bot_info.skia_dir.join('tools', 'ubsan.supp')
+    env = dict(env or {})
+    env['ASAN_OPTIONS'] = 'symbolize=1 detect_leaks=1'
+    env['LSAN_OPTIONS'] = ('symbolize=1 print_suppressions=1 suppressions=%s' %
+                           lsan_suppressions)
+    env['TSAN_OPTIONS'] = 'suppressions=%s' % tsan_suppressions
+    env['UBSAN_OPTIONS'] = 'suppressions=%s' % ubsan_suppressions
+
+    path_to_app = os.path.join(self._bot_info.out_dir,
+                               self._bot_info.configuration, cmd[0])
+    new_cmd = [path_to_app]
+    new_cmd.extend(cmd[1:])
+    return self._bot_info.run(new_cmd, env=env, **kwargs)
diff --git a/infra/bots/skia_repo.isolate b/infra/bots/skia_repo.isolate
new file mode 100644
index 0000000..7410388
--- /dev/null
+++ b/infra/bots/skia_repo.isolate
@@ -0,0 +1,7 @@
+{
+  'variables': {
+    'files': [
+      '../../',
+    ],
+  },
+}