borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 1 | #!/usr/bin/env python |
| 2 | # |
| 3 | # Copyright 2016 Google Inc. |
| 4 | # |
| 5 | # Use of this source code is governed by a BSD-style license that can be |
| 6 | # found in the LICENSE file. |
| 7 | |
| 8 | |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 9 | import contextlib |
| 10 | import math |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 11 | import os |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 12 | import shutil |
| 13 | import socket |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 14 | import subprocess |
| 15 | import sys |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 16 | import time |
| 17 | import urllib2 |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 18 | |
| 19 | from flavor import android_flavor |
| 20 | from flavor import chromeos_flavor |
| 21 | from flavor import cmake_flavor |
| 22 | from flavor import coverage_flavor |
| 23 | from flavor import default_flavor |
| 24 | from flavor import ios_flavor |
| 25 | from flavor import valgrind_flavor |
| 26 | from flavor import xsan_flavor |
| 27 | |
| 28 | |
| 29 | CONFIG_COVERAGE = 'Coverage' |
| 30 | CONFIG_DEBUG = 'Debug' |
| 31 | CONFIG_RELEASE = 'Release' |
| 32 | VALID_CONFIGS = (CONFIG_COVERAGE, CONFIG_DEBUG, CONFIG_RELEASE) |
| 33 | |
| 34 | GM_ACTUAL_FILENAME = 'actual-results.json' |
| 35 | GM_EXPECTATIONS_FILENAME = 'expected-results.json' |
| 36 | GM_IGNORE_TESTS_FILENAME = 'ignored-tests.txt' |
| 37 | |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 38 | GOLD_UNINTERESTING_HASHES_URL = 'https://gold.skia.org/_/hashes' |
| 39 | |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 40 | GS_GM_BUCKET = 'chromium-skia-gm' |
| 41 | GS_SUMMARIES_BUCKET = 'chromium-skia-gm-summaries' |
| 42 | |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 43 | GS_SUBDIR_TMPL_SK_IMAGE = 'skimage/v%s' |
| 44 | GS_SUBDIR_TMPL_SKP = 'playback_%s/skps' |
| 45 | |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 46 | SKIA_REPO = 'https://skia.googlesource.com/skia.git' |
| 47 | INFRA_REPO = 'https://skia.googlesource.com/buildbot.git' |
| 48 | |
| 49 | SERVICE_ACCOUNT_FILE = 'service-account-skia.json' |
| 50 | SERVICE_ACCOUNT_INTERNAL_FILE = 'service-account-skia-internal.json' |
| 51 | |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 52 | VERSION_FILE_SK_IMAGE = 'SK_IMAGE_VERSION' |
| 53 | VERSION_FILE_SKP = 'SKP_VERSION' |
| 54 | |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 55 | |
| 56 | def is_android(bot_cfg): |
| 57 | """Determine whether the given bot is an Android bot.""" |
| 58 | return ('Android' in bot_cfg.get('extra_config', '') or |
| 59 | bot_cfg.get('os') == 'Android') |
| 60 | |
| 61 | def is_chromeos(bot_cfg): |
| 62 | return ('CrOS' in bot_cfg.get('extra_config', '') or |
| 63 | bot_cfg.get('os') == 'ChromeOS') |
| 64 | |
| 65 | def is_cmake(bot_cfg): |
| 66 | return 'CMake' in bot_cfg.get('extra_config', '') |
| 67 | |
| 68 | def is_ios(bot_cfg): |
| 69 | return ('iOS' in bot_cfg.get('extra_config', '') or |
| 70 | bot_cfg.get('os') == 'iOS') |
| 71 | |
| 72 | |
| 73 | def is_valgrind(bot_cfg): |
| 74 | return 'Valgrind' in bot_cfg.get('extra_config', '') |
| 75 | |
| 76 | |
| 77 | def is_xsan(bot_cfg): |
| 78 | return (bot_cfg.get('extra_config') == 'ASAN' or |
| 79 | bot_cfg.get('extra_config') == 'MSAN' or |
| 80 | bot_cfg.get('extra_config') == 'TSAN') |
| 81 | |
| 82 | |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 83 | def download_dir(skia_dir, tmp_dir, version_file, gs_path_tmpl, dst_dir): |
| 84 | # Ensure that the tmp_dir exists. |
| 85 | if not os.path.isdir(tmp_dir): |
| 86 | os.makedirs(tmp_dir) |
| 87 | |
| 88 | # Get the expected version. |
| 89 | with open(os.path.join(skia_dir, version_file)) as f: |
| 90 | expected_version = f.read().rstrip() |
| 91 | |
| 92 | print 'Expected %s = %s' % (version_file, expected_version) |
| 93 | |
| 94 | # Get the actually-downloaded version, if we have one. |
| 95 | actual_version_file = os.path.join(tmp_dir, version_file) |
| 96 | try: |
| 97 | with open(actual_version_file) as f: |
| 98 | actual_version = f.read().rstrip() |
| 99 | except IOError: |
| 100 | actual_version = -1 |
| 101 | |
| 102 | print 'Actual %s = %s' % (version_file, actual_version) |
| 103 | |
| 104 | # If we don't have the desired version, download it. |
| 105 | if actual_version != expected_version: |
| 106 | if actual_version != -1: |
| 107 | os.remove(actual_version_file) |
| 108 | if os.path.isdir(dst_dir): |
| 109 | shutil.rmtree(dst_dir) |
| 110 | os.makedirs(dst_dir) |
| 111 | gs_path = 'gs://%s/%s/*' % (GS_GM_BUCKET, gs_path_tmpl % expected_version) |
| 112 | print 'Downloading from %s' % gs_path |
| 113 | subprocess.check_call(['gsutil', 'cp', '-R', gs_path, dst_dir]) |
| 114 | with open(actual_version_file, 'w') as f: |
| 115 | f.write(expected_version) |
| 116 | |
| 117 | |
| 118 | def get_uninteresting_hashes(hashes_file): |
| 119 | retries = 5 |
| 120 | timeout = 60 |
| 121 | wait_base = 15 |
| 122 | |
| 123 | socket.setdefaulttimeout(timeout) |
| 124 | for retry in range(retries): |
| 125 | try: |
| 126 | with contextlib.closing( |
| 127 | urllib2.urlopen(GOLD_UNINTERESTING_HASHES_URL, timeout=timeout)) as w: |
| 128 | hashes = w.read() |
| 129 | with open(hashes_file, 'w') as f: |
| 130 | f.write(hashes) |
| 131 | break |
| 132 | except Exception as e: |
| 133 | print >> sys.stderr, 'Failed to get uninteresting hashes from %s:\n%s' % ( |
| 134 | GOLD_UNINTERESTING_HASHES_URL, e) |
| 135 | if retry == retries: |
| 136 | raise |
| 137 | waittime = wait_base * math.pow(2, retry) |
| 138 | print 'Retry in %d seconds.' % waittime |
| 139 | time.sleep(waittime) |
| 140 | |
| 141 | |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 142 | class BotInfo(object): |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 143 | def __init__(self, bot_name, swarm_out_dir): |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 144 | """Initialize the bot, given its name. |
| 145 | |
| 146 | Assumes that CWD is the directory containing this file. |
| 147 | """ |
| 148 | self.name = bot_name |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 149 | self.skia_dir = os.path.abspath(os.path.join( |
| 150 | os.path.dirname(os.path.realpath(__file__)), |
| 151 | os.pardir, os.pardir)) |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 152 | self.swarm_out_dir = swarm_out_dir |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 153 | os.chdir(self.skia_dir) |
| 154 | self.build_dir = os.path.abspath(os.path.join(self.skia_dir, os.pardir)) |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 155 | self.spec = self.get_bot_spec(bot_name) |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 156 | self.bot_cfg = self.spec['builder_cfg'] |
| 157 | if self.bot_cfg['role'] == 'Build': |
| 158 | self.out_dir = os.path.join(swarm_out_dir, 'out') |
| 159 | else: |
| 160 | self.out_dir = 'out' |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 161 | self.configuration = self.spec['configuration'] |
| 162 | self.default_env = { |
| 163 | 'SKIA_OUT': self.out_dir, |
| 164 | 'BUILDTYPE': self.configuration, |
| 165 | 'PATH': os.environ['PATH'], |
| 166 | } |
| 167 | self.default_env.update(self.spec['env']) |
| 168 | self.build_targets = [str(t) for t in self.spec['build_targets']] |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 169 | self.is_trybot = self.bot_cfg['is_trybot'] |
| 170 | self.upload_dm_results = self.spec['upload_dm_results'] |
| 171 | self.upload_perf_results = self.spec['upload_perf_results'] |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 172 | self.perf_data_dir = os.path.join(self.swarm_out_dir, 'perfdata', |
| 173 | self.name, 'data') |
| 174 | self.resource_dir = os.path.join(self.build_dir, 'resources') |
| 175 | self.images_dir = os.path.join(self.build_dir, 'images') |
| 176 | self.local_skp_dir = os.path.join(self.build_dir, 'playback', 'skps') |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 177 | self.dm_flags = self.spec['dm_flags'] |
| 178 | self.nanobench_flags = self.spec['nanobench_flags'] |
| 179 | self._ccache = None |
| 180 | self._checked_for_ccache = False |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 181 | self._already_ran = {} |
| 182 | self.tmp_dir = os.path.join(self.build_dir, 'tmp') |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 183 | self.flavor = self.get_flavor(self.bot_cfg) |
| 184 | |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 185 | # These get filled in during subsequent steps. |
| 186 | self.device_dirs = None |
| 187 | self.build_number = None |
| 188 | self.got_revision = None |
| 189 | self.master_name = None |
| 190 | self.slave_name = None |
| 191 | |
borenet | d9fa758 | 2016-02-18 08:05:48 -0800 | [diff] [blame] | 192 | @property |
| 193 | def ccache(self): |
| 194 | if not self._checked_for_ccache: |
| 195 | self._checked_for_ccache = True |
| 196 | if sys.platform != 'win32': |
| 197 | try: |
| 198 | result = subprocess.check_output(['which', 'ccache']) |
| 199 | self._ccache = result.rstrip() |
| 200 | except subprocess.CalledProcessError: |
| 201 | pass |
| 202 | |
| 203 | return self._ccache |
| 204 | |
| 205 | def get_bot_spec(self, bot_name): |
| 206 | """Retrieve the bot spec for this bot.""" |
| 207 | sys.path.append(self.skia_dir) |
| 208 | from tools import buildbot_spec |
| 209 | return buildbot_spec.get_builder_spec(bot_name) |
| 210 | |
| 211 | def get_flavor(self, bot_cfg): |
| 212 | """Return a flavor utils object specific to the given bot.""" |
| 213 | if is_android(bot_cfg): |
| 214 | return android_flavor.AndroidFlavorUtils(self) |
| 215 | elif is_chromeos(bot_cfg): |
| 216 | return chromeos_flavor.ChromeOSFlavorUtils(self) |
| 217 | elif is_cmake(bot_cfg): |
| 218 | return cmake_flavor.CMakeFlavorUtils(self) |
| 219 | elif is_ios(bot_cfg): |
| 220 | return ios_flavor.iOSFlavorUtils(self) |
| 221 | elif is_valgrind(bot_cfg): |
| 222 | return valgrind_flavor.ValgrindFlavorUtils(self) |
| 223 | elif is_xsan(bot_cfg): |
| 224 | return xsan_flavor.XSanFlavorUtils(self) |
| 225 | elif bot_cfg.get('configuration') == CONFIG_COVERAGE: |
| 226 | return coverage_flavor.CoverageFlavorUtils(self) |
| 227 | else: |
| 228 | return default_flavor.DefaultFlavorUtils(self) |
| 229 | |
| 230 | def run(self, cmd, env=None, cwd=None): |
| 231 | _env = {} |
| 232 | _env.update(self.default_env) |
| 233 | _env.update(env or {}) |
| 234 | cwd = cwd or self.skia_dir |
| 235 | print '============' |
| 236 | print 'CMD: %s' % cmd |
| 237 | print 'CWD: %s' % cwd |
| 238 | print 'ENV: %s' % _env |
| 239 | print '============' |
| 240 | subprocess.check_call(cmd, env=_env, cwd=cwd) |
borenet | a7a6f2e | 2016-02-29 05:57:31 -0800 | [diff] [blame^] | 241 | |
| 242 | def compile_steps(self): |
| 243 | for t in self.build_targets: |
| 244 | self.flavor.compile(t) |
| 245 | |
| 246 | def _run_once(self, fn, *args, **kwargs): |
| 247 | if not fn.__name__ in self._already_ran: |
| 248 | self._already_ran[fn.__name__] = True |
| 249 | fn(*args, **kwargs) |
| 250 | |
| 251 | def install(self): |
| 252 | """Copy the required executables and files to the device.""" |
| 253 | self.device_dirs = self.flavor.get_device_dirs() |
| 254 | |
| 255 | # Run any device-specific installation. |
| 256 | self.flavor.install() |
| 257 | |
| 258 | # TODO(borenet): Only copy files which have changed. |
| 259 | # Resources |
| 260 | self.flavor.copy_directory_contents_to_device(self.resource_dir, |
| 261 | self.device_dirs.resource_dir) |
| 262 | |
| 263 | def _key_params(self): |
| 264 | """Build a unique key from the builder name (as a list). |
| 265 | |
| 266 | E.g. arch x86 gpu GeForce320M mode MacMini4.1 os Mac10.6 |
| 267 | """ |
| 268 | # Don't bother to include role, which is always Test. |
| 269 | # TryBots are uploaded elsewhere so they can use the same key. |
| 270 | blacklist = ['role', 'is_trybot'] |
| 271 | |
| 272 | flat = [] |
| 273 | for k in sorted(self.bot_cfg.keys()): |
| 274 | if k not in blacklist: |
| 275 | flat.append(k) |
| 276 | flat.append(self.bot_cfg[k]) |
| 277 | return flat |
| 278 | |
| 279 | def test_steps(self, got_revision, master_name, slave_name, build_number): |
| 280 | """Run the DM test.""" |
| 281 | self.build_number = build_number |
| 282 | self.got_revision = got_revision |
| 283 | self.master_name = master_name |
| 284 | self.slave_name = slave_name |
| 285 | self._run_once(self.install) |
| 286 | |
| 287 | use_hash_file = False |
| 288 | if self.upload_dm_results: |
| 289 | # This must run before we write anything into self.device_dirs.dm_dir |
| 290 | # or we may end up deleting our output on machines where they're the same. |
| 291 | host_dm_dir = os.path.join(self.swarm_out_dir, 'dm') |
| 292 | print 'host dm dir: %s' % host_dm_dir |
| 293 | self.flavor.create_clean_host_dir(host_dm_dir) |
| 294 | if str(host_dm_dir) != str(self.device_dirs.dm_dir): |
| 295 | self.flavor.create_clean_device_dir(self.device_dirs.dm_dir) |
| 296 | |
| 297 | # Obtain the list of already-generated hashes. |
| 298 | hash_filename = 'uninteresting_hashes.txt' |
| 299 | host_hashes_file = self.tmp_dir.join(hash_filename) |
| 300 | hashes_file = self.flavor.device_path_join( |
| 301 | self.device_dirs.tmp_dir, hash_filename) |
| 302 | |
| 303 | try: |
| 304 | get_uninteresting_hashes(host_hashes_file) |
| 305 | except Exception: |
| 306 | pass |
| 307 | |
| 308 | if os.path.exists(host_hashes_file): |
| 309 | self.flavor.copy_file_to_device(host_hashes_file, hashes_file) |
| 310 | use_hash_file = True |
| 311 | |
| 312 | # Run DM. |
| 313 | properties = [ |
| 314 | 'gitHash', self.got_revision, |
| 315 | 'master', self.master_name, |
| 316 | 'builder', self.name, |
| 317 | 'build_number', self.build_number, |
| 318 | ] |
| 319 | if self.is_trybot: |
| 320 | properties.extend([ |
| 321 | 'issue', self.m.properties['issue'], |
| 322 | 'patchset', self.m.properties['patchset'], |
| 323 | ]) |
| 324 | |
| 325 | args = [ |
| 326 | 'dm', |
| 327 | '--undefok', # This helps branches that may not know new flags. |
| 328 | '--verbose', |
| 329 | '--resourcePath', self.device_dirs.resource_dir, |
| 330 | '--skps', self.device_dirs.skp_dir, |
| 331 | '--images', self.flavor.device_path_join( |
| 332 | self.device_dirs.images_dir, 'dm'), |
| 333 | '--nameByHash', |
| 334 | '--properties' |
| 335 | ] + properties |
| 336 | |
| 337 | args.append('--key') |
| 338 | args.extend(self._key_params()) |
| 339 | if use_hash_file: |
| 340 | args.extend(['--uninterestingHashesFile', hashes_file]) |
| 341 | if self.upload_dm_results: |
| 342 | args.extend(['--writePath', self.device_dirs.dm_dir]) |
| 343 | |
| 344 | skip_flag = None |
| 345 | if self.bot_cfg.get('cpu_or_gpu') == 'CPU': |
| 346 | skip_flag = '--nogpu' |
| 347 | elif self.bot_cfg.get('cpu_or_gpu') == 'GPU': |
| 348 | skip_flag = '--nocpu' |
| 349 | if skip_flag: |
| 350 | args.append(skip_flag) |
| 351 | args.extend(self.dm_flags) |
| 352 | |
| 353 | self.flavor.run(args, env=self.default_env) |
| 354 | |
| 355 | if self.upload_dm_results: |
| 356 | # Copy images and JSON to host machine if needed. |
| 357 | self.flavor.copy_directory_contents_to_host(self.device_dirs.dm_dir, |
| 358 | host_dm_dir) |
| 359 | |
| 360 | # See skia:2789. |
| 361 | if ('Valgrind' in self.name and |
| 362 | self.builder_cfg.get('cpu_or_gpu') == 'GPU'): |
| 363 | abandonGpuContext = list(args) |
| 364 | abandonGpuContext.append('--abandonGpuContext') |
| 365 | self.flavor.run(abandonGpuContext) |
| 366 | preAbandonGpuContext = list(args) |
| 367 | preAbandonGpuContext.append('--preAbandonGpuContext') |
| 368 | self.flavor.run(preAbandonGpuContext) |