xref: /aosp_15_r20/external/autotest/tko/perf_upload/perf_uploader.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
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