1# Lint as: python2, python3 2# Copyright (c) 2013 The Chromium OS Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""Uploads performance data to the performance dashboard. 7 8Performance tests may output data that needs to be displayed on the performance 9dashboard. The autotest TKO parser invokes this module with each test 10associated with a job. If a test has performance data associated with it, it 11is uploaded to the performance dashboard. The performance dashboard is owned 12by Chrome team and is available here: https://chromeperf.appspot.com/. Users 13must be logged in with an @google.com account to view chromeOS perf data there. 14 15""" 16 17import six.moves.http_client 18import json 19import os 20import re 21from six.moves import urllib 22 23import common 24from autotest_lib.tko import utils as tko_utils 25 26_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) 27 28_OAUTH_IMPORT_OK = False 29_OAUTH_CREDS = None 30try: 31 from google.oauth2 import service_account 32 from google.auth.transport.requests import Request 33 from google.auth.exceptions import RefreshError 34 _OAUTH_IMPORT_OK = True 35except ImportError as e: 36 tko_utils.dprint('Failed to import google-auth:\n%s' % e) 37 38_DASHBOARD_UPLOAD_URL = 'https://chromeperf.appspot.com/add_point' 39_OAUTH_SCOPES = ['https://www.googleapis.com/auth/userinfo.email'] 40_PRESENTATION_CONFIG_FILE = os.path.join( 41 _ROOT_DIR, 'perf_dashboard_config.json') 42_PRESENTATION_SHADOW_CONFIG_FILE = os.path.join( 43 _ROOT_DIR, 'perf_dashboard_shadow_config.json') 44_SERVICE_ACCOUNT_FILE = '/creds/service_accounts/skylab-drone.json' 45 46# Format for Chrome and ChromeOS version strings. 47VERSION_REGEXP = r'^(\d+)\.(\d+)\.(\d+)\.(\d+)$' 48 49 50class PerfUploadingError(Exception): 51 """Exception raised in perf_uploader""" 52 pass 53 54 55def _parse_config_file(config_file): 56 """Parses a presentation config file and stores the info into a dict. 57 58 The config file contains information about how to present the perf data 59 on the perf dashboard. This is required if the default presentation 60 settings aren't desired for certain tests. 61 62 @param config_file: Path to the configuration file to be parsed. 63 64 @returns A dictionary mapping each unique autotest name to a dictionary 65 of presentation config information. 66 67 @raises PerfUploadingError if config data or main name for the test 68 is missing from the config file. 69 70 """ 71 json_obj = [] 72 if os.path.exists(config_file): 73 with open(config_file, 'r') as fp: 74 json_obj = json.load(fp) 75 config_dict = {} 76 for entry in json_obj: 77 if 'autotest_regex' in entry: 78 config_dict[entry['autotest_regex']] = entry 79 else: 80 config_dict['^' + re.escape(entry['autotest_name']) + '$'] = entry 81 return config_dict 82 83 84def _gather_presentation_info(config_data, test_name): 85 """Gathers presentation info from config data for the given test name. 86 87 @param config_data: A dictionary of dashboard presentation info for all 88 tests, as returned by _parse_config_file(). Info is keyed by autotest 89 name. 90 @param test_name: The name of an autotest. 91 92 @return A dictionary containing presentation information extracted from 93 |config_data| for the given autotest name. 94 95 @raises PerfUploadingError if some required data is missing. 96 """ 97 presentation_dict = None 98 for regex in config_data: 99 match = re.match(regex, test_name) 100 if match: 101 if presentation_dict: 102 raise PerfUploadingError('Duplicate config data refer to the ' 103 'same test %s' % test_name) 104 presentation_dict = config_data[regex] 105 106 if not presentation_dict: 107 raise PerfUploadingError( 108 'No config data is specified for test %s in %s.' % 109 (test_name, _PRESENTATION_CONFIG_FILE)) 110 try: 111 main_name = presentation_dict['main_name'] 112 except KeyError: 113 raise PerfUploadingError( 114 'No main name is specified for test %s in %s.' % 115 (test_name, _PRESENTATION_CONFIG_FILE)) 116 if 'dashboard_test_name' in presentation_dict: 117 test_name = presentation_dict['dashboard_test_name'] 118 return {'main_name': main_name, 'test_name': test_name} 119 120 121def _format_for_upload(board_name, cros_version, chrome_version, 122 hardware_id, hardware_hostname, perf_values, 123 presentation_info, jobname): 124 """Formats perf data suitable to upload to the perf dashboard. 125 126 The perf dashboard expects perf data to be uploaded as a 127 specially-formatted JSON string. In particular, the JSON object must be a 128 dictionary with key "data", and value being a list of dictionaries where 129 each dictionary contains all the information associated with a single 130 measured perf value: main name, bot name, test name, perf value, error 131 value, units, and build version numbers. 132 133 @param board_name: The string name of the image board name. 134 @param cros_version: The string chromeOS version number. 135 @param chrome_version: The string chrome version number. 136 @param hardware_id: String that identifies the type of hardware the test was 137 executed on. 138 @param hardware_hostname: String that identifies the name of the device the 139 test was executed on. 140 @param perf_values: A dictionary of measured perf data as computed by 141 _compute_avg_stddev(). 142 @param presentation_info: A dictionary of dashboard presentation info for 143 the given test, as identified by _gather_presentation_info(). 144 @param jobname: A string uniquely identifying the test run, this enables 145 linking back from a test result to the logs of the test run. 146 147 @return A dictionary containing the formatted information ready to upload 148 to the performance dashboard. 149 150 """ 151 # Client side case - server side comes with its own charts data section. 152 if 'charts' not in perf_values: 153 perf_values = { 154 'format_version': '1.0', 155 'benchmark_name': presentation_info['test_name'], 156 'charts': perf_values, 157 } 158 159 # TODO b:169251326 terms below are set outside of this codebase and 160 # should be updated when possible ("master" -> "main"). # nocheck 161 # see catapult-project/catapult/dashboard/dashboard/add_point.py 162 dash_entry = { 163 'master': presentation_info['main_name'], # nocheck 164 'bot': 'cros-' + board_name, # Prefix to clarify it's ChromeOS. 165 'point_id': _get_id_from_version(chrome_version, cros_version), 166 'versions': { 167 'cros_version': cros_version, 168 'chrome_version': chrome_version, 169 }, 170 'supplemental': { 171 'default_rev': 'r_cros_version', 172 'hardware_identifier': hardware_id, 173 'hardware_hostname': hardware_hostname, 174 'jobname': jobname, 175 }, 176 'chart_data': perf_values, 177 } 178 return {'data': json.dumps(dash_entry)} 179 180 181def _get_version_numbers(test_attributes): 182 """Gets the version numbers from the test attributes and validates them. 183 184 @param test_attributes: The attributes property (which is a dict) of an 185 autotest tko.models.test object. 186 187 @return A pair of strings (ChromeOS version, Chrome version). 188 189 @raises PerfUploadingError if a version isn't formatted as expected. 190 """ 191 chrome_version = test_attributes.get('CHROME_VERSION', '') 192 cros_version = test_attributes.get('CHROMEOS_RELEASE_VERSION', '') 193 cros_milestone = test_attributes.get('CHROMEOS_RELEASE_CHROME_MILESTONE') 194 # Use the release milestone as the milestone if present, othewise prefix the 195 # cros version with the with the Chrome browser milestone. 196 if cros_milestone: 197 cros_version = "%s.%s" % (cros_milestone, cros_version) 198 else: 199 cros_version = chrome_version[:chrome_version.find('.') + 200 1] + cros_version 201 if not re.match(VERSION_REGEXP, cros_version): 202 raise PerfUploadingError('CrOS version "%s" does not match expected ' 203 'format.' % cros_version) 204 if not re.match(VERSION_REGEXP, chrome_version): 205 raise PerfUploadingError('Chrome version "%s" does not match expected ' 206 'format.' % chrome_version) 207 return (cros_version, chrome_version) 208 209 210def _get_id_from_version(chrome_version, cros_version): 211 """Computes the point ID to use, from Chrome and ChromeOS version numbers. 212 213 For ChromeOS row data, data values are associated with both a Chrome 214 version number and a ChromeOS version number (unlike for Chrome row data 215 that is associated with a single revision number). This function takes 216 both version numbers as input, then computes a single, unique integer ID 217 from them, which serves as a 'fake' revision number that can uniquely 218 identify each ChromeOS data point, and which will allow ChromeOS data points 219 to be sorted by Chrome version number, with ties broken by ChromeOS version 220 number. 221 222 To compute the integer ID, we take the portions of each version number that 223 serve as the shortest unambiguous names for each (as described here: 224 http://www.chromium.org/developers/version-numbers). We then force each 225 component of each portion to be a fixed width (padded by zeros if needed), 226 concatenate all digits together (with those coming from the Chrome version 227 number first), and convert the entire string of digits into an integer. 228 We ensure that the total number of digits does not exceed that which is 229 allowed by AppEngine NDB for an integer (64-bit signed value). 230 231 For example: 232 Chrome version: 27.0.1452.2 (shortest unambiguous name: 1452.2) 233 ChromeOS version: 27.3906.0.0 (shortest unambiguous name: 3906.0.0) 234 concatenated together with padding for fixed-width columns: 235 ('01452' + '002') + ('03906' + '000' + '00') = '014520020390600000' 236 Final integer ID: 14520020390600000 237 238 @param chrome_ver: The Chrome version number as a string. 239 @param cros_ver: The ChromeOS version number as a string. 240 241 @return A unique integer ID associated with the two given version numbers. 242 243 """ 244 245 # Number of digits to use from each part of the version string for Chrome 246 # and ChromeOS versions when building a point ID out of these two versions. 247 chrome_version_col_widths = [0, 0, 5, 3] 248 cros_version_col_widths = [0, 5, 3, 2] 249 250 def get_digits_from_version(version_num, column_widths): 251 if re.match(VERSION_REGEXP, version_num): 252 computed_string = '' 253 version_parts = version_num.split('.') 254 for i, version_part in enumerate(version_parts): 255 if column_widths[i]: 256 computed_string += version_part.zfill(column_widths[i]) 257 return computed_string 258 else: 259 return None 260 261 chrome_digits = get_digits_from_version( 262 chrome_version, chrome_version_col_widths) 263 cros_digits = get_digits_from_version( 264 cros_version, cros_version_col_widths) 265 if not chrome_digits or not cros_digits: 266 return None 267 result_digits = chrome_digits + cros_digits 268 max_digits = sum(chrome_version_col_widths + cros_version_col_widths) 269 if len(result_digits) > max_digits: 270 return None 271 return int(result_digits) 272 273 274def _initialize_oauth(): 275 """Initialize oauth using local credentials and scopes. 276 277 @return A boolean if oauth is apparently ready to use. 278 """ 279 global _OAUTH_CREDS 280 if _OAUTH_CREDS: 281 return True 282 if not _OAUTH_IMPORT_OK: 283 return False 284 try: 285 _OAUTH_CREDS = (service_account.Credentials.from_service_account_file( 286 _SERVICE_ACCOUNT_FILE) 287 .with_scopes(_OAUTH_SCOPES)) 288 return True 289 except Exception as e: 290 tko_utils.dprint('Failed to initialize oauth credentials:\n%s' % e) 291 return False 292 293 294def _add_oauth_token(headers): 295 """Add support for oauth2 via service credentials. 296 297 This is currently best effort, we will silently not add the token 298 for a number of possible reasons (missing service account, etc). 299 300 TODO(engeg@): Once this is validated, make mandatory. 301 302 @param headers: A map of request headers to add the token to. 303 """ 304 if _initialize_oauth(): 305 if not _OAUTH_CREDS.valid: 306 try: 307 _OAUTH_CREDS.refresh(Request()) 308 except RefreshError as e: 309 tko_utils.dprint('Failed to refresh oauth token:\n%s' % e) 310 return 311 _OAUTH_CREDS.apply(headers) 312 313 314def _send_to_dashboard(data_obj): 315 """Sends formatted perf data to the perf dashboard. 316 317 @param data_obj: A formatted data object as returned by 318 _format_for_upload(). 319 320 @raises PerfUploadingError if an exception was raised when uploading. 321 322 """ 323 encoded = urllib.parse.urlencode(data_obj) 324 req = urllib.request.Request(_DASHBOARD_UPLOAD_URL, encoded) 325 _add_oauth_token(req.headers) 326 try: 327 urllib.request.urlopen(req) 328 except urllib.error.HTTPError as e: 329 raise PerfUploadingError('HTTPError: %d %s for JSON %s\n' % ( 330 e.code, e.msg, data_obj['data'])) 331 except urllib.error.URLError as e: 332 raise PerfUploadingError( 333 'URLError: %s for JSON %s\n' % 334 (str(e.reason), data_obj['data'])) 335 except six.moves.http_client.HTTPException: 336 raise PerfUploadingError( 337 'HTTPException for JSON %s\n' % data_obj['data']) 338 339 340def _get_image_board_name(platform, image): 341 """Returns the board name of the tested image. 342 343 Note that it can be different from the board name of DUTs the test was 344 scheduled to. 345 346 @param platform: The DUT platform in lab. eg. eve 347 @param image: The image installed in the DUT. eg. eve-arcnext-release. 348 @return: the image board name. 349 """ 350 # This is a hacky way to resolve the mixture of reports in chromeperf 351 # dashboard. This solution is copied from our other reporting 352 # pipeline. 353 image_board_name = platform 354 355 suffixes = ['-arcnext', '-ndktranslation', '-arcvm', '-kernelnext'] 356 357 for suffix in suffixes: 358 if not platform.endswith(suffix) and (suffix + '-') in image: 359 image_board_name += suffix 360 return image_board_name 361 362 363def upload_test(job, test, jobname): 364 """Uploads any perf data associated with a test to the perf dashboard. 365 366 @param job: An autotest tko.models.job object that is associated with the 367 given |test|. 368 @param test: An autotest tko.models.test object that may or may not be 369 associated with measured perf data. 370 @param jobname: A string uniquely identifying the test run, this enables 371 linking back from a test result to the logs of the test run. 372 373 """ 374 375 # Format the perf data for the upload, then upload it. 376 test_name = test.testname 377 image_board_name = _get_image_board_name( 378 job.machine_group, job.keyval_dict.get('build', job.machine_group)) 379 # Append the platform name with '.arc' if the suffix of the control 380 # filename is '.arc'. 381 if job.label and re.match('.*\.arc$', job.label): 382 image_board_name += '.arc' 383 hardware_id = test.attributes.get('hwid', '') 384 hardware_hostname = test.machine 385 config_data = _parse_config_file(_PRESENTATION_CONFIG_FILE) 386 try: 387 shadow_config_data = _parse_config_file(_PRESENTATION_SHADOW_CONFIG_FILE) 388 config_data.update(shadow_config_data) 389 except ValueError as e: 390 tko_utils.dprint('Failed to parse config file %s: %s.' % 391 (_PRESENTATION_SHADOW_CONFIG_FILE, e)) 392 try: 393 cros_version, chrome_version = _get_version_numbers(test.attributes) 394 presentation_info = _gather_presentation_info(config_data, test_name) 395 formatted_data = _format_for_upload(image_board_name, cros_version, 396 chrome_version, hardware_id, 397 hardware_hostname, test.perf_values, 398 presentation_info, jobname) 399 _send_to_dashboard(formatted_data) 400 except PerfUploadingError as e: 401 tko_utils.dprint('Warning: unable to upload perf data to the perf ' 402 'dashboard for test %s: %s' % (test_name, e)) 403 else: 404 tko_utils.dprint('Successfully uploaded perf data to the perf ' 405 'dashboard for test %s.' % test_name) 406