| # Copyright 2023 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import json |
| import logging |
| import os |
| import pathlib |
| import re |
| import stat |
| import subprocess |
| import time |
| import types |
| from importlib.resources import as_file, files |
| from typing import Any, Iterable, Self |
| |
| from perf_publish import data, metrics_allowlist, summarize |
| |
| _LOGGER: logging.Logger = logging.getLogger(__name__) |
| |
| # The 'test_suite' field should be all lower case. It should start with 'fuchsia.', to distinguish |
| # Fuchsia test results from results from other projects that upload to Catapult (Chromeperf), |
| # because the namespace is shared between projects and Catapult does not enforce any separation |
| # between projects. |
| _TEST_SUITE_REGEX: re.Pattern[str] = re.compile( |
| r"^fuchsia\.([a-z0-9_-]+\.)*[a-z0-9_-]+$" |
| ) |
| |
| # The regexp for the 'label' field is fairly permissive. This reflects what is currently generated |
| # by tests. |
| _LABEL_REGEX: re.Pattern[str] = re.compile(r"^[A-Za-z0-9_/.:=+<>\\ -]+$") |
| |
| _FUCHSIA_PERF_EXT: str = ".fuchsiaperf.json" |
| _FUCHSIA_PERF_FULL_EXT: str = ".fuchsiaperf_full.json" |
| _CATAPULT_UPLOAD_ENABLED_EXT: str = ".catapult_json" |
| _CATAPULT_UPLOAD_DISABLED_EXT: str = ".catapult_json_disabled" |
| _SUMMARIZED_RESULTS_FILE: str = f"results{_FUCHSIA_PERF_EXT}" |
| |
| ENV_CATAPULT_DASHBOARD_MASTER: str = "CATAPULT_DASHBOARD_MASTER" |
| ENV_CATAPULT_DASHBOARD_BOT: str = "CATAPULT_DASHBOARD_BOT" |
| ENV_BUILDBUCKET_ID: str = "BUILDBUCKET_ID" |
| ENV_BUILD_CREATE_TIME: str = "BUILD_CREATE_TIME" |
| ENV_RELEASE_VERSION: str = "RELEASE_VERSION" |
| ENV_FUCHSIA_EXPECTED_METRIC_NAMES_DEST_DIR: str = ( |
| "FUCHSIA_EXPECTED_METRIC_NAMES_DEST_DIR" |
| ) |
| ENV_INTEGRATION_INTERNAL_GIT_COMMIT: str = "INTEGRATION_INTERNAL_GIT_COMMIT" |
| ENV_INTEGRATION_PUBLIC_GIT_COMMIT: str = "INTEGRATION_PUBLIC_GIT_COMMIT" |
| ENV_SMART_INTEGRATION_GIT_COMMIT: str = "SMART_INTEGRATION_GIT_COMMIT" |
| |
| |
| def publish_fuchsiaperf( |
| fuchsia_perf_file_paths: Iterable[str | os.PathLike[str]], |
| expected_metric_names_filename: str | os.PathLike[str], |
| test_data_module: types.ModuleType | None = None, |
| env: dict[str, str] = dict(os.environ), |
| runtime_deps_dir: str | os.PathLike[str] | None = None, |
| ) -> None: |
| """Publishes the given metrics. |
| |
| Args: |
| fuchsia_perf_file_paths: paths to the fuchsiaperf.json files containing the metrics. These |
| will be summarized into a single fuchsiaperf.json file. |
| expected_metric_names_filename: file name or path to file containing |
| expected metric names to validate the actual metrics against. |
| test_data_module: Python module containing the expected metric names file as a data file. |
| env: map holding the environment variables. |
| runtime_deps_dir: directory in which to look for necessary dependencies such as the expected |
| metric names file, catapult converter, etc. Defaults to the test runtime_deps dir. |
| """ |
| converter = CatapultConverter.from_env( |
| fuchsia_perf_file_paths, |
| expected_metric_names_filename, |
| test_data_module=test_data_module, |
| env=env, |
| runtime_deps_dir=runtime_deps_dir, |
| ) |
| converter.run() |
| |
| |
| class CatapultConverter: |
| def __init__( |
| self, |
| fuchsia_perf_file_paths: Iterable[str | os.PathLike[str]], |
| expected_metric_names_filename: str | os.PathLike[str], |
| test_data_module: types.ModuleType | None = None, |
| master: str | None = None, |
| bot: str | None = None, |
| build_bucket_id: str | None = None, |
| build_create_time: str | None = None, |
| release_version: str | None = None, |
| integration_internal_git_commit: str | None = None, |
| integration_public_git_commit: str | None = None, |
| smart_integration_git_commit: str | None = None, |
| fuchsia_expected_metric_names_dest_dir: str | None = None, |
| current_time: int | None = None, |
| subprocess_check_call: Any = subprocess.check_call, |
| runtime_deps_dir: str | os.PathLike[str] | None = None, |
| ): |
| """Creates a new catapult converter. |
| |
| Args: |
| fuchsia_perf_file_paths: |
| Paths to the fuchsiaperf.json files containing the metrics. |
| These will be summarized into a single fuchsiaperf.json file. |
| |
| expected_metric_names_filename: |
| File name or path to file containing expected metric names to |
| validate the actual metrics against. |
| |
| test_data_module: |
| Python module containing the expected metric names file as |
| a data file. This should be created by the build system |
| using the data_packages mechanism. |
| |
| integration_internal_git_commit: |
| The internal integration.git revision which produced these data |
| |
| integration_public_git_commit: |
| The public integration.git revision which produced these data |
| |
| smart_integration_git_commit: |
| The smart-integration.git revision which produced these data |
| |
| fuchsia_expected_metric_names_dest_dir: |
| Directory to which expected metrics are written. |
| |
| current_time: |
| The current time, useful for testing. Defaults to time.time. |
| |
| subprocess_check_call: |
| Allows to execute a process raising an exception on error. |
| Useful for testing. Defaults to subprocess.check_call. |
| |
| runtime_deps_dir: |
| Directory in which to look for necessary dependencies such as |
| the expected metric names file, catapult converter, etc. |
| Defaults to the test runtime_deps dir. |
| |
| See //src/testing/catapult_converter/README.md for the rest of args. |
| """ |
| self._release_version = release_version |
| self._integration_internal_git_commit = integration_internal_git_commit |
| self._integration_public_git_commit = integration_public_git_commit |
| self._smart_integration_git_commit = smart_integration_git_commit |
| self._subprocess_check_call = subprocess_check_call |
| self._fuchsia_expected_metric_names_dest_dir = ( |
| fuchsia_expected_metric_names_dest_dir |
| ) |
| if runtime_deps_dir: |
| self._runtime_deps_dir = runtime_deps_dir |
| else: |
| self._runtime_deps_dir = get_associated_runtime_deps_dir(__file__) |
| |
| self._upload_enabled: bool = True |
| if master is None and bot is None: |
| _LOGGER.info( |
| "CatapultConverter: Infra env vars are not set; treating as a local run." |
| ) |
| self._bot: str = "local-bot" |
| self._master: str = "local-master" |
| self._log_url: str = "http://ci.example.com/build/300" |
| self._timestamp: int = ( |
| int(current_time if current_time else time.time()) * 1000 |
| ) |
| # Disable uploading so that we don't accidentally upload with the placeholder values |
| # set here. |
| self._upload_enabled = False |
| elif ( |
| master is not None |
| and bot is not None |
| and build_bucket_id is not None |
| and build_create_time is not None |
| ): |
| self._bot = bot |
| self._master = master |
| self._log_url = f"https://ci.chromium.org/b/{build_bucket_id}" |
| self._timestamp = int(build_create_time) |
| else: |
| raise ValueError( |
| "Catapult-related infra env vars are not set consistently" |
| ) |
| |
| # These data may be produced from either the public integration, or smart integration, but |
| # not both. |
| if ( |
| integration_public_git_commit is not None |
| and smart_integration_git_commit is not None |
| ): |
| raise ValueError( |
| "Data should be optionally produced from either public " |
| "integration or smart integration, but not both" |
| ) |
| |
| fuchsia_perf_file_paths = self._check_extension_and_relocate( |
| fuchsia_perf_file_paths |
| ) |
| |
| _LOGGER.debug("Checking metrics naming") |
| should_summarize: bool = self._check_fuchsia_perf_metrics_naming( |
| expected_metric_names_filename, |
| fuchsia_perf_file_paths, |
| test_data_module=test_data_module, |
| runtime_deps_dir=self._runtime_deps_dir, |
| ) |
| |
| self._results_path = os.path.join( |
| os.path.dirname(fuchsia_perf_file_paths[0]), |
| _SUMMARIZED_RESULTS_FILE, |
| ) |
| if should_summarize: |
| results = summarize.summarize_perf_files(fuchsia_perf_file_paths) |
| assert not os.path.exists(self._results_path) |
| with open(self._results_path, "w") as f: |
| summarize.write_fuchsiaperf_json(f, results) |
| else: |
| if len(fuchsia_perf_file_paths) > 1: |
| raise ValueError("Expected a single file when not summarizing") |
| os.rename(fuchsia_perf_file_paths[0], self._results_path) |
| |
| catapult_extension = ( |
| _CATAPULT_UPLOAD_ENABLED_EXT |
| if self._upload_enabled |
| else _CATAPULT_UPLOAD_DISABLED_EXT |
| ) |
| self._output_file: str = ( |
| self._results_path.removesuffix(_FUCHSIA_PERF_EXT) |
| + catapult_extension |
| ) |
| |
| def _check_extension_and_relocate( |
| self, fuchsia_perf_file_paths: Iterable[str | os.PathLike[str]] |
| ) -> list[str]: |
| perf_file_paths = list(map(str, fuchsia_perf_file_paths)) |
| if len(perf_file_paths) == 0: |
| raise ValueError("Expected at least one fuchsiaperf.json file") |
| files_with_wrong_ext = [] |
| files_to_rename = [] |
| paths = [] |
| |
| for p in perf_file_paths: |
| if p.endswith(_FUCHSIA_PERF_EXT): |
| files_to_rename.append(p) |
| elif p.endswith(_FUCHSIA_PERF_FULL_EXT): |
| paths.append(p) |
| else: |
| files_with_wrong_ext.append(p) |
| |
| if files_with_wrong_ext: |
| raise ValueError( |
| f"The following files must end with {_FUCHSIA_PERF_FULL_EXT} or {_FUCHSIA_PERF_EXT}:" |
| "\n- {}\n".format("\n- ".join(files_with_wrong_ext)) |
| ) |
| |
| for file in files_to_rename: |
| file_without_suffix = file.removesuffix(_FUCHSIA_PERF_EXT) |
| new_file = f"{file_without_suffix}{_FUCHSIA_PERF_FULL_EXT}" |
| assert not os.path.exists(new_file) |
| os.rename(file, new_file) |
| paths.append(new_file) |
| |
| return paths |
| |
| @classmethod |
| def from_env( |
| cls, |
| fuchsia_perf_file_paths: Iterable[str | os.PathLike[str]], |
| expected_metric_names_filename: str | os.PathLike[str], |
| test_data_module: types.ModuleType | None = None, |
| env: dict[str, str] = dict(os.environ), |
| runtime_deps_dir: str | os.PathLike[str] | None = None, |
| current_time: int | None = None, |
| subprocess_check_call: Any = subprocess.check_call, |
| ) -> Self: |
| """Creates a new catapult converter using the environment variables. |
| |
| Args: |
| fuchsia_perf_file_paths: paths to the fuchsiaperf.json files containing the metrics. |
| expected_metric_names_filename: file name or path to file containing expected metric names to |
| validate the actual metrics against. |
| env: map holding the environment variables. |
| test_data_module: Python module containing the expected metric names file as a data file. |
| current_time: the current time, useful for testing. Defaults to time.time. |
| runtime_deps_dir: directory in which to look for necessary dependencies such as the expected |
| metric names file, catapult converter, etc. Defaults to the test runtime_deps dir. |
| subprocess_check_call: allows to execute a process raising an exception on error. |
| Useful for testing. Defaults to subprocess.check_call. |
| """ |
| return cls( |
| fuchsia_perf_file_paths, |
| expected_metric_names_filename, |
| test_data_module=test_data_module, |
| master=env.get(ENV_CATAPULT_DASHBOARD_MASTER), |
| bot=env.get(ENV_CATAPULT_DASHBOARD_BOT), |
| build_bucket_id=env.get(ENV_BUILDBUCKET_ID), |
| build_create_time=env.get(ENV_BUILD_CREATE_TIME), |
| release_version=env.get(ENV_RELEASE_VERSION), |
| integration_internal_git_commit=env.get( |
| ENV_INTEGRATION_INTERNAL_GIT_COMMIT |
| ), |
| integration_public_git_commit=env.get( |
| ENV_INTEGRATION_PUBLIC_GIT_COMMIT |
| ), |
| smart_integration_git_commit=env.get( |
| ENV_SMART_INTEGRATION_GIT_COMMIT |
| ), |
| fuchsia_expected_metric_names_dest_dir=env.get( |
| ENV_FUCHSIA_EXPECTED_METRIC_NAMES_DEST_DIR |
| ), |
| runtime_deps_dir=runtime_deps_dir, |
| current_time=current_time, |
| subprocess_check_call=subprocess_check_call, |
| ) |
| |
| def run(self) -> None: |
| """Publishes the given metrics.""" |
| with as_file(files(data).joinpath("catapult_converter")) as f: |
| f.chmod(f.stat().st_mode | stat.S_IEXEC) |
| args = self._args() |
| _LOGGER.info(f'Performance: Running {f} {" ".join(args)}') |
| self._subprocess_check_call([str(f)] + args) |
| _LOGGER.info( |
| f"Conversion to catapult results format completed. Output file: {self._output_file}" |
| ) |
| |
| def _check_fuchsia_perf_metrics_naming( |
| self, |
| expected_metric_names_file: str | os.PathLike[str], |
| input_files: list[str], |
| test_data_module: types.ModuleType | None, |
| runtime_deps_dir: str | os.PathLike[str], |
| ) -> bool: |
| metrics = self._extract_perf_file_metrics(input_files) |
| if self._fuchsia_expected_metric_names_dest_dir is None: |
| # TODO(b/340319757): Remove this conditional and make the case |
| # where test_data_module is passed the only supported case. |
| # That can be done after all tests have been changed to use |
| # this case. |
| if test_data_module: |
| assert isinstance(expected_metric_names_file, str) |
| with as_file( |
| files(test_data_module).joinpath(expected_metric_names_file) |
| ) as filepath: |
| metric_allowlist = metrics_allowlist.MetricsAllowlist( |
| filepath |
| ) |
| else: |
| metric_allowlist = metrics_allowlist.MetricsAllowlist( |
| os.path.join(runtime_deps_dir, expected_metric_names_file) |
| ) |
| metric_allowlist.check(metrics) |
| return metric_allowlist.should_summarize |
| else: |
| self._write_expectation_file( |
| metrics, |
| expected_metric_names_file, |
| self._fuchsia_expected_metric_names_dest_dir, |
| ) |
| return True |
| |
| def _extract_perf_file_metrics(self, input_files: list[str]) -> set[str]: |
| entries: set[str] = set() |
| for input_file in input_files: |
| with open(input_file) as f: |
| json_data: str = json.load(f) |
| |
| if not isinstance(json_data, list): |
| raise ValueError("Top level fuchsiaperf node should be a list") |
| |
| errors: list[str] = [] |
| for entry in json_data: |
| if not isinstance(entry, dict): |
| raise ValueError( |
| "Expected entries in fuchsiaperf list to be objects" |
| ) |
| if "test_suite" not in entry: |
| raise ValueError( |
| 'Expected key "test_suite" in fuchsiaperf entry' |
| ) |
| if "label" not in entry: |
| raise ValueError( |
| 'Expected key "label" in fuchsiaperf entry' |
| ) |
| |
| test_suite: str = entry["test_suite"] |
| if not re.match(_TEST_SUITE_REGEX, test_suite): |
| errors.append( |
| f'test_suite field "{test_suite}" does not match the pattern ' |
| f'"{_TEST_SUITE_REGEX}"' |
| ) |
| continue |
| |
| label: str = entry["label"] |
| if not re.match(_LABEL_REGEX, label): |
| errors.append( |
| f'test_suite field {label} does not match the pattern "{_LABEL_REGEX}"' |
| ) |
| continue |
| |
| entries.add(f"{test_suite}: {label}") |
| if errors: |
| errors_string = "\n".join(errors) |
| raise ValueError( |
| "Some performance test metrics don't follow the naming conventions:\n" |
| f"{errors_string}" |
| ) |
| return entries |
| |
| def _write_expectation_file( |
| self, |
| metrics: set[str], |
| expected_metric_names_filename: str | os.PathLike[str], |
| fuchsia_expected_metric_names_dest_dir: str, |
| ) -> None: |
| dest_file: str = os.path.join( |
| fuchsia_expected_metric_names_dest_dir, |
| os.path.basename(expected_metric_names_filename), |
| ) |
| with open(dest_file, "w") as f: |
| for metric in sorted(metrics): |
| f.write(f"{metric}\n") |
| |
| def _args(self) -> list[str]: |
| args: list[str] = [ |
| "--input", |
| str(self._results_path), |
| "--output", |
| self._output_file, |
| "--execution-timestamp-ms", |
| str(self._timestamp), |
| "--masters", |
| self._master, |
| "--log-url", |
| self._log_url, |
| "--bots", |
| self._bot, |
| ] |
| |
| if self._release_version is not None: |
| args += ["--product-versions", self._release_version] |
| |
| if self._integration_internal_git_commit is not None: |
| args += [ |
| "--integration-internal-git-commit", |
| self._integration_internal_git_commit, |
| ] |
| |
| if self._integration_public_git_commit is not None: |
| args += [ |
| "--integration-public-git-commit", |
| self._integration_public_git_commit, |
| ] |
| |
| if self._smart_integration_git_commit is not None: |
| args += [ |
| "--smart-integration-git-commit", |
| self._smart_integration_git_commit, |
| ] |
| |
| return args |
| |
| |
| def get_associated_runtime_deps_dir( |
| search_dir: str | os.PathLike[str], |
| ) -> os.PathLike[str]: |
| """Return the directory that contains runtime dependencies. |
| |
| Args: |
| search_dir: Absolute path to directory where runtime_deps dir is an |
| ancestor of. |
| |
| Returns: Path to runtime_deps directory |
| """ |
| cur_path: str = os.path.dirname(search_dir) |
| while not os.path.isdir(os.path.join(cur_path, "runtime_deps")): |
| cur_path = os.path.dirname(cur_path) |
| if cur_path == "/": |
| raise ValueError("Couldn't find required runtime_deps directory") |
| return pathlib.Path(cur_path) / "runtime_deps" |