• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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