Files
2026-07-13 13:17:40 +08:00

584 lines
19 KiB
Python

import hashlib
import os
import random
import string
import time
from typing import List, Optional, Tuple
from google.cloud import storage as gcs_storage
from ray_release.alerts.handle import handle_result, require_result
from ray_release.anyscale_util import (
create_cluster_env_from_image,
get_custom_cluster_env_name,
)
from ray_release.buildkite.output import buildkite_group, buildkite_open_last
from ray_release.cloud_util import archive_directory
from ray_release.cluster_manager.cluster_manager import ClusterManager
from ray_release.cluster_manager.minimal import MinimalClusterManager
from ray_release.command_runner.anyscale_job_runner import AnyscaleJobRunner
from ray_release.config import (
DEFAULT_AUTOSUSPEND_MINS,
DEFAULT_BUILD_TIMEOUT,
DEFAULT_CLUSTER_TIMEOUT,
DEFAULT_COMMAND_TIMEOUT,
DEFAULT_WAIT_FOR_NODES_TIMEOUT,
)
from ray_release.exception import (
ClusterEnvCreateError,
CommandError,
CommandTimeout,
PrepareCommandError,
PrepareCommandTimeout,
ReleaseTestConfigError,
ReleaseTestSetupError,
TestCommandError,
TestCommandTimeout,
)
from ray_release.file_manager.job_file_manager import JobFileManager
from ray_release.job_manager.kuberay_job_manager import KubeRayJobManager
from ray_release.kuberay_util import convert_cluster_compute_to_kuberay_compute_config
from ray_release.logger import logger
from ray_release.reporter.reporter import Reporter
from ray_release.result import Result, ResultStatus, update_result_from_exception
from ray_release.signal_handling import (
reset_signal_handling,
setup_signal_handling,
)
from ray_release.template import get_working_dir, load_test_cluster_compute
from ray_release.test import Test
type_str_to_command_runner = {
"anyscale_job": AnyscaleJobRunner,
}
command_runner_to_cluster_manager = {
AnyscaleJobRunner: MinimalClusterManager,
}
DEFAULT_RUN_TYPE = "anyscale_job"
TIMEOUT_BUFFER_MINUTES = 15
def _get_extra_tags_from_env() -> dict:
env_vars = (
"BUILDKITE_JOB_ID",
"BUILDKITE_PULL_REQUEST",
"BUILDKITE_ORGANIZATION_SLUG",
"BUILDKITE_PIPELINE_SLUG",
"BUILDKITE_BUILD_ID",
"BUILDKITE_BUILD_NUMBER",
"BUILDKITE_SOURCE",
"RELEASE_FREQUENCY",
)
return {key.lower(): os.getenv(key, "") for key in env_vars}
def _load_test_configuration(
test: Test,
anyscale_project: str,
result: Result,
smoke_test: bool = False,
) -> Tuple[ClusterManager, AnyscaleJobRunner, str]:
logger.info(f"Test config: {test}")
# Populate result paramaters
result.stable = test.get("stable", True)
result.smoke_test = smoke_test
buildkite_url = os.getenv("BUILDKITE_BUILD_URL", "")
buildkite_job_id = os.getenv("BUILDKITE_JOB_ID", "")
if buildkite_url:
buildkite_url += "#" + buildkite_job_id
result.buildkite_url = buildkite_url
result.buildkite_job_id = buildkite_job_id
# Setting up working directory
working_dir = get_working_dir(test)
os.chdir(working_dir)
run_type = test["run"].get("type", DEFAULT_RUN_TYPE)
command_runner_cls = type_str_to_command_runner.get(run_type)
if not command_runner_cls:
raise ReleaseTestConfigError(
f"Unknown command runner type: {run_type}. Must be one of "
f"{list(type_str_to_command_runner.keys())}"
)
cluster_manager_cls = command_runner_to_cluster_manager[command_runner_cls]
logger.info(f"Got command runner cls: {command_runner_cls}")
# Extra tags to be set on resources on cloud provider's side
extra_tags = _get_extra_tags_from_env()
# We don't need other attributes as they can be derived from the name
extra_tags["test_name"] = str(test["name"])
extra_tags["test_smoke_test"] = str(result.smoke_test)
extra_tags["release_test_team"] = str(test.get("team", ""))
extra_tags["release_test_env"] = str(test.get("env", ""))
result.extra_tags = extra_tags
artifact_path = test["run"].get("artifact_path", None)
# Instantiate managers and command runner
try:
cluster_manager = cluster_manager_cls(
test,
anyscale_project,
smoke_test=smoke_test,
)
command_runner = command_runner_cls(
cluster_manager,
JobFileManager(cluster_manager=cluster_manager),
working_dir,
artifact_path=artifact_path,
)
except Exception as e:
raise ReleaseTestSetupError(f"Error setting up release test: {e}") from e
if not isinstance(command_runner, AnyscaleJobRunner):
raise ReleaseTestSetupError("Command runner is not an AnyscaleJobRunner")
return cluster_manager, command_runner, artifact_path
def _setup_cluster_environment(
test: Test,
result: Result,
cluster_manager: ClusterManager,
cluster_env_id: Optional[str],
test_definition_root: Optional[str] = None,
) -> Tuple[str, int, int, int, int]:
setup_signal_handling()
# Load configs
cluster_compute = load_test_cluster_compute(test, test_definition_root)
if cluster_env_id:
try:
cluster_manager.cluster_env_id = cluster_env_id
cluster_manager.build_cluster_env()
logger.info(
"Using overridden cluster environment with ID "
f"{cluster_env_id} and build ID "
f"{cluster_manager.cluster_env_build_id}"
)
except Exception as e:
raise ClusterEnvCreateError(
f"Could not get existing overridden cluster environment "
f"{cluster_env_id}: {e}"
) from e
else:
cluster_manager.set_cluster_env()
# Load some timeouts
build_timeout = int(test["run"].get("build_timeout", DEFAULT_BUILD_TIMEOUT))
command_timeout = int(test["run"].get("timeout", DEFAULT_COMMAND_TIMEOUT))
cluster_timeout = int(test["run"].get("session_timeout", DEFAULT_CLUSTER_TIMEOUT))
# Get prepare command timeout, if any
prepare_cmd = test["run"].get("prepare", None)
if prepare_cmd:
prepare_timeout = test["run"].get("prepare_timeout", command_timeout)
else:
prepare_timeout = 0
# Base maximum uptime on the combined command and prepare timeouts
command_and_prepare_timeout = command_timeout + prepare_timeout
# Use default timeout = 0 here if wait_for_nodes is empty. This is to make
# sure we don't inflate the maximum_uptime_minutes too much if we don't wait
# for nodes at all.
# The actual default will be otherwise loaded further down.
wait_timeout = int(test["run"].get("wait_for_nodes", {}).get("timeout", 0))
autosuspend_mins = test["cluster"].get("autosuspend_mins", None)
if autosuspend_mins:
cluster_manager.autosuspend_minutes = autosuspend_mins
autosuspend_base = autosuspend_mins
else:
cluster_manager.autosuspend_minutes = min(
DEFAULT_AUTOSUSPEND_MINS,
int(command_and_prepare_timeout / 60) + TIMEOUT_BUFFER_MINUTES,
)
# Maximum uptime should be based on the command timeout, not the
# DEFAULT_AUTOSUSPEND_MINS
autosuspend_base = (
int(command_and_prepare_timeout / 60) + TIMEOUT_BUFFER_MINUTES
)
maximum_uptime_minutes = test["cluster"].get("maximum_uptime_minutes", None)
if maximum_uptime_minutes:
cluster_manager.maximum_uptime_minutes = maximum_uptime_minutes
else:
cluster_manager.maximum_uptime_minutes = (
autosuspend_base + wait_timeout + TIMEOUT_BUFFER_MINUTES
)
# Set cluster compute here. Note that this may use timeouts provided
# above.
cluster_manager.set_cluster_compute(
cluster_compute,
extra_tags=result.extra_tags,
)
return prepare_cmd, prepare_timeout, build_timeout, cluster_timeout, command_timeout
def _build_local_environment_information(
cluster_manager: ClusterManager,
runner: AnyscaleJobRunner,
build_timeout: int,
cluster_timeout: int,
cluster_env_id: Optional[str],
) -> None:
# Start cluster
buildkite_group(":gear: Building cluster environment")
cluster_manager.cluster_env_id = cluster_env_id
cluster_manager.build_configs(timeout=build_timeout)
runner.job_manager.cluster_startup_timeout = cluster_timeout
def _prepare_remote_environment(
test: Test,
runner: AnyscaleJobRunner,
prepare_cmd: bool,
prepare_timeout: int,
) -> None:
runner.prepare_remote_env()
wait_for_nodes = test["run"].get("wait_for_nodes", None)
if wait_for_nodes:
buildkite_group(":stopwatch: Waiting for nodes to come up")
# Overwrite wait_timeout from above to account for better default
wait_timeout = int(
wait_for_nodes.get("timeout", DEFAULT_WAIT_FOR_NODES_TIMEOUT)
)
num_nodes = test["run"]["wait_for_nodes"]["num_nodes"]
runner.wait_for_nodes(num_nodes, wait_timeout)
if prepare_cmd:
try:
runner.run_prepare_command(prepare_cmd, timeout=prepare_timeout)
except CommandError as e:
raise PrepareCommandError(e)
except CommandTimeout as e:
raise PrepareCommandTimeout(e)
def _upload_working_dir_to_gcs(working_dir: str) -> str:
"""Upload working directory to GCS bucket.
Args:
working_dir: Path to directory to upload.
Returns:
GCS path where directory was uploaded.
"""
# Create archive of working dir
logger.info(f"Archiving working directory: {working_dir}")
archived_file_path = archive_directory(working_dir)
archived_filename = os.path.basename(archived_file_path)
# Upload to GCS
gcs_client = gcs_storage.Client()
bucket = gcs_client.bucket("ray-release-working-dir")
blob = bucket.blob(archived_filename)
blob.upload_from_filename(archived_filename)
return f"gs://ray-release-working-dir/{blob.name}"
def _running_test_script(
test: Test,
smoke_test: bool,
runner: AnyscaleJobRunner,
command_timeout: int,
) -> None:
command = test["run"]["script"]
command_env = test.get_byod_runtime_env()
if smoke_test:
command = f"{command} --smoke-test"
command_env["IS_SMOKE_TEST"] = "1"
is_long_running = test["run"].get("long_running", False)
try:
runner.run_command(
command,
env=command_env,
timeout=command_timeout,
raise_on_timeout=not is_long_running,
)
except (
TestCommandError,
PrepareCommandError,
TestCommandTimeout,
PrepareCommandTimeout,
) as e:
raise e
except CommandError as e:
raise TestCommandError(e)
except CommandTimeout as e:
if not is_long_running:
# Only raise error if command is not long running
raise TestCommandTimeout(e)
def _fetching_results(
result: Result,
runner: AnyscaleJobRunner,
artifact_path: Optional[str],
smoke_test: bool,
start_time_unix: int,
) -> Tuple[dict, Exception]:
fetch_result_exception = None
try:
command_results = runner.fetch_results()
except Exception as e:
logger.exception(f"Could not fetch results for test command: {e}")
command_results = {}
fetch_result_exception = e
if artifact_path:
try:
runner.fetch_artifact()
except Exception as e:
logger.error("Could not fetch artifact for test command")
logger.exception(e)
# Postprocess result:
if "last_update" in command_results:
command_results["last_update_diff"] = time.time() - command_results.get(
"last_update", 0.0
)
try:
metrics = runner.fetch_metrics()
except Exception as e:
logger.exception(f"Could not fetch metrics for test command: {e}")
metrics = {}
if smoke_test:
command_results["smoke_test"] = True
result.results = command_results
result.status = ResultStatus.SUCCESS.value
return metrics, fetch_result_exception
def run_release_test(
test: Test,
result: Result,
anyscale_project: Optional[str] = None,
reporters: Optional[List[Reporter]] = None,
smoke_test: bool = False,
test_definition_root: Optional[str] = None,
image: Optional[str] = None,
) -> Result:
if test.is_kuberay():
return run_release_test_kuberay(
test=test,
result=result,
smoke_test=smoke_test,
test_definition_root=test_definition_root,
)
return run_release_test_anyscale(
test=test,
anyscale_project=anyscale_project,
result=result,
reporters=reporters,
smoke_test=smoke_test,
test_definition_root=test_definition_root,
image=image,
)
def run_release_test_kuberay(
test: Test,
result: Result,
smoke_test: bool = False,
test_definition_root: Optional[str] = None,
) -> Result:
start_time = time.monotonic()
pipeline_exception = None
try:
result.stable = test.get("stable", True)
result.smoke_test = smoke_test
cluster_compute = load_test_cluster_compute(test, test_definition_root)
kuberay_compute_config = convert_cluster_compute_to_kuberay_compute_config(
cluster_compute
)
kuberay_autoscaler_version = cluster_compute.get("autoscaler_version", None)
if kuberay_autoscaler_version:
kuberay_autoscaler_config = {"version": kuberay_autoscaler_version}
else:
kuberay_autoscaler_config = None
working_dir_upload_path = _upload_working_dir_to_gcs(get_working_dir(test))
command_timeout = int(test["run"].get("timeout", DEFAULT_COMMAND_TIMEOUT))
test_name_hash = hashlib.sha256(test["name"].encode()).hexdigest()[:10]
# random 8 digit suffix
random_suffix = "".join(random.choices(string.digits, k=8))
base_job_name = f"{test['name'][:20]}-{test_name_hash}-{random_suffix}"
job_name = base_job_name.replace("_", "-")
logger.info(f"Job name: {job_name}")
kuberay_job_manager = KubeRayJobManager()
retcode, duration = kuberay_job_manager.run_and_wait(
job_name=job_name,
image=test.get_anyscale_byod_image(),
cmd_to_run=test["run"]["script"],
env_vars=test.get_byod_runtime_env(),
working_dir=working_dir_upload_path,
compute_config=kuberay_compute_config,
autoscaler_config=kuberay_autoscaler_config,
timeout=command_timeout,
)
result.return_code = retcode
result.runtime = duration
except Exception as e:
logger.info(f"Exception: {e}")
pipeline_exception = e
result.runtime = time.monotonic() - start_time
if pipeline_exception:
buildkite_group(":rotating_light: Handling errors")
update_result_from_exception(result, pipeline_exception)
raise pipeline_exception
return result
def run_release_test_anyscale(
test: Test,
anyscale_project: str,
result: Result,
reporters: Optional[List[Reporter]] = None,
smoke_test: bool = False,
test_definition_root: Optional[str] = None,
image: Optional[str] = None,
) -> Result:
old_wd = os.getcwd()
start_time = time.monotonic()
runner = None
cluster_manager = None
pipeline_exception = None
# non critical for some tests. So separate it from the general one.
fetch_result_exception = None
try:
buildkite_group(":spiral_note_pad: Loading test configuration")
cluster_manager, runner, artifact_path = _load_test_configuration(
test,
anyscale_project,
result,
smoke_test,
)
buildkite_group(":nut_and_bolt: Setting up cluster environment")
cluster_env_id = None
# If image is provided, create/reuse a custom cluster environment
if image:
cluster_env_id = create_cluster_env_from_image(
image, test.get_name(), test.get_byod_runtime_env()
)
cluster_manager.cluster_env_name = get_custom_cluster_env_name(
image, test.get_name()
)
(
prepare_cmd,
prepare_timeout,
build_timeout,
cluster_timeout,
command_timeout,
) = _setup_cluster_environment(
test,
result,
cluster_manager,
cluster_env_id,
test_definition_root,
)
buildkite_group(":bulb: Building local environment information")
_build_local_environment_information(
cluster_manager,
runner,
build_timeout,
cluster_timeout,
cluster_env_id,
)
# Upload files
buildkite_group(":wrench: Preparing remote environment")
_prepare_remote_environment(
test,
runner,
prepare_cmd,
prepare_timeout,
)
buildkite_group(":runner: Running test script")
start_time_unix = time.time()
_running_test_script(
test,
smoke_test,
runner,
command_timeout,
)
buildkite_group(":floppy_disk: Fetching results")
metrics, fetch_result_exception = _fetching_results(
result,
runner,
artifact_path,
smoke_test,
start_time_unix,
)
except Exception as e:
logger.exception(e)
buildkite_open_last()
pipeline_exception = e
metrics = {}
# Obtain the cluster info again as it is set after the
# command was run in case of anyscale jobs
if runner:
result.job_url = runner.job_url()
result.job_id = runner.job_id()
if result.job_id:
result.last_logs = runner.get_last_logs()
reset_signal_handling()
time_taken = time.monotonic() - start_time
result.runtime = time_taken
result.prometheus_metrics = metrics
os.chdir(old_wd)
if not pipeline_exception:
if require_result(test) and fetch_result_exception:
pipeline_exception = fetch_result_exception
else:
buildkite_group(":mag: Interpreting results")
# Only handle results if we didn't run into issues earlier
try:
handle_result(test, result)
except Exception as e:
pipeline_exception = e
if pipeline_exception:
buildkite_group(":rotating_light: Handling errors")
update_result_from_exception(result, pipeline_exception, with_last_logs=True)
buildkite_group(":memo: Reporting results", open=True)
for reporter in reporters or []:
reporter.report_result(test, result)
if pipeline_exception:
raise pipeline_exception
return result