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 5"""General functions which are useful throughout this project.""" 6 7import base64 8import binascii 9import json 10import logging 11import os 12import re 13import time 14 15from apiclient import discovery 16from apiclient import errors 17from google.appengine.api import memcache 18from google.appengine.api import urlfetch 19from google.appengine.api import urlfetch_errors 20from google.appengine.api import users 21from google.appengine.ext import ndb 22import httplib2 23from oauth2client import client 24 25from dashboard import stored_object 26 27SHERIFF_DOMAINS_KEY = 'sheriff_domains_key' 28IP_WHITELIST_KEY = 'ip_whitelist' 29SERVICE_ACCOUNT_KEY = 'service_account' 30EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email' 31_PROJECT_ID_KEY = 'project_id' 32_DEFAULT_CUSTOM_METRIC_VAL = 1 33 34 35def _GetNowRfc3339(): 36 """Returns the current time formatted per RFC 3339.""" 37 return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) 38 39 40def TickMonitoringCustomMetric(metric_name): 41 """Increments the stackdriver custom metric with the given name. 42 43 This is used for cron job monitoring; if these metrics stop being received 44 an alert mail is sent. For more information on custom metrics, see 45 https://cloud.google.com/monitoring/custom-metrics/using-custom-metrics 46 47 Args: 48 metric_name: The name of the metric being monitored. 49 """ 50 credentials = client.GoogleCredentials.get_application_default() 51 monitoring = discovery.build( 52 'monitoring', 'v3', credentials=credentials) 53 now = _GetNowRfc3339() 54 project_id = stored_object.Get(_PROJECT_ID_KEY) 55 points = [{ 56 'interval': { 57 'startTime': now, 58 'endTime': now, 59 }, 60 'value': { 61 'int64Value': _DEFAULT_CUSTOM_METRIC_VAL, 62 }, 63 }] 64 write_request = monitoring.projects().timeSeries().create( 65 name='projects/%s' %project_id, 66 body={'timeSeries': [{ 67 'metric': { 68 'type': 'custom.googleapis.com/%s' % metric_name, 69 }, 70 'points': points 71 }]}) 72 write_request.execute() 73 74 75def TestPath(key): 76 """Returns the test path for a TestMetadata from an ndb.Key. 77 78 A "test path" is just a convenient string representation of an ndb.Key. 79 Each test path corresponds to one ndb.Key, which can be used to get an 80 entity. 81 82 Args: 83 key: An ndb.Key where all IDs are string IDs. 84 85 Returns: 86 A test path string. 87 """ 88 if key.kind() == 'Test': 89 # The Test key looks like ('Master', 'name', 'Bot', 'name', 'Test' 'name'..) 90 # Pull out every other entry and join with '/' to form the path. 91 return '/'.join(key.flat()[1::2]) 92 assert key.kind() == 'TestMetadata' or key.kind() == 'TestContainer' 93 return key.id() 94 95 96def TestSuiteName(test_key): 97 """Returns the test suite name for a given TestMetadata key.""" 98 assert test_key.kind() == 'TestMetadata' 99 parts = test_key.id().split('/') 100 return parts[2] 101 102 103def TestKey(test_path): 104 """Returns the ndb.Key that corresponds to a test path.""" 105 if test_path is None: 106 return None 107 path_parts = test_path.split('/') 108 if path_parts is None: 109 return None 110 if len(path_parts) < 3: 111 key_list = [('Master', path_parts[0])] 112 if len(path_parts) > 1: 113 key_list += [('Bot', path_parts[1])] 114 return ndb.Key(pairs=key_list) 115 return ndb.Key('TestMetadata', test_path) 116 117 118def TestMetadataKey(key_or_string): 119 """Convert the given (Test or TestMetadata) key or test_path string to a 120 TestMetadata key. 121 122 We are in the process of converting from Test entities to TestMetadata. 123 Unfortunately, we haver trillions of Row entities which have a parent_test 124 property set to a Test, and it's not possible to migrate them all. So we 125 use the Test key in Row queries, and convert between the old and new format. 126 127 Note that the Test entities which the keys refer to may be deleted; the 128 queries over keys still work. 129 """ 130 if key_or_string is None: 131 return None 132 if isinstance(key_or_string, basestring): 133 return ndb.Key('TestMetadata', key_or_string) 134 if key_or_string.kind() == 'TestMetadata': 135 return key_or_string 136 if key_or_string.kind() == 'Test': 137 return ndb.Key('TestMetadata', TestPath(key_or_string)) 138 139 140def OldStyleTestKey(key_or_string): 141 """Get the key for the old style Test entity corresponding to this key or 142 test_path. 143 144 We are in the process of converting from Test entities to TestMetadata. 145 Unfortunately, we haver trillions of Row entities which have a parent_test 146 property set to a Test, and it's not possible to migrate them all. So we 147 use the Test key in Row queries, and convert between the old and new format. 148 149 Note that the Test entities which the keys refer to may be deleted; the 150 queries over keys still work. 151 """ 152 if key_or_string is None: 153 return None 154 elif isinstance(key_or_string, ndb.Key) and key_or_string.kind() == 'Test': 155 return key_or_string 156 if (isinstance(key_or_string, ndb.Key) and 157 key_or_string.kind() == 'TestMetadata'): 158 key_or_string = key_or_string.id() 159 assert isinstance(key_or_string, basestring) 160 path_parts = key_or_string.split('/') 161 key_parts = ['Master', path_parts[0], 'Bot', path_parts[1]] 162 for part in path_parts[2:]: 163 key_parts += ['Test', part] 164 return ndb.Key(*key_parts) 165 166 167def TestMatchesPattern(test, pattern): 168 """Checks whether a test matches a test path pattern. 169 170 Args: 171 test: A TestMetadata entity or a TestMetadata key. 172 pattern: A test path which can include wildcard characters (*). 173 174 Returns: 175 True if it matches, False otherwise. 176 """ 177 if not test: 178 return False 179 if type(test) is ndb.Key: 180 test_path = TestPath(test) 181 else: 182 test_path = test.test_path 183 test_path_parts = test_path.split('/') 184 pattern_parts = pattern.split('/') 185 if len(test_path_parts) != len(pattern_parts): 186 return False 187 for test_path_part, pattern_part in zip(test_path_parts, pattern_parts): 188 if not _MatchesPatternPart(pattern_part, test_path_part): 189 return False 190 return True 191 192 193def _MatchesPatternPart(pattern_part, test_path_part): 194 """Checks whether a pattern (possibly with a *) matches the given string. 195 196 Args: 197 pattern_part: A string which may contain a wildcard (*). 198 test_path_part: Another string. 199 200 Returns: 201 True if it matches, False otherwise. 202 """ 203 if pattern_part == '*' or pattern_part == test_path_part: 204 return True 205 if '*' not in pattern_part: 206 return False 207 # Escape any other special non-alphanumeric characters. 208 pattern_part = re.escape(pattern_part) 209 # There are not supposed to be any other asterisk characters, so all 210 # occurrences of backslash-asterisk can now be replaced with dot-asterisk. 211 re_pattern = re.compile('^' + pattern_part.replace('\\*', '.*') + '$') 212 return re_pattern.match(test_path_part) 213 214 215def TimestampMilliseconds(datetime): 216 """Returns the number of milliseconds since the epoch.""" 217 return int(time.mktime(datetime.timetuple()) * 1000) 218 219 220def GetTestContainerKey(test): 221 """Gets the TestContainer key for the given TestMetadata. 222 223 Args: 224 test: Either a TestMetadata entity or its ndb.Key. 225 226 Returns: 227 ndb.Key('TestContainer', test path) 228 """ 229 test_path = None 230 if type(test) is ndb.Key: 231 test_path = TestPath(test) 232 else: 233 test_path = test.test_path 234 return ndb.Key('TestContainer', test_path) 235 236 237def GetMulti(keys): 238 """Gets a list of entities from a list of keys. 239 240 If this user is logged in, this is the same as ndb.get_multi. However, if the 241 user is logged out and any of the data is internal only, an AssertionError 242 will be raised. 243 244 Args: 245 keys: A list of ndb entity keys. 246 247 Returns: 248 A list of entities, but no internal_only ones if the user is not logged in. 249 """ 250 if IsInternalUser(): 251 return ndb.get_multi(keys) 252 # Not logged in. Check each key individually. 253 entities = [] 254 for key in keys: 255 try: 256 entities.append(key.get()) 257 except AssertionError: 258 continue 259 return entities 260 261 262def MinimumAlertRange(alerts): 263 """Returns the intersection of the revision ranges for a set of alerts. 264 265 Args: 266 alerts: An iterable of Alerts (Anomaly or StoppageAlert entities). 267 268 Returns: 269 A pair (start, end) if there is a valid minimum range, 270 or None if the ranges are not overlapping. 271 """ 272 ranges = [(a.start_revision, a.end_revision) for a in alerts if a] 273 return MinimumRange(ranges) 274 275 276def MinimumRange(ranges): 277 """Returns the intersection of the given ranges, or None.""" 278 if not ranges: 279 return None 280 starts, ends = zip(*ranges) 281 start, end = (max(starts), min(ends)) 282 if start > end: 283 return None 284 return start, end 285 286 287def IsInternalUser(): 288 """Checks whether the user should be able to see internal-only data.""" 289 username = users.get_current_user() 290 if not username: 291 return False 292 cached = GetCachedIsInternalUser(username) 293 if cached is not None: 294 return cached 295 is_internal_user = IsGroupMember(identity=username, group='googlers') 296 SetCachedIsInternalUser(username, is_internal_user) 297 return is_internal_user 298 299 300def GetCachedIsInternalUser(username): 301 return memcache.get(_IsInternalUserCacheKey(username)) 302 303 304def SetCachedIsInternalUser(username, value): 305 memcache.add(_IsInternalUserCacheKey(username), value, time=60*60*24) 306 307 308def _IsInternalUserCacheKey(username): 309 return 'is_internal_user_%s' % username 310 311 312def IsGroupMember(identity, group): 313 """Checks if a user is a group member of using chrome-infra-auth.appspot.com. 314 315 Args: 316 identity: User email address. 317 group: Group name. 318 319 Returns: 320 True if confirmed to be a member, False otherwise. 321 """ 322 try: 323 discovery_url = ('https://chrome-infra-auth.appspot.com' 324 '/_ah/api/discovery/v1/apis/{api}/{apiVersion}/rest') 325 service = discovery.build( 326 'auth', 'v1', discoveryServiceUrl=discovery_url, 327 http=ServiceAccountHttp()) 328 request = service.membership(identity=identity, group=group) 329 response = request.execute() 330 return response['is_member'] 331 except (errors.HttpError, KeyError, AttributeError) as e: 332 logging.error('Failed to check membership of %s: %s', identity, e) 333 return False 334 335 336def ServiceAccountHttp(): 337 """Returns the Credentials of the service account if available.""" 338 account_details = stored_object.Get(SERVICE_ACCOUNT_KEY) 339 if not account_details: 340 raise KeyError('Service account credentials not found.') 341 342 credentials = client.SignedJwtAssertionCredentials( 343 service_account_name=account_details['client_email'], 344 private_key=account_details['private_key'], 345 scope=EMAIL_SCOPE) 346 347 http = httplib2.Http() 348 credentials.authorize(http) 349 return http 350 351 352def IsValidSheriffUser(): 353 """Checks whether the user should be allowed to triage alerts.""" 354 user = users.get_current_user() 355 sheriff_domains = stored_object.Get(SHERIFF_DOMAINS_KEY) 356 if user: 357 domain_matched = sheriff_domains and any( 358 user.email().endswith('@' + domain) for domain in sheriff_domains) 359 return domain_matched or IsGroupMember( 360 identity=user, group='project-chromium-tryjob-access') 361 return False 362 363 364def GetIpWhitelist(): 365 """Returns a list of IP address strings in the whitelist.""" 366 return stored_object.Get(IP_WHITELIST_KEY) 367 368 369def BisectConfigPythonString(config): 370 """Turns a bisect config dict into a properly formatted Python string. 371 372 Args: 373 config: A bisect config dict (see start_try_job.GetBisectConfig) 374 375 Returns: 376 A config string suitable to store in a TryJob entity. 377 """ 378 return 'config = %s\n' % json.dumps( 379 config, sort_keys=True, indent=2, separators=(',', ': ')) 380 381 382def DownloadChromiumFile(path): 383 """Downloads a file in the chromium/src repository. 384 385 This function uses gitiles to fetch files. As of September 2015, 386 gitiles supports fetching base-64 encoding of files. If it supports 387 fetching plain text in the future, that may be simpler. 388 389 Args: 390 path: Path to a file in src repository, without a leading slash or "src/". 391 392 Returns: 393 The contents of the file as a string, or None. 394 """ 395 base_url = 'https://chromium.googlesource.com/chromium/src/+/master/' 396 url = '%s%s?format=TEXT' % (base_url, path) 397 response = urlfetch.fetch(url) 398 if response.status_code != 200: 399 logging.error('Got %d fetching "%s".', response.status_code, url) 400 return None 401 try: 402 plaintext_content = base64.decodestring(response.content) 403 except binascii.Error: 404 logging.error('Failed to decode "%s" from "%s".', response.content, url) 405 return None 406 return plaintext_content 407 408 409def GetRequestId(): 410 """Returns the request log ID which can be used to find a specific log.""" 411 return os.environ.get('REQUEST_LOG_ID') 412 413 414def Validate(expected, actual): 415 """Generic validator for expected keys, values, and types. 416 417 Values are also considered equal if |actual| can be converted to |expected|'s 418 type. For instance: 419 _Validate([3], '3') # Returns True. 420 421 See utils_test.py for more examples. 422 423 Args: 424 expected: Either a list of expected values or a dictionary of expected 425 keys and type. A dictionary can contain a list of expected values. 426 actual: A value. 427 """ 428 def IsValidType(expected, actual): 429 if type(expected) is type and type(actual) is not expected: 430 try: 431 expected(actual) 432 except ValueError: 433 return False 434 return True 435 436 def IsInList(expected, actual): 437 for value in expected: 438 try: 439 if type(value)(actual) == value: 440 return True 441 except ValueError: 442 pass 443 return False 444 445 if not expected: 446 return 447 expected_type = type(expected) 448 actual_type = type(actual) 449 if expected_type is list: 450 if not IsInList(expected, actual): 451 raise ValueError('Invalid value. Expected one of the following: ' 452 '%s. Actual: %s.' % (','.join(expected), actual)) 453 elif expected_type is dict: 454 if actual_type is not dict: 455 raise ValueError('Invalid type. Expected: %s. Actual: %s.' 456 % (expected_type, actual_type)) 457 missing = set(expected.keys()) - set(actual.keys()) 458 if missing: 459 raise ValueError('Missing the following properties: %s' 460 % ','.join(missing)) 461 for key in expected: 462 Validate(expected[key], actual[key]) 463 elif not IsValidType(expected, actual): 464 raise ValueError('Invalid type. Expected: %s. Actual: %s.' % 465 (expected, actual_type)) 466 467 468def FetchURL(request_url, skip_status_code=False): 469 """Wrapper around URL fetch service to make request. 470 471 Args: 472 request_url: URL of request. 473 skip_status_code: Skips return code check when True, default is False. 474 475 Returns: 476 Response object return by URL fetch, otherwise None when there's an error. 477 """ 478 logging.info('URL being fetched: ' + request_url) 479 try: 480 response = urlfetch.fetch(request_url) 481 except urlfetch_errors.DeadlineExceededError: 482 logging.error('Deadline exceeded error checking %s', request_url) 483 return None 484 except urlfetch_errors.DownloadError as err: 485 # DownloadError is raised to indicate a non-specific failure when there 486 # was not a 4xx or 5xx status code. 487 logging.error(err) 488 return None 489 if skip_status_code: 490 return response 491 elif response.status_code != 200: 492 logging.error( 493 'ERROR %s checking %s', response.status_code, request_url) 494 return None 495 return response 496