1# Copyright 2015 The Chromium Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""General functions which are useful throughout this project.""" 5from __future__ import print_function 6from __future__ import division 7from __future__ import absolute_import 8 9import collections 10import logging 11import os 12import re 13import time 14import urllib 15 16from apiclient import discovery 17from apiclient import errors 18from google.appengine.api import app_identity 19from google.appengine.api import memcache 20from google.appengine.api import oauth 21from google.appengine.api import urlfetch 22from google.appengine.api import urlfetch_errors 23from google.appengine.api import users 24from google.appengine.ext import ndb 25import httplib2 26from oauth2client import client 27 28from dashboard.common import stored_object 29 30SHERIFF_DOMAINS_KEY = 'sheriff_domains_key' 31IP_ALLOWLIST_KEY = 'ip_whitelist' 32SERVICE_ACCOUNT_KEY = 'service_account' 33PINPOINT_REPO_EXCLUSION_KEY = 'pinpoint_repo_exclusions' 34EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email' 35_PROJECT_ID_KEY = 'project_id' 36_DEFAULT_CUSTOM_METRIC_VAL = 1 37OAUTH_SCOPES = ('https://www.googleapis.com/auth/userinfo.email',) 38OAUTH_ENDPOINTS = ['/api/', '/add_histograms', '/add_point', '/uploads'] 39 40_AUTOROLL_DOMAINS = ( 41 'chops-service-accounts.iam.gserviceaccount.com', 42 'skia-corp.google.com.iam.gserviceaccount.com', 43 'skia-public.iam.gserviceaccount.com', 44) 45STATISTICS = ['avg', 'count', 'max', 'min', 'std', 'sum'] 46 47# TODO(crbug.com/1116480): This list should come from a call to a Monorail API. 48MONORAIL_PROJECTS = [ 49 'angleproject', 'aomedia', 'apvi', 'boringssl', 'chromedriver', 'chromium', 50 'crashpad', 'dawn', 'gerrit', 'git', 'gn', 'google-breakpad', 'gyp', 51 'libyuv', 'linux-syscall-support', 'monorail', 'nativeclient', 'openscreen', 52 'oss-fuzz', 'pdfium', 'pigweed', 'project-zero', 'skia', 'swiftshader', 53 'tint', 'v8', 'webm', 'webp', 'webports', 'webrtc' 54] 55 56 57class _SimpleCache( 58 collections.namedtuple('_SimpleCache', ('timestamp', 'value'))): 59 60 def IsStale(self, ttl): 61 return time.time() - self.timestamp > ttl 62 63 64_PINPOINT_REPO_EXCLUSION_TTL = 60 # seconds 65_PINPOINT_REPO_EXCLUSION_CACHED = _SimpleCache(0, None) 66 67 68def IsDevAppserver(): 69 return app_identity.get_application_id() == 'None' 70 71 72def _GetNowRfc3339(): 73 """Returns the current time formatted per RFC 3339.""" 74 return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) 75 76 77def GetEmail(): 78 """Returns email address of the current user. 79 80 Uses OAuth2 for /api/ requests, otherwise cookies. 81 82 Returns: 83 The email address as a string or None if there is no user logged in. 84 85 Raises: 86 OAuthRequestError: The request was not a valid OAuth request. 87 OAuthServiceFailureError: An unknown error occurred. 88 """ 89 request_uri = os.environ.get('REQUEST_URI', '') 90 if any(request_uri.startswith(e) for e in OAUTH_ENDPOINTS): 91 # Prevent a CSRF whereby a malicious site posts an api request without an 92 # Authorization header (so oauth.get_current_user() is None), but while the 93 # user is signed in, so their cookies would make users.get_current_user() 94 # return a non-None user. 95 if 'HTTP_AUTHORIZATION' not in os.environ: 96 # The user is not signed in. Avoid raising OAuthRequestError. 97 return None 98 user = oauth.get_current_user(OAUTH_SCOPES) 99 else: 100 user = users.get_current_user() 101 return user.email() if user else None 102 103 104@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True) 105def TickMonitoringCustomMetric(metric_name): 106 """Increments the stackdriver custom metric with the given name. 107 108 This is used for cron job monitoring; if these metrics stop being received 109 an alert mail is sent. For more information on custom metrics, see 110 https://cloud.google.com/monitoring/custom-metrics/using-custom-metrics 111 112 Args: 113 metric_name: The name of the metric being monitored. 114 """ 115 credentials = client.GoogleCredentials.get_application_default() 116 monitoring = discovery.build('monitoring', 'v3', credentials=credentials) 117 now = _GetNowRfc3339() 118 project_id = stored_object.Get(_PROJECT_ID_KEY) 119 points = [{ 120 'interval': { 121 'startTime': now, 122 'endTime': now, 123 }, 124 'value': { 125 'int64Value': _DEFAULT_CUSTOM_METRIC_VAL, 126 }, 127 }] 128 write_request = monitoring.projects().timeSeries().create( 129 name='projects/%s' % project_id, 130 body={ 131 'timeSeries': [{ 132 'metric': { 133 'type': 'custom.googleapis.com/%s' % metric_name, 134 }, 135 'points': points 136 }] 137 }) 138 write_request.execute() 139 140 141def TestPath(key): 142 """Returns the test path for a TestMetadata from an ndb.Key. 143 144 A "test path" is just a convenient string representation of an ndb.Key. 145 Each test path corresponds to one ndb.Key, which can be used to get an 146 entity. 147 148 Args: 149 key: An ndb.Key where all IDs are string IDs. 150 151 Returns: 152 A test path string. 153 """ 154 if key.kind() == 'Test': 155 # The Test key looks like ('Master', 'name', 'Bot', 'name', 'Test' 'name'..) 156 # Pull out every other entry and join with '/' to form the path. 157 return '/'.join(key.flat()[1::2]) 158 assert key.kind() == 'TestMetadata' or key.kind() == 'TestContainer' 159 return key.id() 160 161 162def TestSuiteName(test_key): 163 """Returns the test suite name for a given TestMetadata key.""" 164 assert test_key.kind() == 'TestMetadata' 165 parts = test_key.id().split('/') 166 return parts[2] 167 168 169def TestKey(test_path): 170 """Returns the ndb.Key that corresponds to a test path.""" 171 if test_path is None: 172 return None 173 path_parts = test_path.split('/') 174 if path_parts is None: 175 return None 176 if len(path_parts) < 3: 177 key_list = [('Master', path_parts[0])] 178 if len(path_parts) > 1: 179 key_list += [('Bot', path_parts[1])] 180 return ndb.Key(pairs=key_list) 181 return ndb.Key('TestMetadata', test_path) 182 183 184def TestMetadataKey(key_or_string): 185 """Convert the given (Test or TestMetadata) key or test_path string to a 186 TestMetadata key. 187 188 We are in the process of converting from Test entities to TestMetadata. 189 Unfortunately, we haver trillions of Row entities which have a parent_test 190 property set to a Test, and it's not possible to migrate them all. So we 191 use the Test key in Row queries, and convert between the old and new format. 192 193 Note that the Test entities which the keys refer to may be deleted; the 194 queries over keys still work. 195 """ 196 if key_or_string is None: 197 return None 198 if isinstance(key_or_string, basestring): 199 return ndb.Key('TestMetadata', key_or_string) 200 if key_or_string.kind() == 'TestMetadata': 201 return key_or_string 202 if key_or_string.kind() == 'Test': 203 return ndb.Key('TestMetadata', TestPath(key_or_string)) 204 205 206def OldStyleTestKey(key_or_string): 207 """Get the key for the old style Test entity corresponding to this key or 208 test_path. 209 210 We are in the process of converting from Test entities to TestMetadata. 211 Unfortunately, we haver trillions of Row entities which have a parent_test 212 property set to a Test, and it's not possible to migrate them all. So we 213 use the Test key in Row queries, and convert between the old and new format. 214 215 Note that the Test entities which the keys refer to may be deleted; the 216 queries over keys still work. 217 """ 218 if key_or_string is None: 219 return None 220 elif isinstance(key_or_string, ndb.Key) and key_or_string.kind() == 'Test': 221 return key_or_string 222 if (isinstance(key_or_string, ndb.Key) 223 and key_or_string.kind() == 'TestMetadata'): 224 key_or_string = key_or_string.id() 225 assert isinstance(key_or_string, basestring) 226 path_parts = key_or_string.split('/') 227 key_parts = ['Master', path_parts[0], 'Bot', path_parts[1]] 228 for part in path_parts[2:]: 229 key_parts += ['Test', part] 230 return ndb.Key(*key_parts) 231 232 233def ParseStatisticNameFromChart(chart_name): 234 chart_name_parts = chart_name.split('_') 235 statistic_name = '' 236 237 if chart_name_parts[-1] in STATISTICS: 238 chart_name = '_'.join(chart_name_parts[:-1]) 239 statistic_name = chart_name_parts[-1] 240 return chart_name, statistic_name 241 242 243def MostSpecificMatchingPattern(test, pattern_data_tuples): 244 """Takes a test and a list of (pattern, data) tuples and returns the data 245 for the pattern which most closely matches the test. It does this by 246 ordering the matching patterns, and choosing the one with the most specific 247 top level match. 248 249 For example, if there was a test Master/Bot/Foo/Bar, then: 250 251 */*/*/Bar would match more closely than */*/*/* 252 */*/*/Bar would match more closely than */*/*/Bar.* 253 */*/*/Bar.* would match more closely than */*/*/* 254 """ 255 256 # To implement this properly, we'll use a matcher trie. This trie data 257 # structure will take the tuple of patterns like: 258 # 259 # */*/*/Bar 260 # */*/*/Bar.* 261 # */*/*/* 262 # 263 # and create a trie of the following form: 264 # 265 # (all, *) -> (all, *) -> (all, *) -> (specific, Bar) 266 # T 267 # + -> (partial, Bar.*) 268 # | 269 # + -> (all, *) 270 # 271 # 272 # We can then traverse this trie, where we order the matchers by exactness, 273 # and return the deepest pattern that matches. 274 # 275 # For now, we'll keep this as is. 276 # 277 # TODO(dberris): Refactor this to build a trie. 278 279 matching_patterns = [] 280 for p, v in pattern_data_tuples: 281 if not TestMatchesPattern(test, p): 282 continue 283 matching_patterns.append([p, v]) 284 285 if not matching_patterns: 286 return None 287 288 if isinstance(test, ndb.Key): 289 test_path = TestPath(test) 290 else: 291 test_path = test.test_path 292 test_path_parts = test_path.split('/') 293 294 # This ensures the ordering puts the closest match at index 0 295 def CmpPatterns(a, b): 296 a_parts = a[0].split('/') 297 b_parts = b[0].split('/') 298 for a_part, b_part, test_part in reversed( 299 zip(a_parts, b_parts, test_path_parts)): 300 # We favour a specific match over a partial match, and a partial 301 # match over a catch-all * match. 302 if a_part == b_part: 303 continue 304 if a_part == test_part: 305 return -1 306 if b_part == test_part: 307 return 1 308 if a_part != '*': 309 return -1 310 if b_part != '*': 311 return 1 312 return 0 313 314 # In the case when we find that the patterns are the same, we should return 315 # 0 to indicate that we've found an equality. 316 return 0 317 318 matching_patterns.sort(cmp=CmpPatterns) # pylint: disable=using-cmp-argument 319 320 return matching_patterns[0][1] 321 322 323class ParseTelemetryMetricFailed(Exception): 324 pass 325 326 327def ParseTelemetryMetricParts(test_path): 328 """Parses a test path and returns the grouping_label, measurement, and story. 329 330 Args: 331 test_path_parts: A test path. 332 333 Returns: 334 A tuple of (grouping_label, measurement, story), or None if this doesn't 335 appear to be a telemetry test. 336 """ 337 test_path_parts = test_path.split('/') 338 metric_parts = test_path_parts[3:] 339 340 if len(metric_parts) > 3 or len(metric_parts) == 0: 341 raise ParseTelemetryMetricFailed(test_path) 342 343 # Normal test path structure, ie. M/B/S/foo/bar.html 344 if len(metric_parts) == 2: 345 return '', metric_parts[0], metric_parts[1] 346 347 # 3 part structure, so there's a grouping label in there. 348 # ie. M/B/S/timeToFirstMeaningfulPaint_avg/load_tools/load_tools_weather 349 if len(metric_parts) == 3: 350 return metric_parts[1], metric_parts[0], metric_parts[2] 351 352 # Should be something like M/B/S/EventsDispatching where the trace_name is 353 # left empty and implied to be summary. 354 assert len(metric_parts) == 1 355 return '', metric_parts[0], '' 356 357 358def TestMatchesPattern(test, pattern): 359 """Checks whether a test matches a test path pattern. 360 361 Args: 362 test: A TestMetadata entity or a TestMetadata key. 363 pattern: A test path which can include wildcard characters (*). 364 365 Returns: 366 True if it matches, False otherwise. 367 """ 368 if not test: 369 return False 370 if isinstance(test, ndb.Key): 371 test_path = TestPath(test) 372 else: 373 test_path = test.test_path 374 test_path_parts = test_path.split('/') 375 pattern_parts = pattern.split('/') 376 if len(test_path_parts) != len(pattern_parts): 377 return False 378 for test_path_part, pattern_part in zip(test_path_parts, pattern_parts): 379 if not _MatchesPatternPart(pattern_part, test_path_part): 380 return False 381 return True 382 383 384def _MatchesPatternPart(pattern_part, test_path_part): 385 """Checks whether a pattern (possibly with a *) matches the given string. 386 387 Args: 388 pattern_part: A string which may contain a wildcard (*). 389 test_path_part: Another string. 390 391 Returns: 392 True if it matches, False otherwise. 393 """ 394 if pattern_part == '*' or pattern_part == test_path_part: 395 return True 396 if '*' not in pattern_part: 397 return False 398 # Escape any other special non-alphanumeric characters. 399 pattern_part = re.escape(pattern_part) 400 # There are not supposed to be any other asterisk characters, so all 401 # occurrences of backslash-asterisk can now be replaced with dot-asterisk. 402 re_pattern = re.compile('^' + pattern_part.replace('\\*', '.*') + '$') 403 return re_pattern.match(test_path_part) 404 405 406def TimestampMilliseconds(datetime): 407 """Returns the number of milliseconds since the epoch.""" 408 return int(time.mktime(datetime.timetuple()) * 1000) 409 410 411def GetTestContainerKey(test): 412 """Gets the TestContainer key for the given TestMetadata. 413 414 Args: 415 test: Either a TestMetadata entity or its ndb.Key. 416 417 Returns: 418 ndb.Key('TestContainer', test path) 419 """ 420 test_path = None 421 if isinstance(test, ndb.Key): 422 test_path = TestPath(test) 423 else: 424 test_path = test.test_path 425 return ndb.Key('TestContainer', test_path) 426 427 428def GetMulti(keys): 429 """Gets a list of entities from a list of keys. 430 431 If this user is logged in, this is the same as ndb.get_multi. However, if the 432 user is logged out and any of the data is internal only, an AssertionError 433 will be raised. 434 435 Args: 436 keys: A list of ndb entity keys. 437 438 Returns: 439 A list of entities, but no internal_only ones if the user is not logged in. 440 """ 441 if IsInternalUser(): 442 return ndb.get_multi(keys) 443 # Not logged in. Check each key individually. 444 entities = [] 445 for key in keys: 446 try: 447 entities.append(key.get()) 448 except AssertionError: 449 continue 450 return entities 451 452 453def MinimumAlertRange(alerts): 454 """Returns the intersection of the revision ranges for a set of alerts. 455 456 Args: 457 alerts: An iterable of Alerts. 458 459 Returns: 460 A pair (start, end) if there is a valid minimum range, 461 or None if the ranges are not overlapping. 462 """ 463 ranges = [(a.start_revision, a.end_revision) for a in alerts if a] 464 return MinimumRange(ranges) 465 466 467def MinimumRange(ranges): 468 """Returns the intersection of the given ranges, or None.""" 469 if not ranges: 470 return None 471 starts, ends = zip(*ranges) 472 start, end = (max(starts), min(ends)) 473 if start > end: 474 return None 475 return start, end 476 477 478def IsInternalUser(): 479 """Checks whether the user should be able to see internal-only data.""" 480 if IsDevAppserver(): 481 return True 482 email = GetEmail() 483 if not email: 484 return False 485 cached = GetCachedIsInternalUser(email) 486 if cached is not None: 487 return cached 488 is_internal_user = IsGroupMember(identity=email, group='chromeperf-access') 489 SetCachedIsInternalUser(email, is_internal_user) 490 return is_internal_user 491 492 493def IsAdministrator(): 494 """Checks whether the user is an administrator of the Dashboard.""" 495 if IsDevAppserver(): 496 return True 497 email = GetEmail() 498 if not email: 499 return False 500 cached = GetCachedIsAdministrator(email) 501 if cached is not None: 502 return cached 503 is_administrator = IsGroupMember( 504 identity=email, group='project-chromeperf-admins') 505 SetCachedIsAdministrator(email, is_administrator) 506 return is_administrator 507 508 509def GetCachedIsInternalUser(email): 510 return memcache.get(_IsInternalUserCacheKey(email)) 511 512 513def SetCachedIsInternalUser(email, value): 514 memcache.set(_IsInternalUserCacheKey(email), value, time=60 * 60 * 24) 515 516 517def GetCachedIsAdministrator(email): 518 return memcache.get(_IsAdministratorUserCacheKey(email)) 519 520 521def SetCachedIsAdministrator(email, value): 522 memcache.set(_IsAdministratorUserCacheKey(email), value, time=60 * 60 * 24) 523 524 525def _IsInternalUserCacheKey(email): 526 return 'is_internal_user_{}'.format(email) 527 528 529def _IsAdministratorUserCacheKey(email): 530 return 'is_administrator_{}'.format(email) 531 532 533def ShouldTurnOnUploadCompletionTokenExperiment(): 534 """Checks whether current request should be part of upload completeon token 535 experiment. 536 537 True for requests from project-chromeperf-upload-token-experiment 538 group members. Does not guarantee, that multiple calls for one request will 539 return the same results. So call it only once and than pass the decision to 540 other parts of the code manually. 541 """ 542 if IsDevAppserver(): 543 return True 544 email = GetEmail() 545 if not email: 546 return False 547 return IsGroupMember( 548 identity=email, group='project-chromeperf-upload-token-experiment') 549 550 551def IsGroupMember(identity, group): 552 """Checks if a user is a group member of using chrome-infra-auth.appspot.com. 553 554 Args: 555 identity: User email address. 556 group: Group name. 557 558 Returns: 559 True if confirmed to be a member, False otherwise. 560 """ 561 cached = GetCachedIsGroupMember(identity, group) 562 if cached is not None: 563 return cached 564 try: 565 discovery_url = ('https://chrome-infra-auth.appspot.com' 566 '/_ah/api/discovery/v1/apis/{api}/{apiVersion}/rest') 567 service = discovery.build( 568 'auth', 569 'v1', 570 discoveryServiceUrl=discovery_url, 571 http=ServiceAccountHttp()) 572 request = service.membership(identity=identity, group=group) 573 response = request.execute() 574 is_member = response['is_member'] 575 SetCachedIsGroupMember(identity, group, is_member) 576 return is_member 577 except (errors.HttpError, KeyError, AttributeError) as e: 578 logging.error('Failed to check membership of %s: %s', identity, e) 579 return False 580 581 582def GetCachedIsGroupMember(identity, group): 583 return memcache.get(_IsGroupMemberCacheKey(identity, group)) 584 585 586def SetCachedIsGroupMember(identity, group, value): 587 memcache.set( 588 _IsGroupMemberCacheKey(identity, group), value, time=60 * 60 * 24) 589 590 591def _IsGroupMemberCacheKey(identity, group): 592 return 'is_group_member_%s_%s' % (identity, group) 593 594 595@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True) 596def ServiceAccountEmail(scope=EMAIL_SCOPE): 597 account_details = stored_object.Get(SERVICE_ACCOUNT_KEY) 598 if not account_details: 599 raise KeyError('Service account credentials not found.') 600 601 assert scope, "ServiceAccountHttp scope must not be None." 602 603 return account_details['client_email'], 604 605 606@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True) 607def ServiceAccountHttp(scope=EMAIL_SCOPE, timeout=None): 608 """Returns the Credentials of the service account if available.""" 609 account_details = stored_object.Get(SERVICE_ACCOUNT_KEY) 610 if not account_details: 611 raise KeyError('Service account credentials not found.') 612 613 assert scope, "ServiceAccountHttp scope must not be None." 614 615 client.logger.setLevel(logging.WARNING) 616 credentials = client.SignedJwtAssertionCredentials( 617 service_account_name=account_details['client_email'], 618 private_key=account_details['private_key'], 619 scope=scope) 620 621 http = httplib2.Http(timeout=timeout) 622 credentials.authorize(http) 623 return http 624 625 626@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True) 627def IsValidSheriffUser(): 628 """Checks whether the user should be allowed to triage alerts.""" 629 email = GetEmail() 630 if not email: 631 return False 632 633 sheriff_domains = stored_object.Get(SHERIFF_DOMAINS_KEY) 634 domain_matched = sheriff_domains and any( 635 email.endswith('@' + domain) for domain in sheriff_domains) 636 return domain_matched or IsTryjobUser() 637 638 639def IsTryjobUser(): 640 email = GetEmail() 641 return bool(email) and IsGroupMember( 642 identity=email, group='project-pinpoint-tryjob-access') 643 644 645@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True) 646def GetIpAllowlist(): 647 """Returns a list of IP addresses allowed to post data.""" 648 return stored_object.Get(IP_ALLOWLIST_KEY) 649 650 651def GetRepositoryExclusions(): 652 # TODO(abennetts): determine if this caching hack is useful. 653 global _PINPOINT_REPO_EXCLUSION_CACHED 654 if _PINPOINT_REPO_EXCLUSION_CACHED.IsStale(_PINPOINT_REPO_EXCLUSION_TTL): 655 _PINPOINT_REPO_EXCLUSION_CACHED = _SimpleCache(time.time(), 656 _GetRepositoryExclusions()) 657 return _PINPOINT_REPO_EXCLUSION_CACHED.value 658 659 660@ndb.transactional(propagation=ndb.TransactionOptions.INDEPENDENT, xg=True) 661def _GetRepositoryExclusions(): 662 """Returns a list of repositories to exclude from bisection.""" 663 # TODO(dberris): Move this to git-hosted configurations later. 664 return stored_object.Get(PINPOINT_REPO_EXCLUSION_KEY) or [] 665 666 667def GetRequestId(): 668 """Returns the request log ID which can be used to find a specific log.""" 669 return os.environ.get('REQUEST_LOG_ID') 670 671 672def Validate(expected, actual): 673 """Generic validator for expected keys, values, and types. 674 675 Values are also considered equal if |actual| can be converted to |expected|'s 676 type. For instance: 677 _Validate([3], '3') # Returns True. 678 679 See utils_test.py for more examples. 680 681 Args: 682 expected: Either a list of expected values or a dictionary of expected 683 keys and type. A dictionary can contain a list of expected values. 684 actual: A value. 685 """ 686 687 def IsValidType(expected, actual): 688 if isinstance(expected, type) and not isinstance(actual, expected): 689 try: 690 expected(actual) 691 except ValueError: 692 return False 693 return True 694 695 def IsInList(expected, actual): 696 for value in expected: 697 try: 698 if type(value)(actual) == value: 699 return True 700 except ValueError: 701 pass 702 return False 703 704 if not expected: 705 return 706 expected_type = type(expected) 707 actual_type = type(actual) 708 if expected_type is list: 709 if not IsInList(expected, actual): 710 raise ValueError('Invalid value. Expected one of the following: ' 711 '%s. Actual: %s.' % (','.join(expected), actual)) 712 elif expected_type is dict: 713 if actual_type is not dict: 714 raise ValueError('Invalid type. Expected: %s. Actual: %s.' % 715 (expected_type, actual_type)) 716 missing = set(expected.keys()) - set(actual.keys()) 717 if missing: 718 raise ValueError('Missing the following properties: %s' % 719 ','.join(missing)) 720 for key in expected: 721 Validate(expected[key], actual[key]) 722 elif not IsValidType(expected, actual): 723 raise ValueError('Invalid type. Expected: %s. Actual: %s.' % 724 (expected, actual_type)) 725 726 727def FetchURL(request_url, skip_status_code=False): 728 """Wrapper around URL fetch service to make request. 729 730 Args: 731 request_url: URL of request. 732 skip_status_code: Skips return code check when True, default is False. 733 734 Returns: 735 Response object return by URL fetch, otherwise None when there's an error. 736 """ 737 logging.info('URL being fetched: ' + request_url) 738 try: 739 response = urlfetch.fetch(request_url) 740 except urlfetch_errors.DeadlineExceededError: 741 logging.error('Deadline exceeded error checking %s', request_url) 742 return None 743 except urlfetch_errors.DownloadError as err: 744 # DownloadError is raised to indicate a non-specific failure when there 745 # was not a 4xx or 5xx status code. 746 logging.error('DownloadError: %r', err) 747 return None 748 if skip_status_code: 749 return response 750 elif response.status_code != 200: 751 logging.error('ERROR %s checking %s', response.status_code, request_url) 752 return None 753 return response 754 755 756def GetBuildDetailsFromStdioLink(stdio_link): 757 no_details = (None, None, None, None, None) 758 m = re.match(r'\[(.+?)\]\((.+?)\)', stdio_link) 759 if not m: 760 # This wasn't the markdown-style link we were expecting. 761 return no_details 762 _, link = m.groups() 763 m = re.match( 764 r'(https{0,1}://.*/([^\/]*)/builders/)' 765 r'([^\/]+)/builds/(\d+)/steps/([^\/]+)', link) 766 if not m: 767 # This wasn't a buildbot formatted link. 768 return no_details 769 base_url, master, bot, buildnumber, step = m.groups() 770 bot = urllib.unquote(bot) 771 return base_url, master, bot, buildnumber, step 772 773 774def GetStdioLinkFromRow(row): 775 """Returns the markdown-style buildbot stdio link. 776 777 Due to crbug.com/690630, many row entities have this set to "a_a_stdio_uri" 778 instead of "a_stdio_uri". 779 """ 780 return (getattr(row, 'a_stdio_uri', None) 781 or getattr(row, 'a_a_stdio_uri', None)) 782 783 784def GetBuildbotStatusPageUriFromStdioLink(stdio_link): 785 base_url, _, bot, buildnumber, _ = GetBuildDetailsFromStdioLink(stdio_link) 786 if not base_url: 787 # Can't parse status page 788 return None 789 return '%s%s/builds/%s' % (base_url, urllib.quote(bot), buildnumber) 790 791 792def GetLogdogLogUriFromStdioLink(stdio_link): 793 base_url, master, bot, buildnumber, step = GetBuildDetailsFromStdioLink( 794 stdio_link) 795 if not base_url: 796 # Can't parse status page 797 return None 798 bot = re.sub(r'[ \(\)]', '_', bot) 799 s_param = urllib.quote( 800 'chrome/bb/%s/%s/%s/+/recipes/steps/%s/0/stdout' % 801 (master, bot, buildnumber, step), 802 safe='') 803 return 'https://luci-logdog.appspot.com/v/?s=%s' % s_param 804 805 806def GetRowKey(testmetadata_key, revision): 807 test_container_key = GetTestContainerKey(testmetadata_key) 808 return ndb.Key('Row', revision, parent=test_container_key) 809 810 811def GetSheriffForAutorollCommit(author, message): 812 if author.split('@')[-1] not in _AUTOROLL_DOMAINS: 813 # Not an autoroll. 814 return None 815 # This is an autoroll. The sheriff should be the first person on TBR list. 816 m = re.search(r'TBR[=:]\s*([^,^\s]*)', message, flags=re.IGNORECASE) 817 if not m: 818 return None 819 return m.group(1) 820 821 822def IsMonitored(sheriff_client, test_path): 823 """Checks if the test is monitored by sherrifs.""" 824 825 subscriptions, _ = sheriff_client.Match(test_path, check=True) 826 if subscriptions: 827 return True 828 return False 829