blob: 35fe12b90ceb9f53ffce4c9a155d89b987c67554 [file] [log] [blame]
# 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"