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"""A common base class for pages that are used to edit configs."""
5from __future__ import print_function
6from __future__ import division
7from __future__ import absolute_import
8from future_builtins import map  # pylint: disable=redefined-builtin
9
10import functools
11import itertools
12import json
13import operator
14
15from google.appengine.api import app_identity
16from google.appengine.api import mail
17from google.appengine.api import taskqueue
18from google.appengine.api import users
19from google.appengine.ext import deferred
20
21from dashboard import list_tests
22from dashboard.common import request_handler
23from dashboard.common import utils
24from dashboard.common import xsrf
25
26# Max number of entities to put in one request to /put_entities_task.
27_MAX_TESTS_TO_PUT_AT_ONCE = 25
28
29# The queue to use to re-put tests. Should be present in queue.yaml.
30_TASK_QUEUE_NAME = 'edit-sheriffs-queue'
31
32# Minimum time before starting tasks, in seconds. It appears that the tasks
33# may be executed before the sheriff is saved, so this is a workaround for that.
34# See http://crbug.com/621499
35_TASK_QUEUE_COUNTDOWN = 60
36
37_NUM_PATTERNS_PER_TASK = 10
38
39_NOTIFICATION_EMAIL_BODY = """
40The configuration of %(hostname)s was changed by %(user)s.
41
42Key: %(key)s
43
44New test path patterns:
45%(new_test_path_patterns)s
46
47Old test path patterns
48%(old_test_path_patterns)s
49"""
50
51# The mailing list to which config change notifications are sent,
52# so that the team can keep an audit record of these changes.
53# The "gasper-alerts" address is a historic legacy and not important.
54_NOTIFICATION_ADDRESS = 'chrome-performance-monitoring-alerts@google.com'
55_SENDER_ADDRESS = 'gasper-alerts@google.com'
56
57
58class EditConfigHandler(request_handler.RequestHandler):
59  """Base class for handlers that are used to add or edit entities.
60
61  Specifically, this is a common base class for EditSheriffsHandler
62  and EditAnomalyConfigsHandler. Both of these kinds of entities
63  represent a configuration that can apply to a set of tests, where
64  the set of tests is specified with a list of test path patterns.
65  """
66
67  # The webapp2 docs say that custom __init__ methods should call initialize()
68  # at the beginning of the method (rather than calling super __init__). See:
69  # https://webapp-improved.appspot.com/guide/handlers.html#overriding-init
70  # pylint: disable=super-init-not-called
71  def __init__(self, request, response, model_class):
72    """Constructs a handler object for editing entities of the given class.
73
74    Args:
75      request: Request object (implicitly passed in by webapp2).
76      response: Response object (implicitly passed in by webapp2).
77      model_class: A subclass of ndb.Model.
78    """
79    self.initialize(request, response)
80    self._model_class = model_class
81
82  @xsrf.TokenRequired
83  def post(self):
84    """Updates the user-selected anomaly threshold configuration.
85
86    Request parameters:
87      add-edit: Either 'add' if adding a new config, or 'edit'.
88      add-name: A new anomaly config name, if adding one.
89      edit-name: An existing anomaly config name, if editing one.
90      patterns: Newline-separated list of test path patterns to monitor.
91
92    Depending on the specific sub-class, this will also take other
93    parameters for specific properties of the entity being edited.
94    """
95    try:
96      edit_type = self.request.get('add-edit')
97      if edit_type == 'add':
98        self._AddEntity()
99      elif edit_type == 'edit':
100        self._EditEntity()
101      else:
102        raise request_handler.InvalidInputError('Invalid value for add-edit.')
103    except request_handler.InvalidInputError as error:
104      message = str(error) + ' Model class: ' + self._model_class.__name__
105      self.RenderHtml('result.html', {'errors': [message]})
106
107  def _AddEntity(self):
108    """Adds adds a new entity according to the request parameters."""
109    name = self.request.get('add-name')
110    if not name:
111      raise request_handler.InvalidInputError('No name given when adding new ')
112    if self._model_class.get_by_id(name):
113      raise request_handler.InvalidInputError(
114          'Entity "%s" already exists, cannot add.' % name)
115    entity = self._model_class(id=name)
116    self._UpdateAndReportResults(entity)
117
118  def _EditEntity(self):
119    """Edits an existing entity according to the request parameters."""
120    name = self.request.get('edit-name')
121    if not name:
122      raise request_handler.InvalidInputError('No name given.')
123    entity = self._model_class.get_by_id(name)
124    if not entity:
125      raise request_handler.InvalidInputError(
126          'Entity "%s" does not exist, cannot edit.' % name)
127    self._UpdateAndReportResults(entity)
128
129  def _UpdateAndReportResults(self, entity):
130    """Updates the entity and reports the results of this updating."""
131    new_patterns = _SplitPatternLines(self.request.get('patterns'))
132    old_patterns = entity.patterns
133    entity.patterns = new_patterns
134    self._UpdateFromRequestParameters(entity)
135    entity.put()
136
137    self._RenderResults(entity, new_patterns, old_patterns)
138    self._QueueChangeTestPatternsAndEmail(entity, new_patterns, old_patterns)
139
140  def _QueueChangeTestPatternsAndEmail(self, entity, new_patterns,
141                                       old_patterns):
142    deferred.defer(_QueueChangeTestPatternsTasks, old_patterns, new_patterns)
143
144    user_email = users.get_current_user().email()
145    subject = 'Added or updated %s: %s by %s' % (
146        self._model_class.__name__, entity.key.string_id(), user_email)
147    email_key = entity.key.string_id()
148
149    email_body = _NOTIFICATION_EMAIL_BODY % {
150        'key':
151            email_key,
152        'new_test_path_patterns':
153            json.dumps(
154                list(new_patterns),
155                indent=2,
156                sort_keys=True,
157                separators=(',', ': ')),
158        'old_test_path_patterns':
159            json.dumps(
160                list(old_patterns),
161                indent=2,
162                sort_keys=True,
163                separators=(',', ': ')),
164        'hostname':
165            app_identity.get_default_version_hostname(),
166        'user':
167            user_email,
168    }
169    mail.send_mail(
170        sender=_SENDER_ADDRESS,
171        to=_NOTIFICATION_ADDRESS,
172        subject=subject,
173        body=email_body)
174
175  def _UpdateFromRequestParameters(self, entity):
176    """Updates the given entity based on query parameters.
177
178    This method does not need to put() the entity.
179
180    Args:
181      entity: The entity to update.
182    """
183    raise NotImplementedError()
184
185  def _RenderResults(self, entity, new_patterns, old_patterns):
186    """Outputs results using the results.html template.
187
188    Args:
189      entity: The entity that was edited.
190      new_patterns: New test patterns that this config now applies to.
191      old_patterns: Old Test patterns that this config no longer applies to.
192    """
193
194    def ResultEntry(name, value):
195      """Returns an entry in the results lists to embed on result.html."""
196      return {'name': name, 'value': value, 'class': 'results-pre'}
197
198    self.RenderHtml(
199        'result.html', {
200            'headline': ('Added or updated %s "%s".' %
201                         (self._model_class.__name__, entity.key.string_id())),
202            'results': [
203                ResultEntry('Entity', str(entity)),
204                ResultEntry('New Patterns', '\n'.join(new_patterns)),
205                ResultEntry('Old Patterns', '\n'.join(old_patterns)),
206            ]
207        })
208
209
210def _SplitPatternLines(patterns_string):
211  """Splits up the given newline-separated patterns and validates them."""
212  test_path_patterns = sorted(p for p in patterns_string.splitlines() if p)
213  _ValidatePatterns(test_path_patterns)
214  return test_path_patterns
215
216
217def _ValidatePatterns(test_path_patterns):
218  """Raises an exception if any test path patterns are invalid."""
219  for pattern in test_path_patterns:
220    if not _IsValidTestPathPattern(pattern):
221      raise request_handler.InvalidInputError(
222          'Invalid test path pattern: "%s"' % pattern)
223
224
225def _IsValidTestPathPattern(test_path_pattern):
226  """Checks whether the given test path pattern string is OK."""
227  if '[' in test_path_pattern or ']' in test_path_pattern:
228    return False
229  # Valid test paths will have a Master, bot, and test suite, and will
230  # generally have a chart name and trace name after that.
231  return len(test_path_pattern.split('/')) >= 3
232
233
234def _QueueChangeTestPatternsTasks(old_patterns, new_patterns):
235  """Updates tests that are different between old_patterns and new_patterns.
236
237  The two arguments both represent sets of test paths (i.e. sets of data
238  series). Any tests that are different between these two sets need to be
239  updated.
240
241  Some properties of TestMetadata entities are updated when they are put in the
242  |_pre_put_hook| method of TestMetadata, so any TestMetadata entity that might
243  need to be updated should be re-put.
244
245  Args:
246    old_patterns: An iterable of test path pattern strings.
247    new_patterns: Another iterable of test path pattern strings.
248
249  Returns:
250    A pair (added_test_paths, removed_test_paths), which are, respectively,
251    the test paths that are in the new set but not the old, and those that
252    are in the old set but not the new.
253  """
254  added_patterns, removed_patterns = _ComputeDeltas(old_patterns, new_patterns)
255  patterns = list(added_patterns) + list(removed_patterns)
256
257  def Chunks(seq, size):
258    for i in itertools.count(0, size):
259      if i < len(seq):
260        yield seq[i:i + size]
261      else:
262        break
263
264  for pattern_sublist in Chunks(patterns, _NUM_PATTERNS_PER_TASK):
265    deferred.defer(_GetTestPathsAndAddTask, pattern_sublist)
266
267
268def _GetTestPathsAndAddTask(patterns):
269  test_paths = _AllTestPathsMatchingPatterns(patterns)
270
271  _AddTestsToPutToTaskQueue(test_paths)
272
273
274def _ComputeDeltas(old_items, new_items):
275  """Finds the added and removed items in a new set compared to an old one.
276
277  Args:
278    old_items: A collection of existing items. Could be a list or set.
279    new_items: Another collection of items.
280
281  Returns:
282    A pair of sets (added, removed).
283  """
284  old, new = set(old_items), set(new_items)
285  return new - old, old - new
286
287
288def _RemoveOverlapping(added_items, removed_items):
289  """Returns two sets of items with the common items removed."""
290  added, removed = set(added_items), set(removed_items)
291  return added - removed, removed - added
292
293
294def _AllTestPathsMatchingPatterns(patterns_list):
295  """Returns a list of all test paths matching the given list of patterns."""
296
297  def GetResult(future):
298    return set(future.get_result())
299
300  return sorted(
301      functools.reduce(
302          operator.ior,
303          map(GetResult,
304              map(list_tests.GetTestsMatchingPatternAsync, patterns_list)),
305          set()))
306
307
308def _AddTestsToPutToTaskQueue(test_paths):
309  """Adds tests that we want to re-put in the datastore to a queue.
310
311  We need to re-put the tests so that TestMetadata._pre_put_hook is run, so that
312  the sheriff or alert threshold config of the TestMetadata is updated.
313
314  Args:
315    test_paths: List of test paths of tests to be re-put.
316  """
317  futures = []
318  queue = taskqueue.Queue(_TASK_QUEUE_NAME)
319  for start_index in range(0, len(test_paths), _MAX_TESTS_TO_PUT_AT_ONCE):
320    group = test_paths[start_index:start_index + _MAX_TESTS_TO_PUT_AT_ONCE]
321    urlsafe_keys = [utils.TestKey(t).urlsafe() for t in group]
322    t = taskqueue.Task(
323        url='/put_entities_task',
324        params={'keys': ','.join(urlsafe_keys)},
325        countdown=_TASK_QUEUE_COUNTDOWN)
326    futures.append(queue.add_async(t))
327  for f in futures:
328    f.get_result()
329