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