| # Copyright 2021 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Uploads Wpt test results from Chromium to wpt.fyi.""" |
| |
| import argparse |
| import base64 |
| import gzip |
| import json |
| import logging |
| import os |
| import requests |
| import six |
| import tempfile |
| |
| from blinkpy.common.net.rpc import BuildbucketClient |
| from blinkpy.common.system.log_utils import configure_logging |
| |
| _log = logging.getLogger(__name__) |
| |
| |
| class WptReportUploader(object): |
| def __init__(self, host): |
| self._host = host |
| self._bb_client = BuildbucketClient.from_host(host) |
| self.options = None |
| self._dry_run = False |
| configure_logging(logging_level=logging.INFO, include_time=True) |
| |
| def main(self, argv=None): |
| """Pull wpt_report.json from latest CI runs, merge the reports and |
| upload that to wpt.fyi. |
| |
| Returns: |
| A boolean: True if success, False if there were any failures. |
| """ |
| self.options = self.parse_args(argv) |
| if self.options.verbose: |
| configure_logging(logging_level=logging.DEBUG, include_time=True) |
| self._dry_run = self.options.dry_run |
| |
| rv = 0 |
| |
| builders = [ |
| ("chromium", "ci", "android-webview-pie-x86-wpt-fyi-rel"), |
| ("chromium", "ci", "android-chrome-pie-x86-wpt-fyi-rel"), |
| ] |
| for builder in builders: |
| reports = [] |
| _log.info("Uploading report for %s" % builder[2]) |
| build = self.fetch_latest_complete_build(*builder) |
| if build: |
| _log.info("Find latest completed build %d" % build.get("number")) |
| # pylint: disable=unsubscriptable-object |
| urls = self._host.results_fetcher.fetch_wpt_report_urls( |
| build["id"]) |
| for url in urls: |
| _log.info("Fetching wpt report from %s" % url) |
| body = self._host.web.get_binary(url, |
| return_none_on_404=True) |
| if not body: |
| _log.error("Failed to fetch wpt report.") |
| continue |
| # Ignore retry results on subsequent lines. |
| initial_report, _, _ = body.partition(b'\n') |
| reports.append(json.loads(initial_report)) |
| merged_report = self.merge_reports(reports) |
| if merged_report is None: |
| _log.error("No result to upload, skip...") |
| else: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| path = os.path.join(tmpdir, "reports.json.gz") |
| with gzip.open(path, 'wt', encoding="utf-8") as zipfile: |
| json.dump(merged_report, zipfile) |
| rv = rv | self.upload_report(path) |
| _log.info(" ") |
| |
| return rv |
| |
| def fetch_latest_complete_build(self, project, bucket, builder_name): |
| """Gets latest successful build from a CI builder. |
| |
| This uses the SearchBuilds RPC format specified in: |
| https://cs.chromium.org/chromium/infra/go/src/go.chromium.org/luci/buildbucket/proto/builder_service.proto |
| |
| The 'builds' field of the response is a list of dicts of the following |
| form: |
| [ |
| { |
| "id": "8828280326907235505", |
| "builder": { |
| "builder": "android-webview-pie-x86-wpt-fyi-rel" |
| }, |
| "status": "SUCCESS" |
| }, |
| ... more builds, |
| ] |
| |
| This method returns the latest finished build. |
| """ |
| predicate = { |
| "builder": { |
| "project": project, |
| "bucket": bucket, |
| "builder": builder_name, |
| }, |
| "status": "SUCCESS", |
| } |
| builds = self._bb_client.search_builds( |
| predicate, ['builder.builder', 'number', 'status', 'id'], count=10) |
| return builds[0] if builds else None |
| |
| def get_password(self): |
| from google.cloud import kms |
| import crcmod |
| |
| def crc32c(data): |
| crc32c_fun = crcmod.predefined.mkPredefinedCrcFun('crc-32c') |
| return crc32c_fun(six.ensure_binary(data)) |
| |
| project_id = 'blink-kms' |
| location_id = 'global' |
| key_ring_id = 'chrome-official' |
| key_id = 'autoroller_key' |
| key_data = (b'CiQAcoZ22AXJttAoPI544QvH4C1jSnvVpe/XN+43vZan/RdbSmcSYyph' |
| b'ChQKDLy9d1hq3L5Vr0veUBDI7oTJDBIvCifABA4GBbd+dfwbhbFAuQ5R' |
| b'XZhu4Bl036JRYMtYZNrE4evBBMsO94YQ1qGnkggaGAoQ0eZ5gffcfN+M' |
| b'YBfWzGxvtxDy6KSYBw==') |
| ciphertext = base64.b64decode(key_data) |
| ciphertext_crc32c = crc32c(ciphertext) |
| client = kms.KeyManagementServiceClient() |
| key_name = client.crypto_key_path(project_id, location_id, key_ring_id, key_id) |
| decrypt_response = client.decrypt( |
| request={'name': key_name, 'ciphertext': ciphertext, 'ciphertext_crc32c': ciphertext_crc32c}) |
| if not decrypt_response.plaintext_crc32c == crc32c(decrypt_response.plaintext): |
| raise Exception('The response received from the server was corrupted in-transit.') |
| return decrypt_response.plaintext.decode('utf-8') |
| |
| def upload_report(self, path_to_report): |
| """Upload the wpt report to wpt.fyi |
| |
| The Api is defined at: |
| https://github.com/web-platform-tests/wpt.fyi/tree/main/api#results-creation |
| """ |
| username = "chromium-ci-results-uploader" |
| fqdn = "wpt.fyi" |
| url = "https://%s/api/results/upload" % fqdn |
| |
| with open(path_to_report, 'rb') as fp: |
| params = {'labels': 'master'} |
| files = {'result_file': fp} |
| if self._dry_run: |
| _log.info("Dry run, no report uploaded.") |
| return 0 |
| session = requests.Session() |
| password = self.get_password() |
| session.auth = (username, password) |
| res = session.post(url=url, params=params, files=files) |
| if res.status_code == 200: |
| _log.info("Successfully uploaded wpt report with response: " + res.text.strip()) |
| report_id = res.text.split()[1] |
| _log.info("Report uploaded to https://%s/results?run_id=%s" % (fqdn, report_id)) |
| return 0 |
| else: |
| _log.error("Upload wpt report failed with status code: %d", res.status_code) |
| return 1 |
| |
| def merge_reports(self, reports): |
| if not reports: |
| return None |
| |
| merged_report = {} |
| merged_report['run_info'] = reports[0]['run_info'] |
| merged_report['time_start'] = reports[0]['time_start'] |
| merged_report['results'] = [] |
| merged_report['time_end'] = reports[0]['time_end'] |
| for report in reports: |
| merged_report['time_start'] = min(merged_report['time_start'], |
| report['time_start']) |
| merged_report['results'].extend(report['results']) |
| merged_report['time_end'] = max(merged_report['time_end'], |
| report['time_end']) |
| if not merged_report['results']: |
| return None |
| return merged_report |
| |
| def parse_args(self, argv): |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| '-v', |
| '--verbose', |
| action='store_true', |
| help='log extra details that may be helpful when debugging') |
| parser.add_argument( |
| '--dry-run', |
| action='store_true', |
| help='See what would be done without actually uploading any report.') |
| parser.add_argument( |
| '--credentials-json', |
| help='A JSON file with wpt.fyi credentials') |
| return parser.parse_args(argv) |