1# Copyright 2020 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""Class for interacting with the Skia Gold image diffing service.""" 5 6import enum 7import logging 8import os 9import platform 10import shutil 11import sys 12import tempfile 13import time 14from typing import Any, Dict, List, Optional, Tuple 15 16import dataclasses # Built-in, but pylint gives an ordering false positive. 17 18from skia_gold_common import skia_gold_properties 19 20CHROMIUM_SRC = os.path.realpath( 21 os.path.join(os.path.dirname(__file__), '..', '..')) 22 23GOLDCTL_BINARY = os.path.join(CHROMIUM_SRC, 'tools', 'skia_goldctl') 24if sys.platform == 'win32': 25 GOLDCTL_BINARY = os.path.join(GOLDCTL_BINARY, 'win', 'goldctl') + '.exe' 26elif sys.platform == 'darwin': 27 machine = platform.machine().lower() 28 if any(machine.startswith(m) for m in ('arm64', 'aarch64')): 29 GOLDCTL_BINARY = os.path.join(GOLDCTL_BINARY, 'mac_arm64', 'goldctl') 30 else: 31 GOLDCTL_BINARY = os.path.join(GOLDCTL_BINARY, 'mac_amd64', 'goldctl') 32else: 33 GOLDCTL_BINARY = os.path.join(GOLDCTL_BINARY, 'linux', 'goldctl') 34 35 36StepRetVal = Tuple[int, Optional[str]] 37 38 39class SkiaGoldSession(): 40 @enum.unique 41 class StatusCodes(enum.IntEnum): 42 """Status codes for RunComparison.""" 43 SUCCESS = 0 44 AUTH_FAILURE = 1 45 INIT_FAILURE = 2 46 COMPARISON_FAILURE_REMOTE = 3 47 COMPARISON_FAILURE_LOCAL = 4 48 LOCAL_DIFF_FAILURE = 5 49 NO_OUTPUT_MANAGER = 6 50 51 @dataclasses.dataclass 52 class ComparisonResults(): 53 """Struct-like object for storing results of an image comparison.""" 54 public_triage_link: Optional[str] = None 55 internal_triage_link: Optional[str] = None 56 triage_link_omission_reason: Optional[str] = None 57 local_diff_given_image: Optional[str] = None 58 local_diff_closest_image: Optional[str] = None 59 local_diff_diff_image: Optional[str] = None 60 61 def __init__(self, 62 working_dir: str, 63 gold_properties: skia_gold_properties.SkiaGoldProperties, 64 keys_file: str, 65 corpus: str, 66 instance: str, 67 bucket: Optional[str] = None): 68 """Abstract class to handle all aspects of image comparison via Skia Gold. 69 70 A single SkiaGoldSession is valid for a single instance/corpus/keys_file 71 combination. 72 73 Args: 74 working_dir: The directory to store config files, etc. 75 gold_properties: A skia_gold_properties.SkiaGoldProperties instance for 76 the current test run. 77 keys_file: A path to a JSON file containing various comparison config data 78 such as corpus and debug information like the hardware/software 79 configuration the images will be produced on. 80 corpus: The corpus that images that will be compared belong to. 81 instance: The name of the Skia Gold instance to interact with. 82 bucket: Overrides the formulaic Google Storage bucket name generated by 83 goldctl 84 """ 85 self._working_dir = working_dir 86 self._gold_properties = gold_properties 87 self._corpus = corpus 88 self._instance = instance 89 self._bucket = bucket 90 self._local_png_directory = (self._gold_properties.local_png_directory 91 or tempfile.mkdtemp()) 92 with tempfile.NamedTemporaryFile(suffix='.txt', 93 dir=working_dir, 94 delete=False) as triage_link_file: 95 self._triage_link_file = triage_link_file.name 96 # A map of image name to ComparisonResults for that image. 97 self._comparison_results: Dict[str, SkiaGoldSession.ComparisonResults] = {} 98 self._authenticated = False 99 self._initialized = False 100 101 # Copy the given keys file to the working directory in case it ends up 102 # getting deleted before we try to use it. 103 self._keys_file = os.path.join(working_dir, 'gold_keys.json') 104 shutil.copy(keys_file, self._keys_file) 105 106 def RunComparison(self, 107 name: str, 108 png_file: str, 109 output_manager: Optional[Any] = None, 110 inexact_matching_args: Optional[List[str]] = None, 111 use_luci: bool = True, 112 service_account: Optional[str] = None, 113 optional_keys: Optional[Dict[str, str]] = None, 114 force_dryrun: bool = False) -> StepRetVal: 115 """Helper method to run all steps to compare a produced image. 116 117 Handles authentication, itnitialization, comparison, and, if necessary, 118 local diffing. 119 120 Args: 121 name: The name of the image being compared. 122 png_file: A path to a PNG file containing the image to be compared. 123 output_manager: An output manager to use to store diff links. The 124 argument's type depends on what type a subclasses' _StoreDiffLinks 125 implementation expects. Can be None even if _StoreDiffLinks expects 126 a valid input, but will fail if it ever actually needs to be used. 127 inexact_matching_args: A list of strings containing extra command line 128 arguments to pass to Gold for inexact matching. Can be omitted to use 129 exact matching. 130 use_luci: If true, authentication will use the service account provided by 131 the LUCI context. If false, will attempt to use whatever is set up in 132 gsutil, which is only supported for local runs. 133 service_account: If set, uses the provided service account instead of 134 LUCI_CONTEXT or whatever is set in gsutil. 135 optional_keys: A dict containing optional key/value pairs to pass to Gold 136 for this comparison. Optional keys are keys unrelated to the 137 configuration the image was produced on, e.g. a comment or whether 138 Gold should treat the image as ignored. 139 force_dryrun: A boolean denoting whether dryrun should be forced on 140 regardless of whether this is a local comparison or not. 141 142 Returns: 143 A tuple (status, error). |status| is a value from 144 SkiaGoldSession.StatusCodes signifying the result of the comparison. 145 |error| is an error message describing the status if not successful. 146 """ 147 auth_rc, auth_stdout = self.Authenticate(use_luci=use_luci, 148 service_account=service_account) 149 if auth_rc: 150 return self.StatusCodes.AUTH_FAILURE, auth_stdout 151 152 init_rc, init_stdout = self.Initialize() 153 if init_rc: 154 return self.StatusCodes.INIT_FAILURE, init_stdout 155 156 compare_rc, compare_stdout = self.Compare( 157 name=name, 158 png_file=png_file, 159 inexact_matching_args=inexact_matching_args, 160 optional_keys=optional_keys, 161 force_dryrun=force_dryrun) 162 if not compare_rc: 163 return self.StatusCodes.SUCCESS, None 164 165 logging.error('Gold comparison failed: %s', compare_stdout) 166 if not self._gold_properties.local_pixel_tests: 167 return self.StatusCodes.COMPARISON_FAILURE_REMOTE, compare_stdout 168 169 if self._RequiresOutputManager() and not output_manager: 170 return (self.StatusCodes.NO_OUTPUT_MANAGER, 171 'No output manager for local diff images') 172 173 diff_rc, diff_stdout = self.Diff(name=name, 174 png_file=png_file, 175 output_manager=output_manager) 176 if diff_rc: 177 return self.StatusCodes.LOCAL_DIFF_FAILURE, diff_stdout 178 return self.StatusCodes.COMPARISON_FAILURE_LOCAL, compare_stdout 179 180 def Authenticate(self, 181 use_luci: bool = True, 182 service_account: Optional[str] = None) -> StepRetVal: 183 """Authenticates with Skia Gold for this session. 184 185 Args: 186 use_luci: If true, authentication will use the service account provided 187 by the LUCI context. If false, will attempt to use whatever is set up 188 in gsutil, which is only supported for local runs. 189 service_account: If set, uses the provided service account instead of 190 LUCI_CONTEXT or whatever is set in gsutil. 191 192 Returns: 193 A tuple (return_code, output). |return_code| is the return code of the 194 authentication process. |output| is the stdout + stderr of the 195 authentication process. 196 """ 197 if self._authenticated: 198 return 0, None 199 if self._gold_properties.bypass_skia_gold_functionality: 200 logging.warning('Not actually authenticating with Gold due to ' 201 '--bypass-skia-gold-functionality being present.') 202 return 0, None 203 assert not (use_luci and service_account) 204 205 auth_cmd = [GOLDCTL_BINARY, 'auth', '--work-dir', self._working_dir] 206 if use_luci: 207 auth_cmd.append('--luci') 208 elif service_account: 209 auth_cmd.extend(['--service-account', service_account]) 210 elif not self._gold_properties.local_pixel_tests: 211 raise RuntimeError( 212 'Cannot authenticate to Skia Gold with use_luci=False without a ' 213 'service account unless running local pixel tests') 214 215 rc, stdout = self._RunCmdForRcAndOutput(auth_cmd) 216 if rc == 0: 217 self._authenticated = True 218 return rc, stdout 219 220 def Initialize(self) -> StepRetVal: 221 """Initializes the working directory if necessary. 222 223 This can technically be skipped if the same information is passed to the 224 command used for image comparison, but that is less efficient under the 225 hood. Doing it that way effectively requires an initialization for every 226 comparison (~250 ms) instead of once at the beginning. 227 228 Returns: 229 A tuple (return_code, output). |return_code| is the return code of the 230 initialization process. |output| is the stdout + stderr of the 231 initialization process. 232 """ 233 if self._initialized: 234 return 0, None 235 if self._gold_properties.bypass_skia_gold_functionality: 236 logging.warning('Not actually initializing Gold due to ' 237 '--bypass-skia-gold-functionality being present.') 238 return 0, None 239 240 init_cmd = [ 241 GOLDCTL_BINARY, 242 'imgtest', 243 'init', 244 '--passfail', 245 '--instance', 246 self._instance, 247 '--corpus', 248 self._corpus, 249 '--keys-file', 250 self._keys_file, 251 '--work-dir', 252 self._working_dir, 253 '--failure-file', 254 self._triage_link_file, 255 '--commit', 256 self._gold_properties.git_revision, 257 ] 258 if self._bucket: 259 init_cmd.extend(['--bucket', self._bucket]) 260 if self._gold_properties.IsTryjobRun(): 261 init_cmd.extend([ 262 '--issue', 263 str(self._gold_properties.issue), 264 '--patchset', 265 str(self._gold_properties.patchset), 266 '--jobid', 267 str(self._gold_properties.job_id), 268 '--crs', 269 str(self._gold_properties.code_review_system), 270 '--cis', 271 str(self._gold_properties.continuous_integration_system), 272 ]) 273 274 rc, stdout = self._RunCmdForRcAndOutput(init_cmd) 275 if rc == 0: 276 self._initialized = True 277 return rc, stdout 278 279 def Compare(self, 280 name: str, 281 png_file: str, 282 inexact_matching_args: Optional[List[str]] = None, 283 optional_keys: Optional[Dict[str, str]] = None, 284 force_dryrun: bool = False) -> StepRetVal: 285 """Compares the given image to images known to Gold. 286 287 Triage links can later be retrieved using GetTriageLinks(). 288 289 Args: 290 name: The name of the image being compared. 291 png_file: A path to a PNG file containing the image to be compared. 292 inexact_matching_args: A list of strings containing extra command line 293 arguments to pass to Gold for inexact matching. Can be omitted to use 294 exact matching. 295 optional_keys: A dict containing optional key/value pairs to pass to Gold 296 for this comparison. Optional keys are keys unrelated to the 297 configuration the image was produced on, e.g. a comment or whether 298 Gold should treat the image as ignored. 299 force_dryrun: A boolean denoting whether dryrun should be forced on 300 regardless of whether this is a local comparison or not. 301 302 Returns: 303 A tuple (return_code, output). |return_code| is the return code of the 304 comparison process. |output| is the stdout + stderr of the comparison 305 process. 306 """ 307 if self._gold_properties.bypass_skia_gold_functionality: 308 logging.warning('Not actually comparing with Gold due to ' 309 '--bypass-skia-gold-functionality being present.') 310 return 0, None 311 312 compare_cmd = [ 313 GOLDCTL_BINARY, 314 'imgtest', 315 'add', 316 '--test-name', 317 name, 318 '--png-file', 319 png_file, 320 '--work-dir', 321 self._working_dir, 322 ] 323 if self._gold_properties.local_pixel_tests or force_dryrun: 324 compare_cmd.append('--dryrun') 325 if inexact_matching_args: 326 logging.info('Using inexact matching arguments for image %s: %s', name, 327 inexact_matching_args) 328 compare_cmd.extend(inexact_matching_args) 329 330 optional_keys = optional_keys or {} 331 for k, v in optional_keys.items(): 332 compare_cmd.extend([ 333 '--add-test-optional-key', 334 '%s:%s' % (k, v), 335 ]) 336 337 self._ClearTriageLinkFile() 338 rc, stdout = self._RunCmdForRcAndOutput(compare_cmd) 339 340 self._comparison_results[name] = self.ComparisonResults() 341 if rc == 0: 342 self._comparison_results[name].triage_link_omission_reason = ( 343 'Comparison succeeded, no triage link') 344 elif self._gold_properties.IsTryjobRun(): 345 cl_triage_link = ('https://{instance}-gold.skia.org/cl/{crs}/{issue}') 346 cl_triage_link = cl_triage_link.format( 347 instance=self._instance, 348 crs=self._gold_properties.code_review_system, 349 issue=self._gold_properties.issue) 350 self._comparison_results[name].internal_triage_link = cl_triage_link 351 self._comparison_results[name].public_triage_link =\ 352 self._GeneratePublicTriageLink(cl_triage_link) 353 else: 354 try: 355 with open(self._triage_link_file) as tlf: 356 triage_link = tlf.read().strip() 357 if not triage_link: 358 self._comparison_results[name].triage_link_omission_reason = ( 359 'Gold did not provide a triage link. This is likely a bug on ' 360 "Gold's end.") 361 self._comparison_results[name].internal_triage_link = None 362 self._comparison_results[name].public_triage_link = None 363 else: 364 self._comparison_results[name].internal_triage_link = triage_link 365 self._comparison_results[name].public_triage_link =\ 366 self._GeneratePublicTriageLink(triage_link) 367 except IOError: 368 self._comparison_results[name].triage_link_omission_reason = ( 369 'Failed to read triage link from file') 370 return rc, stdout 371 372 def Diff(self, name: str, png_file: str, output_manager: Any) -> StepRetVal: 373 """Performs a local image diff against the closest known positive in Gold. 374 375 This is used for running tests on a workstation, where uploading data to 376 Gold for ingestion is not allowed, and thus the web UI is not available. 377 378 Image links can later be retrieved using Get*ImageLink(). 379 380 Args: 381 name: The name of the image being compared. 382 png_file: The path to a PNG file containing the image to be diffed. 383 output_manager: An output manager to use to store diff links. The 384 argument's type depends on what type a subclasses' _StoreDiffLinks 385 implementation expects. 386 387 Returns: 388 A tuple (return_code, output). |return_code| is the return code of the 389 diff process. |output| is the stdout + stderr of the diff process. 390 """ 391 # Instead of returning that everything is okay and putting in dummy links, 392 # just fail since this should only be called when running locally and 393 # --bypass-skia-gold-functionality is only meant for use on the bots. 394 if self._gold_properties.bypass_skia_gold_functionality: 395 raise RuntimeError( 396 '--bypass-skia-gold-functionality is not supported when running ' 397 'tests locally.') 398 399 output_dir = self._CreateDiffOutputDir(name) 400 # TODO(skbug.com/10611): Remove this temporary work dir and instead just use 401 # self._working_dir once `goldctl diff` stops clobbering the auth files in 402 # the provided work directory. 403 temp_work_dir = tempfile.mkdtemp() 404 # shutil.copytree() fails if the destination already exists, so use a 405 # subdirectory of the temporary directory. 406 temp_work_dir = os.path.join(temp_work_dir, 'diff_work_dir') 407 try: 408 shutil.copytree(self._working_dir, temp_work_dir) 409 diff_cmd = [ 410 GOLDCTL_BINARY, 411 'diff', 412 '--corpus', 413 self._corpus, 414 '--instance', 415 self._GetDiffGoldInstance(), 416 '--input', 417 png_file, 418 '--test', 419 name, 420 '--work-dir', 421 temp_work_dir, 422 '--out-dir', 423 output_dir, 424 ] 425 rc, stdout = self._RunCmdForRcAndOutput(diff_cmd) 426 self._StoreDiffLinks(name, output_manager, output_dir) 427 return rc, stdout 428 finally: 429 shutil.rmtree(os.path.realpath(os.path.join(temp_work_dir, '..'))) 430 431 def GetTriageLinks(self, name: str) -> Tuple[str, str]: 432 """Gets the triage links for the given image. 433 434 Args: 435 name: The name of the image to retrieve the triage link for. 436 437 Returns: 438 A tuple (public, internal). |public| is a string containing the triage 439 link for the public Gold instance if it is available, or None if it is not 440 available for some reason. |internal| is the same as |public|, but 441 containing a link to the internal Gold instance. The reason for links not 442 being available can be retrieved using GetTriageLinkOmissionReason. 443 """ 444 comparison_results = self._comparison_results.get(name, 445 self.ComparisonResults()) 446 return (comparison_results.public_triage_link, 447 comparison_results.internal_triage_link) 448 449 def GetTriageLinkOmissionReason(self, name: str) -> str: 450 """Gets the reason why a triage link is not available for an image. 451 452 Args: 453 name: The name of the image whose triage link does not exist. 454 455 Returns: 456 A string containing the reason why a triage link is not available. 457 """ 458 if name not in self._comparison_results: 459 return 'No image comparison performed for %s' % name 460 results = self._comparison_results[name] 461 # This method should not be called if there is a valid triage link. 462 assert results.public_triage_link is None 463 assert results.internal_triage_link is None 464 if results.triage_link_omission_reason: 465 return results.triage_link_omission_reason 466 if results.local_diff_given_image: 467 return 'Gold only used to do a local image diff' 468 raise RuntimeError( 469 'Somehow have a ComparisonResults instance for %s that should not ' 470 'exist' % name) 471 472 def GetGivenImageLink(self, name: str) -> str: 473 """Gets the link to the given image used for local diffing. 474 475 Args: 476 name: The name of the image that was diffed. 477 478 Returns: 479 A string containing the link to where the image is saved, or None if it 480 does not exist. 481 """ 482 assert name in self._comparison_results 483 return self._comparison_results[name].local_diff_given_image 484 485 def GetClosestImageLink(self, name: str) -> str: 486 """Gets the link to the closest known image used for local diffing. 487 488 Args: 489 name: The name of the image that was diffed. 490 491 Returns: 492 A string containing the link to where the image is saved, or None if it 493 does not exist. 494 """ 495 assert name in self._comparison_results 496 return self._comparison_results[name].local_diff_closest_image 497 498 def GetDiffImageLink(self, name: str) -> str: 499 """Gets the link to the diff between the given and closest images. 500 501 Args: 502 name: The name of the image that was diffed. 503 504 Returns: 505 A string containing the link to where the image is saved, or None if it 506 does not exist. 507 """ 508 assert name in self._comparison_results 509 return self._comparison_results[name].local_diff_diff_image 510 511 def _GeneratePublicTriageLink(self, internal_link: str) -> str: 512 """Generates a public triage link given an internal one. 513 514 Args: 515 internal_link: A string containing a triage link pointing to an internal 516 Gold instance. 517 518 Returns: 519 A string containing a triage link pointing to the public mirror of the 520 link pointed to by |internal_link|. 521 """ 522 return internal_link.replace('%s-gold' % self._instance, 523 '%s-public-gold' % self._instance) 524 525 def _ClearTriageLinkFile(self) -> None: 526 """Clears the contents of the triage link file. 527 528 This should be done before every comparison since goldctl appends to the 529 file instead of overwriting its contents, which results in multiple triage 530 links getting concatenated together if there are multiple failures. 531 """ 532 open(self._triage_link_file, 'w').close() 533 534 def _CreateDiffOutputDir(self, _name: str) -> str: 535 # We don't use self._local_png_directory here since we want it to be 536 # automatically cleaned up with the working directory. Any subclasses that 537 # want to keep it around can override this method. 538 return tempfile.mkdtemp(dir=self._working_dir) 539 540 def _GetDiffGoldInstance(self) -> str: 541 """Gets the Skia Gold instance to use for the Diff step. 542 543 This can differ based on how a particular instance is set up, mainly 544 depending on whether it is set up for internal results or not. 545 """ 546 # TODO(skbug.com/10610): Decide whether to use the public or 547 # non-public instance once authentication is fixed for the non-public 548 # instance. 549 return str(self._instance) + '-public' 550 551 def _StoreDiffLinks(self, image_name: str, output_manager: Any, 552 output_dir: str) -> None: 553 """Stores the local diff files as links. 554 555 The ComparisonResults entry for |image_name| should have its *_image fields 556 filled after this unless corresponding images were not found on disk. 557 558 Args: 559 image_name: A string containing the name of the image that was diffed. 560 output_manager: An output manager used used to surface links to users, 561 if necessary. The expected argument type depends on each subclasses' 562 implementation of this method. 563 output_dir: A string containing the path to the directory where diff 564 output image files where saved. 565 """ 566 raise NotImplementedError() 567 568 def _RequiresOutputManager(self) -> bool: 569 """Whether this session implementation requires an output manager.""" 570 return True 571 572 @staticmethod 573 def _RunCmdForRcAndOutput(cmd: List[str]) -> Tuple[int, str]: 574 """Runs |cmd| and returns its returncode and output. 575 576 Args: 577 cmd: A list containing the command line to run. 578 579 Returns: 580 A tuple (rc, output), where, |rc| is the returncode of the command and 581 |output| is the stdout + stderr of the command. 582 """ 583 raise NotImplementedError() 584