chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,14 @@
import json
import sys
def main():
argv_file = sys.argv[1]
with open(argv_file, "wt") as fp:
json.dump(sys.argv, fp)
sys.exit(0)
if __name__ == "__main__":
main()
@@ -0,0 +1,43 @@
import os
import sys
import click
@click.command()
@click.argument("state_file", type=str)
@click.argument("exit_1", type=int)
@click.argument("exit_2", type=int)
@click.argument("exit_3", type=int)
def main(
state_file: str,
exit_1: int,
exit_2: int,
exit_3: int,
):
if not os.path.exists(state_file):
state = 0
else:
with open(state_file, "rt") as fp:
state = int(fp.read())
state += 1
with open(state_file, "wt") as fp:
fp.write(str(state))
if state == 1:
print(f"Exiting with status: {exit_1}")
sys.exit(exit_1)
if state == 2:
print(f"Exiting with status: {exit_2}")
sys.exit(exit_2)
if state == 3:
print(f"Exiting with status: {exit_3}")
sys.exit(exit_3)
if __name__ == "__main__":
main()
@@ -0,0 +1,78 @@
- name: hello_world
team: reef
group: hello_world
frequency: nightly
working_dir: hello_world_tests
cluster:
anyscale_sdk_2026: true
byod: {}
cluster_compute: hello_world_compute_config.yaml
run:
timeout: 1800
script: python hello_world.py
variations:
- __suffix__: aws
- name: hello_world_custom
team: reef
group: hello_world
frequency: nightly
working_dir: hello_world_tests
cluster:
anyscale_sdk_2026: true
byod:
type: gpu
post_build_script: byod_hello_world.sh
cluster_compute: hello_world_compute_config.yaml
run:
timeout: 1800
script: python hello_world.py
variations:
- __suffix__: aws
- name: hello_world_2
team: reef
group: hello_world
frequency: nightly
working_dir: hello_world_tests
cluster:
anyscale_sdk_2026: true
byod: {}
cluster_compute: hello_world_compute_config.yaml
run:
timeout: 1800
script: python hello_world.py
variations:
- __suffix__: aws
- name: hello_world_custom_2
team: reef
group: hello_world
frequency: nightly
working_dir: hello_world_tests
cluster:
anyscale_sdk_2026: true
byod:
type: gpu
post_build_script: byod_hello_world.sh
cluster_compute: hello_world_compute_config.yaml
run:
timeout: 1800
script: python hello_world.py
variations:
- __suffix__: aws
- name: hello_world_3
team: reef
group: hello_world
frequency: nightly
working_dir: hello_world_tests
cluster:
anyscale_sdk_2026: true
byod: {}
cluster_compute: hello_world_compute_config.yaml
run:
timeout: 1800
script: python hello_world.py
variations:
- __suffix__: aws
@@ -0,0 +1,31 @@
- name: hello_world
team: reef
group: hello_world
frequency: nightly
working_dir: hello_world_tests
cluster:
anyscale_sdk_2026: true
byod: {}
cluster_compute: hello_world_compute_config.yaml
run:
timeout: 1800
script: python hello_world.py
variations:
- __suffix__: aws
- name: hello_world_custom
team: reef
group: hello_world
frequency: nightly
working_dir: hello_world_tests
cluster:
anyscale_sdk_2026: true
byod:
type: gpu
post_build_script: byod_hello_world.sh
cluster_compute: hello_world_compute_config.yaml
run:
timeout: 1800
script: python hello_world.py
variations:
- __suffix__: aws
+50
View File
@@ -0,0 +1,50 @@
import sys
import pytest
from ray_release.alerts import (
default,
# long_running_tests,
# rllib_tests,
# tune_tests,
# xgboost_tests,
handle,
)
from ray_release.exception import ReleaseTestConfigError, ResultsAlert
from ray_release.result import (
Result,
ResultStatus,
)
from ray_release.test import Test
def test_handle_alert():
# Unknown test suite
with pytest.raises(ReleaseTestConfigError):
handle.handle_result(
Test(name="unit_alert_test", alert="invalid"),
Result(status=ResultStatus.SUCCESS.value),
)
# Alert raised
with pytest.raises(ResultsAlert):
handle.handle_result(
Test(name="unit_alert_test", alert="default"),
Result(status="unsuccessful"),
)
# Everything fine
handle.handle_result(
Test(name="unit_alert_test", alert="default"),
Result(status=ResultStatus.SUCCESS.value),
)
def test_default_alert():
test = Test(name="unit_alert_test", alert="default")
assert default.handle_result(test, Result(status="timeout"))
assert not default.handle_result(test, Result(status=ResultStatus.SUCCESS.value))
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,76 @@
import sys
import pytest
from anyscale.job.models import JobState
from ray_release.anyscale_util import Anyscale
from ray_release.cluster_manager.cluster_manager import ClusterManager
from ray_release.job_manager.anyscale_job_manager import AnyscaleJobManager
from ray_release.test import Test
class FakeJobStatus:
def __init__(self, state: JobState):
self.state = state
class FakeCreateJobResponse:
class Result:
id = "job_id"
result = Result()
class FakeSDK(Anyscale):
def __init__(self):
super().__init__()
self.created_job = None
def project_name_by_id(self, project_id: str) -> str:
return "fake_project_name"
def create_job(self, job_request):
self.created_job = job_request
return FakeCreateJobResponse()
def test_get_last_logs_long_running_job():
"""Test calling get_last_logs() on long-running jobs.
When the job is running longer than 4 hours, get_last_logs() should skip
downloading the logs and return None.
"""
fake_test = Test(name="fake_test")
fake_sdk = FakeSDK()
cluster_manager = ClusterManager(
test=fake_test, project_id="fake_project_id", sdk=fake_sdk
)
anyscale_job_manager = AnyscaleJobManager(cluster_manager=cluster_manager)
anyscale_job_manager._duration = 4 * 3_600 + 1
anyscale_job_manager._job_id = "foo"
anyscale_job_manager.save_last_job_status(FakeJobStatus(state=JobState.SUCCEEDED))
assert anyscale_job_manager.get_last_logs() is None
def test_run_job_exports_cluster_compute_name():
fake_test = Test(name="fake_test")
fake_sdk = FakeSDK()
cluster_manager = ClusterManager(
test=fake_test, project_id="fake_project_id", sdk=fake_sdk
)
cluster_manager.cluster_env_name = "cluster_env"
cluster_manager.cluster_env_build_id = "build_id"
cluster_manager.cluster_compute_id = "compute_id"
cluster_manager.cluster_compute_name = "compute_name"
anyscale_job_manager = AnyscaleJobManager(cluster_manager=cluster_manager)
anyscale_job_manager._run_job("echo hi", {"USER_ENV": "1"})
env_vars = fake_sdk.created_job.config.runtime_env["env_vars"]
assert env_vars["USER_ENV"] == "1"
assert env_vars["ANYSCALE_JOB_CLUSTER_ENV_NAME"] == "cluster_env"
assert env_vars["ANYSCALE_JOB_CLUSTER_COMPUTE_NAME"] == "compute_name"
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,173 @@
import json
from unittest.mock import MagicMock, patch
import pytest
from ray_release.command_runner.anyscale_job_runner import (
TIMEOUT_RETURN_CODE,
AnyscaleJobRunner,
)
from ray_release.exception import (
JobBrokenError,
JobNoLogsError,
JobOutOfRetriesError,
PrepareCommandError,
PrepareCommandTimeout,
TestCommandError,
TestCommandTimeout,
)
from ray_release.job_manager.anyscale_job_manager import (
JOB_FAILED,
JOB_SOFT_INFRA_ERROR,
JOB_STATE_UNKNOWN,
JOB_SUCCEEDED,
)
def _make_output_json(
return_code=0,
workload_time_taken=10.0,
prepare_return_codes=None,
last_prepare_time_taken=5.0,
):
return {
"return_code": return_code,
"workload_time_taken": workload_time_taken,
"prepare_return_codes": prepare_return_codes or [],
"last_prepare_time_taken": last_prepare_time_taken,
"uploaded_results": True,
"uploaded_metrics": True,
"uploaded_artifact": True,
}
@pytest.fixture
def runner():
with patch.object(AnyscaleJobRunner, "__init__", lambda self: None):
r = AnyscaleJobRunner()
r._results_uploaded = True
r._metrics_uploaded = True
r._artifact_uploaded = True
r.prepare_commands = ["echo prepare"]
return r
class TestHandleCommandOutputJobReturnCodes:
def test_succeeded_with_output(self, runner):
output = _make_output_json(return_code=0)
runner.fetch_output = MagicMock(return_value=output)
runner._handle_command_output(JOB_SUCCEEDED)
def test_failed_with_output(self, runner):
output = _make_output_json(return_code=1)
runner.fetch_output = MagicMock(return_value=output)
with pytest.raises(TestCommandError, match="1"):
runner._handle_command_output(JOB_FAILED)
def test_failed_without_output(self, runner):
runner.fetch_output = MagicMock(side_effect=Exception("S3 error"))
runner.get_last_logs = MagicMock(return_value=None)
with pytest.raises(JobNoLogsError):
runner._handle_command_output(JOB_FAILED)
def test_soft_infra_error_raises(self, runner):
with pytest.raises(JobOutOfRetriesError, match="FAILED"):
runner._handle_command_output(JOB_SOFT_INFRA_ERROR)
def test_state_unknown_raises(self, runner):
with pytest.raises(JobBrokenError, match="UNKNOWN"):
runner._handle_command_output(JOB_STATE_UNKNOWN)
class TestHandleCommandOutputFetchFailures:
def test_no_output_and_no_logs_raises(self, runner):
runner.fetch_output = MagicMock(side_effect=Exception("S3 error"))
runner.get_last_logs = MagicMock(return_value=None)
with pytest.raises(JobNoLogsError):
runner._handle_command_output(0)
def test_no_output_but_logs_parsed(self, runner):
output = _make_output_json(return_code=0)
log_line = f"### JSON |{json.dumps(output)}| ###"
runner.fetch_output = MagicMock(side_effect=Exception("S3 error"))
runner.get_last_logs = MagicMock(return_value=log_line)
# Should succeed without raising
runner._handle_command_output(0)
def test_no_output_logs_with_nonzero_workload_status(self, runner):
output = _make_output_json(return_code=1)
log_line = f"### JSON |{json.dumps(output)}| ###"
runner.fetch_output = MagicMock(side_effect=Exception("S3 error"))
runner.get_last_logs = MagicMock(return_value=log_line)
with pytest.raises(TestCommandError, match="1"):
runner._handle_command_output(0)
class TestHandleCommandOutputPrepareCommands:
def test_prepare_timeout_raises(self, runner):
output = _make_output_json(
prepare_return_codes=[TIMEOUT_RETURN_CODE],
last_prepare_time_taken=60.0,
)
runner.fetch_output = MagicMock(return_value=output)
with pytest.raises(PrepareCommandTimeout, match="60"):
runner._handle_command_output(0)
def test_prepare_error_raises(self, runner):
output = _make_output_json(prepare_return_codes=[1])
runner.fetch_output = MagicMock(return_value=output)
with pytest.raises(PrepareCommandError, match="echo prepare"):
runner._handle_command_output(0)
def test_prepare_success_continues(self, runner):
output = _make_output_json(prepare_return_codes=[0])
runner.fetch_output = MagicMock(return_value=output)
# Should succeed without raising
runner._handle_command_output(0)
class TestHandleCommandOutputWorkloadStatus:
def test_success(self, runner):
output = _make_output_json(return_code=0)
runner.fetch_output = MagicMock(return_value=output)
# Should return without raising
runner._handle_command_output(0)
def test_nonzero_raises(self, runner):
output = _make_output_json(return_code=42)
runner.fetch_output = MagicMock(return_value=output)
with pytest.raises(TestCommandError, match="42"):
runner._handle_command_output(0)
def test_none_return_code_raises(self, runner):
output = _make_output_json(return_code=None)
runner.fetch_output = MagicMock(return_value=output)
with pytest.raises(TestCommandError, match="None"):
runner._handle_command_output(0)
def test_timeout_raises_by_default(self, runner):
output = _make_output_json(
return_code=TIMEOUT_RETURN_CODE, workload_time_taken=300.0
)
runner.fetch_output = MagicMock(return_value=output)
with pytest.raises(TestCommandTimeout, match="300"):
runner._handle_command_output(0)
def test_timeout_suppressed_when_not_raising(self, runner):
output = _make_output_json(return_code=TIMEOUT_RETURN_CODE)
runner.fetch_output = MagicMock(return_value=output)
# Should return without raising
runner._handle_command_output(0, raise_on_timeout=False)
class TestHandleCommandOutputSideEffects:
def test_upload_flags_set_from_output(self, runner):
output = _make_output_json()
output["uploaded_results"] = False
output["uploaded_metrics"] = False
output["uploaded_artifact"] = False
runner.fetch_output = MagicMock(return_value=output)
runner._handle_command_output(0)
assert runner._results_uploaded is False
assert runner._metrics_uploaded is False
assert runner._artifact_uploaded is False
@@ -0,0 +1,240 @@
import json
import os
import sys
from unittest.mock import patch
import pytest
from ray_release.command_runner._anyscale_job_wrapper import (
OUTPUT_JSON_FILENAME,
TIMEOUT_RETURN_CODE,
main,
run_bash_command,
run_spilling_check,
)
cloud_storage_kwargs = dict(
results_cloud_storage_uri=None,
metrics_cloud_storage_uri=None,
output_cloud_storage_uri=None,
upload_cloud_storage_uri=None,
artifact_path=None,
)
def test_run_bash_command_success():
assert run_bash_command("exit 0", 1000) == 0
def test_run_bash_command_fail():
assert run_bash_command("exit 1", 1000) == 1
def test_run_bash_command_timeout():
assert run_bash_command("sleep 10", 1) == TIMEOUT_RETURN_CODE
def _check_output_json(expected_return_code, prepare_return_codes=None):
with open(OUTPUT_JSON_FILENAME, "r") as fp:
output = json.load(fp)
assert output["return_code"] == expected_return_code
assert output["prepare_return_codes"] == (prepare_return_codes or [])
assert output["uploaded_results"] is False
assert output["collected_metrics"] is False
assert output["uploaded_metrics"] is False
def test_prepare_commands_validation(tmpdir):
with pytest.raises(ValueError):
main(
test_workload="exit 0",
test_workload_timeout=10,
test_no_raise_on_timeout=False,
prepare_commands=["exit 0"],
prepare_commands_timeouts=[],
**cloud_storage_kwargs
)
with pytest.raises(ValueError):
main(
test_workload="exit 0",
test_workload_timeout=10,
test_no_raise_on_timeout=False,
prepare_commands=[],
prepare_commands_timeouts=[1],
**cloud_storage_kwargs
)
def test_end_to_end(tmpdir):
expected_return_code = 0
assert (
main(
test_workload="exit 0",
test_workload_timeout=10,
test_no_raise_on_timeout=False,
prepare_commands=[],
prepare_commands_timeouts=[],
**cloud_storage_kwargs
)
== expected_return_code
)
_check_output_json(expected_return_code)
def test_end_to_end_prepare_commands(tmpdir):
expected_return_code = 0
assert (
main(
test_workload="exit 0",
test_workload_timeout=10,
test_no_raise_on_timeout=False,
prepare_commands=["exit 0", "exit 0"],
prepare_commands_timeouts=[1, 1],
**cloud_storage_kwargs
)
== expected_return_code
)
_check_output_json(expected_return_code, [0, 0])
def test_end_to_end_long_running(tmpdir):
expected_return_code = 0
assert (
main(
test_workload="sleep 10",
test_workload_timeout=1,
test_no_raise_on_timeout=True,
prepare_commands=[],
prepare_commands_timeouts=[],
**cloud_storage_kwargs
)
== expected_return_code
)
_check_output_json(TIMEOUT_RETURN_CODE)
def test_end_to_end_timeout(tmpdir):
expected_return_code = TIMEOUT_RETURN_CODE
assert (
main(
test_workload="sleep 10",
test_workload_timeout=1,
test_no_raise_on_timeout=False,
prepare_commands=[],
prepare_commands_timeouts=[],
**cloud_storage_kwargs
)
== expected_return_code
)
_check_output_json(expected_return_code)
def test_end_to_end_prepare_timeout(tmpdir):
expected_return_code = 1
assert (
main(
test_workload="exit 0",
test_workload_timeout=10,
test_no_raise_on_timeout=False,
prepare_commands=["exit 0", "sleep 10"],
prepare_commands_timeouts=[1, 1],
**cloud_storage_kwargs
)
== expected_return_code
)
_check_output_json(None, [0, TIMEOUT_RETURN_CODE])
@pytest.mark.parametrize("long_running", (True, False))
def test_end_to_end_failure(tmpdir, long_running):
expected_return_code = 1
assert (
main(
test_workload="exit 1",
test_workload_timeout=1,
test_no_raise_on_timeout=long_running,
prepare_commands=[],
prepare_commands_timeouts=[],
**cloud_storage_kwargs
)
== expected_return_code
)
_check_output_json(expected_return_code)
def test_end_to_end_prepare_failure(tmpdir):
expected_return_code = 1
assert (
main(
test_workload="exit 0",
test_workload_timeout=10,
test_no_raise_on_timeout=False,
prepare_commands=["exit 0", "exit 1"],
prepare_commands_timeouts=[1, 1],
**cloud_storage_kwargs
)
== expected_return_code
)
_check_output_json(None, [0, 1])
_PROM_SPILL_SAMPLE = [
{"metric": {}, "values": [[1700000000, "1073741824"]]},
]
@pytest.mark.parametrize(
"metrics_payload,expected_return_code",
[
# No spilling — empty list (Prometheus `> 0` filter dropped all points)
({"spilled_bytes": []}, 0),
# Spilling occurred — non-empty list
({"spilled_bytes": _PROM_SPILL_SAMPLE}, 1),
# Missing value (None) — fail with "could not retrieve" error
({"spilled_bytes": None}, 1),
# Missing key entirely — fail with "could not retrieve" error
({}, 1),
],
)
def test_run_spilling_check_with_metrics_file(
tmpdir, metrics_payload, expected_return_code
):
metrics_path = str(tmpdir / "metrics.json")
with open(metrics_path, "w") as f:
json.dump(metrics_payload, f)
with patch.dict(os.environ, {"METRICS_OUTPUT_JSON": metrics_path}):
assert run_spilling_check() == expected_return_code
def test_run_spilling_check_missing_file(tmpdir):
metrics_path = str(tmpdir / "missing.json")
with patch.dict(os.environ, {"METRICS_OUTPUT_JSON": metrics_path}):
assert run_spilling_check() == 1
def test_run_spilling_check_unset_metrics_env(tmpdir):
env = {k: v for k, v in os.environ.items() if k != "METRICS_OUTPUT_JSON"}
with patch.dict(os.environ, env, clear=True):
assert run_spilling_check() == 1
def test_run_spilling_check_malformed_json(tmpdir):
metrics_path = str(tmpdir / "metrics.json")
with open(metrics_path, "w") as f:
f.write("{not valid json")
with patch.dict(os.environ, {"METRICS_OUTPUT_JSON": metrics_path}):
assert run_spilling_check() == 1
@pytest.mark.parametrize("payload", [[1, 2, 3], "spilled_bytes", 42, None])
def test_run_spilling_check_non_dict_json(tmpdir, payload):
# Valid JSON but not a dict, so `metrics.get(...)` raises AttributeError.
metrics_path = str(tmpdir / "metrics.json")
with open(metrics_path, "w") as f:
json.dump(payload, f)
with patch.dict(os.environ, {"METRICS_OUTPUT_JSON": metrics_path}):
assert run_spilling_check() == 1
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+109
View File
@@ -0,0 +1,109 @@
import sys
from typing import Dict, List
from unittest import mock
import pytest
from ray_release.scripts.ray_bisect import (
_bisect,
_get_test,
_obtain_test_result,
_sanity_check,
)
def test_sanity_check():
def _mock_run_test(*args, **kwawrgs) -> Dict[str, Dict[int, str]]:
return {
"passing_revision": {0: "passed", 1: "passed"},
"failing_revision": {0: "failed", 1: "failed"},
"flaky_revision": {0: "failed", 1: "passed"},
}
with mock.patch(
"ray_release.scripts.ray_bisect._run_test",
side_effect=_mock_run_test,
):
assert _sanity_check({}, "passing_revision", "failing_revision", 2)
assert _sanity_check({}, "passing_revision", "flaky_revision", 2)
assert not _sanity_check({}, "failing_revision", "passing_revision", 2)
assert not _sanity_check({}, "passing_revision", "passing_revision", 2)
assert not _sanity_check({}, "failing_revision", "failing_revision", 2)
assert not _sanity_check({}, "flaky_revision", "failing_revision", 2)
def test_obtain_test_result():
test_cases = [
{
"c0": {0: "passed"},
},
{
"c0": {0: "passed", 1: "passed"},
"c1": {0: "hard_failed", 1: "hard_failed"},
},
]
def _mock_check_output(input: List[str]) -> str:
commit, run = tuple(input[-1].split("-"))
return bytes(test_case[commit][int(run)], "utf-8")
for test_case in test_cases:
with mock.patch(
"subprocess.check_output",
side_effect=_mock_check_output,
):
commits = set(test_case.keys())
rerun_per_commit = len(test_case[list(commits)[0]])
_obtain_test_result(commits, rerun_per_commit) == test_case
def test_get_test():
test = _get_test(
"test_name", ["release/ray_release/tests/test_collection_data.yaml"]
)
assert test.get_name() == "test_name"
def test_bisect():
test_cases = {
"c3": {
"c0": {0: "passed"},
"c1": {0: "passed"},
"c3": {0: "hard_failed"},
"c4": {0: "soft_failed"},
},
"c1": {
"c0": {0: "passed"},
"c1": {0: "hard_failed"},
"c2": {0: "hard_failed"},
},
"cc1": {
"cc0": {0: "passed"},
"cc1": {0: "hard_failed"},
},
"c2": {
"c0": {0: "passed", 1: "passed"},
"c2": {0: "passed", 1: "hard_failed"},
"c3": {0: "hard_failed", 1: "passed"},
"c4": {0: "soft_failed", 1: "soft_failed"},
},
}
for output, input in test_cases.items():
def _side_effect(ret):
def _mock_run_test(*args, **kwawrgs) -> Dict[str, str]:
return ret
return _mock_run_test
with mock.patch(
"ray_release.scripts.ray_bisect._run_test",
side_effect=_side_effect(input),
):
for concurreny in range(1, 4):
assert _bisect({}, list(input.keys()), concurreny, 1) == output
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+869
View File
@@ -0,0 +1,869 @@
import os
import sys
import tempfile
import unittest
from typing import TYPE_CHECKING, Callable, Dict
if TYPE_CHECKING:
from ray_release.github_client import GitHubRepo
from unittest.mock import patch
import yaml
from ray_release.bazel import bazel_runfile
from ray_release.buildkite.concurrency import (
get_concurrency_group,
get_test_resources_from_cluster_compute,
)
from ray_release.buildkite.filter import filter_tests, group_tests
from ray_release.buildkite.settings import (
Frequency,
Priority,
get_default_settings,
get_test_filters,
split_ray_repo_str,
update_settings_from_buildkite,
update_settings_from_environment,
)
from ray_release.buildkite.step import (
RELEASE_QUEUE_CLIENT,
RELEASE_QUEUE_DEFAULT,
get_step,
)
from ray_release.configs.global_config import init_global_config
from ray_release.exception import ReleaseTestConfigError
from ray_release.test import Test
from ray_release.wheels import (
DEFAULT_BRANCH,
)
init_global_config(bazel_runfile("release/ray_release/configs/oss_config.yaml"))
class MockBuildkiteAgent:
def __init__(self, return_dict: Dict):
self.return_dict = return_dict
def __call__(self, key: str):
return self.return_dict.get(key, None)
class MockReturn:
return_dict = {}
def __getattribute__(self, item):
return_dict = object.__getattribute__(self, "return_dict")
if item in return_dict:
mocked = return_dict[item]
if isinstance(mocked, Callable):
return mocked()
else:
return lambda *a, **kw: mocked
return object.__getattribute__(self, item)
class MockBuildkitePythonAPI(MockReturn):
def builds(self):
return self
def artifacts(self):
return self
class MockTest(Test):
def update_from_s3(self) -> None:
self["update_from_s3"] = True
def is_jailed_with_open_issue(self, ray_github: "GitHubRepo") -> bool:
return False
class BuildkiteSettingsTest(unittest.TestCase):
def setUp(self) -> None:
self.buildkite = {}
self.buildkite_mock = MockBuildkiteAgent(self.buildkite)
def testSplitRayRepoStr(self):
url, branch = split_ray_repo_str("https://github.com/ray-project/ray.git")
self.assertEqual(url, "https://github.com/ray-project/ray.git")
self.assertEqual(branch, DEFAULT_BRANCH)
url, branch = split_ray_repo_str(
"https://github.com/ray-project/ray/tree/branch/sub"
)
self.assertEqual(url, "https://github.com/ray-project/ray.git")
self.assertEqual(branch, "branch/sub")
url, branch = split_ray_repo_str("https://github.com/user/ray/tree/branch/sub")
self.assertEqual(url, "https://github.com/user/ray.git")
self.assertEqual(branch, "branch/sub")
url, branch = split_ray_repo_str("ray-project:branch/sub")
self.assertEqual(url, "https://github.com/ray-project/ray.git")
self.assertEqual(branch, "branch/sub")
url, branch = split_ray_repo_str("user:branch/sub")
self.assertEqual(url, "https://github.com/user/ray.git")
self.assertEqual(branch, "branch/sub")
url, branch = split_ray_repo_str("user")
self.assertEqual(url, "https://github.com/user/ray.git")
self.assertEqual(branch, DEFAULT_BRANCH)
def testGetTestAttrRegexFilters(self):
test_filters = get_test_filters("")
self.assertDictEqual(test_filters, {})
test_filters = get_test_filters("name:xxx")
self.assertDictEqual(test_filters, {"name": ["xxx"]})
test_filters = get_test_filters("name:xxx\n")
self.assertDictEqual(test_filters, {"name": ["xxx"]})
test_filters = get_test_filters("name:xxx\n\nteam:yyy")
self.assertDictEqual(test_filters, {"name": ["xxx"], "team": ["yyy"]})
test_filters = get_test_filters("name:xxx\n \nteam:yyy\n")
self.assertDictEqual(test_filters, {"name": ["xxx"], "team": ["yyy"]})
# Test multiple filters with same attribute (OR logic)
test_filters = get_test_filters("name:xxx\nname:yyy")
self.assertDictEqual(test_filters, {"name": ["xxx", "yyy"]})
with self.assertRaises(ReleaseTestConfigError):
get_test_filters("xxx")
def testSettingsOverrideEnv(self):
settings = get_default_settings()
# With no environment variables, default settings shouldn't be updated
updated_settings = settings.copy()
update_settings_from_environment(updated_settings)
self.assertDictEqual(settings, updated_settings)
environ = os.environ.copy()
# Invalid frequency
os.environ.clear()
os.environ.update(environ)
os.environ["RELEASE_FREQUENCY"] = "invalid"
updated_settings = settings.copy()
with self.assertRaises(ReleaseTestConfigError):
update_settings_from_environment(updated_settings)
# Invalid priority
os.environ.clear()
os.environ.update(environ)
os.environ["RELEASE_PRIORITY"] = "invalid"
updated_settings = settings.copy()
with self.assertRaises(ReleaseTestConfigError):
update_settings_from_environment(updated_settings)
# Invalid test attr regex filters
os.environ.clear()
os.environ.update(environ)
os.environ["TEST_ATTR_REGEX_FILTERS"] = "xxxx"
updated_settings = settings.copy()
with self.assertRaises(ReleaseTestConfigError):
update_settings_from_environment(updated_settings)
os.environ.clear()
os.environ.update(environ)
os.environ["TEST_ATTR_REGEX_FILTERS"] = "name:xxx\nteam:yyy\n"
updated_settings = settings.copy()
update_settings_from_environment(updated_settings)
print(updated_settings)
self.assertDictEqual(
updated_settings["test_filters"],
{
"name": ["xxx"],
"team": ["yyy"],
},
)
os.environ.clear()
os.environ.update(environ)
os.environ["RELEASE_FREQUENCY"] = "nightly"
os.environ["RAY_TEST_REPO"] = "https://github.com/user/ray.git"
os.environ["RAY_TEST_BRANCH"] = "sub/branch"
os.environ["TEST_NAME"] = "name_filter"
os.environ["RELEASE_PRIORITY"] = "manual"
updated_settings = settings.copy()
update_settings_from_environment(updated_settings)
self.assertDictEqual(
updated_settings,
{
"frequency": Frequency.NIGHTLY,
"prefer_smoke_tests": False,
"test_filters": {"name": ["name_filter"]},
"ray_test_repo": "https://github.com/user/ray.git",
"ray_test_branch": "sub/branch",
"priority": Priority.MANUAL,
"no_concurrency_limit": False,
},
)
os.environ["RELEASE_FREQUENCY"] = "any-smoke"
update_settings_from_environment(updated_settings)
self.assertDictEqual(
updated_settings,
{
"frequency": Frequency.ANY,
"prefer_smoke_tests": True,
"test_filters": {"name": ["name_filter"]},
"ray_test_repo": "https://github.com/user/ray.git",
"ray_test_branch": "sub/branch",
"priority": Priority.MANUAL,
"no_concurrency_limit": False,
},
)
###
# Buildkite PR build settings
# Default PR
os.environ.clear()
os.environ.update(environ)
os.environ["BUILDKITE_REPO"] = "https://github.com/ray-project/ray.git"
os.environ[
"BUILDKITE_PULL_REQUEST_REPO"
] = "https://github.com/user/ray-fork.git"
os.environ["BUILDKITE_BRANCH"] = "user:some_branch"
updated_settings = settings.copy()
update_settings_from_environment(updated_settings)
self.assertEqual(
updated_settings["ray_test_repo"], "https://github.com/user/ray-fork.git"
)
self.assertEqual(updated_settings["ray_test_branch"], "some_branch")
# PR without prefix
os.environ.clear()
os.environ.update(environ)
os.environ["BUILDKITE_REPO"] = "https://github.com/ray-project/ray.git"
os.environ["BUILDKITE_PULL_REQUEST_REPO"] = "git://github.com/user/ray-fork.git"
os.environ["BUILDKITE_BRANCH"] = "some_branch"
updated_settings = settings.copy()
update_settings_from_environment(updated_settings)
self.assertEqual(
updated_settings["ray_test_repo"], "https://github.com/user/ray-fork.git"
)
self.assertEqual(updated_settings["ray_test_branch"], "some_branch")
# Branch build but pointing to fork
os.environ.clear()
os.environ.update(environ)
os.environ["BUILDKITE_REPO"] = "https://github.com/ray-project/ray.git"
os.environ["BUILDKITE_BRANCH"] = "user:some_branch"
updated_settings = settings.copy()
update_settings_from_environment(updated_settings)
self.assertEqual(
updated_settings["ray_test_repo"], "https://github.com/user/ray.git"
)
self.assertEqual(updated_settings["ray_test_branch"], "some_branch")
# Branch build but pointing to main repo branch
os.environ.clear()
os.environ.update(environ)
os.environ["BUILDKITE_REPO"] = "https://github.com/ray-project/ray.git"
os.environ["BUILDKITE_BRANCH"] = "some_branch"
updated_settings = settings.copy()
update_settings_from_environment(updated_settings)
self.assertEqual(
updated_settings["ray_test_repo"], "https://github.com/ray-project/ray.git"
)
self.assertEqual(updated_settings["ray_test_branch"], "some_branch")
# Empty BUILDKITE_PULL_REQUEST_REPO
os.environ.clear()
os.environ.update(environ)
os.environ["BUILDKITE_REPO"] = "https://github.com/ray-project/ray.git"
os.environ["BUILDKITE_BRANCH"] = "some_branch"
os.environ["BUILDKITE_PULL_REQUEST_REPO"] = ""
updated_settings = settings.copy()
update_settings_from_environment(updated_settings)
self.assertEqual(
updated_settings["ray_test_repo"], "https://github.com/ray-project/ray.git"
)
self.assertEqual(updated_settings["ray_test_branch"], "some_branch")
def testSettingsOverrideBuildkite(self):
settings = get_default_settings()
with patch(
"ray_release.buildkite.settings.get_buildkite_prompt_value",
self.buildkite_mock,
):
# With no buildkite variables, default settings shouldn't be updated
updated_settings = settings.copy()
update_settings_from_buildkite(updated_settings)
self.assertDictEqual(settings, updated_settings)
buildkite = self.buildkite.copy()
# Invalid frequency
self.buildkite.clear()
self.buildkite.update(buildkite)
self.buildkite["release-frequency"] = "invalid"
updated_settings = settings.copy()
with self.assertRaises(ReleaseTestConfigError):
update_settings_from_buildkite(updated_settings)
# Invalid priority
self.buildkite.clear()
self.buildkite.update(buildkite)
self.buildkite["release-priority"] = "invalid"
updated_settings = settings.copy()
with self.assertRaises(ReleaseTestConfigError):
update_settings_from_buildkite(updated_settings)
# Invalid test attr regex filters
self.buildkite.clear()
self.buildkite.update(buildkite)
self.buildkite["release-test-filters"] = "xxxx"
updated_settings = settings.copy()
with self.assertRaises(ReleaseTestConfigError):
update_settings_from_buildkite(updated_settings)
self.buildkite.clear()
self.buildkite.update(buildkite)
self.buildkite["release-test-filters"] = "name:xxx\ngroup:yyy"
updated_settings = settings.copy()
update_settings_from_buildkite(updated_settings)
self.assertDictEqual(
updated_settings["test_filters"],
{
"name": ["xxx"],
"group": ["yyy"],
},
)
self.buildkite.clear()
self.buildkite.update(buildkite)
self.buildkite["release-frequency"] = "nightly"
self.buildkite["release-ray-test-repo-branch"] = "user:sub/branch"
self.buildkite["release-test-name"] = "name_filter"
self.buildkite["release-priority"] = "manual"
updated_settings = settings.copy()
update_settings_from_buildkite(updated_settings)
self.assertDictEqual(
updated_settings,
{
"frequency": Frequency.NIGHTLY,
"prefer_smoke_tests": False,
"test_filters": {"name": ["name_filter"]},
"ray_test_repo": "https://github.com/user/ray.git",
"ray_test_branch": "sub/branch",
"priority": Priority.MANUAL,
"no_concurrency_limit": False,
},
)
self.buildkite["release-frequency"] = "any-smoke"
update_settings_from_buildkite(updated_settings)
self.assertDictEqual(
updated_settings,
{
"frequency": Frequency.ANY,
"prefer_smoke_tests": True,
"test_filters": {"name": ["name_filter"]},
"ray_test_repo": "https://github.com/user/ray.git",
"ray_test_branch": "sub/branch",
"priority": Priority.MANUAL,
"no_concurrency_limit": False,
},
)
def _filter_names(self, *args, **kwargs):
filtered = filter_tests(*args, **kwargs)
return [(t[0]["name"], t[1]) for t in filtered]
@patch(
"ray_release.test_automation.state_machine.TestStateMachine.get_ray_repo",
return_value=None,
)
def testFilterTests(self, *args):
test = MockTest(
{
"name": "test_1",
"frequency": "nightly",
"smoke_test": {"frequency": "nightly"},
"team": "team_1",
"run": {"type": "job"},
}
)
tests = [
test,
MockTest(
{
"name": "test_2",
"frequency": "weekly",
"smoke_test": {"frequency": "nightly"},
"team": "team_2",
"run": {"type": "client"},
}
),
MockTest({"name": "other_1", "frequency": "weekly", "team": "team_2"}),
MockTest(
{
"name": "other_2",
"frequency": "nightly",
"smoke_test": {"frequency": "manual"},
"team": "team_2",
"run": {"type": "job"},
}
),
MockTest({"name": "other_3", "frequency": "manual", "team": "team_2"}),
MockTest({"name": "test_3", "frequency": "nightly", "team": "team_2"}),
MockTest(
{
"name": "test_4.kuberay",
"frequency": "nightly",
"env": "kuberay",
"team": "team_2",
"run": {"type": "job"},
}
),
]
# Test filter by prefix alone
filtered = self._filter_names(
tests, frequency=Frequency.ANY, test_filters={"prefix": ["test"]}
)
self.assertSequenceEqual(
filtered,
[
("test_1", False),
("test_2", False),
("test_3", False),
("test_4.kuberay", False),
],
)
# Test filter by prefix and regex together
filtered = self._filter_names(
tests,
frequency=Frequency.NIGHTLY,
test_filters={"prefix": ["test"], "name": ["other.*"]},
)
self.assertSequenceEqual(
filtered,
[],
)
filtered = self._filter_names(tests, frequency=Frequency.ANY)
self.assertSequenceEqual(
filtered,
[
("test_1", False),
("test_2", False),
("other_1", False),
("other_2", False),
("other_3", False),
("test_3", False),
("test_4.kuberay", False),
],
)
assert not test.get("update_from_s3")
filtered = self._filter_names(
tests,
frequency=Frequency.ANY,
prefer_smoke_tests=True,
)
self.assertSequenceEqual(
filtered,
[
("test_1", True),
("test_2", True),
("other_1", False),
("other_2", True),
("other_3", False),
("test_3", False),
("test_4.kuberay", False),
],
)
filtered = self._filter_names(tests, frequency=Frequency.NIGHTLY)
self.assertSequenceEqual(
filtered,
[
("test_1", False),
("test_2", True),
("other_2", False),
("test_3", False),
("test_4.kuberay", False),
],
)
filtered = self._filter_names(
tests,
frequency=Frequency.NIGHTLY,
prefer_smoke_tests=True,
)
self.assertSequenceEqual(
filtered,
[
("test_1", True),
("test_2", True),
("other_2", True),
("test_3", False),
("test_4.kuberay", False),
],
)
filtered = self._filter_names(tests, frequency=Frequency.WEEKLY)
self.assertSequenceEqual(filtered, [("test_2", False), ("other_1", False)])
filtered = self._filter_names(
tests,
frequency=Frequency.NIGHTLY,
test_filters={"name": ["other.*"]},
)
self.assertSequenceEqual(
filtered,
[
("other_2", False),
],
)
filtered = self._filter_names(
tests,
frequency=Frequency.NIGHTLY,
test_filters={"name": ["test.*"]},
)
self.assertSequenceEqual(
filtered,
[
("test_1", False),
("test_2", True),
("test_3", False),
("test_4.kuberay", False),
],
)
filtered = self._filter_names(
tests, frequency=Frequency.NIGHTLY, test_filters={"name": ["test"]}
)
self.assertSequenceEqual(
filtered,
[
("test_1", False),
("test_2", True),
("test_3", False),
("test_4.kuberay", False),
],
)
filtered = self._filter_names(
tests,
frequency=Frequency.NIGHTLY,
test_filters={"name": ["test.*"], "team": ["team_1"]},
)
self.assertSequenceEqual(filtered, [("test_1", False)])
filtered = self._filter_names(
tests,
frequency=Frequency.NIGHTLY,
test_filters={"name": ["test_1|test_2"]},
)
self.assertSequenceEqual(filtered, [("test_1", False), ("test_2", True)])
# Test OR logic within same attribute
filtered = self._filter_names(
tests,
frequency=Frequency.NIGHTLY,
test_filters={"name": ["^test_1$", "^test_3$"]},
)
self.assertSequenceEqual(filtered, [("test_1", False), ("test_3", False)])
# Test OR logic with AND across attributes
filtered = self._filter_names(
tests,
frequency=Frequency.NIGHTLY,
test_filters={
"name": ["^test_1$", "^test_3$"],
"team": ["team_1", "team_2"],
},
)
self.assertSequenceEqual(filtered, [("test_1", False), ("test_3", False)])
# Filter by nested properties
filtered = self._filter_names(
tests,
frequency=Frequency.ANY,
test_filters={"run/type": ["job"]},
)
self.assertSequenceEqual(
filtered, [("test_1", False), ("other_2", False), ("test_4.kuberay", False)]
)
filtered = self._filter_names(
tests,
frequency=Frequency.ANY,
test_filters={"run/type": ["client"]},
)
self.assertSequenceEqual(filtered, [("test_2", False)])
filtered = self._filter_names(
tests,
frequency=Frequency.ANY,
test_filters={"run/invalid": ["xxx"]},
)
self.assertSequenceEqual(filtered, [])
def testGroupTests(self):
tests = [
(Test(name="x1", group="x"), False),
(Test(name="x2", group="x"), False),
(Test(name="y1", group="y"), False),
(Test(name="ungrouped"), False),
(Test(name="x3", group="x"), False),
]
grouped = group_tests(tests)
self.assertEqual(len(grouped), 3) # Three groups
self.assertEqual(len(grouped["x"]), 3)
self.assertSequenceEqual(
[t["name"] for t, _ in grouped["x"]], ["x1", "x2", "x3"]
)
self.assertEqual(len(grouped["y"]), 1)
def testGetStep(self):
test = MockTest(
{
"name": "test",
"frequency": "nightly",
"run": {"script": "test_script.py"},
"smoke_test": {"frequency": "nightly"},
"cluster": {"byod": {"type": "cpu"}},
}
)
with patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"}):
step = get_step(test, smoke_test=False)
joined_commands = "\n".join(step["commands"])
self.assertNotIn("--smoke-test", joined_commands)
step = get_step(test, smoke_test=True)
joined_commands = "\n".join(step["commands"])
self.assertIn("--smoke-test", joined_commands)
step = get_step(test, priority_val=20)
self.assertEqual(step["priority"], 20)
def testInstanceResources(self):
# AWS instances
cpus, gpus = get_test_resources_from_cluster_compute(
{
"head_node_type": {"instance_type": "m5.4xlarge"}, # 16 CPUs, 0 GPUs
"worker_node_types": [
{
"instance_type": "m5.8xlarge", # 32 CPUS, 0 GPUs
"max_workers": 4,
},
{
"instance_type": "g3.8xlarge", # 32 CPUs, 2 GPUs
"min_workers": 8,
},
],
}
)
self.assertEqual(cpus, 16 + 32 * 4 + 32 * 8)
self.assertEqual(gpus, 2 * 8)
cpus, gpus = get_test_resources_from_cluster_compute(
{
"head_node_type": {
"instance_type": "n1-standard-16" # 16 CPUs, 0 GPUs
},
"worker_node_types": [
{
"instance_type": "random-str-xxx-32", # 32 CPUS, 0 GPUs
"max_workers": 4,
},
{
"instance_type": "a2-highgpu-2g", # 24 CPUs, 2 GPUs
"min_workers": 8,
},
],
}
)
self.assertEqual(cpus, 16 + 32 * 4 + 24 * 8)
self.assertEqual(gpus, 2 * 8)
def testInstanceResourcesNewSchema(self):
# New schema: head_node + worker_nodes
cpus, gpus = get_test_resources_from_cluster_compute(
{
"head_node": {"instance_type": "m5.4xlarge"}, # 16 CPUs, 0 GPUs
"worker_nodes": [
{
"instance_type": "m5.8xlarge", # 32 CPUS, 0 GPUs
"min_nodes": 1,
"max_nodes": 4,
},
{
"instance_type": "g3.8xlarge", # 32 CPUs, 2 GPUs
"min_nodes": 8,
"max_nodes": 8,
},
],
},
is_new_schema=True,
)
self.assertEqual(cpus, 16 + 32 * 4 + 32 * 8)
self.assertEqual(gpus, 2 * 8)
def testInstanceResourcesNewSchemaEmpty(self):
# New schema: empty config returns 0 resources
cpus, gpus = get_test_resources_from_cluster_compute({}, is_new_schema=True)
self.assertEqual(cpus, 0)
self.assertEqual(gpus, 0)
def testInstanceResourcesNewSchemaMissingInstanceType(self):
# New schema: worker without instance_type is skipped
cpus, gpus = get_test_resources_from_cluster_compute(
{
"head_node": {"instance_type": "m5.4xlarge"}, # 16 CPUs
"worker_nodes": [
{"min_nodes": 4}, # no instance_type, skipped
],
},
is_new_schema=True,
)
self.assertEqual(cpus, 16)
self.assertEqual(gpus, 0)
def testConcurrencyGroups(self):
def _return(ret):
def _inner(*args, **kwargs):
return ret
return _inner
test = Test(
{
"name": "test_1",
}
)
def test_concurrency(cpu, gpu, group):
with patch(
"ray_release.buildkite.concurrency.get_test_resources",
_return((cpu, gpu)),
):
group_name, _ = get_concurrency_group(test)
self.assertEqual(group_name, group)
test_concurrency(12800, 9, "large-gpu")
test_concurrency(12800, 8, "small-gpu")
test_concurrency(12800, 1, "small-gpu")
test_concurrency(12800, 0, "enormous")
test_concurrency(1025, 0, "enormous")
test_concurrency(1024, 0, "large")
test_concurrency(513, 0, "large")
test_concurrency(512, 0, "medium")
test_concurrency(129, 0, "medium")
test_concurrency(128, 0, "small")
test_concurrency(9, 0, "tiny")
test_concurrency(32, 0, "tiny")
test_concurrency(8, 0, "minuscule")
test_concurrency(1, 0, "minuscule")
test_concurrency(33, 0, "small")
def testConcurrencyGroupSmokeTest(self):
with tempfile.TemporaryDirectory() as tmpdir:
cluster_config_full = {
"head_node_type": {
"instance_type": "n1-standard-16" # 16 CPUs, 0 GPUs
},
"worker_node_types": [
{
"instance_type": "random-str-xxx-32", # 32 CPUS, 0 GPUs
"max_workers": 10,
},
],
}
cluster_config_smoke = {
"head_node_type": {
"instance_type": "n1-standard-16" # 16 CPUs, 0 GPUs
},
"worker_node_types": [
{
"instance_type": "random-str-xxx-32", # 32 CPUS, 0 GPUs
"max_workers": 1,
},
],
}
cluster_config_full_path = os.path.join(tmpdir, "full.yaml")
with open(cluster_config_full_path, "w") as fp:
yaml.safe_dump(cluster_config_full, fp)
cluster_config_smoke_path = os.path.join(tmpdir, "smoke.yaml")
with open(cluster_config_smoke_path, "w") as fp:
yaml.safe_dump(cluster_config_smoke, fp)
test = MockTest(
{
"name": "test_1",
"cluster": {
"cluster_compute": cluster_config_full_path,
"byod": {"type": "cpu"},
},
"smoke_test": {
"cluster": {
"cluster_compute": cluster_config_smoke_path,
"byod": {"type": "cpu"},
},
},
}
)
with patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"}):
step = get_step(test, smoke_test=False)
self.assertEqual(step["concurrency_group"], "medium")
step = get_step(test, smoke_test=True)
self.assertEqual(step["concurrency_group"], "small")
def testStepQueueClient(self):
test_regular = MockTest(
{
"name": "test",
"frequency": "nightly",
"run": {"script": "test_script.py"},
"cluster": {"byod": {"type": "cpu"}},
}
)
test_client = MockTest(
{
"name": "test",
"frequency": "nightly",
"run": {"script": "test_script.py", "type": "client"},
"cluster": {"byod": {"type": "cpu"}},
}
)
with patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"}):
step = get_step(test_regular)
self.assertEqual(step["agents"]["queue"], str(RELEASE_QUEUE_DEFAULT))
step = get_step(test_client)
self.assertEqual(step["agents"]["queue"], str(RELEASE_QUEUE_CLIENT))
if __name__ == "__main__":
import pytest
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,191 @@
import os
import sys
import tempfile
from typing import List
from unittest.mock import patch
import pytest
from ray_release.bazel import bazel_runfile
from ray_release.byod.build import (
_get_ray_commit,
build_anyscale_base_byod_images,
build_anyscale_custom_byod_image,
)
from ray_release.byod.build_context import BuildContext
from ray_release.configs.global_config import get_global_config, init_global_config
from ray_release.test import Test
def test_get_ray_commit() -> None:
assert (
_get_ray_commit(
{
"RAY_WANT_COMMIT_IN_IMAGE": "abc123",
"COMMIT_TO_TEST": "def456",
"BUILDKITE_COMMIT": "987789",
}
)
== "abc123"
)
assert (
_get_ray_commit(
{
"COMMIT_TO_TEST": "def456",
"BUILDKITE_COMMIT": "987789",
}
)
== "def456"
)
assert _get_ray_commit({"BUILDKITE_COMMIT": "987789"}) == "987789"
assert _get_ray_commit({"PATH": "/usr/bin"}) == ""
init_global_config(bazel_runfile("release/ray_release/configs/oss_config.yaml"))
def test_build_anyscale_custom_byod_image() -> None:
cmds = []
def _mock_check_call(
cmd: List[str],
*args,
**kwargs,
) -> None:
cmds.append(cmd)
with patch("ray_release.byod.build._image_exist", return_value=False), patch.dict(
"os.environ",
{
"BUILDKITE_COMMIT": "abc123",
"RAYCI_BUILD_ID": "a1b2c3d4",
},
), patch("subprocess.check_call", side_effect=_mock_check_call,), patch(
"subprocess.check_output",
return_value=b"abc123",
):
test = Test(
name="name",
cluster={"byod": {"post_build_script": "foo.sh"}},
)
with tempfile.TemporaryDirectory() as byod_dir:
with open(os.path.join(byod_dir, "foo.sh"), "wt") as f:
f.write("echo foo")
build_context: BuildContext = {
"post_build_script": test.get_byod_post_build_script(),
}
build_anyscale_custom_byod_image(
test.get_anyscale_byod_image(),
test.get_anyscale_base_byod_image(),
build_context,
release_byod_dir=byod_dir,
)
assert (" ".join(cmds[0])).startswith(
"docker build --progress=plain . "
"--build-arg BASE_IMAGE=029272617770.dkr.ecr.us-west-2."
"amazonaws.com/anyscale/ray:a1b2c3d4-py310-cpu "
"-t 029272617770.dkr.ecr.us-west-2."
"amazonaws.com/anyscale/ray:a1b2c3d4-py310-cpu-"
)
def test_build_anyscale_base_byod_images() -> None:
def _mock_image_exist(image: str) -> bool:
return True
with patch(
"os.environ",
{
"BUILDKITE_COMMIT": "abc123",
"RAYCI_BUILD_ID": "a1b2c3d4",
},
), patch("subprocess.check_call", return_value=None), patch(
"ray_release.byod.build._image_exist", side_effect=_mock_image_exist
):
tests = [
Test(name="aws", env="aws", cluster={"byod": {}}),
Test(name="aws", env="aws", cluster={"byod": {"type": "gpu"}}),
Test(
# This is a duplicate of the default.
name="aws",
env="aws",
python="3.10",
cluster={"byod": {"type": "cpu"}},
),
Test(name="aws", env="aws", python="3.11", cluster={"byod": {}}),
Test(name="aws", env="aws", cluster={"byod": {"type": "cu121"}}),
Test(
name="aws",
env="aws",
python="3.10",
cluster={"byod": {"type": "cu116"}},
),
Test(
name="aws",
env="aws",
python="3.11",
cluster={"byod": {"type": "cu118"}},
),
Test(name="gce", env="gce", cluster={"byod": {}}),
]
images = build_anyscale_base_byod_images(tests)
global_config = get_global_config()
aws_cr = global_config["byod_ecr"]
# Base images are always from AWS ECR (see get_anyscale_base_byod_image),
# so GCE and AWS CPU tests resolve to the same image (6 unique, not 7).
assert set(images) == {
f"{aws_cr}/anyscale/ray:a1b2c3d4-py310-cpu",
f"{aws_cr}/anyscale/ray:a1b2c3d4-py310-cu116",
f"{aws_cr}/anyscale/ray:a1b2c3d4-py310-cu121",
f"{aws_cr}/anyscale/ray:a1b2c3d4-py311-cu118",
f"{aws_cr}/anyscale/ray-ml:a1b2c3d4-py310-gpu",
f"{aws_cr}/anyscale/ray:a1b2c3d4-py311-cpu",
}
def test_get_anyscale_base_byod_image_always_uses_aws_ecr() -> None:
"""Base images should always come from AWS ECR, regardless of test env."""
global_config = get_global_config()
aws_cr = global_config["byod_ecr"]
with patch.dict(
"os.environ",
{
"BUILDKITE_COMMIT": "abc123",
"RAYCI_BUILD_ID": "a1b2c3d4",
},
):
expected_cpu = f"{aws_cr}/anyscale/ray:a1b2c3d4-py310-cpu"
for env in ("aws", "gce", "azure", "kuberay"):
test = Test(name="t", env=env, cluster={"byod": {}})
assert test.get_anyscale_base_byod_image() == expected_cpu, env
gpu_test = Test(name="t", env="gce", cluster={"byod": {"type": "gpu"}})
assert gpu_test.get_anyscale_base_byod_image() == (
f"{aws_cr}/anyscale/ray-ml:a1b2c3d4-py310-gpu"
)
# When ray_version is set, base image uses anyscale/ray prefix
# instead of byod_ecr.
versioned_cpu_test = Test(
name="t",
env="gce",
cluster={"byod": {}, "ray_version": "2.50.0"},
)
versioned_gpu_test = Test(
name="t",
env="gce",
cluster={"byod": {"type": "gpu"}, "ray_version": "2.50.0"},
)
assert versioned_cpu_test.get_anyscale_base_byod_image() == (
"anyscale/ray:2.50.0-py310-cpu"
)
assert versioned_gpu_test.get_anyscale_base_byod_image() == (
"anyscale/ray:2.50.0-py310-cu121"
)
if __name__ == "__main__":
sys.exit(pytest.main(["-vv", __file__]))
@@ -0,0 +1,240 @@
import os
import sys
import tempfile
import pytest
from ray_release.byod.build_context import (
_INSTALL_PYTHON_DEPS_SCRIPT,
build_context_digest,
decode_build_context,
encode_build_context,
fill_build_context_dir,
make_build_context,
)
def test_make_build_context() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
script_path = os.path.join(tmpdir, "post_build.sh")
with open(script_path, "w") as f:
f.write("echo hello")
depset_path = os.path.join(tmpdir, "deps.lock")
with open(depset_path, "w") as f:
f.write("numpy==1.0.0")
ctx = make_build_context(
base_dir=tmpdir,
envs={"FOO": "bar"},
post_build_script="post_build.sh",
python_depset="deps.lock",
)
assert ctx == {
"envs": {"FOO": "bar"},
"post_build_script": "post_build.sh",
"post_build_script_digest": ctx["post_build_script_digest"],
"python_depset": "deps.lock",
"python_depset_digest": ctx["python_depset_digest"],
"install_python_deps_script_digest": ctx[
"install_python_deps_script_digest"
],
}
assert ctx["post_build_script_digest"].startswith("sha256:")
assert ctx["python_depset_digest"].startswith("sha256:")
assert ctx["install_python_deps_script_digest"].startswith("sha256:")
def test_make_build_context_partial() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
script_path = os.path.join(tmpdir, "post_build.sh")
with open(script_path, "w") as f:
f.write("echo hello")
ctx = make_build_context(
base_dir=tmpdir,
post_build_script="post_build.sh",
)
assert ctx == {
"post_build_script": "post_build.sh",
"post_build_script_digest": ctx["post_build_script_digest"],
}
assert ctx["post_build_script_digest"].startswith("sha256:")
def test_make_build_context_empty() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
ctx = make_build_context(base_dir=tmpdir)
assert ctx == {}
def test_encode_build_context() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, "build.sh"), "w") as f:
f.write("echo hello")
ctx = make_build_context(
base_dir=tmpdir,
envs={"ZZZ": "last", "AAA": "first"},
post_build_script="build.sh",
)
encoded = encode_build_context(ctx)
# Verify minified (no spaces)
assert " " not in encoded
# Verify deterministic via digest - same content in different order produces same digest
ctx_reordered = {
"post_build_script_digest": ctx["post_build_script_digest"],
"post_build_script": "build.sh",
"envs": {"AAA": "first", "ZZZ": "last"},
}
assert build_context_digest(ctx) == build_context_digest(ctx_reordered)
def test_decode_build_context() -> None:
data = '{"envs":{"FOO":"bar"},"post_build_script":"build.sh"}'
ctx = decode_build_context(data)
assert ctx["envs"] == {"FOO": "bar"}
assert ctx["post_build_script"] == "build.sh"
def test_encode_decode_roundtrip() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, "script.sh"), "w") as f:
f.write("echo hello")
with open(os.path.join(tmpdir, "deps.lock"), "w") as f:
f.write("numpy==1.0.0")
ctx = make_build_context(
base_dir=tmpdir,
envs={"KEY": "value"},
post_build_script="script.sh",
python_depset="deps.lock",
)
encoded = encode_build_context(ctx)
decoded = decode_build_context(encoded)
assert decoded == ctx
def test_build_context_digest() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, "build.sh"), "w") as f:
f.write("echo hello")
with open(os.path.join(tmpdir, "build2.sh"), "w") as f:
f.write("echo world")
ctx1 = make_build_context(
base_dir=tmpdir,
post_build_script="build.sh",
)
ctx2 = make_build_context(
base_dir=tmpdir,
post_build_script="build2.sh",
)
ctx3 = make_build_context(
base_dir=tmpdir,
envs={"FOO": "bar"},
post_build_script="build.sh",
)
digest1 = build_context_digest(ctx1)
digest2 = build_context_digest(ctx2)
digest3 = build_context_digest(ctx3)
# Different file contents produce different digests
assert digest1 != digest2
# Different envs produce different digests
assert digest1 != digest3
# All three are different
assert len({digest1, digest2, digest3}) == 3
def test_fill_build_context_dir_empty() -> None:
with (
tempfile.TemporaryDirectory() as source_dir,
tempfile.TemporaryDirectory() as build_dir,
):
ctx = make_build_context(base_dir=source_dir)
fill_build_context_dir(ctx, source_dir, build_dir)
# Check Dockerfile
with open(os.path.join(build_dir, "Dockerfile")) as f:
dockerfile = f.read()
expected_dockerfile = "\n".join(
[
"# syntax=docker/dockerfile:1.3-labs",
"ARG BASE_IMAGE",
"FROM ${BASE_IMAGE}",
"",
]
)
assert dockerfile == expected_dockerfile
# Check no other files were created
assert os.listdir(build_dir) == ["Dockerfile"]
def test_fill_build_context_dir() -> None:
with (
tempfile.TemporaryDirectory() as source_dir,
tempfile.TemporaryDirectory() as build_dir,
):
# Create input files
with open(os.path.join(source_dir, "post_build.sh"), "w") as f:
f.write("#!/bin/bash\necho hello")
with open(os.path.join(source_dir, "deps.lock"), "w") as f:
f.write("numpy==1.0.0\npandas==2.0.0")
ctx = make_build_context(
base_dir=source_dir,
envs={"FOO": "bar", "BAZ": "qux"},
post_build_script="post_build.sh",
python_depset="deps.lock",
)
fill_build_context_dir(ctx, source_dir, build_dir)
# Check Dockerfile
with open(os.path.join(build_dir, "Dockerfile")) as f:
dockerfile = f.read()
expected_dockerfile = "\n".join(
[
"# syntax=docker/dockerfile:1.3-labs",
"ARG BASE_IMAGE",
"FROM ${BASE_IMAGE}",
"ENV \\",
" BAZ=qux \\",
" FOO=bar",
"COPY install_python_deps.sh /tmp/install_python_deps.sh",
"COPY python_depset.lock python_depset.lock",
"RUN bash /tmp/install_python_deps.sh python_depset.lock",
"COPY post_build_script.sh /tmp/post_build_script.sh",
"RUN bash /tmp/post_build_script.sh",
"",
]
)
assert dockerfile == expected_dockerfile
# Check copied files
with open(os.path.join(build_dir, "post_build_script.sh")) as f:
assert f.read() == "#!/bin/bash\necho hello"
with open(os.path.join(build_dir, "python_depset.lock")) as f:
assert f.read() == "numpy==1.0.0\npandas==2.0.0"
with open(os.path.join(build_dir, "install_python_deps.sh")) as f:
assert f.read() == _INSTALL_PYTHON_DEPS_SCRIPT
if __name__ == "__main__":
sys.exit(pytest.main(["-vv", __file__]))
@@ -0,0 +1,110 @@
import os
import sys
import tempfile
from unittest.mock import patch
import pytest
from ray_release.cloud_util import (
_parse_abfss_uri,
_upload_file_to_azure,
upload_working_dir_to_azure,
)
class FakeBlobServiceClient:
def __init__(self, account_url, credential):
self.account_url = account_url
self.credential = credential
self.blob_client = FakeBlobClient()
def get_blob_client(self, container, blob):
return self.blob_client
class FakeBlobClient:
def __init__(self):
self.uploaded_data = None
def upload_blob(self, data, overwrite=True):
self.uploaded_data = data.read()
def test_upload_file_to_azure():
with tempfile.TemporaryDirectory() as tmp_path:
local_file = os.path.join(tmp_path, "test.txt")
expected_content = "test content"
with open(local_file, "w") as f:
f.write(expected_content)
container = "test_container"
account = "test_account"
azure_path = f"abfss://{container}@{account}.dfs.core.windows.net/path/test.txt"
fake_blob_client = FakeBlobClient()
fake_blob_service_client = FakeBlobServiceClient(
f"https://{account}.blob.core.windows.net", "test-credential"
)
fake_blob_service_client.blob_client = fake_blob_client
_upload_file_to_azure(str(local_file), azure_path, fake_blob_service_client)
with open(local_file, "rb") as f:
expected_data = f.read()
assert fake_blob_client.uploaded_data == expected_data
@patch("ray_release.cloud_util._upload_file_to_azure")
def test_upload_working_dir_to_azure(mock_upload_file_to_azure):
with tempfile.TemporaryDirectory() as tmp_path:
working_dir = os.path.join(tmp_path, "working_dir")
os.makedirs(working_dir)
with open(os.path.join(working_dir, "test.txt"), "w") as f:
f.write("test content")
azure_directory_uri = (
"abfss://container@account.dfs.core.windows.net/path/working_dir"
)
upload_working_dir_to_azure(working_dir, azure_directory_uri)
args = mock_upload_file_to_azure.call_args.kwargs
assert args["local_file_path"].endswith(".zip")
assert args["azure_file_path"].startswith(f"{azure_directory_uri}/")
assert args["azure_file_path"].endswith(".zip")
@pytest.mark.parametrize(
"uri, expected_account, expected_container, expected_path",
[
(
"abfss://container@account.dfs.core.windows.net/path/test.txt",
"account",
"container",
"path/test.txt",
),
("abfss://container@account.dfs.core.windows.net/", "account", "container", ""),
(
"abfss://container@account.dfs.core.windows.net/path/",
"account",
"container",
"path/",
),
(
"abfss://container@account.dfs.core.windows.net/path/to/file.txt",
"account",
"container",
"path/to/file.txt",
),
(
"abfss://container-name@account-123.dfs.core.windows.net/path",
"account-123",
"container-name",
"path",
),
],
)
def test_parse_abfss_uri(uri, expected_account, expected_container, expected_path):
account, container, path = _parse_abfss_uri(uri)
assert account == expected_account
assert container == expected_container
assert path == expected_path
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
- name: test_name
group: test_group
working_dir: cluster_tests
frequency: nightly
team: team
cluster:
byod: {}
cluster_compute: cpt_autoscaling_1-3_aws.yaml
anyscale_sdk_2026: true
run:
timeout: 3600
script: test_script.py
+588
View File
@@ -0,0 +1,588 @@
import copy
import sys
import pytest
import yaml
from ray_release.config import (
CLOUD_ID_TO_NAME,
_substitute_variable,
get_test_cloud_name,
load_schema_file,
parse_test_definition,
read_and_validate_release_test_collection,
validate_cluster_compute,
validate_test,
)
from ray_release.exception import ReleaseTestConfigError
from ray_release.test import Test
_TEST_COLLECTION_FILES = [
"release/release_tests.yaml",
"release/release_data_tests.yaml",
"release/release_multimodal_inference_benchmarks_tests.yaml",
"release/ray_release/tests/test_collection_data.yaml",
]
VALID_TEST = {
"name": "validation_test",
"group": "validation_group",
"working_dir": "validation_dir",
"python": "3.10",
"frequency": "nightly",
"team": "release",
"cluster": {
"byod": {"type": "gpu"},
"cluster_compute": "tpl_cpu_small.yaml",
"autosuspend_mins": 10,
},
"run": {
"timeout": 100,
"script": "python validate.py",
"wait_for_nodes": {"num_nodes": 2, "timeout": 100},
"type": "client",
},
"smoke_test": {"run": {"timeout": 20}, "frequency": "nightly"},
"alert": "default",
}
def test_parse_test_definition():
"""
Unit test for the ray_release.config.parse_test_definition function. In particular,
we check that the code correctly parse a test definition that have the 'variations'
field.
"""
test_definitions = yaml.safe_load(
"""
- name: sample_test
working_dir: sample_dir
frequency: nightly
team: sample
cluster:
byod:
type: gpu
cluster_compute: compute.yaml
run:
timeout: 100
script: python script.py
variations:
- __suffix__: aws
- __suffix__: gce
cluster:
cluster_compute: compute_gce.yaml
"""
)
# Check that parsing returns two tests, one for each variation (aws and gce). Check
# that both tests are valid, and their fields are populated correctly
tests = parse_test_definition(test_definitions)
aws_test = tests[0]
gce_test = tests[1]
schema = load_schema_file()
assert not validate_test(aws_test, schema)
assert not validate_test(gce_test, schema)
assert aws_test["name"] == "sample_test.aws"
assert gce_test["cluster"]["cluster_compute"] == "compute_gce.yaml"
assert gce_test["cluster"]["byod"]["type"] == "gpu"
invalid_test_definition = test_definitions[0]
# Intentionally make the test definition invalid by create an empty 'variations'
# field. Check that the parser throws exception at runtime
invalid_test_definition["variations"] = []
with pytest.raises(ReleaseTestConfigError):
parse_test_definition([invalid_test_definition])
# Intentionally make the test definition invalid by making one 'variation' entry
# missing the __suffix__ entry. Check that the parser throws exception at runtime
invalid_test_definition["variations"] = [{"__suffix__": "aws"}, {}]
with pytest.raises(ReleaseTestConfigError):
parse_test_definition([invalid_test_definition])
def test_parse_test_definition_with_python_version():
"""
Unit test for the ray_release.config.parse_test_definition function. In particular,
we check that the code correctly parse a test definition that have the 'variations' & 'python'
field.
"""
test_definitions = yaml.safe_load(
"""
- name: sample_test
working_dir: sample_dir
frequency: nightly
team: sample
python: "3.10"
cluster:
byod:
type: gpu
cluster_compute: compute.yaml
run:
timeout: 100
script: python script.py
variations:
- __suffix__: aws
- __suffix__: gce
cluster:
cluster_compute: compute_gce.yaml
"""
)
# Check that parsing returns two tests, one for each variation (aws and gce). Check
# that both tests are valid, and their fields are populated correctly
tests = parse_test_definition(test_definitions)
aws_test = tests[0]
gce_test = tests[1]
schema = load_schema_file()
assert not validate_test(aws_test, schema)
assert not validate_test(gce_test, schema)
assert aws_test["name"] == "sample_test.aws"
assert gce_test["cluster"]["cluster_compute"] == "compute_gce.yaml"
assert gce_test["cluster"]["byod"]["type"] == "gpu"
invalid_test_definition = test_definitions[0]
# Intentionally make the test definition invalid by create an empty 'variations'
# field. Check that the parser throws exception at runtime
invalid_test_definition["variations"] = []
with pytest.raises(ReleaseTestConfigError):
parse_test_definition([invalid_test_definition])
# Intentionally make the test definition invalid by making one 'variation' entry
# missing the __suffix__ entry. Check that the parser throws exception at runtime
invalid_test_definition["variations"] = [{"__suffix__": "aws"}, {}]
with pytest.raises(ReleaseTestConfigError):
parse_test_definition([invalid_test_definition])
def test_parse_test_definition_with_defaults():
test_definitions = yaml.safe_load(
"""
- name: DEFAULTS
working_dir: default_working_dir
- name: sample_test_with_default_working_dir
frequency: nightly
team: sample
cluster:
byod:
type: gpu
cluster_compute: compute.yaml
run:
timeout: 100
script: python script.py
- name: sample_test_with_overridden_working_dir
working_dir: overridden_working_dir
frequency: nightly
team: sample
cluster:
byod:
type: gpu
cluster_compute: compute.yaml
run:
timeout: 100
script: python script.py
"""
)
test_with_default, test_with_override = parse_test_definition(test_definitions)
schema = load_schema_file()
assert not validate_test(test_with_default, schema)
assert not validate_test(test_with_override, schema)
assert test_with_default["working_dir"] == "default_working_dir"
assert test_with_override["working_dir"] == "overridden_working_dir"
def test_parse_test_definition_with_matrix_and_variations_raises():
# Matrix and variations are mutually exclusive.
test_definitions = yaml.safe_load(
"""
- name: test
frequency: nightly
team: team
working_dir: sample_dir
cluster:
byod:
type: gpu
cluster_compute: "{{os}}.yaml"
matrix:
setup:
os: [windows, linux]
run:
timeout: 100
script: python script.py
variations:
- __suffix__: amd64
- __suffix__: arm64
"""
)
with pytest.raises(ReleaseTestConfigError):
parse_test_definition(test_definitions)
def test_parse_test_definition_with_matrix_and_adjustments():
test_definitions = yaml.safe_load(
"""
- name: "test-{{compute}}-{{arg}}"
matrix:
setup:
compute: [fixed, autoscaling]
arg: [0, 1]
adjustments:
- with:
# Only run arg 2 with fixed compute
compute: fixed
arg: 2
frequency: nightly
team: team
working_dir: sample_dir
cluster:
byod:
type: gpu
runtime_env:
- SCALING_MODE={{compute}}
cluster_compute: "{{compute}}.yaml"
run:
timeout: 100
script: python script.py --arg "{{arg}}"
"""
)
tests = parse_test_definition(test_definitions)
schema = load_schema_file()
assert len(tests) == 5 # 4 from matrix, 1 from adjustments
assert not any(validate_test(test, schema) for test in tests)
for i, (compute, arg) in enumerate(
[
("fixed", 0),
("fixed", 1),
("autoscaling", 0),
("autoscaling", 1),
("fixed", 2),
]
):
assert tests[i]["name"] == f"test-{compute}-{arg}"
assert tests[i]["cluster"]["cluster_compute"] == f"{compute}.yaml"
assert tests[i]["cluster"]["byod"]["runtime_env"] == [f"SCALING_MODE={compute}"]
class TestSubstituteVariable:
def test_does_not_mutate_original(self):
test_definition = {"name": "test-{{arg}}"}
substituted = _substitute_variable(test_definition, "arg", "1")
assert substituted is not test_definition
assert test_definition == {"name": "test-{{arg}}"}
def test_substitute_variable_in_string(self):
test_definition = {"name": "test-{{arg}}"}
substituted = _substitute_variable(test_definition, "arg", "1")
assert substituted == {"name": "test-1"}
def test_substitute_variable_in_list(self):
test_definition = {"items": ["item-{{arg}}"]}
substituted = _substitute_variable(test_definition, "arg", "1")
assert substituted == {"items": ["item-1"]}
def test_substitute_variable_in_dict(self):
test_definition = {"outer": {"inner": "item-{{arg}}"}}
substituted = _substitute_variable(test_definition, "arg", "1")
assert substituted == {"outer": {"inner": "item-1"}}
def test_schema_validation():
test = VALID_TEST.copy()
schema = load_schema_file()
assert not validate_test(Test(**test), schema)
# Remove some optional arguments
del test["alert"]
del test["python"]
del test["run"]["wait_for_nodes"]
del test["cluster"]["autosuspend_mins"]
assert not validate_test(Test(**test), schema)
# Add some faulty arguments
# Faulty frequency
invalid_test = Test(**copy.deepcopy(VALID_TEST))
invalid_test["frequency"] = "invalid"
assert validate_test(invalid_test, schema)
# Faulty job type
invalid_test = Test(**copy.deepcopy(VALID_TEST))
invalid_test["run"]["type"] = "invalid"
assert validate_test(invalid_test, schema)
# Faulty file manager type
invalid_test = Test(**copy.deepcopy(VALID_TEST))
invalid_test["run"]["file_manager"] = "invalid"
assert validate_test(invalid_test, schema)
# Faulty smoke test
invalid_test = Test(**copy.deepcopy(VALID_TEST))
del invalid_test["smoke_test"]["frequency"]
assert validate_test(invalid_test, schema)
# Faulty Python version
invalid_test = Test(**copy.deepcopy(VALID_TEST))
invalid_test["python"] = "invalid"
assert validate_test(invalid_test, schema)
# Faulty BYOD type
invalid_test = Test(**copy.deepcopy(VALID_TEST))
invalid_test["cluster"]["byod"]["type"] = "invalid"
assert validate_test(invalid_test, schema)
# Faulty BYOD and Python version match
invalid_test = Test(**copy.deepcopy(VALID_TEST))
invalid_test["cluster"]["byod"]["type"] = "gpu"
invalid_test["python"] = "3.11"
assert validate_test(invalid_test, schema)
def test_compute_config_invalid_ebs():
compute_config = {
"aws": {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {
"VolumeSize": 1000,
},
}
]
}
}
assert validate_cluster_compute(compute_config)
compute_config["aws"]["BlockDeviceMappings"][0]["Ebs"][
"DeleteOnTermination"
] = False
assert validate_cluster_compute(compute_config)
compute_config["aws"]["BlockDeviceMappings"][0]["Ebs"]["DeleteOnTermination"] = True
assert not validate_cluster_compute(compute_config)
compute_config["head_node_type"] = {}
compute_config["head_node_type"]["aws_advanced_configurations"] = {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {
"VolumeSize": 1000,
},
}
]
}
assert validate_cluster_compute(compute_config)
compute_config["head_node_type"]["aws_advanced_configurations"][
"BlockDeviceMappings"
][0]["Ebs"]["DeleteOnTermination"] = False
assert validate_cluster_compute(compute_config)
compute_config["head_node_type"]["aws_advanced_configurations"][
"BlockDeviceMappings"
][0]["Ebs"]["DeleteOnTermination"] = True
assert not validate_cluster_compute(compute_config)
compute_config["worker_node_types"] = [{}]
compute_config["worker_node_types"][0]["aws_advanced_configurations"] = {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {
"VolumeSize": 1000,
},
}
]
}
assert validate_cluster_compute(compute_config)
compute_config["worker_node_types"][0]["aws_advanced_configurations"][
"BlockDeviceMappings"
][0]["Ebs"]["DeleteOnTermination"] = False
assert validate_cluster_compute(compute_config)
compute_config["worker_node_types"][0]["aws_advanced_configurations"][
"BlockDeviceMappings"
][0]["Ebs"]["DeleteOnTermination"] = True
assert not validate_cluster_compute(compute_config)
def test_validate_cluster_compute_new_schema_valid():
"""New schema: empty config is valid (all fields optional)."""
assert not validate_cluster_compute({}, is_new_schema=True)
def test_validate_cluster_compute_new_schema_valid_with_fields():
"""New schema: config with new-schema keys is valid."""
compute_config = {
"cloud": "my_cloud",
"head_node": {"instance_type": "m5.4xlarge"},
"worker_nodes": [{"instance_type": "m5.xlarge", "min_nodes": 1}],
}
assert not validate_cluster_compute(compute_config, is_new_schema=True)
def test_validate_cluster_compute_new_schema_rejects_legacy_keys():
"""New schema: config with legacy keys is rejected."""
compute_config = {
"cloud_id": "cld_123",
"head_node_type": {"instance_type": "m5.4xlarge"},
}
error = validate_cluster_compute(compute_config, is_new_schema=True)
assert error is not None
assert "legacy schema keys" in error
assert "anyscale_sdk_2026=true" in error
def test_validate_cluster_compute_legacy_rejects_new_keys():
"""Legacy schema: config with new-schema keys is rejected."""
compute_config = {
"cloud_id": "cld_123",
"head_node": {"instance_type": "m5.4xlarge"},
}
error = validate_cluster_compute(compute_config, is_new_schema=False)
assert error is not None
assert "new schema keys" in error
assert "anyscale_sdk_2026=false" in error
def test_validate_cluster_compute_legacy_rejects_empty():
"""Legacy schema: empty config is rejected (no legacy keys)."""
error = validate_cluster_compute({}, is_new_schema=False)
assert error is not None
assert "does not have legacy schema keys" in error
def test_validate_cluster_compute_new_schema_ebs_top_level():
"""New schema: EBS DeleteOnTermination is checked in top-level advanced_instance_config."""
compute_config = {
"advanced_instance_config": {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {"VolumeSize": 1000},
}
]
},
}
# Missing DeleteOnTermination should fail
assert validate_cluster_compute(compute_config, is_new_schema=True)
# Set DeleteOnTermination to True should pass
compute_config["advanced_instance_config"]["BlockDeviceMappings"][0]["Ebs"][
"DeleteOnTermination"
] = True
assert not validate_cluster_compute(compute_config, is_new_schema=True)
def test_validate_cluster_compute_new_schema_ebs_head_node():
"""New schema: EBS DeleteOnTermination is checked in head_node.advanced_instance_config."""
compute_config = {
"head_node": {
"instance_type": "m5.4xlarge",
"advanced_instance_config": {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {"VolumeSize": 1000},
}
]
},
},
}
# Missing DeleteOnTermination should fail
assert validate_cluster_compute(compute_config, is_new_schema=True)
# Set DeleteOnTermination to True should pass
compute_config["head_node"]["advanced_instance_config"]["BlockDeviceMappings"][0][
"Ebs"
]["DeleteOnTermination"] = True
assert not validate_cluster_compute(compute_config, is_new_schema=True)
def test_validate_cluster_compute_new_schema_ebs_worker_nodes():
"""New schema: EBS checks in worker_nodes[*].advanced_instance_config."""
compute_config = {
"worker_nodes": [
{
"instance_type": "m5.xlarge",
"advanced_instance_config": {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {"VolumeSize": 500},
}
]
},
}
],
}
assert validate_cluster_compute(compute_config, is_new_schema=True)
compute_config["worker_nodes"][0]["advanced_instance_config"][
"BlockDeviceMappings"
][0]["Ebs"]["DeleteOnTermination"] = True
assert not validate_cluster_compute(compute_config, is_new_schema=True)
def test_get_test_cloud_name_from_cluster_cloud():
"""get_test_cloud_name() returns cluster.cloud when set."""
test = Test(
{
"name": "test",
"cluster": {"cluster_compute": "tpl.yaml", "cloud": "my_cloud"},
}
)
assert get_test_cloud_name(test) == "my_cloud"
def test_get_test_cloud_name_from_cloud_id_mapping():
"""get_test_cloud_name() falls back to CLOUD_ID_TO_NAME mapping."""
for cloud_id, expected_name in CLOUD_ID_TO_NAME.items():
test = Test(
{
"name": "test",
"cluster": {"cluster_compute": "tpl.yaml", "cloud_id": cloud_id},
}
)
assert get_test_cloud_name(test) == expected_name
def test_get_test_cloud_name_unknown_cloud_id():
"""get_test_cloud_name() raises ReleaseTestConfigError for unknown cloud_id."""
test = Test(
{
"name": "test",
"cluster": {"cluster_compute": "tpl.yaml", "cloud_id": "cld_unknown"},
}
)
with pytest.raises(ReleaseTestConfigError):
get_test_cloud_name(test)
def test_load_and_validate_test_collection_file():
tests = read_and_validate_release_test_collection(_TEST_COLLECTION_FILES)
assert [test for test in tests if test.get_name() == "test_name"]
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,149 @@
import sys
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from ray_release.scripts.custom_byod_build import main
@patch("ray_release.scripts.custom_byod_build.build_anyscale_custom_byod_image")
def test_custom_byod_build(mock_build_anyscale_custom_byod_image):
mock_build_anyscale_custom_byod_image.return_value = None
runner = CliRunner()
result = runner.invoke(
main,
[
"--image-name",
"test-image",
"--base-image",
"test-base-image",
"--post-build-script",
"test_post_build_script.sh",
"--python-depset",
"python_depset.lock",
],
)
assert result.exit_code == 0
@patch("ray_release.scripts.custom_byod_build.build_anyscale_custom_byod_image")
def test_custom_byod_build_without_lock_file(
mock_build_anyscale_custom_byod_image,
):
mock_build_anyscale_custom_byod_image.return_value = None
runner = CliRunner()
result = runner.invoke(
main,
[
"--image-name",
"test-image",
"--base-image",
"test-base-image",
"--post-build-script",
"test_post_build_script.sh",
],
)
assert result.exit_code == 0
@patch("ray_release.scripts.custom_byod_build.build_anyscale_custom_byod_image")
def test_custom_byod_build_missing_arg(mock_build_anyscale_custom_byod_image):
mock_build_anyscale_custom_byod_image.return_value = None
runner = CliRunner()
result = runner.invoke(
main,
[
"--base-image",
"test-base-image",
"--post-build-script",
"test_post_build_script.sh",
],
)
assert result.exit_code == 2
assert "Error: Missing option '--image-name'" in result.output
result = runner.invoke(
main,
[
"--image-name",
"test-image",
"--post-build-script",
"test_post_build_script.sh",
],
)
assert result.exit_code == 2
assert "Error: Missing option '--base-image'" in result.output
result = runner.invoke(
main, ["--image-name", "test-image", "--base-image", "test-base-image"]
)
assert result.exit_code == 2
assert (
"At least one of post_build_script, python_depset, or env must be provided"
in result.output
)
result = runner.invoke(
main,
[
"--image-name",
"test-image",
"--base-image",
"test-base-image",
"--python-depset",
"python_depset.lock",
],
)
assert result.exit_code == 0
@patch("ray_release.scripts.custom_byod_build.build_anyscale_custom_byod_image")
def test_custom_byod_build_with_env(mock_build_anyscale_custom_byod_image):
mock_build_anyscale_custom_byod_image.return_value = None
runner = CliRunner()
result = runner.invoke(
main,
[
"--image-name",
"test-image",
"--base-image",
"test-base-image",
"--env",
"FOO=bar",
"--env",
"BAZ=qux",
],
)
assert result.exit_code == 0
build_context = mock_build_anyscale_custom_byod_image.call_args[0][2]
assert build_context["envs"] == {"FOO": "bar", "BAZ": "qux"}
assert "post_build_script" not in build_context
assert "python_depset" not in build_context
@patch("ray_release.scripts.custom_byod_build.build_anyscale_custom_byod_image")
def test_custom_byod_build_with_env_and_script(mock_build_anyscale_custom_byod_image):
mock_build_anyscale_custom_byod_image.return_value = None
runner = CliRunner()
result = runner.invoke(
main,
[
"--image-name",
"test-image",
"--base-image",
"test-base-image",
"--post-build-script",
"test_post_build_script.sh",
"--env",
"KEY=value",
],
)
assert result.exit_code == 0
build_context = mock_build_anyscale_custom_byod_image.call_args[0][2]
assert build_context["envs"] == {"KEY": "value"}
assert build_context["post_build_script"] == "test_post_build_script.sh"
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,347 @@
import os
import sys
import tempfile
from pathlib import Path
from unittest import mock
import pytest
import yaml
from ray_release.bazel import bazel_runfile
from ray_release.configs.global_config import get_global_config, init_global_config
from ray_release.custom_byod_build_init_helper import (
_get_step_name,
_short_tag,
build_short_gpu_map,
create_custom_build_yaml,
generate_custom_build_step_key,
get_prerequisite_step,
)
from ray_release.test import Test
# AZURE_REGISTRY_NAME import temporarily commented out: the Azure ACR login
# step it was used to verify is disabled due to CI issues.
# from ray_release.util import AZURE_REGISTRY_NAME
init_global_config(bazel_runfile("release/ray_release/configs/oss_config.yaml"))
_GPU_MAP = build_short_gpu_map(bazel_runfile("ray-images.json"))
@mock.patch.dict(os.environ, {"RAY_WANT_COMMIT_IN_IMAGE": "abc123"})
@mock.patch("ray_release.custom_byod_build_init_helper.get_images_from_tests")
def test_create_custom_build_yaml(mock_get_images_from_tests):
config = get_global_config()
custom_byod_images = [
(
"ray-project/ray-ml:abc123-custom-123456789abc123456789",
"ray-project/ray-ml:abc123-base",
"custom_script.sh",
None,
None,
),
(
"ray-project/ray-ml:abc123-custom1",
"ray-project/ray-ml:abc123-base",
"",
None,
None,
),
(
"ray-project/ray-ml:abc123-py37-cpu-custom-abcdef123456789abc123456789",
"ray-project/ray-ml:abc123-py37-cpu-base",
"custom_script.sh",
None,
None,
), # longer than 40 chars
(
"ray-project/ray-ml:abc123-py37-cpu-custom-abcdef123456789abc987654321",
"ray-project/ray-ml:abc123-py37-cpu-base",
"custom_script.sh",
"python_depset.lock",
None,
),
(
"custom_ecr/ray-ml:abc123-py37-cpu-custom-abcdef123456789abc987654321",
"anyscale/ray:2.50.0-py37-cpu",
"custom_script.sh",
"python_depset.lock",
None,
),
(
"ray-project/ray-ml:abc123-envonly-abcdef123456789abc000000000",
"ray-project/ray-ml:abc123-base",
None,
None,
{"MY_ENV": "my_value", "OTHER_ENV": "other_value"},
),
]
custom_image_test_names_map = {
"ray-project/ray-ml:abc123-custom-123456789abc123456789": ["test_1"],
"ray-project/ray-ml:abc123-custom1": ["test_2"],
"ray-project/ray-ml:abc123-py37-cpu-custom-abcdef123456789abc123456789": [
"test_1",
"test_2",
],
"ray-project/ray-ml:abc123-py37-cpu-custom-abcdef123456789abc987654321": [
"test_1",
"test_2",
],
"custom_ecr/ray-ml:abc123-py37-cpu-custom-abcdef123456789abc987654321": [
"test_3",
],
"ray-project/ray-ml:abc123-envonly-abcdef123456789abc000000000": [
"test_4",
],
}
mock_get_images_from_tests.return_value = (
custom_byod_images,
custom_image_test_names_map,
)
step_keys = [
generate_custom_build_step_key(image)
for image, _, _, _, _ in custom_byod_images
]
# List of dummy tests
tests = [
Test(
name="test_1",
frequency="manual",
group="test_group",
team="test_team",
working_dir="test_working_dir",
),
Test(
name="test_2",
frequency="manual",
group="test_group",
team="test_team",
working_dir="test_working_dir",
),
Test(
name="test_3",
frequency="manual",
group="test_group",
team="test_team",
working_dir="test_working_dir",
cluster={
"ray_version": "2.50.0",
},
),
Test(
name="test_4",
frequency="manual",
group="test_group",
team="test_team",
working_dir="test_working_dir",
),
]
with tempfile.TemporaryDirectory() as tmpdir:
create_custom_build_yaml(
os.path.join(tmpdir, "custom_byod_build.rayci.yml"),
tests,
_GPU_MAP,
)
with open(os.path.join(tmpdir, "custom_byod_build.rayci.yml"), "r") as f:
content = yaml.safe_load(f)
assert content["group"] == "Custom images build"
assert len(content["steps"]) == 5
assert (
content["steps"][0]["label"]
== f":tapioca: build custom: ray-ml:custom ({step_keys[0]}) test_1"
)
assert (
content["steps"][1]["label"]
== f":tapioca: build custom: ray-ml:py37-cpu-custom ({step_keys[2]}) test_1 test_2"
)
assert (
content["steps"][2]["label"]
== f":tapioca: build custom: ray-ml:py37-cpu-custom ({step_keys[3]}) test_1 test_2"
)
assert (
"export RAY_WANT_COMMIT_IN_IMAGE=abc123"
in content["steps"][0]["commands"][0]
)
assert (
content["steps"][0]["commands"][1]
== f"bash release/gcloud_docker_login.sh {config['aws2gce_credentials']}"
)
# Azure ACR login step is replaced by a placeholder echo while
# Azure image push is disabled due to CI issues. Verify the
# placeholder is present at the expected index instead of an
# `az acr login --name <AZURE_REGISTRY_NAME>` command.
assert "Skipping Azure ACR login" in content["steps"][0]["commands"][3]
assert (
f"--region {config['byod_ecr_region']}"
in content["steps"][0]["commands"][4]
)
assert f"{config['byod_ecr']}" in content["steps"][0]["commands"][4]
assert (
f"--image-name {custom_byod_images[0][0]}"
in content["steps"][0]["commands"][5]
)
assert (
f"--image-name {custom_byod_images[2][0]}"
in content["steps"][1]["commands"][5]
)
assert (
f"--image-name {custom_byod_images[3][0]}"
in content["steps"][2]["commands"][5]
)
assert content["steps"][3]["depends_on"] == "forge"
# Verify env-only image step has --env flags. Command index
# decremented from 6 to 5 because the Azure ACR login command was
# collapsed into a single placeholder echo while Azure image push
# is disabled due to CI issues.
env_only_cmd = content["steps"][4]["commands"][5]
assert f"--image-name {custom_byod_images[5][0]}" in env_only_cmd
assert "--env MY_ENV=my_value" in env_only_cmd
assert "--env OTHER_ENV=other_value" in env_only_cmd
# Env-only step should not have --post-build-script or --python-depset
assert "--post-build-script" not in env_only_cmd
assert "--python-depset" not in env_only_cmd
_ECR = get_global_config()["byod_ecr"]
@pytest.mark.parametrize(
("image", "base_image", "expected"),
[
# Anyscale-prefixed base images → "forge"
("anyscale/ray:abc123-custom", "anyscale/ray:abc123-base", "forge"),
("custom_ecr/ray-ml:abc123-custom", "anyscale/ray:2.50.0-py310-cpu", "forge"),
# ray-ml: python dimension only
(
"ray-project/ray-ml:a-c",
"ray-project/ray-ml:a-py310-gpu",
"anyscalemlbuild--python310",
),
(
"ray-project/ray-ml:a-c",
"ray-project/ray-ml:a-py311-gpu",
"anyscalemlbuild--python311",
),
# ray cpu → anyscalecpubuild (python only)
(
"ray-project/ray:a-c",
"ray-project/ray:a-py310-cpu",
"anyscalecpubuild--python310",
),
(
"ray-project/ray:a-c",
"ray-project/ray:a-py313-cpu",
"anyscalecpubuild--python313",
),
# ray cuda → anyscalecudabuild (platform + python)
(
"ray-project/ray:a-c",
"ray-project/ray:a-py311-cu123",
"anyscalecudabuild--gpucu1232cudnn9-python311",
),
(
"ray-project/ray:a-c",
"ray-project/ray:a-py312-cu130",
"anyscalecudabuild--gpucu1300cudnn-python312",
),
# ray-llm
(
"ray-project/ray-llm:a-c",
"ray-project/ray-llm:a-py311-cu128",
"anyscalellmbuild--gpucu1281cudnn-python311",
),
(
"ray-project/ray-llm:a-c",
"ray-project/ray-llm:a-py312-cu130",
"anyscalellmbuild--gpucu1300cudnn-python312",
),
# ECR-prefixed base_image (production format)
(
f"{_ECR}/anyscale/ray:bid-py310-cpu-hash",
f"{_ECR}/anyscale/ray:bid-py310-cpu",
"anyscalecpubuild--python310",
),
# Build ID with dashes
(
"ray-project/ray:a-b-c-x",
"ray-project/ray:a-b-c-py312-cpu",
"anyscalecpubuild--python312",
),
# Unknown platform falls back to raw value
(
"ray-project/ray:a-c",
"ray-project/ray:a-py310-cu999",
"anyscalecudabuild--gpucu999-python310",
),
# No python version → bare config key (cuda is default for ray without suffix)
("ray-project/ray:a-c", "ray-project/ray:a-base", "anyscalecudabuild"),
("ray-project/ray-ml:a-c", "ray-project/ray-ml:a-base", "anyscalemlbuild"),
],
ids=[
"forge-bare",
"forge-versioned",
"ray-ml-py310",
"ray-ml-py311",
"ray-cpu-py310",
"ray-cpu-py313",
"ray-cuda-cu123",
"ray-cuda-cu130",
"ray-llm-cu128",
"ray-llm-cu130",
"ecr-prefix",
"dashed-build-id",
"unknown-platform",
"no-python-ray",
"no-python-ray-ml",
],
)
def test_get_prerequisite_step(image, base_image, expected):
assert get_prerequisite_step(image, base_image, _GPU_MAP) == expected
@pytest.mark.parametrize(
"gpu, expected",
[
("cpu", "cpu"),
("tpu", "tpu"),
("cu12.3.2-cudnn9", "cu123"),
("cu11.8.0-cudnn8", "cu118"),
("cu12-cudnn9", "cu12"),
],
)
def test_short_tag(gpu, expected):
assert _short_tag(gpu) == expected
def test_short_gpu_map_built_from_ray_images_json():
"""The map built from ray-images.json has no short-tag collisions."""
ray_images_path = str(Path(bazel_runfile("ray-images.json")))
gpu_map = build_short_gpu_map(ray_images_path)
assert "cpu" in gpu_map
# Verify at least one CUDA gpu is present.
cuda_entries = {k: v for k, v in gpu_map.items() if k.startswith("cu")}
assert len(cuda_entries) > 0
# Verify round-trip: _short_tag(full) maps back to the same key.
for short, full in gpu_map.items():
assert (
_short_tag(full) == short
), f"_short_tag({full!r}) = {_short_tag(full)!r}, expected {short!r}"
def test_get_step_name():
test_names = [
"test_1",
"test_2",
"test_3",
]
assert (
_get_step_name(
"ray-project/ray-ml:a1b2c3d4-py39-cpu-abcdef123456789abc123456789",
"abc123",
test_names,
)
== ":tapioca: build custom: ray-ml:py39-cpu (abc123) test_1 test_2"
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,489 @@
import json
import os
import sys
from unittest.mock import patch
import pytest
import yaml
from click.testing import CliRunner
from ray_release.buildkite.filter import filter_tests
from ray_release.buildkite.settings import get_frequency, get_test_filters
from ray_release.config import read_and_validate_release_test_collection
from ray_release.configs.global_config import init_global_config
from ray_release.custom_byod_build_init_helper import (
build_short_gpu_map,
collect_rayci_select_keys,
)
from ray_release.scripts.custom_image_build_and_test_init import (
_split_into_batches,
main,
)
_bazel_workspace_dir = os.environ.get("BUILD_WORKSPACE_DIRECTORY", "")
def _expected_rayci_select_keys(sample_yaml: str) -> set:
"""Mirror the script's filter + key computation so expectations can't drift."""
init_global_config(
os.path.join(
_bazel_workspace_dir,
"release/ray_release/configs/oss_config.yaml",
)
)
gpu_map = build_short_gpu_map(os.path.join(_bazel_workspace_dir, "ray-images.json"))
test_collection = read_and_validate_release_test_collection(
[os.path.join(_bazel_workspace_dir, sample_yaml)]
)
filtered = filter_tests(
test_collection,
frequency=get_frequency("nightly"),
test_filters=get_test_filters("prefix:hello_world"),
run_jailed_tests=True,
run_unstable_tests=True,
)
tests = [test for test, _ in filtered]
return collect_rayci_select_keys(tests, gpu_map)
@patch.dict("os.environ", {"BUILDKITE": "1"})
@patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"})
@patch("ray_release.test.Test.update_from_s3", return_value=None)
@patch("ray_release.test.Test.is_jailed_with_open_issue", return_value=False)
def test_custom_image_build_and_test_init(
mock_update_from_s3, mock_is_jailed_with_open_issue
):
runner = CliRunner()
custom_build_jobs_output_file = "custom_build_jobs.yaml"
test_jobs_output_file = "test_jobs.json"
rayci_select_output_file = "rayci_select.txt"
result = runner.invoke(
main,
[
"--test-collection-file",
"release/ray_release/tests/sample_tests.yaml",
"--global-config",
"oss_config.yaml",
"--frequency",
"nightly",
"--run-jailed-tests",
"--run-unstable-tests",
"--test-filters",
"prefix:hello_world",
"--custom-build-jobs-output-file",
custom_build_jobs_output_file,
"--test-jobs-output-file",
test_jobs_output_file,
"--rayci-select-output-file",
rayci_select_output_file,
],
catch_exceptions=False,
)
with open(
os.path.join(_bazel_workspace_dir, custom_build_jobs_output_file), "r"
) as f:
custom_build_jobs = yaml.safe_load(f)
assert len(custom_build_jobs["steps"]) == 1 # 1 custom build job
with open(
os.path.join(
_bazel_workspace_dir, f"{os.path.splitext(test_jobs_output_file)[0]}_0.json"
),
"r",
) as f:
test_jobs = json.load(f)
assert len(test_jobs) == 1 # 1 group
assert len(test_jobs[0]["steps"]) == 2 # 2 tests
assert test_jobs[0]["steps"][0]["label"].startswith("hello_world.aws")
assert test_jobs[0]["steps"][1]["label"].startswith("hello_world_custom.aws")
with open(os.path.join(_bazel_workspace_dir, rayci_select_output_file), "r") as f:
raw = f.read()
keys = [k for k in raw.split(",") if k]
expected = _expected_rayci_select_keys(
"release/ray_release/tests/sample_tests.yaml"
)
assert set(keys) == expected
assert len(keys) == len(set(keys)) # no duplicates
assert keys == sorted(keys) # stable ordering
assert len(expected) == 3 # cpu base + gpu base + custom-BYOD
assert result.exit_code == 0
@patch.dict("os.environ", {"BUILDKITE": "1"})
@patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"})
@patch("ray_release.test.Test.update_from_s3", return_value=None)
@patch("ray_release.test.Test.is_jailed_with_open_issue", return_value=False)
def test_custom_image_build_and_test_init_with_block_step(
mock_update_from_s3, mock_is_jailed_with_open_issue
):
num_tests_expected = 5
runner = CliRunner()
custom_build_jobs_output_file = "custom_build_jobs.yaml"
test_jobs_output_file = "test_jobs.json"
rayci_select_output_file = "rayci_select.txt"
result = runner.invoke(
main,
[
"--test-collection-file",
"release/ray_release/tests/sample_5_tests.yaml",
"--global-config",
"oss_config.yaml",
"--frequency",
"nightly",
"--run-jailed-tests",
"--run-unstable-tests",
"--test-filters",
"prefix:hello_world",
"--custom-build-jobs-output-file",
custom_build_jobs_output_file,
"--test-jobs-output-file",
test_jobs_output_file,
"--rayci-select-output-file",
rayci_select_output_file,
"--selection-block-threshold",
"5",
],
catch_exceptions=False,
)
with open(
os.path.join(_bazel_workspace_dir, custom_build_jobs_output_file), "r"
) as f:
custom_build_jobs = yaml.safe_load(f)
assert len(custom_build_jobs["steps"]) == 1 # 1 custom build job
with open(
os.path.join(
_bazel_workspace_dir, f"{os.path.splitext(test_jobs_output_file)[0]}_0.json"
),
"r",
) as f:
test_jobs = json.load(f)
print(test_jobs)
assert len(test_jobs) == 2 # 2 groups: block and hello_world
assert len(test_jobs[0]["steps"]) == 1 # 1 block step
assert test_jobs[0]["steps"][0]["block"] == "Run release tests"
assert test_jobs[0]["steps"][0]["key"] == "block_run_release_tests"
assert (
test_jobs[0]["steps"][0]["prompt"]
== f"You are triggering {num_tests_expected} tests. Do you want to proceed?"
)
assert len(test_jobs[1]["steps"]) == num_tests_expected # 5 tests
assert test_jobs[1]["steps"][0]["label"].startswith("hello_world.aws")
assert test_jobs[1]["steps"][1]["label"].startswith("hello_world_custom.aws")
with open(os.path.join(_bazel_workspace_dir, rayci_select_output_file), "r") as f:
raw = f.read()
keys = [k for k in raw.split(",") if k]
expected = _expected_rayci_select_keys(
"release/ray_release/tests/sample_5_tests.yaml"
)
assert set(keys) == expected
assert len(keys) == len(set(keys)) # no duplicates
assert keys == sorted(keys) # stable ordering
assert len(expected) == 3 # cpu base + gpu base + custom-BYOD (collapsed)
assert result.exit_code == 0
@patch.dict("os.environ", {"AUTOMATIC": "1"})
@patch.dict("os.environ", {"BUILDKITE": "1"})
@patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"})
@patch("ray_release.test.Test.update_from_s3", return_value=None)
@patch("ray_release.test.Test.is_jailed_with_open_issue", return_value=False)
def test_custom_image_build_and_test_init_without_block_step_automatic(
mock_update_from_s3, mock_is_jailed_with_open_issue
):
num_tests_expected = 5
runner = CliRunner()
custom_build_jobs_output_file = "custom_build_jobs.yaml"
test_jobs_output_file = "test_jobs.json"
result = runner.invoke(
main,
[
"--test-collection-file",
"release/ray_release/tests/sample_5_tests.yaml",
"--global-config",
"oss_config.yaml",
"--frequency",
"nightly",
"--run-jailed-tests",
"--run-unstable-tests",
"--test-filters",
"prefix:hello_world",
"--custom-build-jobs-output-file",
custom_build_jobs_output_file,
"--test-jobs-output-file",
test_jobs_output_file,
],
catch_exceptions=False,
)
with open(
os.path.join(_bazel_workspace_dir, custom_build_jobs_output_file), "r"
) as f:
custom_build_jobs = yaml.safe_load(f)
assert len(custom_build_jobs["steps"]) == 1 # 1 custom build job
with open(
os.path.join(
_bazel_workspace_dir, f"{os.path.splitext(test_jobs_output_file)[0]}_0.json"
),
"r",
) as f:
test_jobs = json.load(f)
print(test_jobs)
assert len(test_jobs) == 1 # 1 group: hello_world
assert len(test_jobs[0]["steps"]) == num_tests_expected # 5 tests
assert test_jobs[0]["steps"][0]["label"].startswith("hello_world.aws")
assert test_jobs[0]["steps"][1]["label"].startswith("hello_world_custom.aws")
assert result.exit_code == 0
@patch.dict("os.environ", {"BUILDKITE": "1"})
@patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"})
@patch("ray_release.test.Test.update_from_s3", return_value=None)
@patch("ray_release.test.Test.is_jailed_with_open_issue", return_value=False)
def test_rayci_select_skipped_when_no_filter(
mock_update_from_s3, mock_is_jailed_with_open_issue
):
"""Unfiltered runs (e.g. full nightly) skip RAYCI_SELECT so rayci runs everything."""
runner = CliRunner()
custom_build_jobs_output_file = "custom_build_jobs.yaml"
test_jobs_output_file = "test_jobs.json"
rayci_select_output_file = "rayci_select_no_filter.txt"
abs_path = os.path.join(_bazel_workspace_dir, rayci_select_output_file)
if os.path.exists(abs_path):
os.remove(abs_path)
result = runner.invoke(
main,
[
"--test-collection-file",
"release/ray_release/tests/sample_tests.yaml",
"--global-config",
"oss_config.yaml",
"--frequency",
"nightly",
"--run-jailed-tests",
"--run-unstable-tests",
"--custom-build-jobs-output-file",
custom_build_jobs_output_file,
"--test-jobs-output-file",
test_jobs_output_file,
"--rayci-select-output-file",
rayci_select_output_file,
],
catch_exceptions=False,
)
assert result.exit_code == 0
assert not os.path.exists(abs_path)
def test_split_into_batches_single_batch():
"""All groups fit under the limit → 1 batch."""
steps = [
{"group": "a", "steps": [{"label": "1"}, {"label": "2"}]},
{"group": "b", "steps": [{"label": "3"}]},
]
batches = _split_into_batches(steps, max_jobs=500)
assert len(batches) == 1
assert batches[0] == steps
def test_split_into_batches_multi_batch():
"""Groups that don't fit together are split across batches."""
# Each group counts as 1 + len(steps) jobs (Buildkite counts the group
# itself). group a = 1+3 = 4, group b = 1+3 = 4, group c = 1+2 = 3.
steps = [
{"group": "a", "steps": [{"x": 1}, {"x": 2}, {"x": 3}]},
{"group": "b", "steps": [{"x": 4}, {"x": 5}, {"x": 6}]},
{"group": "c", "steps": [{"x": 7}, {"x": 8}]},
]
batches = _split_into_batches(steps, max_jobs=5)
# a alone = 4 jobs, + b (4) would be 8 > 5, so split
# batch 0: [a] (4 jobs)
# batch 1: [b] (4 jobs)
# batch 2: [c] (3 jobs)
assert [[g["group"] for g in batch] for batch in batches] == [
["a"],
["b"],
["c"],
]
def test_split_into_batches_packs_small_groups():
"""Small groups are packed into the same batch when they fit."""
steps = [
{"group": "a", "steps": [{"x": 1}]}, # 2 jobs
{"group": "b", "steps": [{"x": 2}]}, # 2 jobs
{"group": "c", "steps": [{"x": 3}]}, # 2 jobs
]
batches = _split_into_batches(steps, max_jobs=5)
# a+b=4 jobs fits; +c=6 > 5, so c goes to batch 1
assert [[g["group"] for g in batch] for batch in batches] == [
["a", "b"],
["c"],
]
def test_split_into_batches_oversized_group_raises():
"""A single group exceeding the limit is a user error; raise."""
steps = [
{"group": "huge", "steps": [{"x": i} for i in range(10)]}, # 11 jobs
]
with pytest.raises(ValueError, match="exceeds the limit"):
_split_into_batches(steps, max_jobs=5)
def test_split_into_batches_counts_parallelism():
"""A step with parallelism=N counts as N jobs."""
steps = [
{
"group": "a",
"steps": [{"parallelism": 4}, {"label": "x"}],
}, # 1 + 4 + 1 = 6 jobs
]
batches = _split_into_batches(steps, max_jobs=6)
assert len(batches) == 1
with pytest.raises(ValueError, match="exceeds the limit"):
_split_into_batches(steps, max_jobs=5)
@patch.dict("os.environ", {"AUTOMATIC": "1"})
@patch.dict("os.environ", {"BUILDKITE": "1"})
@patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"})
@patch("ray_release.test.Test.update_from_s3", return_value=None)
@patch("ray_release.test.Test.is_jailed_with_open_issue", return_value=False)
def test_custom_image_build_and_test_init_writes_indexed_chunk_file(
mock_update_from_s3, mock_is_jailed_with_open_issue
):
"""Single-batch runs should still write an indexed chunk file (no un-suffixed path)."""
runner = CliRunner()
custom_build_jobs_output_file = "custom_build_jobs.yaml"
test_jobs_output_file = "test_jobs_indexed.json"
result = runner.invoke(
main,
[
"--test-collection-file",
"release/ray_release/tests/sample_5_tests.yaml",
"--global-config",
"oss_config.yaml",
"--frequency",
"nightly",
"--run-jailed-tests",
"--run-unstable-tests",
"--test-filters",
"prefix:hello_world",
"--custom-build-jobs-output-file",
custom_build_jobs_output_file,
"--test-jobs-output-file",
test_jobs_output_file,
],
catch_exceptions=False,
)
assert result.exit_code == 0
# Default max (450) is well above 5 tests, so expect exactly one chunk.
assert os.path.exists(
os.path.join(_bazel_workspace_dir, "test_jobs_indexed_0.json")
)
# The un-suffixed path must not exist — callers should glob *_N.json.
assert not os.path.exists(os.path.join(_bazel_workspace_dir, test_jobs_output_file))
@patch.dict("os.environ", {"AUTOMATIC": "1"})
@patch.dict("os.environ", {"BUILDKITE": "1"})
@patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"})
@patch("ray_release.test.Test.update_from_s3", return_value=None)
@patch("ray_release.test.Test.is_jailed_with_open_issue", return_value=False)
def test_custom_image_build_and_test_init_cleans_stale_chunks(
mock_update_from_s3, mock_is_jailed_with_open_issue
):
"""Chunks from a previous run must be removed before writing new ones.
Persistent Buildkite checkouts keep the workspace between runs; a stale
release_tests_2.json from a prior invocation would otherwise get picked
up by the upload-loop and inject outdated steps into the pipeline.
"""
stale_path = os.path.join(_bazel_workspace_dir, "test_jobs_stale_9.json")
with open(stale_path, "wt") as fp:
json.dump([{"group": "leftover", "steps": [{"command": "echo stale"}]}], fp)
assert os.path.exists(stale_path)
runner = CliRunner()
result = runner.invoke(
main,
[
"--test-collection-file",
"release/ray_release/tests/sample_5_tests.yaml",
"--global-config",
"oss_config.yaml",
"--frequency",
"nightly",
"--run-jailed-tests",
"--run-unstable-tests",
"--test-filters",
"prefix:hello_world",
"--custom-build-jobs-output-file",
"custom_build_jobs.yaml",
"--test-jobs-output-file",
"test_jobs_stale.json",
],
catch_exceptions=False,
)
assert result.exit_code == 0
assert not os.path.exists(
stale_path
), f"Stale chunk file {stale_path} was not cleaned up"
@patch(
"ray_release.scripts.custom_image_build_and_test_init.subprocess.run",
return_value=None,
)
@patch.dict("os.environ", {"AUTOMATIC": "1"})
@patch.dict("os.environ", {"BUILDKITE": "1"})
@patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"})
@patch("ray_release.test.Test.update_from_s3", return_value=None)
@patch("ray_release.test.Test.is_jailed_with_open_issue", return_value=False)
def test_custom_image_build_and_test_init_uploads_chunks(
mock_update_from_s3, mock_is_jailed_with_open_issue, mock_subprocess_run
):
"""--upload-to-buildkite shells out to buildkite-agent once per chunk."""
runner = CliRunner()
test_jobs_output_file = "test_jobs_upload.json"
result = runner.invoke(
main,
[
"--test-collection-file",
"release/ray_release/tests/sample_5_tests.yaml",
"--global-config",
"oss_config.yaml",
"--frequency",
"nightly",
"--run-jailed-tests",
"--run-unstable-tests",
"--test-filters",
"prefix:hello_world",
"--custom-build-jobs-output-file",
"custom_build_jobs.yaml",
"--test-jobs-output-file",
test_jobs_output_file,
"--upload-to-buildkite",
],
catch_exceptions=False,
)
assert result.exit_code == 0
# Filter for pipeline uploads; settings loading also calls
# `buildkite-agent meta-data get ...` through the same patched subprocess.
upload_calls = [
c
for c in mock_subprocess_run.call_args_list
if c.args and c.args[0][:3] == ["buildkite-agent", "pipeline", "upload"]
]
# 5 tests under default max (450) → exactly one chunk → one upload call.
assert len(upload_calls) == 1
cmd = upload_calls[0].args[0]
assert cmd[3].endswith("test_jobs_upload_0.json")
assert upload_calls[0].kwargs.get("check") is True
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+45
View File
@@ -0,0 +1,45 @@
import os
import pytest
from ray_release.config import DEFAULT_ANYSCALE_PROJECT
from ray_release.env import load_environment, populate_os_env
from ray_release.exception import ReleaseTestConfigError
from ray_release.util import DeferredEnvVar
TEST_ENV_VAR = DeferredEnvVar(
"TEST_ENV_VAR",
"value1",
)
def test_deferred_env_var():
assert str(TEST_ENV_VAR) == "value1"
os.environ["TEST_ENV_VAR"] = "other2"
assert str(TEST_ENV_VAR) == "other2"
def test_load_env_invalid():
with pytest.raises(ReleaseTestConfigError):
load_environment("invalid")
def test_load_env_changes():
old_val = str(DEFAULT_ANYSCALE_PROJECT)
env_dict = load_environment("aws")
populate_os_env(env_dict)
new_val = str(DEFAULT_ANYSCALE_PROJECT)
assert new_val
assert old_val != new_val
assert "prj_" in new_val
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,449 @@
"""
Tests for the GitHub REST API client.
Uses the `responses` library to intercept outbound HTTP requests without
patching Python internals, so tests exercise the full client code path:
URL construction, headers, JSON parsing, and dataclass population.
"""
import sys
import pytest
import responses
from ray_release.github_client import (
GitHubClient,
GitHubException,
GitHubIssue,
GitHubLabel,
GitHubPull,
GitHubRepo,
)
REPO = "owner/repo"
BASE = "https://api.github.com"
def _client() -> GitHubClient:
return GitHubClient("test-token")
def _repo(client: GitHubClient = None) -> GitHubRepo:
return (_client() if client is None else client).get_repo(REPO)
def _issue_json(**overrides) -> dict:
base = {
"number": 42,
"state": "open",
"title": "Flaky test in CI",
"html_url": f"https://github.com/{REPO}/issues/42",
"labels": [{"name": "bug"}, {"name": "core"}],
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# GitHubClient — auth headers
# ---------------------------------------------------------------------------
@responses.activate
def test_auth_header_is_sent():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/pulls/1",
json={"number": 1, "user": {"login": "octocat"}},
)
client = GitHubClient("my-secret-token")
client.get_repo(REPO).get_pull(1)
sent_headers = responses.calls[0].request.headers
assert sent_headers["Authorization"] == "Bearer my-secret-token"
assert sent_headers["Accept"] == "application/vnd.github+json"
assert "X-GitHub-Api-Version" in sent_headers
@responses.activate
def test_token_not_leaked_on_api_error():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/pulls/1",
json={"message": "Bad credentials"},
status=401,
)
secret = "super-secret-token-xyz"
with pytest.raises(GitHubException) as exc_info:
GitHubClient(secret).get_repo(REPO).get_pull(1)
exc = exc_info.value
assert secret not in str(exc)
assert secret not in str(exc.data)
assert secret not in str(dict(exc.headers))
# ---------------------------------------------------------------------------
# GitHubRepo.get_issue
# ---------------------------------------------------------------------------
@responses.activate
def test_get_issue_returns_populated_issue():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(),
)
issue = _repo().get_issue(42)
assert isinstance(issue, GitHubIssue)
assert issue.number == 42
assert issue.state == "open"
assert issue.title == "Flaky test in CI"
assert issue.html_url == f"https://github.com/{REPO}/issues/42"
@responses.activate
def test_get_issue_populates_labels():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(
labels=[{"name": "weekly-release-blocker"}, {"name": "serve"}]
),
)
issue = _repo().get_issue(42)
labels = issue.get_labels()
assert len(labels) == 2
assert labels[0].name == "weekly-release-blocker"
assert labels[1].name == "serve"
@responses.activate
def test_get_issue_with_no_labels():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(labels=[]),
)
issue = _repo().get_issue(42)
assert issue.get_labels() == []
@responses.activate
def test_get_issue_raises_github_exception_on_404():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/9999",
json={"message": "Not Found"},
status=404,
)
with pytest.raises(GitHubException) as exc_info:
_repo().get_issue(9999)
assert exc_info.value.status == 404
assert exc_info.value.data == {"message": "Not Found"}
# ---------------------------------------------------------------------------
# GitHubRepo.get_label + get_issues
# ---------------------------------------------------------------------------
def test_get_label_returns_label_with_name():
label = _repo().get_label("weekly-release-blocker")
assert isinstance(label, GitHubLabel)
assert label.name == "weekly-release-blocker"
@responses.activate
def test_get_issues_passes_state_and_label_params():
responses.add(responses.GET, f"{BASE}/repos/{REPO}/issues", json=[])
repo = _repo()
label = repo.get_label("weekly-release-blocker")
repo.get_issues(state="open", labels=[label])
qs = responses.calls[0].request.url
assert "state=open" in qs
assert "labels=weekly-release-blocker" in qs
assert "per_page=100" in qs
@responses.activate
def test_get_issues_returns_list_of_issues():
page1 = [_issue_json(number=1, title="A"), _issue_json(number=2, title="B")]
responses.add(responses.GET, f"{BASE}/repos/{REPO}/issues", json=page1)
issues = _repo().get_issues(state="open")
assert len(issues) == 2
assert issues[0].number == 1
assert issues[1].number == 2
@responses.activate
def test_get_issues_paginates():
page1 = [_issue_json(number=i) for i in range(100)]
page2 = [_issue_json(number=i) for i in range(100, 115)]
next_url = f"{BASE}/repos/{REPO}/issues?page=2&per_page=100"
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues",
json=page1,
headers={"Link": f'<{next_url}>; rel="next"'},
)
responses.add(responses.GET, next_url, json=page2)
issues = _repo().get_issues()
assert len(issues) == 115
@responses.activate
def test_get_issues_raises_on_error():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues",
json={"message": "Forbidden"},
status=403,
)
with pytest.raises(GitHubException) as exc_info:
_repo().get_issues()
assert exc_info.value.status == 403
# ---------------------------------------------------------------------------
# GitHubRepo.get_pull
# ---------------------------------------------------------------------------
@responses.activate
def test_get_pull_returns_number_and_user_login():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/pulls/99",
json={"number": 99, "user": {"login": "octocat"}},
)
pull = _repo().get_pull(99)
assert isinstance(pull, GitHubPull)
assert pull.number == 99
assert pull.user.login == "octocat"
@responses.activate
def test_get_pull_raises_on_error():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/pulls/9999",
json={"message": "Not Found"},
status=404,
)
with pytest.raises(GitHubException) as exc_info:
_repo().get_pull(9999)
assert exc_info.value.status == 404
# ---------------------------------------------------------------------------
# GitHubIssue.create_comment
# ---------------------------------------------------------------------------
@responses.activate
def test_create_comment_posts_to_correct_url():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(),
)
responses.add(
responses.POST,
f"{BASE}/repos/{REPO}/issues/42/comments",
json={"id": 1},
status=201,
)
issue = _repo().get_issue(42)
issue.create_comment("Test has been failing. Jailing.")
post_body = responses.calls[1].request.body
assert b"Test has been failing. Jailing." in post_body
@responses.activate
def test_create_comment_raises_on_error():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(),
)
responses.add(
responses.POST,
f"{BASE}/repos/{REPO}/issues/42/comments",
json={"message": "Unprocessable"},
status=422,
)
issue = _repo().get_issue(42)
with pytest.raises(GitHubException) as exc_info:
issue.create_comment("hi")
assert exc_info.value.status == 422
# ---------------------------------------------------------------------------
# GitHubIssue.edit
# ---------------------------------------------------------------------------
@responses.activate
def test_edit_state_sends_patch_and_updates_local_state():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(state="open"),
)
responses.add(
responses.PATCH,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(state="closed"),
status=200,
)
issue = _repo().get_issue(42)
assert issue.state == "open"
issue.edit(state="closed")
assert issue.state == "closed"
patch_body = responses.calls[1].request.body
assert b'"closed"' in patch_body
@responses.activate
def test_edit_labels_sends_patch_without_state():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(),
)
responses.add(
responses.PATCH,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(labels=[{"name": "jailed-test"}, {"name": "core"}]),
status=200,
)
issue = _repo().get_issue(42)
issue.edit(labels=["jailed-test", "core"])
patch_body = responses.calls[1].request.body
assert b"jailed-test" in patch_body
assert b"state" not in patch_body
labels = issue.get_labels()
assert len(labels) == 2
assert labels[0].name == "jailed-test"
assert labels[1].name == "core"
@responses.activate
def test_edit_title_sends_patch_and_updates_local_title():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(title="Old title"),
)
responses.add(
responses.PATCH,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(title="New title", state="open"),
status=200,
)
issue = _repo().get_issue(42)
issue.edit(title="New title", state="open")
patch_body = responses.calls[1].request.body
assert b"New title" in patch_body
assert issue.title == "New title"
@responses.activate
def test_edit_raises_on_error():
responses.add(
responses.GET,
f"{BASE}/repos/{REPO}/issues/42",
json=_issue_json(),
)
responses.add(
responses.PATCH,
f"{BASE}/repos/{REPO}/issues/42",
json={"message": "Not Found"},
status=404,
)
issue = _repo().get_issue(42)
with pytest.raises(GitHubException) as exc_info:
issue.edit(state="closed")
assert exc_info.value.status == 404
# ---------------------------------------------------------------------------
# GitHubRepo.create_issue
# ---------------------------------------------------------------------------
@responses.activate
def test_create_issue_posts_and_returns_issue():
responses.add(
responses.POST,
f"{BASE}/repos/{REPO}/issues",
json=_issue_json(number=99, title="New bug", state="open"),
status=201,
)
issue = _repo().create_issue(title="New bug", body="It broke.", labels=["bug"])
assert issue.number == 99
assert issue.title == "New bug"
assert issue.state == "open"
post_body = responses.calls[0].request.body
assert b"New bug" in post_body
assert b"It broke." in post_body
assert b"bug" in post_body
@responses.activate
def test_create_issue_raises_on_error():
responses.add(
responses.POST,
f"{BASE}/repos/{REPO}/issues",
json={"message": "Forbidden"},
status=403,
)
with pytest.raises(GitHubException) as exc_info:
_repo().create_issue(title="New bug", body="body")
assert exc_info.value.status == 403
# ---------------------------------------------------------------------------
# GitHubException
# ---------------------------------------------------------------------------
def test_github_exception_stores_status_and_data():
exc = GitHubException(404, {"message": "Not Found"}, {"x-header": "val"})
assert exc.status == 404
assert exc.data == {"message": "Not Found"}
assert exc.headers == {"x-header": "val"}
assert "404" in str(exc)
def test_github_exception_is_exception():
assert isinstance(GitHubException(500), Exception)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,117 @@
import os
import sys
from tempfile import TemporaryDirectory
import pytest
from ray_release.configs.global_config import (
get_global_config,
init_global_config,
)
_TEST_CONFIG = """
byod:
ray_cr_repo: ray
release_byod:
ray_ml_cr_repo: ray-ml
ray_llm_cr_repo: ray-llm
byod_ecr: 029272617770.dkr.ecr.us-west-2.amazonaws.com
byod_ecr_region: us-west-2
gcp_cr: us-west1-docker.pkg.dev/anyscale-oss-ci
azure_cr: rayreleasetest.azurecr.io
state_machine:
pr:
aws_bucket: ray-ci-pr-results
branch:
aws_bucket: ray-ci-results
disabled: 1
github_repo: anyscale/ray
bisect:
disabled: 1
credentials:
aws2gce: release/aws2gce_iam.json
ci_pipeline:
buildkite_org: ray-project
premerge:
- w00t
postmerge:
- hi
- three
release_image_step:
ray_cpu: anyscalecpubuild
ray_cuda: anyscalecudabuild
ray_ml: anyscalemlbuild
ray_llm: anyscalellmbuild
"""
def test_init_global_config() -> None:
with TemporaryDirectory() as tmp:
config_file = os.path.join(tmp, "config")
with open(config_file, "w") as f:
f.write(_TEST_CONFIG)
init_global_config(os.path.join(tmp, "config"))
config = get_global_config()
assert config["aws2gce_credentials"] == "release/aws2gce_iam.json"
assert config["ci_pipeline_premerge"] == ["w00t"]
assert config["ci_pipeline_postmerge"] == ["hi", "three"]
assert (
os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
== "/workdir/release/aws2gce_iam.json"
)
assert config["state_machine_pr_aws_bucket"] == "ray-ci-pr-results"
assert config["state_machine_disabled"] is True
assert config["release_image_step_ray_cpu"] == "anyscalecpubuild"
assert config["release_image_step_ray_cuda"] == "anyscalecudabuild"
assert config["release_image_step_ray_ml"] == "anyscalemlbuild"
assert config["release_image_step_ray_llm"] == "anyscalellmbuild"
assert config["byod_ecr"] == "029272617770.dkr.ecr.us-west-2.amazonaws.com"
assert config["byod_ecr_region"] == "us-west-2"
assert config["byod_gcp_cr"] == "us-west1-docker.pkg.dev/anyscale-oss-ci"
assert config["byod_azure_cr"] == "rayreleasetest.azurecr.io"
assert config["state_machine_branch_aws_bucket"] == "ray-ci-results"
assert config["state_machine_github_repo"] == "anyscale/ray"
assert config["buildkite_org"] == "ray-project"
assert config["state_machine_bisect_disabled"] is True
_TEST_CONFIG_NO_OPTIONAL = """
release_byod:
byod_ecr: 029272617770.dkr.ecr.us-west-2.amazonaws.com
byod_ecr_region: us-west-2
gcp_cr: us-west1-docker.pkg.dev/anyscale-oss-ci
aws2gce_credentials: release/aws2gce_iam.json
state_machine:
branch:
aws_bucket: ray-ci-results
ci_pipeline:
postmerge:
- hi
"""
def test_global_config_absent_targets_are_none() -> None:
"""A config that omits the targeting keys yields None (no silent fallback to
ray's repo/org), so misconfiguration fails loudly instead of mis-targeting."""
import ray_release.configs.global_config as gc
with TemporaryDirectory() as tmp:
config_file = os.path.join(tmp, "config")
with open(config_file, "w") as f:
f.write(_TEST_CONFIG_NO_OPTIONAL)
# Load a fresh config, then restore the prior singleton so this test does
# not contaminate others sharing the process.
prev = gc.config
gc.config = None
try:
gc._init_global_config(config_file)
config = gc.get_global_config()
assert config["state_machine_github_repo"] is None
assert config["buildkite_org"] is None
assert config["state_machine_bisect_disabled"] is False
finally:
gc.config = prev
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+446
View File
@@ -0,0 +1,446 @@
import os
import shutil
import sys
import tempfile
import time
import unittest
from typing import Callable, Optional, Type
import pytest
from ray_release.alerts.handle import result_to_handle_map
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.exception import (
CommandError,
CommandTimeout,
ExitCode,
FetchResultError,
LogsError,
PrepareCommandError,
PrepareCommandTimeout,
ReleaseTestConfigError,
ResultsAlert,
TestCommandError,
TestCommandTimeout,
)
from ray_release.file_manager.job_file_manager import JobFileManager
from ray_release.glue import (
command_runner_to_cluster_manager,
run_release_test,
type_str_to_command_runner,
)
from ray_release.logger import logger
from ray_release.reporter.reporter import Reporter
from ray_release.result import Result
from ray_release.test import Test
from ray_release.tests.utils import APIDict, MockSDK
def _fail_on_call(error_type: Type[Exception] = RuntimeError, message: str = "Fail"):
def _fail(*args, **kwargs):
raise error_type(message)
return _fail
class MockReturn:
return_dict = {}
def __getattribute__(self, item):
return_dict = object.__getattribute__(self, "return_dict")
if item in return_dict:
mocked = return_dict[item]
if isinstance(mocked, Callable):
return mocked()
else:
return lambda *a, **kw: mocked
return object.__getattribute__(self, item)
class MockTest(Test):
def get_anyscale_byod_image(self) -> str:
return ""
class GlueTest(unittest.TestCase):
def writeClusterEnv(self, content: str):
with open(os.path.join(self.tempdir, "cluster_env.yaml"), "wt") as fp:
fp.write(content)
def writeClusterCompute(self, content: str):
with open(os.path.join(self.tempdir, "cluster_compute.yaml"), "wt") as fp:
fp.write(content)
def setUp(self) -> None:
self.tempdir = tempfile.mkdtemp()
self.sdk = MockSDK()
self.sdk.returns["get_project"] = APIDict(
result=APIDict(name="unit_test_project")
)
self.sdk.returns["get_cloud"] = APIDict(result=APIDict(provider="AWS"))
self.writeClusterEnv("{'env': true}")
self.writeClusterCompute(
"{'head_node_type': {'name': 'head_node', 'instance_type': 'm5a.4xlarge'}, 'worker_node_types': []}"
)
with open(os.path.join(self.tempdir, "driver_fail.sh"), "wt") as f:
f.write("exit 1\n")
with open(os.path.join(self.tempdir, "driver_succeed.sh"), "wt") as f:
f.write("exit 0\n")
this_sdk = self.sdk
this_tempdir = self.tempdir
self.instances = {}
self.cluster_manager_return = {}
self.command_runner_return = {}
this_instances = self.instances
this_cluster_manager_return = self.cluster_manager_return
this_command_runner_return = self.command_runner_return
class MockClusterManager(MockReturn, MinimalClusterManager):
def __init__(
self,
test_name: str,
project_id: str,
sdk=None,
smoke_test: bool = False,
):
super(MockClusterManager, self).__init__(
test_name,
project_id,
this_sdk,
smoke_test=smoke_test,
)
self.return_dict = this_cluster_manager_return
this_instances["cluster_manager"] = self
class FakeFileManager(JobFileManager):
def __init__(self, cluster_manager: ClusterManager):
super(FakeFileManager, self).__init__(cluster_manager)
def download_from_cloud(
self, key: str, target: str, delete_after_download: bool = False
):
with open(target, "wt") as f:
f.write("fake download content")
def delete(self, key: str, recursive: bool = False):
pass
class MockCommandRunner(MockReturn, AnyscaleJobRunner):
return_dict = self.cluster_manager_return
def __init__(
self,
cluster_manager: ClusterManager,
file_manager: JobFileManager,
working_dir,
sdk=None,
artifact_path: Optional[str] = None,
):
super(MockCommandRunner, self).__init__(
cluster_manager,
FakeFileManager(cluster_manager),
this_tempdir,
sdk=this_sdk,
artifact_path=artifact_path,
)
self.return_dict = this_command_runner_return
self.mock_alert_return = None
def mock_alerter(test: Test, result: Result):
return self.mock_alert_return
result_to_handle_map["unit_test_alerter"] = (mock_alerter, False)
type_str_to_command_runner["unit_test"] = MockCommandRunner
command_runner_to_cluster_manager[MockCommandRunner] = MockClusterManager
self.test = MockTest(
name="unit_test_end_to_end",
run=dict(
type="unit_test",
prepare="prepare_cmd",
script="test_cmd",
wait_for_nodes=dict(num_nodes=4, timeout=40),
),
working_dir=self.tempdir,
cluster=dict(
cluster_env="cluster_env.yaml",
cluster_compute="cluster_compute.yaml",
byod={},
),
alert="unit_test_alerter",
)
self.kuberay_test = MockTest(
name="unit_test_end_to_end_kuberay",
run=dict(
type="unit_test",
prepare="prepare_cmd",
script="test_cmd",
wait_for_nodes=dict(num_nodes=4, timeout=40),
),
working_dir=self.tempdir,
cluster=dict(
cluster_env="cluster_env.yaml",
cluster_compute="cluster_compute.yaml",
byod={},
),
env="kuberay",
alert="unit_test_alerter",
)
self.anyscale_project = "prj_unit12345678"
def tearDown(self) -> None:
shutil.rmtree(self.tempdir)
def _succeed_until(self, until: str):
# These commands should succeed
self.cluster_manager_return["cluster_compute_id"] = "valid"
self.cluster_manager_return["create_cluster_compute"] = None
if until == "cluster_compute":
return
self.cluster_manager_return["cluster_env_id"] = "valid"
self.cluster_manager_return["create_cluster_env"] = None
self.cluster_manager_return["cluster_env_build_id"] = "valid"
self.cluster_manager_return["build_cluster_env"] = None
if until == "cluster_env":
return
if until == "cluster_start":
return
self.command_runner_return["prepare_remote_env"] = None
if until == "remote_env":
return
self.command_runner_return["wait_for_nodes"] = None
if until == "wait_for_nodes":
return
self.command_runner_return["run_prepare_command"] = None
self.command_runner_return["job_url"] = "http://mock-job-url"
self.command_runner_return["job_id"] = "mock-job-id"
if until == "prepare_command":
return
self.command_runner_return["run_command"] = None
if until == "test_command":
return
self.command_runner_return["fetch_results"] = {
"time_taken": 50,
"last_update": time.time() - 60,
}
if until == "fetch_results":
return
self.command_runner_return["get_last_logs_ex"] = "Lorem ipsum"
if until == "get_last_logs":
return
self.mock_alert_return = None
def _run(self, result: Result, kuberay: bool = False, **kwargs):
if kuberay:
run_release_test(test=self.kuberay_test, result=result, **kwargs)
else:
run_release_test(
test=self.test,
anyscale_project=self.anyscale_project,
result=result,
**kwargs
)
def testInvalidClusterCompute(self):
result = Result()
# Fails because file not found
os.unlink(os.path.join(self.tempdir, "cluster_compute.yaml"))
with self.assertRaisesRegex(ReleaseTestConfigError, "Path not found"):
self._run(result)
self.assertEqual(result.return_code, ExitCode.CONFIG_ERROR.value)
with self.assertRaisesRegex(ReleaseTestConfigError, "Path not found"):
self._run(result, True)
self.assertEqual(result.return_code, ExitCode.CONFIG_ERROR.value)
# Fails because invalid jinja template
self.writeClusterCompute("{{ INVALID")
with self.assertRaisesRegex(ReleaseTestConfigError, "yaml template"):
self._run(result)
self.assertEqual(result.return_code, ExitCode.CONFIG_ERROR.value)
with self.assertRaisesRegex(ReleaseTestConfigError, "yaml template"):
self._run(result, True)
self.assertEqual(result.return_code, ExitCode.CONFIG_ERROR.value)
# Fails because invalid json
self.writeClusterCompute("{'test': true, 'fail}")
with self.assertRaisesRegex(ReleaseTestConfigError, "quoted scalar"):
self._run(result)
self.assertEqual(result.return_code, ExitCode.CONFIG_ERROR.value)
with self.assertRaisesRegex(ReleaseTestConfigError, "quoted scalar"):
self._run(result, True)
self.assertEqual(result.return_code, ExitCode.CONFIG_ERROR.value)
def testPrepareCommandFails(self):
result = Result()
self._succeed_until("wait_for_nodes")
# Prepare command fails
self.command_runner_return["run_prepare_command"] = _fail_on_call(CommandError)
with self.assertRaises(PrepareCommandError):
self._run(result)
self.assertEqual(result.return_code, ExitCode.PREPARE_ERROR.value)
# Prepare command times out
self.command_runner_return["run_prepare_command"] = _fail_on_call(
CommandTimeout
)
with self.assertRaises(PrepareCommandTimeout):
self._run(result)
# Special case: Prepare commands are usually waiting for nodes
# (this may change in the future!)
self.assertEqual(result.return_code, ExitCode.CLUSTER_WAIT_TIMEOUT.value)
self.assertIsNone(result.job_url)
def testTestCommandFails(self):
result = Result()
self._succeed_until("prepare_command")
# Test command fails
self.command_runner_return["run_command"] = _fail_on_call(CommandError)
with self.assertRaises(TestCommandError):
self._run(result)
self.assertEqual(result.return_code, ExitCode.COMMAND_ERROR.value)
# Test command times out
self.command_runner_return["run_command"] = _fail_on_call(CommandTimeout)
with self.assertRaises(TestCommandTimeout):
self._run(result)
self.assertEqual(result.return_code, ExitCode.COMMAND_TIMEOUT.value)
self.assertIsNotNone(result.job_url)
def testTestCommandTimeoutLongRunning(self):
result = Result()
self._succeed_until("fetch_results")
# Test command times out
self.command_runner_return["run_command"] = _fail_on_call(CommandTimeout)
with self.assertRaises(TestCommandTimeout):
self._run(result)
self.assertEqual(result.return_code, ExitCode.COMMAND_TIMEOUT.value)
# But now set test to long running
self.test["run"]["long_running"] = True
self._run(result) # Will not fail this time
self.assertGreaterEqual(result.results["last_update_diff"], 60.0)
def testSmokeUnstableTest(self):
result = Result()
self._succeed_until("complete")
self.test["stable"] = False
self._run(result, smoke_test=True)
# Ensure stable and smoke_test are set correctly.
assert not result.stable
assert result.smoke_test
def testFetchResultFails(self):
result = Result()
self._succeed_until("test_command")
self.command_runner_return["fetch_results"] = _fail_on_call(FetchResultError)
with self.assertLogs(logger, "ERROR") as cm:
self._run(result)
self.assertTrue(any("Could not fetch results" in o for o in cm.output))
self.assertEqual(result.return_code, ExitCode.SUCCESS.value)
self.assertEqual(result.status, "success")
def testFetchResultFailsReqNonEmptyResult(self):
# set `require_result` bit.
new_handler = (result_to_handle_map["unit_test_alerter"], True)
result_to_handle_map["unit_test_alerter"] = new_handler
result = Result()
self._succeed_until("test_command")
self.command_runner_return["fetch_results"] = _fail_on_call(FetchResultError)
with self.assertRaisesRegex(FetchResultError, "Fail"):
with self.assertLogs(logger, "ERROR") as cm:
self._run(result)
self.assertTrue(any("Could not fetch results" in o for o in cm.output))
self.assertEqual(result.return_code, ExitCode.FETCH_RESULT_ERROR.value)
self.assertEqual(result.status, "infra_error")
def testLastLogsFails(self):
result = Result()
self._succeed_until("fetch_results")
self.command_runner_return["get_last_logs_ex"] = _fail_on_call(LogsError)
with self.assertLogs(logger, "ERROR") as cm:
self._run(result)
self.assertTrue(any("Error fetching logs" in o for o in cm.output))
self.assertEqual(result.return_code, ExitCode.SUCCESS.value)
self.assertEqual(result.status, "success")
def testAlertFails(self):
result = Result()
self._succeed_until("get_last_logs")
self.mock_alert_return = "Alert raised"
with self.assertRaises(ResultsAlert):
self._run(result)
self.assertEqual(result.return_code, ExitCode.COMMAND_ALERT.value)
self.assertEqual(result.status, "error")
def testReportFails(self):
result = Result()
self._succeed_until("complete")
class FailReporter(Reporter):
def report_result_ex(self, test: Test, result: Result):
raise RuntimeError
with self.assertLogs(logger, "ERROR") as cm:
self._run(result, reporters=[FailReporter()])
self.assertTrue(any("Error reporting results" in o for o in cm.output))
self.assertEqual(result.return_code, ExitCode.SUCCESS.value)
self.assertEqual(result.status, "success")
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,70 @@
import sys
import pytest
from ray_release.kuberay_util import convert_cluster_compute_to_kuberay_compute_config
def test_convert_cluster_compute_to_kuberay_compute_config():
compute_config = {
"head_node_type": {
"resources": {
"limits": {
"cpu": "16",
"memory": "32Gi",
}
}
},
"worker_node_types": [
{
"name": "worker",
"resources": {
"limits": {
"cpu": "4",
"memory": "8Gi",
},
"requests": {
"cpu": "4",
"memory": "8Gi",
},
},
"min_workers": 0,
"max_workers": 2,
"use_spot": False,
}
],
}
kuberay_compute_config = convert_cluster_compute_to_kuberay_compute_config(
compute_config
)
assert kuberay_compute_config == {
"head_node": {
"resources": {
"limits": {
"cpu": "16",
"memory": "32Gi",
}
}
},
"worker_nodes": [
{
"group_name": "worker",
"min_nodes": 0,
"max_nodes": 2,
"resources": {
"limits": {
"cpu": "4",
"memory": "8Gi",
},
"requests": {
"cpu": "4",
"memory": "8Gi",
},
},
}
],
}
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,76 @@
import sys
import pytest
from ray_release.log_aggregator import LogAggregator
def test_compute_stack_pattern():
assert (
LogAggregator(
"\n".join(
[
"haha",
"Traceback (most recent call last):",
' File "/tmp/something", line 584',
"Exception: yaya45",
"hehe",
]
)
).compute_crash_pattern()
== "somethingline Exception: yaya"
)
def test_compute_signature():
assert (
LogAggregator._compute_signature(
[
"Traceback (most recent call last):",
' File "/tmp/something", line 584',
' File "/tmp/another", deedeebeeaacfa-abc' "Exception: yaya45",
]
)
== "somethingline another-abcException: yaya"
)
def test_compute_stack_trace():
trace = [
"Traceback (most recent call last):",
' File "/tmp/something", line 584, in run_release_test',
" raise pipeline_exception",
"ray_release.exception.JobNoLogsError: Could not obtain logs for the job.",
]
error_trace = [
"[2023-01-01] ERROR: something is wrong",
"Traceback (most recent call last):",
' File "/tmp/something", line 584, in run_release_test',
" raise pipeline_exception",
"ray_release.exception.JobStartupTimeout: Cluster did not start.",
]
error_trace_short = [
"[2023-01-01] ERROR: something is wrong"
' File "/tmp/something", line 584, in run_release_test',
" raise pipeline_exception",
"ray_release.exception.JobStartupTimeout: Cluster did not start.",
]
assert LogAggregator._compute_stack_trace(["haha"] + trace + ["hehe"]) == trace
assert (
LogAggregator._compute_stack_trace(["haha"] + error_trace + ["hehe"])
== error_trace
)
assert (
LogAggregator._compute_stack_trace(["haha"] + error_trace_short + ["hehe"])
== error_trace_short
)
assert (
LogAggregator._compute_stack_trace(
["haha"] + trace + ["w00t"] + error_trace + ["hehe"]
)
== error_trace
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,124 @@
import os
import sys
import pytest
import yaml
from ray_release.bazel import bazel_runfile
from ray_release.config import (
DEFAULT_CLOUD_ID,
DEFAULT_CLOUD_NAME,
RELEASE_TEST_CONFIG_FILES,
parse_test_definition,
)
from ray_release.template import (
_load_and_render_yaml_template,
load_test_cluster_compute,
)
_COMPUTE_CONFIG_FILE_ENV = "COMPUTE_CONFIG_FILE"
_COMPUTE_CONFIG_FILE_FLAG = "--compute-config-file"
def _extract_compute_config_file_arg(argv):
"""Extract ``--compute-config-file[=VALUE]`` from argv.
Returns (override_path, remaining_argv). pytest's ``pytest_addoption``
hook is only collected from conftest.py or plugins — not test modules —
so we parse the flag ourselves and pass the value via an env var that
``pytest_generate_tests`` reads.
"""
override = None
remaining = []
i = 0
while i < len(argv):
arg = argv[i]
if arg.startswith(f"{_COMPUTE_CONFIG_FILE_FLAG}="):
override = arg.split("=", 1)[1]
elif (
arg == _COMPUTE_CONFIG_FILE_FLAG
and i + 1 < len(argv)
and not argv[i + 1].startswith("-")
):
override = argv[i + 1]
i += 1
else:
remaining.append(arg)
i += 1
return override, remaining
def _collect_new_sdk_tests():
"""Flattened list of release-collection tests with anyscale_sdk_2026=true."""
tests = []
for config_file in RELEASE_TEST_CONFIG_FILES:
with open(bazel_runfile(config_file)) as fp:
tests += parse_test_definition(yaml.safe_load(fp))
return [t for t in tests if t.uses_anyscale_sdk_2026()]
def pytest_generate_tests(metafunc):
if "target" not in metafunc.fixturenames:
return
path = os.environ.get(_COMPUTE_CONFIG_FILE_ENV)
if path:
metafunc.parametrize(
"target",
[("path", os.path.abspath(path))],
ids=[os.path.basename(path)],
)
else:
tests = _collect_new_sdk_tests()
if not tests:
raise RuntimeError(
"No tests with anyscale_sdk_2026=true found across "
f"{RELEASE_TEST_CONFIG_FILES}. Parametrizing on an empty "
"list would cause this validation gate to pass silently. "
"If the anyscale_sdk_2026 flag has been retired, update "
"this test to discover the right compute-config files via "
"the new mechanism — do not leave the gate empty."
)
metafunc.parametrize(
"target",
[("test", t) for t in tests],
ids=[t["name"] for t in tests],
)
def _load_single_file(path):
env = {
"ANYSCALE_CLOUD_ID": str(DEFAULT_CLOUD_ID),
"ANYSCALE_CLOUD_NAME": str(DEFAULT_CLOUD_NAME),
}
return _load_and_render_yaml_template(path, env=env)
def test_new_sdk_compute_config_is_valid(target):
from anyscale.compute_config import ComputeConfig
kind, payload = target
if kind == "test":
cluster_compute = load_test_cluster_compute(payload)
label = (
f"test {payload['name']!r} "
f"(cluster_compute={payload['cluster']['cluster_compute']!r})"
)
else:
cluster_compute = _load_single_file(payload)
label = f"file {payload!r}"
assert cluster_compute is not None, f"{label}: failed to load/render YAML"
try:
ComputeConfig.from_dict(cluster_compute)
except Exception as e:
pytest.fail(
f"ComputeConfig.from_dict failed for {label}: {type(e).__name__}: {e}"
)
if __name__ == "__main__":
override, remaining = _extract_compute_config_file_arg(sys.argv[1:])
if override:
os.environ[_COMPUTE_CONFIG_FILE_ENV] = override
sys.exit(pytest.main(remaining + ["-v", __file__]))
+71
View File
@@ -0,0 +1,71 @@
import os
import sys
from unittest import mock
import pytest
from ray_release.exception import (
ExitCode,
ReleaseTestConfigError,
ReleaseTestError,
ReleaseTestSetupError,
)
from ray_release.result import Result, ResultStatus, update_result_from_exception
def test_update_result_from_exception():
# config error
result = Result()
update_result_from_exception(result, ReleaseTestConfigError())
assert result.return_code == ExitCode.CONFIG_ERROR.value
assert result.last_logs is None
# release test error
result = Result()
result.runtime = 10
try:
raise ReleaseTestError()
except ReleaseTestError as e:
update_result_from_exception(result, e, with_last_logs=True)
assert result.return_code == ExitCode.UNSPECIFIED.value
assert result.status == ResultStatus.RUNTIME_ERROR.value
assert result.runtime == 10
assert "ReleaseTestError" in result.last_logs
assert __file__ in result.last_logs
# unknown error
result = Result()
update_result_from_exception(result, Exception("generic"))
assert result.return_code == ExitCode.UNKNOWN.value
assert result.status == ResultStatus.UNKNOWN.value
assert result.runtime == 0
assert result.last_logs is None
# retriable
with mock.patch.dict(os.environ, {"BUILDKITE_TIME_LIMIT_FOR_RETRY": "100"}):
result = Result()
result.runtime = 10
update_result_from_exception(result, ReleaseTestSetupError())
assert result.return_code == ExitCode.SETUP_ERROR.value
assert result.status == ResultStatus.TRANSIENT_INFRA_ERROR.value
assert result.runtime == 10
# retry limit reached, not retriable
with mock.patch.dict(os.environ, {"BUILDKITE_RETRY_COUNT": "1"}):
result = Result()
result.runtime = 10
update_result_from_exception(result, ReleaseTestSetupError())
assert result.return_code == ExitCode.SETUP_ERROR.value
assert result.status == ResultStatus.INFRA_ERROR.value
assert result.runtime == 10
# too long to run, not retriable
with mock.patch.dict(os.environ, {"BUILDKITE_TIME_LIMIT_FOR_RETRY": "1"}):
result = Result()
result.runtime = 3600
update_result_from_exception(result, ReleaseTestSetupError())
assert result.return_code == ExitCode.SETUP_ERROR.value
assert result.status == ResultStatus.INFRA_ERROR.value
assert result.runtime == 3600
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+76
View File
@@ -0,0 +1,76 @@
import sys
import pytest
from ray_release import retry
def test_retry_with_no_error():
invocation_count = 0
# Function doesn't raise exception; use a dummy value to check invocation.
@retry.retry()
def no_error_func() -> int:
nonlocal invocation_count
invocation_count += 1
return 1
assert no_error_func() == 1
assert invocation_count == 1
# Test senario: exception count is less than retry count.
def test_retry_with_limited_error():
invocation_count = 0
# Function doesn't raise exception; use a dummy value to check invocation.
@retry.retry(init_delay_sec=1, jitter_sec=1)
def limited_error() -> int:
nonlocal invocation_count
invocation_count += 1
if invocation_count == 1:
raise Exception("Manual exception")
return 1
assert limited_error() == 1
assert invocation_count == 2
# Test senario: exception count exceeds retry count.
def test_retry_with_unlimited_error():
invocation_count = 0
@retry.retry(init_delay_sec=1, jitter_sec=1, backoff=1, max_retry_count=3)
def unlimited_error() -> int:
nonlocal invocation_count
invocation_count += 1
raise Exception("Manual exception")
with pytest.raises(Exception, match="Manual exception"):
unlimited_error()
assert invocation_count == 3
def test_retry_on_certain_errors():
invocation_count = 0
# Function doesn't raise exception; use a dummy value to check invocation.
@retry.retry(init_delay_sec=1, jitter_sec=1, exceptions=(KeyError,))
def limited_error() -> int:
nonlocal invocation_count
invocation_count += 1
if invocation_count == 1:
raise KeyError("Manual exception")
return 1
assert limited_error() == 1
assert invocation_count == 2
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,139 @@
import json
import os
import subprocess
import sys
import tempfile
import pytest
from ray_release.exception import ExitCode
@pytest.fixture
def setup(tmpdir):
state_file = os.path.join(tmpdir, "state.txt")
test_script = os.path.join(
os.path.dirname(__file__), "..", "..", "run_release_test.sh"
)
os.environ["NO_INSTALL"] = "1"
os.environ["NO_CLONE"] = "1"
os.environ["NO_ARTIFACTS"] = "1"
os.environ[
"RAY_TEST_SCRIPT"
] = "python ray_release/tests/_test_run_release_test_sh.py"
os.environ["OVERRIDE_SLEEP_TIME"] = "0"
os.environ["MAX_RETRIES"] = "3"
yield state_file, test_script
def _read_state(state_file):
with open(state_file, "rt") as f:
return int(f.read())
def _run_script(test_script, state_file, *exits):
assert len(exits) == 3
if os.path.exists(state_file):
os.unlink(state_file)
try:
return subprocess.check_call(
f"{test_script} "
f"{state_file} "
f"{' '.join(str(e.value) for e in exits)}",
shell=True,
)
except subprocess.CalledProcessError as e:
return e.returncode
def test_repeat(setup):
state_file, test_script = setup
assert (
_run_script(
test_script,
state_file,
ExitCode.SUCCESS,
ExitCode.SUCCESS,
ExitCode.SUCCESS,
)
== ExitCode.SUCCESS.value
)
assert _read_state(state_file) == 1
assert (
_run_script(
test_script,
state_file,
ExitCode.RAY_WHEELS_TIMEOUT,
ExitCode.SUCCESS,
ExitCode.SUCCESS,
)
== ExitCode.SUCCESS.value
)
assert _read_state(state_file) == 2
assert (
_run_script(
test_script,
state_file,
ExitCode.RAY_WHEELS_TIMEOUT,
ExitCode.CLUSTER_ENV_BUILD_TIMEOUT,
ExitCode.SUCCESS,
)
== ExitCode.SUCCESS.value
)
assert _read_state(state_file) == 3
assert (
_run_script(
test_script,
state_file,
ExitCode.CLUSTER_STARTUP_TIMEOUT,
ExitCode.CLUSTER_WAIT_TIMEOUT,
ExitCode.RAY_WHEELS_TIMEOUT,
)
== 79 # BUILDKITE_RETRY_CODE
)
assert _read_state(state_file) == 3
assert (
_run_script(
test_script,
state_file,
ExitCode.RAY_WHEELS_TIMEOUT,
ExitCode.COMMAND_ALERT,
ExitCode.SUCCESS,
)
== 79 # BUILDKITE_RETRY_CODE
)
assert _read_state(state_file) == 2
def test_parameters(setup):
state_file, test_script = setup
os.environ["RAY_TEST_SCRIPT"] = "python ray_release/tests/_test_catch_args.py"
with tempfile.TemporaryDirectory() as tmpdir:
argv_file = os.path.join(tmpdir, "argv.json")
subprocess.check_call(
f"{test_script} " f"{argv_file} " f"--smoke-test",
shell=True,
)
with open(argv_file, "rt") as fp:
data = json.load(fp)
assert "--smoke-test" in data
if __name__ == "__main__":
import pytest
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,399 @@
import sys
from typing import List, Optional
import pytest
from ray_release.bazel import bazel_runfile
from ray_release.configs.global_config import init_global_config
from ray_release.result import (
Result,
ResultStatus,
)
from ray_release.test import (
Test,
TestResult,
TestState,
)
from ray_release.test_automation.ci_state_machine import (
CONTINUOUS_FAILURE_TO_FLAKY,
CONTINUOUS_PASSING_TO_PASSING,
FAILING_TO_FLAKY_MESSAGE,
JAILED_MESSAGE,
JAILED_TAG,
CITestStateMachine,
)
from ray_release.test_automation.release_state_machine import ReleaseTestStateMachine
from ray_release.test_automation.state_machine import (
NO_TEAM,
WEEKLY_RELEASE_BLOCKER_TAG,
TestStateMachine,
)
init_global_config(bazel_runfile("release/ray_release/configs/oss_config.yaml"))
class MockLabel:
def __init__(self, name: str):
self.name = name
class MockIssue:
def __init__(
self,
number: int,
title: str,
state: str = "open",
labels: Optional[List[MockLabel]] = None,
):
self.number = number
self.title = title
self.state = state
self.labels = labels or []
self.comments = []
def edit(
self, state: str = None, labels: List[MockLabel] = None, title: str = None
):
if state:
self.state = state
if labels:
self.labels = labels
if title:
self.title = title
if state:
self.state = state
def create_comment(self, comment: str):
self.comments.append(comment)
def get_labels(self):
return self.labels
class MockIssueDB:
issue_id = 1
issue_db = {}
class MockRepo:
def create_issue(self, labels: List[str], title: str, *args, **kwargs):
label_objs = [MockLabel(label) for label in labels]
issue = MockIssue(MockIssueDB.issue_id, title=title, labels=label_objs)
MockIssueDB.issue_db[MockIssueDB.issue_id] = issue
MockIssueDB.issue_id += 1
return issue
def get_issue(self, number: int):
return MockIssueDB.issue_db[number]
def get_issues(self, state: str, labels: List[MockLabel]) -> List[MockIssue]:
issues = []
for issue in MockIssueDB.issue_db.values():
if issue.state != state:
continue
issue_labels = [label.name for label in issue.labels]
if all(label.name in issue_labels for label in labels):
issues.append(issue)
return issues
def get_label(self, name: str):
return MockLabel(name)
class MockBuildkiteBuild:
def create_build(self, *args, **kwargs):
return {
"number": 1,
"jobs": [{"id": "1"}],
}
def list_all_for_pipeline(self, *args, **kwargs):
return []
class MockBuildkiteJob:
def unblock_job(self, *args, **kwargs):
return {}
class MockBuildkite:
def builds(self):
return MockBuildkiteBuild()
def jobs(self):
return MockBuildkiteJob()
TestStateMachine.ray_repo = MockRepo()
TestStateMachine.ray_buildkite = MockBuildkite()
def test_ci_empty_results():
test = Test(name="w00t", team="ci", state=TestState.FLAKY)
test.test_results = []
CITestStateMachine(test).move()
# do not change the state
assert test.get_state() == TestState.FLAKY
def test_ci_move_from_passing_to_flaky():
"""
Test the entire lifecycle of a CI test when it moves from passing to flaky.
"""
test = Test(name="w00t", team="ci")
# start from passing
assert test.get_state() == TestState.PASSING
# passing to flaky
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.SUCCESS.value)),
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
] * 10
CITestStateMachine(test).move()
assert test.get_state() == TestState.FLAKY
issue = MockIssueDB.issue_db[test.get(Test.KEY_GITHUB_ISSUE_NUMBER)]
assert issue.state == "open"
assert issue.title == "CI test w00t is flaky"
# flaky to jail
issue.edit(labels=[MockLabel(JAILED_TAG)])
CITestStateMachine(test).move()
assert test.get_state() == TestState.JAILED
assert issue.comments[-1] == JAILED_MESSAGE
def test_ci_move_from_passing_to_failing_to_flaky():
"""
Test the entire lifecycle of a CI test when it moves from passing to failing.
Check that the conditions are met for each state transition. Also check that
gihub issues are created and closed correctly.
"""
test = Test(name="test", team="ci")
# start from passing
assert test.get_state() == TestState.PASSING
# passing to failing
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
]
CITestStateMachine(test).move()
assert test.get_state() == TestState.FAILING
# failing to consistently failing
test.test_results.extend(
[
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
]
)
CITestStateMachine(test).move()
assert test.get_state() == TestState.CONSITENTLY_FAILING
issue = MockIssueDB.issue_db[test.get(Test.KEY_GITHUB_ISSUE_NUMBER)]
assert issue.state == "open"
assert "ci-test" in [label.name for label in issue.labels]
# move from consistently failing to flaky
test.test_results.extend(
[TestResult.from_result(Result(status=ResultStatus.ERROR.value))]
* CONTINUOUS_FAILURE_TO_FLAKY
)
CITestStateMachine(test).move()
assert test.get_state() == TestState.FLAKY
assert issue.comments[-1] == FAILING_TO_FLAKY_MESSAGE
# go back to passing
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.SUCCESS.value)),
] * CONTINUOUS_PASSING_TO_PASSING
CITestStateMachine(test).move()
assert test.get_state() == TestState.PASSING
assert test.get(Test.KEY_GITHUB_ISSUE_NUMBER) == issue.number
assert issue.state == "closed"
# go back to failing and reuse the github issue
test.test_results = 3 * [
TestResult.from_result(Result(status=ResultStatus.ERROR.value))
]
CITestStateMachine(test).move()
assert test.get_state() == TestState.CONSITENTLY_FAILING
assert test.get(Test.KEY_GITHUB_ISSUE_NUMBER) == issue.number
assert issue.state == "open"
def test_release_move_from_passing_to_failing():
test = Test(name="test", team="ci")
# Test original state
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.SUCCESS.value)),
]
assert test.get_state() == TestState.PASSING
# Test moving from passing to failing
test.test_results.insert(
0,
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
)
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.FAILING
assert test[Test.KEY_BISECT_BUILD_NUMBER] == 1
# Test moving from failing to consistently failing
test.test_results.insert(
0,
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
)
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.CONSITENTLY_FAILING
assert test[Test.KEY_GITHUB_ISSUE_NUMBER] == MockIssueDB.issue_id - 1
def test_release_move_from_failing_to_consisently_failing():
test = Test(name="test", team="ci")
test[Test.KEY_BISECT_BUILD_NUMBER] = 1
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
]
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.FAILING
test[Test.KEY_BISECT_BLAMED_COMMIT] = "1234567890"
sm = ReleaseTestStateMachine(test)
sm.move()
sm.comment_blamed_commit_on_github_issue()
issue = MockIssueDB.issue_db[test.get(Test.KEY_GITHUB_ISSUE_NUMBER)]
assert test.get_state() == TestState.CONSITENTLY_FAILING
assert "Blamed commit: 1234567890" in issue.comments[0]
labels = [label.name for label in issue.get_labels()]
assert "ci" in labels
def test_release_move_from_failing_to_passing():
test = Test(name="test", team="ci")
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
]
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.CONSITENTLY_FAILING
assert test[Test.KEY_GITHUB_ISSUE_NUMBER] == MockIssueDB.issue_id - 1
test.test_results.insert(
0,
TestResult.from_result(Result(status=ResultStatus.SUCCESS.value)),
)
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.PASSING
assert test.get(Test.KEY_BISECT_BUILD_NUMBER) is None
assert test.get(Test.KEY_BISECT_BLAMED_COMMIT) is None
def test_release_move_from_failing_to_jailed():
test = Test(name="test", team="ci")
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
]
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.CONSITENTLY_FAILING
test.test_results.insert(
0,
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
)
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.JAILED
# Test moving from jailed to jailed
issue = MockIssueDB.issue_db[test.get(Test.KEY_GITHUB_ISSUE_NUMBER)]
issue.edit(state="closed")
test.test_results.insert(
0,
TestResult.from_result(Result(status=ResultStatus.ERROR.value)),
)
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.JAILED
assert issue.state == "open"
# Test moving from jailed to passing
test.test_results.insert(
0,
TestResult.from_result(Result(status=ResultStatus.SUCCESS.value)),
)
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.PASSING
assert issue.state == "closed"
def test_get_release_blockers() -> None:
MockIssueDB.issue_id = 1
MockIssueDB.issue_db = {}
TestStateMachine.ray_repo.create_issue(labels=["non-blocker"], title="non-blocker")
TestStateMachine.ray_repo.create_issue(
labels=[WEEKLY_RELEASE_BLOCKER_TAG], title="blocker"
)
issues = TestStateMachine.get_release_blockers()
assert len(issues) == 1
assert issues[0].title == "blocker"
def test_get_issue_owner() -> None:
issue = TestStateMachine.ray_repo.create_issue(labels=["core"], title="hi")
assert TestStateMachine.get_issue_owner(issue) == "core"
issue = TestStateMachine.ray_repo.create_issue(labels=["w00t"], title="bye")
assert TestStateMachine.get_issue_owner(issue) == NO_TEAM
def test_release_bisect_disabled(monkeypatch) -> None:
"""When bisect is disabled in config, no bisect build is triggered."""
import ray_release.test_automation.state_machine as sm_mod
real_get_global_config = sm_mod.get_global_config
def fake_get_global_config():
cfg = dict(real_get_global_config())
cfg["state_machine_bisect_disabled"] = True
return cfg
monkeypatch.setattr(sm_mod, "get_global_config", fake_get_global_config)
test = Test(name="bisect-off", team="ci")
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.SUCCESS.value)),
]
assert test.get_state() == TestState.PASSING
test.test_results.insert(
0, TestResult.from_result(Result(status=ResultStatus.ERROR.value))
)
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.FAILING
# Bisect gated off -> no build number recorded.
assert test.get(Test.KEY_BISECT_BUILD_NUMBER) is None
def test_release_bisect_enabled_triggers(monkeypatch) -> None:
"""When bisect is enabled (default), a bisect build is triggered."""
test = Test(name="bisect-on", team="ci")
test.test_results = [
TestResult.from_result(Result(status=ResultStatus.SUCCESS.value)),
]
test.test_results.insert(
0, TestResult.from_result(Result(status=ResultStatus.ERROR.value))
)
sm = ReleaseTestStateMachine(test)
sm.move()
assert test.get_state() == TestState.FAILING
assert test[Test.KEY_BISECT_BUILD_NUMBER] == 1
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+70
View File
@@ -0,0 +1,70 @@
import shlex
import sys
from unittest.mock import patch
import pytest
from ray_release.bazel import bazel_runfile
from ray_release.buildkite.step import get_step, get_step_for_test_group
from ray_release.configs.global_config import init_global_config
from ray_release.test import Test
init_global_config(bazel_runfile("release/ray_release/configs/oss_config.yaml"))
def _stub_test(val: dict) -> Test:
"""
A helper function to create a test object with a given dictionary.
"""
test = Test(
{
"name": "test with spaces",
"cluster": {
"byod": {},
},
"run": {
"script": "python test.py",
"timeout": 100,
"num_retries": 3,
},
}
)
test.update(val)
return test
@patch("ray_release.test.Test.update_from_s3", return_value=None)
def test_get_step(mock):
with patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"}):
step = get_step(_stub_test({}), run_id=2)
assert step["label"] == "test with spaces (None) (2)"
assert step["retry"]["automatic"][0]["limit"] == 3
assert "commands" in step
first_command = shlex.split(step["commands"][0])
assert first_command[0] == "./release/run_release_test.sh"
assert first_command[1] == "test with spaces"
@patch("ray_release.test.Test.update_from_s3", return_value=None)
def test_get_step_for_test_group(mock):
grouped_tests = {
"group1": [
(_stub_test({"name": "test1", "repeated_run": 3}), False),
(_stub_test({"name": "test2"}), False),
],
"group2": [(_stub_test({"name": "test3"}), False)],
}
with patch.dict("os.environ", {"RAYCI_BUILD_ID": "a1b2c3d4"}):
steps = get_step_for_test_group(grouped_tests)
assert len(steps) == 2
assert steps[0]["group"] == "group1"
assert [step["label"] for step in steps[0]["steps"]] == [
"test1 (None) (0)",
"test1 (None) (1)",
"test1 (None) (2)",
"test2 (None) (0)",
]
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,56 @@
import sys
import pytest
from ray_release.exception import ReleaseTestConfigError
from ray_release.template import bazel_runfile, get_working_dir
from ray_release.test import Test
def test_get_working_dir_with_path_from_root():
test_with_path_from_root = Test(
{
"name": "test",
"working_dir": "//ray_testing/ray_release/tests",
}
)
assert (
get_working_dir(test_with_path_from_root, None, "/tmp/bazel_workspace")
== "/tmp/bazel_workspace/ray_testing/ray_release/tests"
)
assert get_working_dir(test_with_path_from_root, None, None) == bazel_runfile(
"ray_testing/ray_release/tests"
)
def test_get_working_dir_with_relative_path():
test_with_relative_path = Test(
{
"name": "test",
"working_dir": "ray_release/tests",
}
)
assert (
get_working_dir(test_with_relative_path, None, "/tmp/bazel_workspace")
== "/tmp/bazel_workspace/release/ray_release/tests"
)
assert get_working_dir(test_with_relative_path, None, None) == bazel_runfile(
"release/ray_release/tests"
)
def test_get_working_dir_fail():
test_with_path_from_root = Test(
{
"name": "test",
"working_dir": "//ray_testing/ray_release/tests",
}
)
with pytest.raises(ReleaseTestConfigError):
get_working_dir(
test_with_path_from_root, "/tmp/test_definition_root", "tmp/bazel_workspace"
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+656
View File
@@ -0,0 +1,656 @@
import asyncio
import json
import os
import platform
import sys
from typing import List
from unittest import mock
from unittest.mock import AsyncMock, patch
import aioboto3
import boto3
import pytest
import responses
from ray_release.bazel import bazel_runfile
from ray_release.configs.global_config import (
get_global_config,
init_global_config,
)
from ray_release.github_client import GitHubClient
from ray_release.test import (
DATAPLANE_ECR_ML_REPO,
DATAPLANE_ECR_REPO,
LINUX_TEST_PREFIX,
MACOS_BISECT_DAILY_RATE_LIMIT,
MACOS_TEST_PREFIX,
WINDOWS_TEST_PREFIX,
ResultStatus,
Test,
TestResult,
TestState,
TestType,
_convert_env_list_to_dict,
)
from ray_release.util import ANYSCALE_RAY_IMAGE_PREFIX, dict_hash
init_global_config(bazel_runfile("release/ray_release/configs/oss_config.yaml"))
class MockTest(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get_name(self) -> str:
return self.get("name", "")
def get_test_results(self, limit: int) -> List[TestResult]:
return self.get("test_results", [])
def is_high_impact(self) -> bool:
return self.get(Test.KEY_IS_HIGH_IMPACT, "false") == "true"
def _stub_test(val: dict) -> Test:
test = Test(
{
"name": "test",
"cluster": {},
}
)
test.update(val)
return test
def _stub_test_result(
status: ResultStatus = ResultStatus.SUCCESS, rayci_step_id="123", commit="456"
) -> TestResult:
return TestResult(
status=status.value,
commit=commit,
branch="master",
url="url",
timestamp=0,
pull_request="1",
rayci_step_id=rayci_step_id,
duration_ms=5.0,
)
def test_convert_env_list_to_dict():
with mock.patch.dict(os.environ, {"ENV": "env"}):
assert _convert_env_list_to_dict(["a=b", "c=d=e", "ENV"]) == {
"a": "b",
"c": "d=e",
"ENV": "env",
}
def test_get_python_version():
assert _stub_test({}).get_python_version() == "3.10"
assert _stub_test({"python": "3.11"}).get_python_version() == "3.11"
def test_get_byod_runtime_env():
test = _stub_test(
{
"python": "3.11",
"cluster": {
"byod": {
"runtime_env": ["a=b"],
},
},
}
)
runtime_env = test.get_byod_runtime_env()
assert runtime_env.get("a") == "b"
def test_get_anyscale_byod_image():
os.environ["RAYCI_BUILD_ID"] = "a1b2c3d4"
assert (
_stub_test({"python": "3.7", "cluster": {"byod": {}}}).get_anyscale_byod_image()
== f"{get_global_config()['byod_ecr']}/{DATAPLANE_ECR_REPO}:a1b2c3d4-py37-cpu"
)
assert _stub_test(
{
"python": "3.8",
"cluster": {
"byod": {
"type": "gpu",
}
},
}
).get_anyscale_byod_image() == (
f"{get_global_config()['byod_ecr']}/"
f"{DATAPLANE_ECR_ML_REPO}:a1b2c3d4-py38-gpu"
)
assert _stub_test(
{
"python": "3.8",
"cluster": {
"byod": {
"type": "gpu",
"post_build_script": "foo.sh",
}
},
}
).get_anyscale_byod_image() == (
f"{get_global_config()['byod_ecr']}"
f"/{DATAPLANE_ECR_ML_REPO}:a1b2c3d4-py38-gpu-"
"5f311914c59730d72cee8e2a015c5d6eedf6523bfbf5abe2494e0cb85a5a7b70"
)
def test_get_anyscale_byod_image_ray_version():
os.environ["RAYCI_BUILD_ID"] = "a1b2c3d4"
assert (
_stub_test({"python": "3.7", "cluster": {"byod": {}}}).get_anyscale_byod_image()
== f"{get_global_config()['byod_ecr']}/{DATAPLANE_ECR_REPO}:a1b2c3d4-py37-cpu"
)
assert _stub_test(
{
"python": "3.8",
"cluster": {
"ray_version": "2.50.0",
"byod": {
"type": "gpu",
},
},
}
).get_anyscale_byod_image() == (f"{ANYSCALE_RAY_IMAGE_PREFIX}:2.50.0-py38-cu121")
assert _stub_test(
{
"python": "3.8",
"cluster": {
"ray_version": "2.50.0",
"byod": {
"type": "gpu",
"post_build_script": "foo.sh",
},
},
}
).get_anyscale_byod_image() == (
f"{get_global_config()['byod_ecr']}"
f"/{DATAPLANE_ECR_ML_REPO}:a1b2c3d4-py38-gpu-"
"5f311914c59730d72cee8e2a015c5d6eedf6523bfbf5abe2494e0cb85a5a7b70"
"-2.50.0"
)
_ISSUE_URL = "https://api.github.com/repos/owner/repo/issues/1"
_ISSUE_JSON = {"number": 1, "state": "open", "title": "", "html_url": "", "labels": []}
def _repo():
return GitHubClient("token").get_repo("owner/repo")
def test_is_jailed_with_open_issue_not_jailed() -> None:
assert not Test(state="passing").is_jailed_with_open_issue(_repo())
def test_is_jailed_with_open_issue_no_issue_number() -> None:
assert not Test(state="jailed").is_jailed_with_open_issue(_repo())
@responses.activate
def test_is_jailed_with_open_issue_open() -> None:
responses.add(responses.GET, _ISSUE_URL, json={**_ISSUE_JSON, "state": "open"})
assert Test(state="jailed", github_issue_number="1").is_jailed_with_open_issue(
_repo()
)
@responses.activate
def test_is_jailed_with_open_issue_closed() -> None:
responses.add(responses.GET, _ISSUE_URL, json={**_ISSUE_JSON, "state": "closed"})
assert not Test(state="jailed", github_issue_number="1").is_jailed_with_open_issue(
_repo()
)
@responses.activate
def test_is_jailed_with_open_issue_github_exception() -> None:
responses.add(responses.GET, _ISSUE_URL, json={"message": "Not Found"}, status=404)
assert not Test(
name="test", state="jailed", github_issue_number="1"
).is_jailed_with_open_issue(_repo())
def test_is_stable() -> None:
assert Test().is_stable()
assert Test(stable=True).is_stable()
assert not Test(stable=False).is_stable()
@patch.dict(
os.environ,
{
"BUILDKITE_BRANCH": "food",
"BUILDKITE_PULL_REQUEST": "1",
"RAYCI_STEP_ID": "g4_s5",
},
)
def test_result_from_bazel_event() -> None:
result = TestResult.from_bazel_event(
{
"testResult": {"status": "PASSED", "testAttemptDurationMillis": "5"},
}
)
assert result.is_passing()
assert result.branch == "food"
assert result.pull_request == "1"
assert result.rayci_step_id == "g4_s5"
assert result.duration_ms == 5
result = TestResult.from_bazel_event(
{
"testResult": {"status": "FAILED"},
}
)
assert result.is_failing()
assert result.duration_ms is None
def test_from_bazel_event() -> None:
test = Test.from_bazel_event(
{
"id": {"testResult": {"label": "//ray/ci:test"}},
},
"ci",
)
assert test.get_name() == f"{platform.system().lower()}://ray/ci:test"
assert test.get_oncall() == "ci"
@patch.object(boto3, "client")
@patch.dict(
os.environ,
{"BUILDKITE_PIPELINE_ID": get_global_config()["ci_pipeline_postmerge"][0]},
)
def test_update_from_s3(mock_client) -> None:
mock_object = mock.Mock()
mock_object.return_value.get.return_value.read.return_value = json.dumps(
{
"state": "failing",
"team": "core",
"github_issue_number": "1234",
}
).encode("utf-8")
mock_client.return_value.get_object = mock_object
test = _stub_test({"team": "ci"})
test.update_from_s3()
assert test.get_state() == TestState.FAILING
assert test.get_oncall() == "ci"
assert test["github_issue_number"] == "1234"
@patch("ray_release.test.Test._get_s3_name")
@patch("ray_release.test.Test.gen_from_s3")
def test_gen_from_name(mock_gen_from_s3, _) -> None:
mock_gen_from_s3.return_value = [
_stub_test({"name": "a"}),
_stub_test({"name": "good"}),
_stub_test({"name": "test"}),
]
assert Test.gen_from_name("good").get_name() == "good"
def test_get_test_type() -> None:
assert (
_stub_test({"name": f"{LINUX_TEST_PREFIX}_test"}).get_test_type()
== TestType.LINUX_TEST
)
assert (
_stub_test({"name": f"{MACOS_TEST_PREFIX}_test"}).get_test_type()
== TestType.MACOS_TEST
)
assert (
_stub_test({"name": f"{WINDOWS_TEST_PREFIX}_test"}).get_test_type()
== TestType.WINDOWS_TEST
)
assert _stub_test({"name": "release_test"}).get_test_type() == TestType.RELEASE_TEST
def test_get_bisect_daily_rate_limit() -> None:
assert (
_stub_test({"name": f"{MACOS_TEST_PREFIX}_test"}).get_bisect_daily_rate_limit()
) == MACOS_BISECT_DAILY_RATE_LIMIT
def test_get_s3_name() -> None:
assert Test._get_s3_name("linux://python/ray/test") == "linux:__python_ray_test"
def test_is_high_impact() -> None:
assert _stub_test(
{"name": "test", Test.KEY_IS_HIGH_IMPACT: "true"}
).is_high_impact()
assert not _stub_test(
{"name": "test", Test.KEY_IS_HIGH_IMPACT: "false"}
).is_high_impact()
assert not _stub_test({"name": "test"}).is_high_impact()
@patch("ray_release.test.Test._gen_test_result")
def test_gen_test_results(mock_gen_test_result) -> None:
def _mock_gen_test_result(
client: aioboto3.Session.client,
bucket: str,
key: str,
) -> TestResult:
return (
_stub_test_result(ResultStatus.SUCCESS)
if key == "good"
else _stub_test_result(ResultStatus.ERROR)
)
mock_gen_test_result.side_effect = AsyncMock(side_effect=_mock_gen_test_result)
results = asyncio.run(
_stub_test({})._gen_test_results(
bucket="bucket",
keys=["good", "bad", "bad", "good"],
)
)
assert [result.status for result in results] == [
ResultStatus.SUCCESS.value,
ResultStatus.ERROR.value,
ResultStatus.ERROR.value,
ResultStatus.SUCCESS.value,
]
@patch("ray_release.test.Test.gen_microcheck_test")
@patch("ray_release.test.Test.gen_from_name")
def gen_microcheck_step_ids(mock_gen_from_name, mock_gen_microcheck_test) -> None:
core_test = MockTest(
{
"name": "linux://core_test",
Test.KEY_IS_HIGH_IMPACT: "false",
"test_results": [
_stub_test_result(rayci_step_id="corebuild", commit="123"),
],
}
)
data_test_01 = MockTest(
{
"name": "linux://data_test_01",
Test.KEY_IS_HIGH_IMPACT: "true",
"test_results": [
_stub_test_result(rayci_step_id="databuild", commit="123"),
],
}
)
data_test_02 = MockTest(
{
"name": "linux://data_test_02",
Test.KEY_IS_HIGH_IMPACT: "true",
"test_results": [
_stub_test_result(rayci_step_id="data15build", commit="123"),
_stub_test_result(rayci_step_id="databuild", commit="123"),
_stub_test_result(rayci_step_id="databuild", commit="456"),
],
}
)
all_tests = [core_test, data_test_01, data_test_02]
mock_gen_microcheck_test.return_value = [test.get_target() for test in all_tests]
mock_gen_from_name.side_effect = lambda x: [
test for test in all_tests if test.get_name() == x
][0]
assert Test.gen_microcheck_step_ids("linux", "") == {"databuild"}
def test_get_test_target():
input_to_output = {
"linux://test": "//test",
"darwin://test": "//test",
"windows://test": "//test",
"test": "test",
}
for input, output in input_to_output.items():
assert Test({"name": input}).get_target() == output
@mock.patch.dict(
os.environ,
{"BUILDKITE_PULL_REQUEST_BASE_BRANCH": "base", "BUILDKITE_COMMIT": "commit"},
)
@mock.patch("subprocess.check_call")
@mock.patch("subprocess.check_output")
def test_get_changed_files(mock_check_output, mock_check_call) -> None:
mock_check_output.return_value = b"file1\nfile2\n"
assert Test._get_changed_files("") == {"file1", "file2"}
@mock.patch("ray_release.test.Test._get_test_targets_per_file")
@mock.patch("ray_release.test.Test._get_changed_files")
def test_get_changed_tests(
mock_get_changed_files, mock_get_test_targets_per_file
) -> None:
mock_get_changed_files.return_value = {"test_src", "build_src"}
mock_get_test_targets_per_file.side_effect = (
lambda x, _: {"//t1", "//t2"} if x == "test_src" else {}
)
assert Test._get_changed_tests("") == {"//t1", "//t2"}
@mock.patch.dict(
os.environ,
{"BUILDKITE_PULL_REQUEST_BASE_BRANCH": "base", "BUILDKITE_COMMIT": "commit"},
)
@mock.patch("subprocess.check_call")
@mock.patch("subprocess.check_output")
def test_get_human_specified_tests(mock_check_output, mock_check_call) -> None:
mock_check_output.return_value = b"hi\n@microcheck //test01 //test02\nthere"
assert Test._get_human_specified_tests("") == {"//test01", "//test02"}
def test_gen_microcheck_tests() -> None:
test_harness = [
{
"input": [],
"changed_tests": set(),
"human_tests": set(),
"output": set(),
},
{
"input": [
_stub_test(
{
"name": "linux://core_good",
"team": "core",
Test.KEY_IS_HIGH_IMPACT: "true",
}
),
_stub_test(
{
"name": "linux://serve_good",
"team": "serve",
Test.KEY_IS_HIGH_IMPACT: "true",
}
),
],
"changed_tests": {"//core_new"},
"human_tests": {"//human_test"},
"output": {
"//core_good",
"//core_new",
"//human_test",
},
},
]
for test in test_harness:
with mock.patch(
"ray_release.test.Test.gen_from_s3",
return_value=test["input"],
), mock.patch(
"ray_release.test.Test._get_changed_tests",
return_value=test["changed_tests"],
), mock.patch(
"ray_release.test.Test._get_human_specified_tests",
return_value=test["human_tests"],
):
assert (
Test.gen_microcheck_tests(
prefix="linux",
bazel_workspace_dir="",
team="core",
)
== test["output"]
)
@patch("ray_release.test.Test.get_byod_base_image_tag")
def test_get_byod_image_tag(mock_get_byod_base_image_tag):
test = _stub_test(
{
"name": "linux://test",
"cluster": {
"byod": {
"post_build_script": "test_post_build_script.sh",
"python_depset": "test_python_depset.lock",
},
},
}
)
mock_get_byod_base_image_tag.return_value = "test-image"
custom_info = {
"post_build_script": "test_post_build_script.sh",
"python_depset": "test_python_depset.lock",
}
hash_value = dict_hash(custom_info)
assert test.get_byod_image_tag() == f"test-image-{hash_value}"
@patch("ray_release.test.Test.get_byod_base_image_tag")
def test_get_byod_image_tag_ray_version(mock_get_byod_base_image_tag):
test = _stub_test(
{
"name": "linux://test",
"cluster": {
"ray_version": "2.50.0",
"byod": {
"post_build_script": "test_post_build_script.sh",
"python_depset": "test_python_depset.lock",
},
},
}
)
mock_get_byod_base_image_tag.return_value = "test-image"
custom_info = {
"post_build_script": "test_post_build_script.sh",
"python_depset": "test_python_depset.lock",
}
hash_value = dict_hash(custom_info)
assert test.get_byod_image_tag() == f"test-image-{hash_value}-2.50.0"
def test_require_custom_byod_image():
# No custom build needed
assert not _stub_test({"cluster": {"byod": {}}}).require_custom_byod_image()
# post_build_script triggers custom build
assert _stub_test(
{"cluster": {"byod": {"post_build_script": "foo.sh"}}}
).require_custom_byod_image()
# python_depset triggers custom build
assert _stub_test(
{"cluster": {"byod": {"python_depset": "deps.lock"}}}
).require_custom_byod_image()
# runtime_env triggers custom build
assert _stub_test(
{"cluster": {"byod": {"runtime_env": ["FOO=bar"]}}}
).require_custom_byod_image()
# empty runtime_env does not trigger custom build
assert not _stub_test(
{"cluster": {"byod": {"runtime_env": []}}}
).require_custom_byod_image()
@patch("ray_release.test.Test.get_byod_base_image_tag")
def test_get_byod_image_tag_runtime_env_only(mock_get_byod_base_image_tag):
"""Tests with only runtime_env get a custom image tag including env hash."""
test = _stub_test(
{
"name": "linux://test",
"cluster": {
"byod": {
"runtime_env": ["MY_VAR=123"],
},
},
}
)
mock_get_byod_base_image_tag.return_value = "test-image"
custom_info = {
"post_build_script": None,
"python_depset": None,
"runtime_env": {"MY_VAR": "123"},
}
hash_value = dict_hash(custom_info)
assert test.get_byod_image_tag() == f"test-image-{hash_value}"
# A different test with the same runtime_env but a different run script
# should produce the same image tag (run script doesn't affect the image).
test2 = _stub_test(
{
"name": "linux://other_test",
"cluster": {
"byod": {
"runtime_env": ["MY_VAR=123"],
},
},
"run": {
"script": "python different_script.py",
},
}
)
assert test2.get_byod_image_tag() == f"test-image-{hash_value}"
@patch("ray_release.test.Test.get_byod_base_image_tag")
def test_get_byod_image_tag_with_runtime_env_and_script(mock_get_byod_base_image_tag):
"""Tests with runtime_env AND post_build_script include both in the hash."""
test = _stub_test(
{
"name": "linux://test",
"cluster": {
"byod": {
"post_build_script": "test_script.sh",
"runtime_env": ["KEY=val"],
},
},
}
)
mock_get_byod_base_image_tag.return_value = "test-image"
custom_info = {
"post_build_script": "test_script.sh",
"python_depset": None,
"runtime_env": {"KEY": "val"},
}
hash_value = dict_hash(custom_info)
assert test.get_byod_image_tag() == f"test-image-{hash_value}"
# Verify this is different from the hash without runtime_env
custom_info_no_env = {
"post_build_script": "test_script.sh",
"python_depset": None,
}
assert dict_hash(custom_info) != dict_hash(custom_info_no_env)
def test_uses_anyscale_sdk_2026():
assert not Test({"name": "t", "cluster": {}}).uses_anyscale_sdk_2026()
assert not Test({"name": "t"}).uses_anyscale_sdk_2026()
assert Test(
{"name": "t", "cluster": {"anyscale_sdk_2026": True}}
).uses_anyscale_sdk_2026()
assert not Test(
{"name": "t", "cluster": {"anyscale_sdk_2026": False}}
).uses_anyscale_sdk_2026()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
+52
View File
@@ -0,0 +1,52 @@
from collections import Counter
from typing import Any
UNIT_TEST_PROJECT_ID = "prj_HqxHjwtn2uRtzR3DW6AmBYZh"
UNIT_TEST_CLOUD_ID = "cld_4F7k8814aZzGG8TNUGPKnc"
class UnitTestError(RuntimeError):
pass
def fail_always(*a, **kw):
raise UnitTestError()
def fail_once(result: Any):
class _Failer:
def __init__(self):
self.failed = False
def __call__(self, *args, **kwargs):
if not self.failed:
self.failed = True
raise UnitTestError()
return result
return _Failer()
class APIDict(dict):
__slots__ = ()
__getattr__ = dict.__getitem__
__setattr__ = dict.__setattr__
class MockSDK:
def __init__(self):
self.returns = {}
self.call_counter = Counter()
def reset(self):
self.returns = {}
self.call_counter = Counter()
def __getattr__(self, item):
self.call_counter[item] += 1
result = self.returns.get(item)
if callable(result):
return result
else:
return lambda *a, **kw: result