import logging import os import shutil import sys import tempfile import time from functools import partial from pathlib import Path import pytest import pytest_asyncio import requests import yaml import ray from ray._common.network_utils import build_address from ray._common.test_utils import async_wait_for_condition, wait_for_condition from ray._common.utils import get_or_create_event_loop from ray._private.ray_constants import DEFAULT_DASHBOARD_AGENT_LISTEN_PORT from ray._private.runtime_env.py_modules import upload_py_modules_if_needed from ray._private.runtime_env.working_dir import upload_working_dir_if_needed from ray._private.test_utils import ( chdir, format_web_url, get_current_unused_port, run_string_as_driver_nonblocking, wait_until_server_available, ) from ray.dashboard.modules.job.common import ( JOB_ACTOR_NAME_TEMPLATE, SUPERVISOR_ACTOR_RAY_NAMESPACE, JobSubmitRequest, validate_request_type, ) from ray.dashboard.modules.job.job_head import JobAgentSubmissionClient from ray.dashboard.tests.conftest import * # noqa from ray.job_submission import JobStatus, JobSubmissionClient from ray.runtime_env.runtime_env import RuntimeEnv, RuntimeEnvConfig from ray.tests.conftest import _ray_start from ray.util.state import get_node, list_actors, list_nodes # This test requires you have AWS credentials set up (any AWS credentials will # do, this test only accesses a public bucket). logger = logging.getLogger(__name__) DRIVER_SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "subprocess_driver_scripts") EVENT_LOOP = get_or_create_event_loop() def get_node_id_for_supervisor_actor_for_job( address: str, job_submission_id: str ) -> str: actors = list_actors( address=address, filters=[("ray_namespace", "=", SUPERVISOR_ACTOR_RAY_NAMESPACE)], ) for actor in actors: if actor.name == JOB_ACTOR_NAME_TEMPLATE.format(job_id=job_submission_id): return actor.node_id raise ValueError(f"actor not found for job_submission_id {job_submission_id}") def get_node_ip_by_id(node_id: str) -> str: node = get_node(id=node_id) return node.node_ip class JobAgentSubmissionBrowserClient(JobAgentSubmissionClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._session.headers[ "User-Agent" ] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" # noqa: E501 @pytest_asyncio.fixture async def job_sdk_client(make_sure_dashboard_http_port_unused): with _ray_start(include_dashboard=True, num_cpus=1) as ctx: node_ip = ctx.address_info["node_ip_address"] agent_address = build_address(node_ip, DEFAULT_DASHBOARD_AGENT_LISTEN_PORT) assert wait_until_server_available(agent_address) head_address = ctx.address_info["webui_url"] assert wait_until_server_available(head_address) yield ( JobAgentSubmissionClient(format_web_url(agent_address)), JobSubmissionClient(format_web_url(head_address)), ) def _check_job( client: JobSubmissionClient, job_id: str, status: JobStatus, timeout: int = 10 ) -> bool: res_status = client.get_job_status(job_id) assert res_status == status return True @pytest.fixture( scope="module", params=[ "no_working_dir", "local_working_dir", "s3_working_dir", "local_py_modules", "working_dir_and_local_py_modules_whl", "local_working_dir_zip", "pip_txt", "conda_yaml", "local_py_modules", ], ) def runtime_env_option(request): import_in_task_script = """ import ray ray.init(address="auto") @ray.remote def f(): import pip_install_test ray.get(f.remote()) """ if request.param == "no_working_dir": yield { "runtime_env": {}, "entrypoint": "echo hello", "expected_logs": "hello\n", } elif request.param in { "local_working_dir", "local_working_dir_zip", "local_py_modules", "working_dir_and_local_py_modules_whl", }: with tempfile.TemporaryDirectory() as tmp_dir: path = Path(tmp_dir) hello_file = path / "test.py" with hello_file.open(mode="w") as f: f.write("from test_module import run_test\n") f.write("print(run_test())") module_path = path / "test_module" module_path.mkdir(parents=True) test_file = module_path / "test.py" with test_file.open(mode="w") as f: f.write("def run_test():\n") f.write(" return 'Hello from test_module!'\n") # noqa: Q000 init_file = module_path / "__init__.py" with init_file.open(mode="w") as f: f.write("from test_module.test import run_test\n") if request.param == "local_working_dir": yield { "runtime_env": {"working_dir": tmp_dir}, "entrypoint": "python test.py", "expected_logs": "Hello from test_module!\n", } elif request.param == "local_working_dir_zip": local_zipped_dir = shutil.make_archive( os.path.join(tmp_dir, "test"), "zip", tmp_dir ) yield { "runtime_env": {"working_dir": local_zipped_dir}, "entrypoint": "python test.py", "expected_logs": "Hello from test_module!\n", } elif request.param == "local_py_modules": yield { "runtime_env": {"py_modules": [str(Path(tmp_dir) / "test_module")]}, "entrypoint": ( "python -c 'import test_module;print(test_module.run_test())'" ), "expected_logs": "Hello from test_module!\n", } elif request.param == "working_dir_and_local_py_modules_whl": yield { "runtime_env": { "working_dir": "s3://runtime-env-test/script_runtime_env.zip", "py_modules": [ Path(os.path.dirname(__file__)) / "pip_install_test-0.5-py3-none-any.whl" ], }, "entrypoint": ( "python script.py && python -c 'import pip_install_test'" ), "expected_logs": ( "Executing main() from script.py !!\n" "Good job! You installed a pip module." ), } else: raise ValueError(f"Unexpected pytest fixture option {request.param}") elif request.param == "s3_working_dir": yield { "runtime_env": { "working_dir": "s3://runtime-env-test/script_runtime_env.zip", }, "entrypoint": "python script.py", "expected_logs": "Executing main() from script.py !!\n", } elif request.param == "pip_txt": with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir): pip_list = ["pip-install-test==0.5"] relative_filepath = "requirements.txt" pip_file = Path(relative_filepath) pip_file.write_text("\n".join(pip_list)) runtime_env = {"pip": {"packages": relative_filepath, "pip_check": False}} yield { "runtime_env": runtime_env, "entrypoint": ( f"python -c 'import pip_install_test' && " f"python -c '{import_in_task_script}'" ), "expected_logs": "Good job! You installed a pip module.", } elif request.param == "conda_yaml": with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir): conda_dict = {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]} relative_filepath = "environment.yml" conda_file = Path(relative_filepath) conda_file.write_text(yaml.dump(conda_dict)) runtime_env = {"conda": relative_filepath} yield { "runtime_env": runtime_env, "entrypoint": f"python -c '{import_in_task_script}'", # TODO(architkulkarni): Uncomment after #22968 is fixed. # "entrypoint": "python -c 'import pip_install_test'", "expected_logs": "Good job! You installed a pip module.", } else: assert False, f"Unrecognized option: {request.param}." @pytest.mark.asyncio async def test_submit_job(job_sdk_client, runtime_env_option, monkeypatch): # This flag allows for local testing of runtime env conda functionality # without needing a built Ray wheel. Rather than insert the link to the # wheel into the conda spec, it links to the current Python site. monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1") agent_client, head_client = job_sdk_client runtime_env = runtime_env_option["runtime_env"] runtime_env = upload_working_dir_if_needed( runtime_env, include_gitignore=True, logger=logger ) runtime_env = upload_py_modules_if_needed( runtime_env, include_gitignore=True, logger=logger ) runtime_env = RuntimeEnv(**runtime_env_option["runtime_env"]).to_dict() request = validate_request_type( {"runtime_env": runtime_env, "entrypoint": runtime_env_option["entrypoint"]}, JobSubmitRequest, ) submit_result = await agent_client.submit_job_internal(request) job_id = submit_result.submission_id try: job_start_time = time.time() wait_for_condition( partial( _check_job, client=head_client, job_id=job_id, status=JobStatus.SUCCEEDED, ), timeout=300, ) job_duration = time.time() - job_start_time print(f"The job took {job_duration}s to succeed.") except RuntimeError as e: # If the job is still pending, include job logs and info in error. if head_client.get_job_status(job_id) == JobStatus.PENDING: logs = head_client.get_job_logs(job_id) info = head_client.get_job_info(job_id) raise RuntimeError( f"Job was stuck in PENDING.\nLogs: {logs}\nInfo: {info}" ) from e # There is only one node, so there is no need to replace the client of the JobAgent resp = await agent_client.get_job_logs_internal(job_id) assert runtime_env_option["expected_logs"] in resp.logs @pytest.mark.asyncio async def test_submit_job_rejects_browsers( job_sdk_client, runtime_env_option, monkeypatch ): # This flag allows for local testing of runtime env conda functionality # without needing a built Ray wheel. Rather than insert the link to the # wheel into the conda spec, it links to the current Python site. monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1") agent_client, head_client = job_sdk_client agent_address = agent_client._agent_address agent_client = JobAgentSubmissionBrowserClient(agent_address) runtime_env = runtime_env_option["runtime_env"] runtime_env = upload_working_dir_if_needed( runtime_env, include_gitignore=True, logger=logger ) runtime_env = upload_py_modules_if_needed( runtime_env, include_gitignore=True, logger=logger ) runtime_env = RuntimeEnv(**runtime_env_option["runtime_env"]).to_dict() request = validate_request_type( {"runtime_env": runtime_env, "entrypoint": runtime_env_option["entrypoint"]}, JobSubmitRequest, ) with pytest.raises(RuntimeError) as exc: _ = await agent_client.submit_job_internal(request) assert "status code 403" in str(exc.value) @pytest.mark.asyncio async def test_delete_job_rejects_browsers(job_sdk_client, monkeypatch): """Test that DELETE job requests from browsers are rejected.""" monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1") agent_client, head_client = job_sdk_client # First, submit a job using the normal client runtime_env = RuntimeEnv().to_dict() request = validate_request_type( {"runtime_env": runtime_env, "entrypoint": "echo hello"}, JobSubmitRequest, ) submit_result = await agent_client.submit_job_internal(request) job_id = submit_result.submission_id # Now try to delete the job using browser-like headers agent_address = agent_client._agent_address browser_client = JobAgentSubmissionBrowserClient(agent_address) with pytest.raises(RuntimeError) as exc: _ = await browser_client.delete_job_internal(job_id) assert "status code 403" in str(exc.value) await browser_client.close() @pytest.mark.asyncio async def test_timeout(job_sdk_client): agent_client, head_client = job_sdk_client runtime_env = RuntimeEnv( pip={ "packages": ["tensorflow", "requests", "botocore", "torch"], "pip_check": False, "pip_version": "==23.3.2;python_version=='3.9.16'", }, config=RuntimeEnvConfig(setup_timeout_seconds=1), ).to_dict() request = validate_request_type( {"runtime_env": runtime_env, "entrypoint": "echo hello"}, JobSubmitRequest, ) submit_result = await agent_client.submit_job_internal(request) job_id = submit_result.submission_id wait_for_condition( partial(_check_job, client=head_client, job_id=job_id, status=JobStatus.FAILED), timeout=10, ) data = head_client.get_job_info(job_id) assert "Failed to set up runtime environment" in data.message assert "Timeout" in data.message assert "setup_timeout_seconds" in data.message @pytest.mark.asyncio async def test_runtime_env_setup_failure(job_sdk_client): agent_client, head_client = job_sdk_client runtime_env = RuntimeEnv(working_dir="s3://does_not_exist.zip").to_dict() request = validate_request_type( {"runtime_env": runtime_env, "entrypoint": "echo hello"}, JobSubmitRequest, ) submit_result = await agent_client.submit_job_internal(request) job_id = submit_result.submission_id wait_for_condition( partial(_check_job, client=head_client, job_id=job_id, status=JobStatus.FAILED), timeout=10, ) data = head_client.get_job_info(job_id) assert "Failed to set up runtime environment" in data.message @pytest.mark.asyncio async def test_stop_long_running_job(job_sdk_client): """ Submit a job that runs for a while and stop it in the middle. """ agent_client, head_client = job_sdk_client with tempfile.TemporaryDirectory() as tmp_dir: path = Path(tmp_dir) driver_script = """ print('Hello !') import time time.sleep(300) # This should never finish raise RuntimeError('Intentionally failed.') """ test_script_file = path / "test_script.py" with open(test_script_file, "w+") as file: file.write(driver_script) runtime_env = {"working_dir": tmp_dir} runtime_env = upload_working_dir_if_needed( runtime_env, include_gitignore=True, scratch_dir=tmp_dir, logger=logger ) runtime_env = RuntimeEnv(**runtime_env).to_dict() request = validate_request_type( {"runtime_env": runtime_env, "entrypoint": "python test_script.py"}, JobSubmitRequest, ) submit_result = await agent_client.submit_job_internal(request) job_id = submit_result.submission_id resp = await agent_client.stop_job_internal(job_id) assert resp.stopped is True wait_for_condition( partial( _check_job, client=head_client, job_id=job_id, status=JobStatus.STOPPED ), timeout=10, ) @pytest.mark.asyncio async def test_tail_job_logs_with_echo(job_sdk_client): agent_client, head_client = job_sdk_client runtime_env = RuntimeEnv().to_dict() entrypoint = "python -c \"import time; [(print('Hello', i), time.sleep(0.1)) for i in range(100)]\"" # noqa: E501 request = validate_request_type( { "runtime_env": runtime_env, "entrypoint": entrypoint, }, JobSubmitRequest, ) submit_result = await agent_client.submit_job_internal(request) job_id = submit_result.submission_id i = 0 async for lines in agent_client.tail_job_logs(job_id): print(lines, end="") for line in lines.strip().split("\n"): if ( "Runtime env is setting up." in line or "Running entrypoint for job" in line ): continue assert line.split(" ") == ["Hello", str(i)] i += 1 wait_for_condition( partial( _check_job, client=head_client, job_id=job_id, status=JobStatus.SUCCEEDED ), timeout=10, ) @pytest.mark.asyncio @pytest.mark.parametrize( "ray_start_cluster_head", [ { "include_dashboard": True, "dashboard_agent_listen_port": DEFAULT_DASHBOARD_AGENT_LISTEN_PORT, } ], indirect=True, ) async def test_job_log_in_multiple_node( make_sure_dashboard_http_port_unused, enable_test_module, disable_aiohttp_cache, ray_start_cluster_head, ): cluster = ray_start_cluster_head assert wait_until_server_available(cluster.webui_url) is True webui_url = cluster.webui_url webui_url = format_web_url(webui_url) cluster.add_node( dashboard_agent_listen_port=DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 1 ) cluster.add_node( dashboard_agent_listen_port=DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 2 ) node_ip = cluster.head_node.node_ip_address agent_address = build_address(node_ip, DEFAULT_DASHBOARD_AGENT_LISTEN_PORT) assert wait_until_server_available(agent_address) client = JobAgentSubmissionClient(format_web_url(agent_address)) def _check_nodes(): try: assert len(list_nodes()) == 3 return True except Exception as ex: logger.info(ex) return False wait_for_condition(_check_nodes, timeout=15) job_ids = [] job_check_status = [] JOB_NUM = 10 job_agent_ports = [ DEFAULT_DASHBOARD_AGENT_LISTEN_PORT, DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 1, DEFAULT_DASHBOARD_AGENT_LISTEN_PORT + 2, ] for index in range(JOB_NUM): runtime_env = RuntimeEnv().to_dict() request = validate_request_type( { "runtime_env": runtime_env, "entrypoint": f"while true; do echo hello index-{index}" " && sleep 3600; done", }, JobSubmitRequest, ) submit_result = await client.submit_job_internal(request) job_ids.append(submit_result.submission_id) job_check_status.append(False) async def _check_all_jobs_log(): response = requests.get(webui_url + "/nodes?view=summary") response.raise_for_status() summary = response.json() assert summary["result"] is True, summary["msg"] summary = summary["data"]["summary"] for index, job_id in enumerate(job_ids): if job_check_status[index]: continue result_log = f"hello index-{index}" # Try to get the node id which supervisor actor running in. node_id = get_node_id_for_supervisor_actor_for_job(cluster.address, job_id) for node_info in summary: if node_info["raylet"]["nodeId"] == node_id: break assert node_info["raylet"]["nodeId"] == node_id, f"node id: {node_id}" # Try to get the agent HTTP port by node id. for agent_port in job_agent_ports: if f"--listen-port={agent_port}" in " ".join(node_info["cmdline"]): break assert f"--listen-port={agent_port}" in " ".join( node_info["cmdline"] ), f"port: {agent_port}" # Finally, we got the whole agent address, and try to get the job log. ip = get_node_ip_by_id(node_id) agent_address = f"{ip}:{agent_port}" assert wait_until_server_available(agent_address) client = JobAgentSubmissionClient(format_web_url(agent_address)) resp = await client.get_job_logs_internal(job_id) assert result_log in resp.logs, f"logs: {resp.logs}" job_check_status[index] = True return True st = time.time() while time.time() - st <= 30: try: await _check_all_jobs_log() break except Exception as ex: print("error:", ex) time.sleep(1) assert all(job_check_status), job_check_status def test_agent_logs_not_streamed_to_drivers(): """Ensure when the job submission is used, (ray.init is called from an agent), the agent logs are not streamed to drivers. Related: https://github.com/ray-project/ray/issues/29944 """ script = """ import ray from ray.job_submission import JobSubmissionClient, JobStatus from ray._private.test_utils import format_web_url from ray._common.test_utils import wait_for_condition ray.init() address = ray._private.worker._global_node.webui_url address = format_web_url(address) client = JobSubmissionClient(address) submission_id = client.submit_job(entrypoint="ls") wait_for_condition( lambda: client.get_job_status(submission_id) == JobStatus.SUCCEEDED ) """ proc = run_string_as_driver_nonblocking(script) out_str = proc.stdout.read().decode("ascii") err_str = proc.stderr.read().decode("ascii") print(out_str, err_str) assert "(raylet)" not in out_str assert "(raylet)" not in err_str @pytest.mark.asyncio async def test_non_default_dashboard_agent_http_port(tmp_path): """Test that we can connect to the dashboard agent with a non-default http port. """ import subprocess dashboard_agent_port = get_current_unused_port() cmd = f"ray start --head --dashboard-agent-listen-port {dashboard_agent_port}" subprocess.check_output(cmd, shell=True) try: # We will need to wait for the ray to be started in the subprocess. address_info = ray.init("auto", ignore_reinit_error=True).address_info node_ip = address_info["node_ip_address"] dashboard_agent_listen_port = address_info["dashboard_agent_listen_port"] agent_address = build_address(node_ip, dashboard_agent_listen_port) print("agent address = ", agent_address) agent_client = JobAgentSubmissionClient(format_web_url(agent_address)) head_client = JobSubmissionClient(format_web_url(address_info["webui_url"])) assert wait_until_server_available(agent_address) # Submit a job through the agent. runtime_env = RuntimeEnv().to_dict() request = validate_request_type( { "runtime_env": runtime_env, "entrypoint": "echo hello", }, JobSubmitRequest, ) submit_result = await agent_client.submit_job_internal(request) job_id = submit_result.submission_id async def verify(): # Wait for job finished. wait_for_condition( partial( _check_job, client=head_client, job_id=job_id, status=JobStatus.SUCCEEDED, ), timeout=10, ) resp = await agent_client.get_job_logs_internal(job_id) assert "hello" in resp.logs, resp.logs return True await async_wait_for_condition(verify, retry_interval_ms=2000) finally: subprocess.check_output("ray stop --force", shell=True) if __name__ == "__main__": sys.exit(pytest.main(["-v", __file__]))