import os import sys import tempfile import time from pathlib import Path from typing import Dict, Optional, Tuple from unittest.mock import AsyncMock, MagicMock, Mock, patch import aiohttp import pytest import ray.experimental.internal_kv as kv from ray._common.test_utils import wait_for_condition from ray._private import worker from ray._private.ray_constants import ( KV_NAMESPACE_DASHBOARD, PROCESS_TYPE_DASHBOARD, ) from ray._private.test_utils import ( format_web_url, wait_until_server_available, ) from ray._raylet import GcsClient from ray.dashboard.consts import ( DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX, GCS_RPC_TIMEOUT_SECONDS, RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR, ) from ray.dashboard.modules.dashboard_sdk import ( DEFAULT_DASHBOARD_ADDRESS, ClusterInfo, parse_cluster_info, ) from ray.dashboard.modules.job.pydantic_models import JobType from ray.dashboard.modules.job.sdk import JobStatus, JobSubmissionClient from ray.dashboard.tests.conftest import * # noqa from ray.runtime_env.runtime_env import RuntimeEnv from ray.tests.conftest import _ray_start from ray.util.state import list_nodes import psutil def _check_job_succeeded(client: JobSubmissionClient, job_id: str) -> bool: status = client.get_job_status(job_id) if status == JobStatus.FAILED: logs = client.get_job_logs(job_id) raise RuntimeError(f"Job failed\nlogs:\n{logs}") return status == JobStatus.SUCCEEDED def check_internal_kv_gced(): return len(kv._internal_kv_list("gcs://")) == 0 @pytest.mark.parametrize( "address_param", [ ("ray://1.2.3.4:10001", "ray", "1.2.3.4:10001"), ("other_module://", "other_module", ""), ("other_module://address", "other_module", "address"), ], ) @pytest.mark.parametrize("create_cluster_if_needed", [True, False]) @pytest.mark.parametrize("cookies", [None, {"test_cookie_key": "test_cookie_val"}]) @pytest.mark.parametrize("metadata", [None, {"test_metadata_key": "test_metadata_val"}]) @pytest.mark.parametrize("headers", [None, {"test_headers_key": "test_headers_val"}]) @pytest.mark.parametrize("extra_kwargs", [{}, {"cloud": "my-cloud"}]) def test_parse_cluster_info( address_param: Tuple[str, str, str], create_cluster_if_needed: bool, cookies: Optional[Dict[str, str]], metadata: Optional[Dict[str, str]], headers: Optional[Dict[str, str]], extra_kwargs: Dict[str, str], ): """ Test ray.dashboard.modules.dashboard_sdk.parse_cluster_info for different format of addresses. """ mock_get_job_submission_client_cluster = Mock(return_value="Ray ClusterInfo") mock_module = Mock() mock_module.get_job_submission_client_cluster_info = Mock( return_value="Other module ClusterInfo" ) mock_import_module = Mock(return_value=mock_module) address, module_string, inner_address = address_param with ( patch.multiple( "ray.dashboard.modules.dashboard_sdk", get_job_submission_client_cluster_info=mock_get_job_submission_client_cluster, ), patch.multiple("importlib", import_module=mock_import_module), ): if module_string == "ray": with pytest.raises(ValueError, match="ray://"): parse_cluster_info( address, create_cluster_if_needed=create_cluster_if_needed, cookies=cookies, metadata=metadata, headers=headers, **extra_kwargs, ) elif module_string == "other_module": assert ( parse_cluster_info( address, create_cluster_if_needed=create_cluster_if_needed, cookies=cookies, metadata=metadata, headers=headers, **extra_kwargs, ) == "Other module ClusterInfo" ) mock_import_module.assert_called_once_with(module_string) mock_module.get_job_submission_client_cluster_info.assert_called_once_with( inner_address, create_cluster_if_needed=create_cluster_if_needed, cookies=cookies, metadata=metadata, headers=headers, **extra_kwargs, ) def test_parse_cluster_info_default_address(): assert parse_cluster_info( address=None, ) == ClusterInfo(address=DEFAULT_DASHBOARD_ADDRESS) def test_submit_job_does_not_mutate_runtime_env(): class TestClient(JobSubmissionClient): def __init__(self): self._default_metadata = {} def _upload_working_dir_if_needed(self, runtime_env): runtime_env["working_dir"] = "gcs://test.zip" def _upload_py_modules_if_needed(self, runtime_env): runtime_env["py_modules"] = ["gcs://test_module.zip"] def _do_request(self, method, endpoint, **kwargs): return MagicMock( status_code=200, json=lambda: {"job_id": "test_job", "submission_id": "test_job"}, ) runtime_env = {"working_dir": "/tmp/test", "py_modules": ["/tmp/test_module"]} original_runtime_env = { "working_dir": runtime_env["working_dir"], "py_modules": list(runtime_env["py_modules"]), } assert ( TestClient().submit_job(entrypoint="echo hi", runtime_env=runtime_env) == "test_job" ) assert runtime_env == original_runtime_env @pytest.mark.parametrize("expiration_s", [0, 10]) def test_temporary_uri_reference(monkeypatch, expiration_s): """Test that temporary GCS URI references are deleted after expiration_s.""" monkeypatch.setenv( "RAY_RUNTIME_ENV_TEMPORARY_REFERENCE_EXPIRATION_S", str(expiration_s) ) # We can't use a fixture with a shared Ray runtime because we need to set the # expiration_s env var before Ray starts. with _ray_start(include_dashboard=True, num_cpus=1) as ctx: headers = {"Connection": "keep-alive", "Authorization": "TOK:"} address = ctx.address_info["webui_url"] assert wait_until_server_available(address) client = JobSubmissionClient(format_web_url(address), headers=headers) with tempfile.TemporaryDirectory() as tmp_dir: path = Path(tmp_dir) hello_file = path / "hi.txt" with hello_file.open(mode="w") as f: f.write("hi\n") start = time.time() runtime_env = {"working_dir": tmp_dir} job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env) assert runtime_env == {"working_dir": tmp_dir} wait_for_condition( _check_job_succeeded, client=client, job_id=job_id, timeout=30 ) # Give time for deletion to occur if expiration_s is 0. time.sleep(2) # Need to connect to Ray to check internal_kv. # ray.init(address="auto") print("Starting Internal KV checks at time ", time.time() - start) if expiration_s > 0: assert not check_internal_kv_gced() wait_for_condition(check_internal_kv_gced, timeout=2 * expiration_s) assert expiration_s < time.time() - start < 2 * expiration_s print("Internal KV was GC'ed at time ", time.time() - start) else: wait_for_condition(check_internal_kv_gced) print("Internal KV was GC'ed at time ", time.time() - start) # Regression test for #46625: reusing the same runtime_env after # the package has been GC'ed should re-upload the local working_dir. job_id = client.submit_job( entrypoint="echo hi", runtime_env=runtime_env ) wait_for_condition( _check_job_succeeded, client=client, job_id=job_id, timeout=30 ) def get_register_agents_number(gcs_client): keys = gcs_client.internal_kv_keys( prefix=DASHBOARD_AGENT_ADDR_NODE_ID_PREFIX, namespace=KV_NAMESPACE_DASHBOARD, timeout=GCS_RPC_TIMEOUT_SECONDS, ) return len(keys) @pytest.mark.parametrize( "ray_start_cluster_head_with_env_vars", [ { "include_dashboard": True, "env_vars": {RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR: "1"}, }, { "include_dashboard": True, "env_vars": {RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR: "0"}, }, ], indirect=True, ) def test_jobs_run_on_head_by_default_E2E(ray_start_cluster_head_with_env_vars): allow_driver_on_worker_nodes = ( os.environ.get(RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES_ENV_VAR) == "1" ) # Cluster setup cluster = ray_start_cluster_head_with_env_vars cluster.add_node(dashboard_agent_listen_port=52366) cluster.add_node(dashboard_agent_listen_port=52367) assert wait_until_server_available(cluster.webui_url) is True webui_url = cluster.webui_url webui_url = format_web_url(webui_url) client = JobSubmissionClient(webui_url) gcs_client = GcsClient(address=cluster.gcs_address) def _check_nodes(num_nodes): try: assert len(list_nodes()) == num_nodes return True except Exception as ex: print(ex) return False wait_for_condition(lambda: _check_nodes(num_nodes=3), timeout=15) wait_for_condition(lambda: get_register_agents_number(gcs_client) == 3, timeout=20) # Submit 20 simple jobs. for i in range(20): client.submit_job(entrypoint="echo hi", submission_id=f"job_{i}") import pprint def check_all_jobs_succeeded(): submission_jobs = [ job for job in client.list_jobs() if job.type == JobType.SUBMISSION ] for job in submission_jobs: pprint.pprint(job) if job.status != JobStatus.SUCCEEDED: return False return True # Wait until all jobs have finished. wait_for_condition(check_all_jobs_succeeded, timeout=60, retry_interval_ms=1000) # Check driver_node_id of all jobs. submission_jobs = [ job for job in client.list_jobs() if job.type == JobType.SUBMISSION ] driver_node_ids = [job.driver_node_id for job in submission_jobs] # Spuriously fails with probability (1/3)^20. pprint.pprint(driver_node_ids) num_ids = len(set(driver_node_ids)) assert (num_ids > 1) if allow_driver_on_worker_nodes else (num_ids == 1), [ id[:5] for id in driver_node_ids ] @pytest.fixture def runtime_env_working_dir(): with tempfile.TemporaryDirectory() as tmp_dir: path = Path(tmp_dir) working_dir = path / "working_dir" working_dir.mkdir(parents=True) yield working_dir @pytest.fixture def py_module_whl(): with tempfile.NamedTemporaryFile(suffix=".whl") as tmp_file: yield tmp_file.name def test_job_submission_with_runtime_env_as_dict( runtime_env_working_dir, py_module_whl ): working_dir_str = str(runtime_env_working_dir) with _ray_start(num_cpus=1): client = JobSubmissionClient() runtime_env = {"working_dir": working_dir_str, "py_modules": [py_module_whl]} job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env) job_details = client.get_job_info(job_id) parsed_runtime_env = job_details.runtime_env assert "gcs://" in parsed_runtime_env["working_dir"] assert len(parsed_runtime_env["py_modules"]) == 1 assert "gcs://" in parsed_runtime_env["py_modules"][0] def test_job_submission_with_runtime_env_as_object( runtime_env_working_dir, py_module_whl ): working_dir_str = str(runtime_env_working_dir) with _ray_start(num_cpus=1): client = JobSubmissionClient() runtime_env = RuntimeEnv( working_dir=working_dir_str, py_modules=[py_module_whl] ) job_id = client.submit_job(entrypoint="echo hi", runtime_env=runtime_env) job_details = client.get_job_info(job_id) parsed_runtime_env = job_details.runtime_env assert "gcs://" in parsed_runtime_env["working_dir"] assert len(parsed_runtime_env["py_modules"]) == 1 assert "gcs://" in parsed_runtime_env["py_modules"][0] @pytest.mark.asyncio async def test_tail_job_logs_passes_headers_to_websocket(ray_start_regular): """ Test that authentication headers are passed to WebSocket connections. This test verifies that headers provided to JobSubmissionClient are explicitly passed to the ws_connect() method, not just to the ClientSession. This is required because aiohttp's ClientSession does not automatically include session headers in WebSocket upgrade requests. """ dashboard_url = ray_start_regular.dashboard_url test_headers = {"Authorization": "Bearer test-token"} client = JobSubmissionClient(format_web_url(dashboard_url), headers=test_headers) # Submit a simple job job_id = client.submit_job(entrypoint="echo hello") # Mock the aiohttp ClientSession and WebSocket mock_ws = AsyncMock() mock_ws.receive = AsyncMock() mock_ws.receive.side_effect = [ # First call returns a text message MagicMock(type=aiohttp.WSMsgType.TEXT, data="test log line\n"), # Second call indicates WebSocket is closed MagicMock(type=aiohttp.WSMsgType.CLOSED), ] mock_ws.close_code = 1000 # Normal closure mock_session = AsyncMock() mock_session.ws_connect = AsyncMock(return_value=mock_ws) mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) # Patch ClientSession to use our mock with patch("aiohttp.ClientSession", return_value=mock_session): # Tail logs log_lines = [] async for lines in client.tail_job_logs(job_id): log_lines.append(lines) # Verify ws_connect was called with headers mock_session.ws_connect.assert_called_once() call_args = mock_session.ws_connect.call_args assert "headers" in call_args.kwargs assert call_args.kwargs["headers"] == test_headers @pytest.mark.asyncio async def test_tail_job_logs_websocket_abnormal_closure(ray_start_regular): """ Test that ABNORMAL_CLOSURE raises RuntimeError when tailing logs. This test uses its own Ray cluster and kills the dashboard while tailing logs to simulate an abnormal WebSocket closure. """ dashboard_url = ray_start_regular.dashboard_url client = JobSubmissionClient(format_web_url(dashboard_url)) # Submit a long-running job driver_script = """ import time for i in range(100): print("Hello", i) time.sleep(0.5) """ entrypoint = f"python -c '{driver_script}'" job_id = client.submit_job(entrypoint=entrypoint) # Start tailing logs and stop Ray while tailing # Expect RuntimeError when WebSocket closes abnormally with pytest.raises( RuntimeError, match="WebSocket connection closed unexpectedly with close code", ): i = 0 async for lines in client.tail_job_logs(job_id): print(lines, end="") i += 1 # Kill the dashboard after receiving a few log lines if i == 3: print("\nKilling the dashboard to close websocket abnormally...") dash_info = worker._global_node.all_processes[PROCESS_TYPE_DASHBOARD][0] psutil.Process(dash_info.process.pid).kill() if __name__ == "__main__": sys.exit(pytest.main(["-v", __file__]))