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,26 @@
import requests
import ray
ray.init()
@ray.remote
class Counter:
def __init__(self):
self.counter = 0
def inc(self):
self.counter += 1
def get_counter(self):
return self.counter
counter = Counter.remote()
for _ in range(5):
ray.get(counter.inc.remote())
print(ray.get(counter.get_counter.remote()))
print(requests.__version__)
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -ex
unset RAY_ADDRESS
if ! [ -x "$(command -v conda)" ]; then
echo "conda doesn't exist. Please download conda for this machine"
exit 1
else
echo "conda exists"
fi
# This is required to use conda activate
source "$(conda info --base)/etc/profile.d/conda.sh"
PYTHON_VERSION=$(python -c"from platform import python_version; print(python_version())")
RAY_VERSIONS=("2.0.1")
for RAY_VERSION in "${RAY_VERSIONS[@]}"
do
env_name=${JOB_COMPATIBILITY_TEST_TEMP_ENV}
# Check if the conda env exists
if conda env list | grep -q "${env_name}"; then
# Clean up if env name is already taken from previous leaking runs
conda env remove --name="${env_name}"
fi
printf "\n\n\n"
echo "========================================================================================="
printf "Creating new conda environment with python %s for ray %s \n" "${PYTHON_VERSION}" "${RAY_VERSION}"
echo "========================================================================================="
printf "\n\n\n"
# Include `pip` explicitly: conda-forge's `python` package stopped
# bundling pip as a dep, and without it `conda activate` puts us in
# an env with python but no pip, so subsequent `pip install` falls
# back to the base miniforge env's pip. That clobbers the editable
# ray 3.0.0.dev0 in base with ray 2.0.1, and every subsequent
# dashboard test that imports `ray._common` fails because 2.0.1
# predates that module.
conda create -y -n "${env_name}" python="${PYTHON_VERSION}" pip=25.2
conda activate "${env_name}"
python -m pip install --upgrade pip
# Pin pydantic version due to: https://github.com/ray-project/ray/issues/36990.
# ray<2.9 is only compatible with pydantic<2 and setuptools < 70.
python -m pip install -U "pydantic<2" ray=="${RAY_VERSION}" ray[default]=="${RAY_VERSION}" setuptools==69.5.1
printf "\n\n\n"
echo "========================================================="
printf "Installed ray job server version: "
SERVER_RAY_VERSION=$(python -c "import ray; print(ray.__version__)")
printf "%s \n" "${SERVER_RAY_VERSION}"
echo "========================================================="
printf "\n\n\n"
ray stop --force
ray start --head
conda deactivate
CLIENT_RAY_VERSION=$(python -c "import ray; print(ray.__version__)")
CLIENT_RAY_COMMIT=$(python -c "import ray; print(ray.__commit__)")
printf "\n\n\n"
echo "========================================================================================="
printf "Using Ray %s on %s as job client \n" "${CLIENT_RAY_VERSION}" "${CLIENT_RAY_COMMIT}"
echo "========================================================================================="
printf "\n\n\n"
export RAY_ADDRESS="http://127.0.0.1:8265"
cleanup () {
unset RAY_ADDRESS
ray stop --force
conda remove -y --name "${env_name}" --all
}
JOB_ID=$(python -c "import uuid; print(uuid.uuid4().hex)")
# Get directory of current file. https://stackoverflow.com/questions/59895/
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
if ! ray job submit --job-id="${JOB_ID}" --working-dir="${DIR}" --runtime-env-json='{"pip": ["requests==2.26.0", "setuptools==69.5.1"]}' -- python script.py; then
cleanup
exit 1
fi
if ! ray job status "${JOB_ID}"; then
cleanup
exit 1
fi
if ! ray job logs "${JOB_ID}"; then
cleanup
exit 1
fi
if ! pytest -vs "${DIR}"/../test_backwards_compatibility.py::test_error_message; then
cleanup
exit 1
fi
cleanup
done
@@ -0,0 +1,30 @@
import os
import ray
from ray._raylet import GcsClient
from ray.dashboard.modules.job.job_manager import JobManager
TEST_NAMESPACE = "jobs_test_namespace"
def create_ray_cluster(_tracing_startup_hook=None):
return ray.init(
num_cpus=16,
num_gpus=1,
resources={"Custom": 1},
namespace=TEST_NAMESPACE,
log_to_driver=True,
_tracing_startup_hook=_tracing_startup_hook,
)
def create_job_manager(ray_cluster, tmp_path):
address_info = ray_cluster
gcs_client = GcsClient(address=address_info["gcs_address"])
return JobManager(gcs_client, tmp_path)
def _driver_script_path(file_name: str) -> str:
return os.path.join(
os.path.dirname(__file__), "subprocess_driver_scripts", file_name
)
@@ -0,0 +1,31 @@
"""
A dummy ray driver script that executes in subprocess.
Prints global worker's `load_code_from_local` property that ought to be set
whenever `JobConfig.code_search_path` is specified
"""
def run():
import ray
from ray.job_config import JobConfig
ray.init(job_config=JobConfig(code_search_path=["/home/code/"]))
@ray.remote
def foo() -> bool:
return ray._private.worker.global_worker.load_code_from_local
load_code_from_local = ray.get(foo.remote())
statement = "propagated" if load_code_from_local else "NOT propagated"
# Step 1: Print the statement indicating that the code_search_path have been
# properly respected
print(f"Code search path is {statement}")
# Step 2: Print the whole runtime_env to validate that it's been passed
# appropriately from submit_job API
print(ray.get_runtime_context().runtime_env)
if __name__ == "__main__":
run()
@@ -0,0 +1,22 @@
import os
import ray
cuda_env = ray._private.accelerators.nvidia_gpu.NOSET_CUDA_VISIBLE_DEVICES_ENV_VAR
if os.environ.get("RAY_TEST_RESOURCES_SPECIFIED") == "1":
assert cuda_env not in os.environ
if os.environ.get("RAY_TEST_GPUS_SPECIFIED") == "1":
assert "CUDA_VISIBLE_DEVICES" in os.environ
else:
assert "CUDA_VISIBLE_DEVICES" not in os.environ
else:
assert os.environ[cuda_env] == "1"
@ray.remote
def f():
assert cuda_env not in os.environ
# Will raise if task fails.
ray.get(f.remote())
@@ -0,0 +1,23 @@
"""
A dummy ray driver script that executes in subprocess.
Checks that job manager's environment variable is different.
"""
import os
import ray
def run():
ray.init()
@ray.remote
def foo():
print("worker", os.nice(0))
ray.get(foo.remote())
if __name__ == "__main__":
print("driver", os.nice(0))
run()
@@ -0,0 +1,13 @@
import ray
ray.init()
@ray.remote(num_cpus=1)
def f():
pass
print("Hanging...")
ray.get(f.remote())
print("Success!")
@@ -0,0 +1,58 @@
import argparse
import sys
import time
import ray
# This prefix is used to identify the output log line that contains the runtime env.
RUNTIME_ENV_LOG_LINE_PREFIX = "ray_job_test_runtime_env_output:"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Dashboard agent.")
parser.add_argument(
"--conflict",
type=str,
)
parser.add_argument(
"--worker-process-setup-hook",
type=str,
)
args = parser.parse_args()
if args.worker_process_setup_hook:
ray.init(
runtime_env={
"worker_process_setup_hook": lambda: print(
args.worker_process_setup_hook
)
}
)
@ray.remote
def f():
pass
ray.get(f.remote())
time.sleep(5)
sys.exit(0)
if args.conflict == "pip":
ray.init(runtime_env={"pip": ["numpy"]})
print(
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
)
elif args.conflict == "env_vars":
ray.init(runtime_env={"env_vars": {"A": "1"}})
print(
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
)
else:
ray.init(
runtime_env={
"env_vars": {"C": "1"},
}
)
print(
RUNTIME_ENV_LOG_LINE_PREFIX + ray._private.worker.global_worker.runtime_env
)
@@ -0,0 +1,27 @@
"""
Test script that attempts to set its own runtime_env, but we should ensure
we ended up using job submission API call's runtime_env instead of scripts
"""
def run():
import os
import ray
ray.init(
runtime_env={
"env_vars": {"TEST_SUBPROCESS_JOB_CONFIG_ENV_VAR": "SHOULD_BE_OVERRIDEN"}
},
)
@ray.remote
def foo():
return "bar"
ray.get(foo.remote())
print(os.environ.get("TEST_SUBPROCESS_JOB_CONFIG_ENV_VAR", None))
if __name__ == "__main__":
run()
@@ -0,0 +1,26 @@
import os
import ray
def run():
ray.init()
@ray.remote(runtime_env={"env_vars": {"FOO": "bar"}})
def get_task_working_dir():
# Check behavior of working_dir: The cwd should contain the
# current file, which is being used as a job entrypoint script.
assert os.path.exists("per_task_runtime_env.py")
return ray.get_runtime_context().runtime_env.working_dir()
driver_working_dir = ray.get_runtime_context().runtime_env.working_dir()
task_working_dir = ray.get(get_task_working_dir.remote())
assert driver_working_dir == task_working_dir, (
driver_working_dir,
task_working_dir,
)
if __name__ == "__main__":
run()
@@ -0,0 +1,22 @@
"""
A dummy ray driver script that executes in subprocess. Prints namespace
from ray's runtime context for job submission API testing.
"""
import ray
def run():
ray.init()
@ray.remote
def foo():
return "bar"
ray.get(foo.remote())
print(ray.get_runtime_context().namespace)
if __name__ == "__main__":
run()
@@ -0,0 +1,22 @@
"""
A dummy ray driver script that executes in subprocess. Prints runtime_env
from ray's runtime context for job submission API testing.
"""
import ray
def run():
ray.init()
@ray.remote
def foo():
return "bar"
ray.get(foo.remote())
print(ray.get_runtime_context().runtime_env)
if __name__ == "__main__":
run()
@@ -0,0 +1,15 @@
"""Tests that Ray Tune works with the working_dir set in Jobs.
Ray Tune internally sets environment variables using runtime_env.
If the inherited internal runtime environment overwrites the working_dir
from jobs with an empty working_dir, this test will fail. See #25484"""
from ray_tune_dependency import foo
from ray import tune
def objective(*args):
foo()
tune.run(objective)
@@ -0,0 +1,5 @@
"""A file dependency for testing working_dir behavior with Ray Tune."""
def foo():
pass
@@ -0,0 +1,6 @@
def run():
raise Exception("Script failed with exception !")
if __name__ == "__main__":
run()
@@ -0,0 +1,124 @@
import logging
import os
import subprocess
import sys
import uuid
from contextlib import contextmanager
import pytest
from ray.job_submission import JobStatus, JobSubmissionClient
logger = logging.getLogger(__name__)
@contextmanager
def conda_env(env_name):
# Set env name for shell script
os.environ["JOB_COMPATIBILITY_TEST_TEMP_ENV"] = env_name
# Delete conda env if it already exists
try:
yield
finally:
# Clean up created conda env upon test exit to prevent leaking
del os.environ["JOB_COMPATIBILITY_TEST_TEMP_ENV"]
subprocess.run(
f"conda env remove -y --name {env_name}", shell=True, stdout=subprocess.PIPE
)
def _compatibility_script_path(file_name: str) -> str:
return os.path.join(
os.path.dirname(__file__), "backwards_compatibility_scripts", file_name
)
class TestBackwardsCompatibility:
@pytest.mark.skipif(
sys.platform == "darwin",
reason="ray 2.0.1 runs differently on apple silicon than today's.",
)
def test_cli(self):
"""
Test that the current commit's CLI works with old server-side Ray versions.
1) Create a new conda environment with old ray version X installed;
inherits same env as current conda envionment except ray version
2) (Server) Start head node and dashboard with old ray version X
3) (Client) Use current commit's CLI code to do sample job submission flow
4) Deactivate the new conda environment and back to original place
"""
# Shell script creates and cleans up tmp conda environment regardless
# of the outcome
env_name = f"jobs-backwards-compatibility-{uuid.uuid4().hex}"
with conda_env(env_name):
shell_cmd = f"{_compatibility_script_path('test_backwards_compatibility.sh')}" # noqa: E501
try:
subprocess.check_output(shell_cmd, shell=True, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
logger.error(str(e))
logger.error(e.stdout.decode())
raise e
@pytest.mark.skipif(
os.environ.get("JOB_COMPATIBILITY_TEST_TEMP_ENV") is None,
reason="This test is only meant to be run from the "
"test_backwards_compatibility.sh shell script.",
)
def test_error_message():
"""
Check that we get a good error message when running against an old server version.
"""
# Import lazily so the module still loads when the compatibility script
# installs an older Ray that does not expose `ray._common`.
from ray._common.test_utils import wait_for_condition
client = JobSubmissionClient("http://127.0.0.1:8265")
# Check that a basic job successfully runs.
job_id = client.submit_job(
entrypoint="echo 'hello world'",
)
wait_for_condition(lambda: client.get_job_status(job_id) == JobStatus.SUCCEEDED)
# `entrypoint_num_cpus`, `entrypoint_num_gpus`, `entrypoint_resources`, and
# `entrypoint_label_selector`
# are not supported in ray<2.2.0.
for unsupported_submit_kwargs in [
{"entrypoint_num_cpus": 1},
{"entrypoint_num_gpus": 1},
{"entrypoint_resources": {"custom": 1}},
{"entrypoint_label_selector": {"fragile_node": "!1"}},
]:
with pytest.raises(
Exception,
match="Ray version 2.0.1 is running on the cluster. "
"`entrypoint_num_cpus`, `entrypoint_num_gpus`, "
"`entrypoint_resources`, and `entrypoint_label_selector` kwargs"
" are not supported on the Ray cluster. Please ensure the cluster is "
"running Ray 2.2 or higher.",
):
client.submit_job(
entrypoint="echo hello",
**unsupported_submit_kwargs,
)
with pytest.raises(
Exception,
match="Ray version 2.0.1 is running on the cluster. "
"`entrypoint_memory` kwarg"
" is not supported on the Ray cluster. Please ensure the cluster is "
"running Ray 2.8 or higher.",
):
client.submit_job(
entrypoint="echo hello",
entrypoint_memory=4,
)
assert True
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,695 @@
import json
import logging
import os
import shlex
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from subprocess import list2cmdline
from typing import Optional
from unittest import mock
import pytest
import yaml
from click.testing import CliRunner
from ray.dashboard.modules.job.cli import job_cli_group
logger = logging.getLogger(__name__)
@pytest.fixture
def mock_sdk_client():
class AsyncIterator:
def __init__(self, seq):
self._seq = seq
self.iter = iter(self._seq)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.iter)
except StopIteration:
self.iter = iter(self._seq)
raise StopAsyncIteration
if "RAY_ADDRESS" in os.environ:
del os.environ["RAY_ADDRESS"]
with mock.patch("ray.dashboard.modules.job.cli.JobSubmissionClient") as mock_client:
# In python 3.6 it will fail with error
# 'async for' requires an object with __aiter__ method, got MagicMock"
mock_client().tail_job_logs.return_value = AsyncIterator(range(10))
# We need to return a string for the address and not a MagicMock
mock_client().get_address.return_value = ""
yield mock_client
@pytest.fixture
def runtime_env_formats():
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
test_env = {
"working_dir": "s3://bogus.zip",
"conda": "conda_env",
"pip": ["pip-install-test"],
"env_vars": {"hi": "hi2"},
}
yaml_file = path / "env.yaml"
with yaml_file.open(mode="w") as f:
yaml.dump(test_env, f)
yield test_env, json.dumps(test_env), yaml_file
@contextmanager
def set_env_var(key: str, val: Optional[str] = None):
old_val = os.environ.get(key, None)
if val is not None:
os.environ[key] = val
elif key in os.environ:
del os.environ[key]
yield
if key in os.environ:
del os.environ[key]
if old_val is not None:
os.environ[key] = old_val
def check_exit_code(result, exit_code):
assert result.exit_code == exit_code, result.output
def _expected_entrypoint(*args):
"""Return the expected entrypoint string for the current platform.
On Windows, the CLI uses subprocess.list2cmdline (double quotes).
On POSIX, it uses shlex.join (single quotes).
"""
if sys.platform == "win32":
return list2cmdline(args)
return shlex.join(args)
def _job_cli_group_test_address(mock_sdk_client, cmd, *args):
runner = CliRunner()
create_cluster_if_needed = True if cmd == "submit" else False
# Test passing address via command line.
result = runner.invoke(job_cli_group, [cmd, "--address=arg_addr", *args])
mock_sdk_client.assert_called_with(
"arg_addr", create_cluster_if_needed, headers=None, verify=True
)
with pytest.raises(AssertionError):
mock_sdk_client.assert_called_with(
"some_other_addr", True, headers=None, verify=True
)
check_exit_code(result, 0)
# Test passing address via env var.
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, [cmd, *args])
check_exit_code(result, 0)
# RAY_ADDRESS is read inside the SDK client.
mock_sdk_client.assert_called_with(
None, create_cluster_if_needed, headers=None, verify=True
)
# Test passing no address.
result = runner.invoke(job_cli_group, [cmd, *args])
check_exit_code(result, 0)
mock_sdk_client.assert_called_with(
None, create_cluster_if_needed, headers=None, verify=True
)
class TestList:
def test_address(self, mock_sdk_client):
_job_cli_group_test_address(mock_sdk_client, "list")
def test_list(self, mock_sdk_client):
runner = CliRunner()
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["list"],
)
check_exit_code(result, 0)
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
check_exit_code(result, 0)
result = runner.invoke(
job_cli_group,
["list"],
)
check_exit_code(result, 0)
class TestSubmit:
def test_address(self, mock_sdk_client):
_job_cli_group_test_address(mock_sdk_client, "submit", "--", "echo", "hello")
def test_working_dir(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
result = runner.invoke(
job_cli_group,
["submit", "--working-dir", "blah", "--", "echo hello"],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={"working_dir": "blah"},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
result = runner.invoke(
job_cli_group, ["submit", "--working-dir='.'", "--", "echo hello"]
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={"working_dir": "'.'"},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_runtime_env(self, mock_sdk_client, runtime_env_formats):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
env_dict, env_json, env_yaml = runtime_env_formats
with set_env_var("RAY_ADDRESS", "env_addr"):
# Test passing via file.
result = runner.invoke(
job_cli_group, ["submit", "--runtime-env", env_yaml, "--", "echo hello"]
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env=env_dict,
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
# Test passing via json.
result = runner.invoke(
job_cli_group,
["submit", "--runtime-env-json", env_json, "--", "echo hello"],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env=env_dict,
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
# Test passing both throws an error.
result = runner.invoke(
job_cli_group,
[
"submit",
"--runtime-env",
env_yaml,
"--runtime-env-json",
env_json,
"--",
"echo hello",
],
)
check_exit_code(result, 1)
assert "Only one of" in str(result.exception)
# Test overriding working_dir.
env_dict.update(working_dir=".")
result = runner.invoke(
job_cli_group,
[
"submit",
"--runtime-env",
env_yaml,
"--working-dir",
".",
"--",
"echo hello",
],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env=env_dict,
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
result = runner.invoke(
job_cli_group,
[
"submit",
"--runtime-env-json",
env_json,
"--working-dir",
".",
"--",
"echo hello",
],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env=env_dict,
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_job_id(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, ["submit", "--", "echo hello"])
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
result = runner.invoke(
job_cli_group,
["submit", "--submission-id=my_job_id", "--", "echo hello"],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id="my_job_id",
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_entrypoint_num_cpus(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", "--entrypoint-num-cpus=2", "--", "echo hello"],
)
assert result.exit_code == 0
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=2,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_entrypoint_num_gpus(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", "--entrypoint-num-gpus=2", "--", "echo hello"],
)
assert result.exit_code == 0
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=2,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
def test_entrypoint_memory(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", "--entrypoint-memory=4", "--", "echo hello"],
)
assert result.exit_code == 0
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=4,
entrypoint_resources=None,
entrypoint_label_selector=None,
)
@pytest.mark.parametrize(
"resources",
[
("--entrypoint-num-cpus=2", {"entrypoint_num_cpus": 2}),
("--entrypoint-num-gpus=2", {"entrypoint_num_gpus": 2}),
(
"""--entrypoint-resources={"Custom":3}""",
{"entrypoint_resources": {"Custom": 3}},
),
],
)
def test_entrypoint_resources(self, mock_sdk_client, resources):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", resources[0], "--", "echo hello"],
)
print(result.output)
assert result.exit_code == 0
expected_kwargs = {
"entrypoint": _expected_entrypoint("echo hello"),
"submission_id": None,
"runtime_env": {},
"metadata": None,
"entrypoint_num_cpus": None,
"entrypoint_num_gpus": None,
"entrypoint_memory": None,
"entrypoint_resources": None,
"entrypoint_label_selector": None,
}
expected_kwargs.update(resources[1])
mock_client_instance.submit_job.assert_called_with(**expected_kwargs)
def test_entrypoint_resources_invalid_json(self, mock_sdk_client):
runner = CliRunner()
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
[
"submit",
"""--entrypoint-resources={"Custom":3""",
"--",
"echo hello world",
],
)
print(result.output)
assert result.exit_code == 1
assert "not a valid JSON string" in result.output
def test_entrypoint_label_selector(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
[
"submit",
"""--entrypoint-label-selector={"fragile_node":"!1"}""",
"--",
"echo hello",
],
)
assert result.exit_code == 0
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
metadata=None,
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector={"fragile_node": "!1"},
)
def test_metadata(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
[
"submit",
"--metadata-json",
'{"key": "value"}',
"--",
"echo hello",
],
)
check_exit_code(result, 0)
mock_client_instance.submit_job.assert_called_with(
entrypoint=_expected_entrypoint("echo hello"),
submission_id=None,
runtime_env={},
entrypoint_num_cpus=None,
entrypoint_num_gpus=None,
entrypoint_memory=None,
entrypoint_resources=None,
entrypoint_label_selector=None,
metadata={"key": "value"},
)
def test_metadata_invalid_json(self, mock_sdk_client):
runner = CliRunner()
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
[
"submit",
"--metadata-json",
'{"key": "value"',
"--",
"echo hello",
],
)
print(result.output)
check_exit_code(result, 1)
assert "not a valid JSON string" in result.output
@pytest.mark.parametrize(
"cli_val, verify_param",
[
("True", True),
("true", True),
("1", True),
("False", False),
("false", False),
("0", False),
("a/rel/path", "a/rel/path"),
("/an/abs/path", "/an/abs/path"),
],
)
def test_entrypoint_verify(self, mock_sdk_client, cli_val, verify_param):
runner = CliRunner()
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", f"--verify={cli_val}", "--", "echo hello"],
)
assert result.exit_code == 0
mock_sdk_client.assert_called_with(
None, True, headers=None, verify=verify_param
)
class TestDelete:
def test_address(self, mock_sdk_client):
_job_cli_group_test_address(mock_sdk_client, "delete", "fake_job_id")
def test_delete(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, ["delete", "job_id"])
check_exit_code(result, 0)
mock_client_instance.delete_job.assert_called_with("job_id")
class TestStatus:
def test_address(self, mock_sdk_client):
_job_cli_group_test_address(mock_sdk_client, "status", "fake_job_id")
def test_status(self, mock_sdk_client):
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(job_cli_group, ["status", "job_id"])
check_exit_code(result, 0)
mock_client_instance.get_job_info.assert_called_with("job_id")
class TestEntrypointShellQuoting:
"""Regression test for https://github.com/ray-project/ray/issues/56232.
`ray job submit` previously used `subprocess.list2cmdline` unconditionally
to join entrypoint arguments. That function wraps arguments in double
quotes, which causes POSIX shells on the server to expand $VAR references.
The fix uses `shlex.join` on POSIX platforms (which single-quotes
arguments to prevent expansion) and `list2cmdline` on Windows (which
double-quotes arguments as expected by cmd.exe).
"""
def test_entrypoint_preserves_shell_variables(self, mock_sdk_client):
"""Ensure $VAR in entrypoint is single-quoted on POSIX, not double-quoted."""
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
with mock.patch("ray.dashboard.modules.job.cli.sys") as mock_sys:
mock_sys.platform = "linux"
result = runner.invoke(
job_cli_group,
[
"submit",
"--",
"python",
"-m",
"launcher",
"--config",
"$CONFIG_PATH",
],
)
check_exit_code(result, 0)
call_kwargs = mock_client_instance.submit_job.call_args
entrypoint = call_kwargs.kwargs["entrypoint"]
# shlex.join must single-quote the $VAR argument so that
# the server-side POSIX shell does NOT expand it.
assert "'$CONFIG_PATH'" in entrypoint, (
f"Expected single-quoted $CONFIG_PATH in entrypoint, "
f"got: {entrypoint!r}"
)
# Double quotes around $CONFIG_PATH would cause expansion.
assert '"$CONFIG_PATH"' not in entrypoint, (
f"Double-quoted $CONFIG_PATH would be expanded by the "
f"server shell, got: {entrypoint!r}"
)
def test_entrypoint_uses_list2cmdline_on_windows(self, mock_sdk_client):
"""On Windows, entrypoint should use list2cmdline (double quotes)."""
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
with mock.patch("ray.dashboard.modules.job.cli.sys") as mock_sys:
mock_sys.platform = "win32"
result = runner.invoke(
job_cli_group,
[
"submit",
"--",
"echo",
"hello world",
],
)
check_exit_code(result, 0)
call_kwargs = mock_client_instance.submit_job.call_args
entrypoint = call_kwargs.kwargs["entrypoint"]
# list2cmdline wraps args with spaces in double quotes
assert (
entrypoint == 'echo "hello world"'
), f"Expected list2cmdline output on Windows, got: {entrypoint!r}"
def test_entrypoint_simple_args_not_over_quoted(self, mock_sdk_client):
"""Simple arguments without special chars should not be quoted."""
runner = CliRunner()
mock_client_instance = mock_sdk_client.return_value
with set_env_var("RAY_ADDRESS", "env_addr"):
result = runner.invoke(
job_cli_group,
["submit", "--", "echo", "hello"],
)
check_exit_code(result, 0)
call_kwargs = mock_client_instance.submit_job.call_args
entrypoint = call_kwargs.kwargs["entrypoint"]
assert entrypoint == "echo hello"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,356 @@
import json
import logging
import os
import subprocess
import sys
from contextlib import contextmanager
from typing import Optional, Tuple
import pytest
import ray
logger = logging.getLogger(__name__)
@pytest.fixture
def shutdown_only():
yield None
# The code after the yield will run as teardown code.
ray.shutdown()
# Delete the cluster address just in case.
ray._common.utils.reset_ray_address()
@contextmanager
def set_env_var(key: str, val: Optional[str] = None):
old_val = os.environ.get(key, None)
if val is not None:
os.environ[key] = val
elif key in os.environ:
del os.environ[key]
try:
yield
finally:
if key in os.environ:
del os.environ[key]
if old_val is not None:
os.environ[key] = old_val
@pytest.fixture
def ray_start_stop():
subprocess.check_output(["ray", "start", "--head"])
try:
with set_env_var("RAY_ADDRESS", "http://127.0.0.1:8265"):
yield
finally:
subprocess.check_output(["ray", "stop", "--force"])
@contextmanager
def ray_cluster_manager():
"""
Used not as fixture in case we want to set RAY_ADDRESS first.
"""
subprocess.check_output(["ray", "start", "--head"])
try:
yield
finally:
subprocess.check_output(["ray", "stop", "--force"])
def _run_cmd(cmd: str, should_fail=False) -> Tuple[str, str]:
"""Convenience wrapper for subprocess.run.
We always run with shell=True to simulate the CLI.
Asserts that the process succeeds/fails depending on should_fail.
Returns (stdout, stderr).
"""
print(f"Running command: '{cmd}'")
p: subprocess.CompletedProcess = subprocess.run(
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
if p.returncode == 0:
print("Command succeeded.")
if should_fail:
raise RuntimeError(
f"Expected command to fail, but got exit code: {p.returncode}."
)
else:
print(f"Command failed with exit code: {p.returncode}.")
if not should_fail:
raise RuntimeError(
f"Expected command to succeed, but got exit code: {p.returncode}."
)
return p.stdout.decode("utf-8"), p.stderr.decode("utf-8")
class TestJobSubmitHook:
"""Tests the RAY_JOB_SUBMIT_HOOK env var."""
def test_hook(self, ray_start_stop):
with set_env_var("RAY_JOB_SUBMIT_HOOK", "ray._private.test_utils.job_hook"):
stdout, _ = _run_cmd("ray job submit -- echo hello")
assert "hook intercepted: echo hello" in stdout
class TestRayJobHeaders:
"""
Integration version of job CLI test that ensures interaction with the
following components are working as expected:
1) Ray client: use of RAY_JOB_HEADERS and ray.init() in job_head.py
2) Ray dashboard: `ray start --head`
"""
def test_empty_ray_job_headers(self, ray_start_stop):
with set_env_var("RAY_JOB_HEADERS", None):
stdout, _ = _run_cmd("ray job submit -- echo hello")
assert "hello" in stdout
assert "succeeded" in stdout
@pytest.mark.parametrize("ray_job_headers", ['{"key": "value"}'])
def test_ray_job_headers(self, ray_start_stop, ray_job_headers: str):
with set_env_var("RAY_JOB_HEADERS", ray_job_headers):
_run_cmd("ray job submit -- echo hello", should_fail=False)
@pytest.mark.parametrize("ray_job_headers", ["{key value}"])
def test_ray_incorrectly_formatted_job_headers(
self, ray_start_stop, ray_job_headers: str
):
with set_env_var("RAY_JOB_HEADERS", ray_job_headers):
_run_cmd("ray job submit -- echo hello", should_fail=True)
class TestRayAddress:
"""
Integration version of job CLI test that ensures interaction with the
following components are working as expected:
1) Ray client: use of RAY_ADDRESS and ray.init() in job_head.py
2) Ray dashboard: `ray start --head`
"""
def test_empty_ray_address(self, ray_start_stop):
with set_env_var("RAY_ADDRESS", None):
stdout, _ = _run_cmd("ray job submit -- echo hello")
assert "hello" in stdout
assert "succeeded" in stdout
@pytest.mark.parametrize(
"ray_api_server_address,should_fail",
[
("http://127.0.0.1:8265", False), # correct API server
("127.0.0.1:8265", True), # wrong format without http
("http://127.0.0.1:9999", True), # wrong port
],
)
def test_ray_api_server_address(
self,
ray_start_stop,
ray_api_server_address: str,
should_fail: bool,
):
# Set a `RAY_ADDRESS` that would not work with the `ray job submit` CLI because it uses the `ray://` prefix.
# This verifies that the `RAY_API_SERVER_ADDRESS` env var takes precedence.
with set_env_var("RAY_ADDRESS", "ray://127.0.0.1:8265"):
with set_env_var("RAY_API_SERVER_ADDRESS", ray_api_server_address):
_run_cmd("ray job submit -- echo hello", should_fail=should_fail)
@pytest.mark.parametrize(
"ray_client_address,should_fail",
[
("127.0.0.1:8265", True),
("ray://127.0.0.1:8265", True),
("http://127.0.0.1:8265", False),
],
)
def test_ray_client_address(
self, ray_start_stop, ray_client_address: str, should_fail: bool
):
with set_env_var("RAY_ADDRESS", ray_client_address):
_run_cmd("ray job submit -- echo hello", should_fail=should_fail)
def test_valid_http_ray_address(self, ray_start_stop):
stdout, _ = _run_cmd("ray job submit -- echo hello")
assert "hello" in stdout
assert "succeeded" in stdout
class TestJobSubmit:
def test_basic_submit(self, ray_start_stop):
"""Should tail logs and wait for process to exit."""
cmd = "sleep 1 && echo hello && sleep 1 && echo hello"
stdout, _ = _run_cmd(f"ray job submit -- bash -c '{cmd}'")
# 'hello' should appear four times: twice when we print the entrypoint, then
# two more times in the logs from the `echo`.
assert stdout.count("hello") == 4
assert "succeeded" in stdout
def test_submit_no_wait(self, ray_start_stop):
"""Should exit immediately w/o printing logs."""
cmd = "echo hello && sleep 1000"
stdout, _ = _run_cmd(f"ray job submit --no-wait -- bash -c '{cmd}'")
assert "hello" not in stdout
assert "Tailing logs until the job exits" not in stdout
def test_submit_with_logs_instant_job(self, ray_start_stop):
"""Should exit immediately and print logs even if job returns instantly."""
cmd = "echo hello"
stdout, _ = _run_cmd(f"ray job submit -- bash -c '{cmd}'")
# 'hello' should appear twice: once when we print the entrypoint, then
# again from the `echo`.
assert stdout.count("hello") == 2
def test_multiple_ray_init(self, ray_start_stop):
cmd = (
"python -c 'import ray; ray.init(); ray.shutdown(); "
"ray.init(); ray.shutdown();'"
)
stdout, _ = _run_cmd(f"ray job submit -- {cmd}")
assert "succeeded" in stdout
def test_metadata(self, ray_start_stop):
cmd = "echo hello"
stdout, _ = _run_cmd(
f'ray job submit --metadata-json=\'{{"key": "value"}}\' -- {cmd}'
)
assert "hello" in stdout
assert "succeeded" in stdout
def test_job_failed(self, ray_start_stop):
cmd = "python -c 'import ray; ray.init(); assert 1 == 2;'"
_run_cmd(f"ray job submit -- {cmd}", should_fail=True)
class TestRuntimeEnv:
def test_bad_runtime_env(self, ray_start_stop):
"""Should fail with helpful error if runtime env setup fails."""
stdout, _ = _run_cmd(
'ray job submit --runtime-env-json=\'{"pip": '
'["does-not-exist"]}\' -- echo hi',
should_fail=True,
)
assert "Tailing logs until the job exits" in stdout
assert "runtime_env setup failed" in stdout
assert "No matching distribution found for does-not-exist" in stdout
class TestJobStop:
def test_basic_stop(self, ray_start_stop):
"""Should wait until the job is stopped."""
cmd = "sleep 1000"
job_id = "test_basic_stop"
_run_cmd(f"ray job submit --no-wait --job-id={job_id} -- {cmd}")
stdout, _ = _run_cmd(f"ray job stop {job_id}")
assert "Waiting for job" in stdout
assert f"Job '{job_id}' was stopped" in stdout
def test_stop_no_wait(self, ray_start_stop):
"""Should not wait until the job is stopped."""
cmd = "echo hello && sleep 1000"
job_id = "test_stop_no_wait"
_run_cmd(f"ray job submit --no-wait --job-id={job_id} -- bash -c '{cmd}'")
stdout, _ = _run_cmd(f"ray job stop --no-wait {job_id}")
assert "Waiting for job" not in stdout
assert f"Job '{job_id}' was stopped" not in stdout
class TestJobList:
def test_empty(self, ray_start_stop):
stdout, _ = _run_cmd("ray job list")
assert "[]" in stdout
def test_list(self, ray_start_stop):
_run_cmd("ray job submit --job-id='hello_id' -- echo hello")
runtime_env = {"env_vars": {"TEST": "123"}}
_run_cmd(
"ray job submit --job-id='hi_id' "
f"--runtime-env-json='{json.dumps(runtime_env)}' -- echo hi"
)
stdout, _ = _run_cmd("ray job list")
assert "123" in stdout
assert "hello_id" in stdout
assert "hi_id" in stdout
class TestJobDelete:
def test_basic_delete(self, ray_start_stop):
cmd = "sleep 1000"
job_id = "test_basic_delete"
_run_cmd(f"ray job submit --no-wait --submission-id={job_id} -- {cmd}")
# Job shouldn't be able to be deleted because it is not in a terminal state.
stdout, stderr = _run_cmd(f"ray job delete {job_id}", should_fail=True)
assert "it is in a non-terminal state" in stderr
# Submit a job that finishes quickly.
cmd = "echo hello"
job_id = "test_basic_delete_quick"
_run_cmd(f"ray job submit --submission-id={job_id} -- bash -c '{cmd}'")
# Job should be able to be deleted because it is finished.
stdout, _ = _run_cmd(f"ray job delete {job_id}")
assert f"Job '{job_id}' deleted successfully" in stdout
class TestJobStatus:
# `ray job status` should exit with 0 if the job exists and non-zero if it doesn't.
# This is the contract between Ray and KubRay v1.3.0.
def test_status_job_exists(self, ray_start_stop):
cmd = "echo hello"
job_id = "test_job_id"
_run_cmd(
f"ray job submit --submission-id={job_id} -- bash -c '{cmd}'",
should_fail=False,
)
_run_cmd(f"ray job status {job_id}", should_fail=False)
def test_status_job_does_not_exist(self, ray_start_stop):
job_id = "test_job_id"
_run_cmd(f"ray job status {job_id}", should_fail=True)
def test_quote_escaping(ray_start_stop):
cmd = "echo \"hello 'world'\""
job_id = "test_quote_escaping"
stdout, _ = _run_cmd(
f"ray job submit --job-id={job_id} -- {cmd}",
)
assert "hello 'world'" in stdout
def test_resources(shutdown_only):
ray.init(num_cpus=1, num_gpus=1, resources={"Custom": 1}, _memory=4)
# Check the case of too many resources.
for id, arg in [
("entrypoint_num_cpus", "--entrypoint-num-cpus=2"),
("entrypoint_num_gpus", "--entrypoint-num-gpus=2"),
("entrypoint_memory", "--entrypoint-memory=5"),
("entrypoint_resources", "--entrypoint-resources='{\"Custom\": 2}'"),
]:
_run_cmd(f"ray job submit --submission-id={id} --no-wait {arg} -- echo hi")
stdout, _ = _run_cmd(f"ray job status {id}")
assert "waiting for resources" in stdout
# Check the case of sufficient resources.
stdout, _ = _run_cmd(
"ray job submit --entrypoint-num-cpus=1 "
"--entrypoint-num-gpus=1 --entrypoint-memory=4 --entrypoint-resources='{"
'"Custom": 1}\' -- echo hello',
)
assert "hello" in stdout
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,302 @@
import asyncio
import json
from dataclasses import asdict
from unittest.mock import AsyncMock, MagicMock
import pytest
from google.protobuf.json_format import Parse
from ray.core.generated.gcs_pb2 import JobsAPIInfo
from ray.dashboard.modules.job.common import (
JobErrorType,
JobInfo,
JobInfoStorageClient,
JobStatus,
JobSubmitRequest,
http_uri_components_to_uri,
uri_to_http_components,
validate_request_type,
)
class TestJobSubmitRequestValidation:
def test_validate_entrypoint(self):
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
assert r.entrypoint == "abc"
with pytest.raises(TypeError, match="required positional argument"):
validate_request_type({}, JobSubmitRequest)
with pytest.raises(TypeError, match="must be a string"):
validate_request_type({"entrypoint": 123}, JobSubmitRequest)
def test_validate_submission_id(self):
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
assert r.entrypoint == "abc"
assert r.submission_id is None
r = validate_request_type(
{"entrypoint": "abc", "submission_id": "123"}, JobSubmitRequest
)
assert r.entrypoint == "abc"
assert r.submission_id == "123"
with pytest.raises(TypeError, match="must be a string"):
validate_request_type(
{"entrypoint": 123, "submission_id": 1}, JobSubmitRequest
)
def test_validate_runtime_env(self):
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
assert r.entrypoint == "abc"
assert r.runtime_env is None
r = validate_request_type(
{"entrypoint": "abc", "runtime_env": {"hi": "hi2"}}, JobSubmitRequest
)
assert r.entrypoint == "abc"
assert r.runtime_env == {"hi": "hi2"}
with pytest.raises(TypeError, match="must be a dict"):
validate_request_type(
{"entrypoint": "abc", "runtime_env": 123}, JobSubmitRequest
)
with pytest.raises(TypeError, match="keys must be strings"):
validate_request_type(
{"entrypoint": "abc", "runtime_env": {1: "hi"}}, JobSubmitRequest
)
def test_validate_metadata(self):
r = validate_request_type({"entrypoint": "abc"}, JobSubmitRequest)
assert r.entrypoint == "abc"
assert r.metadata is None
r = validate_request_type(
{"entrypoint": "abc", "metadata": {"hi": "hi2"}}, JobSubmitRequest
)
assert r.entrypoint == "abc"
assert r.metadata == {"hi": "hi2"}
with pytest.raises(TypeError, match="must be a dict"):
validate_request_type(
{"entrypoint": "abc", "metadata": 123}, JobSubmitRequest
)
with pytest.raises(TypeError, match="keys must be strings"):
validate_request_type(
{"entrypoint": "abc", "metadata": {1: "hi"}}, JobSubmitRequest
)
with pytest.raises(TypeError, match="values must be strings"):
validate_request_type(
{"entrypoint": "abc", "metadata": {"hi": 1}}, JobSubmitRequest
)
def test_validate_entrypoint_label_selector(self):
r = validate_request_type(
{
"entrypoint": "abc",
"entrypoint_label_selector": {"fragile_node": "!1"},
},
JobSubmitRequest,
)
assert r.entrypoint_label_selector == {"fragile_node": "!1"}
with pytest.raises(TypeError, match="must be a dict"):
validate_request_type(
{"entrypoint": "abc", "entrypoint_label_selector": "bad"},
JobSubmitRequest,
)
with pytest.raises(TypeError, match="keys must be strings"):
validate_request_type(
{"entrypoint": "abc", "entrypoint_label_selector": {1: "bad"}},
JobSubmitRequest,
)
with pytest.raises(TypeError, match="values must be strings"):
validate_request_type(
{"entrypoint": "abc", "entrypoint_label_selector": {"k": 1}},
JobSubmitRequest,
)
def test_entrypoint_resources_disallow_strings(self):
with pytest.raises(TypeError, match="values must be numbers"):
validate_request_type(
{"entrypoint": "abc", "entrypoint_resources": {"Custom": "1"}},
JobSubmitRequest,
)
def test_uri_to_http_and_back():
assert uri_to_http_components("gcs://hello.zip") == ("gcs", "hello.zip")
assert uri_to_http_components("gcs://hello.whl") == ("gcs", "hello.whl")
with pytest.raises(ValueError, match="'blah' is not a valid Protocol"):
uri_to_http_components("blah://halb.zip")
with pytest.raises(ValueError, match="does not end in .zip or .whl"):
assert uri_to_http_components("gcs://hello.not_zip")
with pytest.raises(ValueError, match="does not end in .zip or .whl"):
assert uri_to_http_components("gcs://hello")
assert http_uri_components_to_uri("gcs", "hello.zip") == "gcs://hello.zip"
assert http_uri_components_to_uri("blah", "halb.zip") == "blah://halb.zip"
assert http_uri_components_to_uri("blah", "halb.whl") == "blah://halb.whl"
for original_uri in ["gcs://hello.zip", "gcs://fasdf.whl"]:
new_uri = http_uri_components_to_uri(*uri_to_http_components(original_uri))
assert new_uri == original_uri
def test_dynamic_status_message():
info = JobInfo(
status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_num_cpus=1
)
assert "may be waiting for resources" in info.message
info = JobInfo(
status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_num_gpus=1
)
assert "may be waiting for resources" in info.message
info = JobInfo(status=JobStatus.PENDING, entrypoint="echo hi", entrypoint_memory=4)
assert "may be waiting for resources" in info.message
info = JobInfo(
status=JobStatus.PENDING,
entrypoint="echo hi",
entrypoint_resources={"Custom": 1},
)
assert "may be waiting for resources" in info.message
info = JobInfo(
status=JobStatus.PENDING, entrypoint="echo hi", runtime_env={"conda": "env"}
)
assert "may be waiting for the runtime environment" in info.message
def test_job_info_to_json():
info = JobInfo(
status=JobStatus.PENDING,
entrypoint="echo hi",
entrypoint_num_cpus=1,
entrypoint_num_gpus=1,
entrypoint_memory=4,
entrypoint_resources={"Custom": 1},
runtime_env={"pip": ["pkg"]},
)
expected_items = {
"status": "PENDING",
"message": (
"Job has not started yet. It may be waiting for resources "
"(CPUs, GPUs, memory, custom resources) to become available. "
"It may be waiting for the runtime environment to be set up."
),
"entrypoint": "echo hi",
"entrypoint_num_cpus": 1,
"entrypoint_num_gpus": 1,
"entrypoint_memory": 4,
"entrypoint_resources": {"Custom": 1},
"runtime_env_json": '{"pip": ["pkg"]}',
}
# Check that the expected items are in the JSON.
assert expected_items.items() <= info.to_json().items()
new_job_info = JobInfo.from_json(info.to_json())
assert new_job_info == info
# If `status` is just a string, then operations like status.is_terminal()
# would fail, so we should make sure that it's a JobStatus.
assert isinstance(new_job_info.status, JobStatus)
def test_job_info_json_to_proto():
"""Test that JobInfo JSON can be converted to JobsAPIInfo protobuf."""
info = JobInfo(
status=JobStatus.PENDING,
entrypoint="echo hi",
error_type=JobErrorType.JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE,
start_time=123,
end_time=456,
metadata={"hi": "hi2"},
entrypoint_num_cpus=1,
entrypoint_num_gpus=1,
entrypoint_memory=4,
entrypoint_resources={"Custom": 1},
runtime_env={"pip": ["pkg"]},
driver_agent_http_address="http://localhost:1234",
driver_node_id="node_id",
)
info_json = json.dumps(info.to_json())
info_proto = Parse(info_json, JobsAPIInfo())
assert info_proto.status == "PENDING"
assert info_proto.entrypoint == "echo hi"
assert info_proto.start_time == 123
assert info_proto.end_time == 456
assert info_proto.metadata == {"hi": "hi2"}
assert info_proto.entrypoint_num_cpus == 1
assert info_proto.entrypoint_num_gpus == 1
assert info_proto.entrypoint_memory == 4
assert info_proto.entrypoint_resources == {"Custom": 1}
assert info_proto.runtime_env_json == '{"pip": ["pkg"]}'
assert info_proto.message == (
"Job has not started yet. It may be waiting for resources "
"(CPUs, GPUs, memory, custom resources) to become available. "
"It may be waiting for the runtime environment to be set up."
)
assert info_proto.error_type == "JOB_SUPERVISOR_ACTOR_UNSCHEDULABLE"
assert info_proto.driver_agent_http_address == "http://localhost:1234"
assert info_proto.driver_node_id == "node_id"
minimal_info = JobInfo(status=JobStatus.PENDING, entrypoint="echo hi")
minimal_info_json = json.dumps(minimal_info.to_json())
minimal_info_proto = Parse(minimal_info_json, JobsAPIInfo())
assert minimal_info_proto.status == "PENDING"
assert minimal_info_proto.entrypoint == "echo hi"
for unset_optional_field in [
"entrypoint_num_cpus",
"entrypoint_num_gpus",
"entrypoint_memory",
"runtime_env_json",
"error_type",
"driver_agent_http_address",
"driver_node_id",
]:
assert not minimal_info_proto.HasField(unset_optional_field)
def test_get_all_jobs_filters_out_none_job_info():
prefix = JobInfoStorageClient.JOB_DATA_KEY_PREFIX
mock_gcs = MagicMock()
mock_gcs.async_internal_kv_keys = AsyncMock(
return_value=[
(prefix + "job1").encode(),
(prefix + "job2").encode(),
]
)
storage = JobInfoStorageClient(mock_gcs)
job_info_1 = JobInfo(status=JobStatus.RUNNING, entrypoint="echo 1")
async def mock_get_info(job_id, timeout=30):
if job_id == "job1":
return job_info_1
return None
storage.get_info = mock_get_info
result = asyncio.run(storage.get_all_jobs())
assert result == {"job1": job_info_1}
for job_id, job_info in result.items():
asdict(job_info) # This should not raise an exception
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,177 @@
import json
import os
import pprint
import sys
import jsonschema
import pytest
import requests
from ray._common.test_utils import (
run_string_as_driver,
wait_for_condition,
)
from ray._private.test_utils import (
format_web_url,
run_string_as_driver_nonblocking,
)
from ray.dashboard import dashboard
from ray.dashboard.consts import RAY_CLUSTER_ACTIVITY_HOOK
from ray.dashboard.modules.job.job_head import RayActivityResponse
from ray.dashboard.tests.conftest import * # noqa
@pytest.fixture
def set_ray_cluster_activity_hook(request):
"""
Fixture that sets RAY_CLUSTER_ACTIVITY_HOOK environment variable
for test_e2e_component_activities_hook.
"""
external_hook = request.param
assert (
external_hook
), "Please pass value of RAY_CLUSTER_ACTIVITY_HOOK env var to this fixture"
old_hook = os.environ.get(RAY_CLUSTER_ACTIVITY_HOOK)
os.environ[RAY_CLUSTER_ACTIVITY_HOOK] = external_hook
yield external_hook
if old_hook is not None:
os.environ[RAY_CLUSTER_ACTIVITY_HOOK] = old_hook
else:
del os.environ[RAY_CLUSTER_ACTIVITY_HOOK]
@pytest.mark.parametrize(
"set_ray_cluster_activity_hook",
[
"ray._private.test_utils.external_ray_cluster_activity_hook1",
"ray._private.test_utils.external_ray_cluster_activity_hook2",
"ray._private.test_utils.external_ray_cluster_activity_hook3",
"ray._private.test_utils.external_ray_cluster_activity_hook4",
"ray._private.test_utils.external_ray_cluster_activity_hook5",
],
indirect=True,
)
def test_component_activities_hook(set_ray_cluster_activity_hook, call_ray_start):
"""
Tests /api/component_activities returns correctly for various
responses of RAY_CLUSTER_ACTIVITY_HOOK.
Verify no active drivers are correctly reflected in response.
"""
external_hook = set_ray_cluster_activity_hook
response = requests.get("http://127.0.0.1:8265/api/component_activities")
response.raise_for_status()
# Validate schema of response
data = response.json()
schema_path = os.path.join(
os.path.dirname(dashboard.__file__),
"modules/job/component_activities_schema.json",
)
pprint.pprint(data)
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
# Validate driver response can be cast to RayActivityResponse object
# and that there are no active drivers.
driver_ray_activity_response = RayActivityResponse(**data["driver"])
assert driver_ray_activity_response.is_active == "INACTIVE"
assert driver_ray_activity_response.reason is None
# Validate external component response can be cast to RayActivityResponse object
if external_hook[-1] == "5":
external_activity_response = RayActivityResponse(**data["test_component5"])
assert external_activity_response.is_active == "ACTIVE"
assert external_activity_response.reason == "Counter: 1"
elif external_hook[-1] == "4":
external_activity_response = RayActivityResponse(**data["external_component"])
assert external_activity_response.is_active == "ERROR"
assert (
"'Error in external cluster activity hook'"
in external_activity_response.reason
)
elif external_hook[-1] == "3":
external_activity_response = RayActivityResponse(**data["external_component"])
assert external_activity_response.is_active == "ERROR"
elif external_hook[-1] == "2":
external_activity_response = RayActivityResponse(**data["test_component2"])
assert external_activity_response.is_active == "ERROR"
elif external_hook[-1] == "1":
external_activity_response = RayActivityResponse(**data["test_component1"])
assert external_activity_response.is_active == "ACTIVE"
assert external_activity_response.reason == "Counter: 1"
# Call endpoint again to validate different response
response = requests.get("http://127.0.0.1:8265/api/component_activities")
response.raise_for_status()
data = response.json()
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
external_activity_response = RayActivityResponse(**data["test_component1"])
assert external_activity_response.is_active == "ACTIVE"
assert external_activity_response.reason == "Counter: 2"
def test_active_component_activities(ray_start_with_dashboard):
# Verify drivers which don't have namespace starting with _ray_internal_
# are considered active.
webui_url = ray_start_with_dashboard["webui_url"]
webui_url = format_web_url(webui_url)
driver_template = """
import ray
ray.init(address="auto", namespace="{namespace}")
import time
time.sleep({sleep_time_s})
"""
run_string_as_driver(
driver_template.format(namespace="my_namespace", sleep_time_s=0)
)
run_string_as_driver_nonblocking(
driver_template.format(namespace="my_namespace", sleep_time_s=5)
)
run_string_as_driver_nonblocking(
driver_template.format(namespace="_ray_internal_job_info_id1", sleep_time_s=5)
)
# Simulate the default driver that gets created by dashboard
run_string_as_driver_nonblocking(
driver_template.format(namespace="_ray_internal_dashboard", sleep_time_s=5)
)
def verify_driver_response():
# Verify drivers are considered active after running script
response = requests.get(f"{webui_url}/api/component_activities")
response.raise_for_status()
# Validate schema of response
data = response.json()
schema_path = os.path.join(
os.path.dirname(dashboard.__file__),
"modules/job/component_activities_schema.json",
)
jsonschema.validate(instance=data, schema=json.load(open(schema_path)))
# Validate ray_activity_response field can be cast to RayActivityResponse object
driver_ray_activity_response = RayActivityResponse(**data["driver"])
print(driver_ray_activity_response)
assert driver_ray_activity_response.is_active == "ACTIVE"
# Drivers with namespace starting with "_ray_internal" are not
# considered active drivers. Two active drivers are the second one
# run with namespace "my_namespace" and the one started
# from ray_start_with_dashboard
assert driver_ray_activity_response.reason == "Number of active drivers: 2"
return True
wait_for_condition(verify_driver_response)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,777 @@
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Optional
from unittest.mock import patch
import pytest
import requests
import yaml
import ray
from ray._common.test_utils import wait_for_condition
from ray._private.runtime_env.packaging import (
create_package,
download_and_unpack_package,
get_uri_for_file,
)
from ray._private.test_utils import (
chdir,
format_web_url,
wait_until_server_available,
)
from ray.dashboard.modules.dashboard_sdk import ClusterInfo, parse_cluster_info
from ray.dashboard.modules.job.common import uri_to_http_components
from ray.dashboard.modules.job.pydantic_models import JobDetails
from ray.dashboard.modules.job.tests.test_cli_integration import set_env_var
from ray.dashboard.modules.version import CURRENT_VERSION
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
# 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")
@pytest.fixture(scope="module")
def headers():
return {"Connection": "keep-alive", "Authorization": "TOK:<MY_TOKEN>"}
@pytest.fixture(scope="module")
def ray_start_context():
with _ray_start(include_dashboard=True, num_cpus=1) as ctx:
yield ctx
@pytest.fixture(scope="module")
def job_sdk_client(headers, ray_start_context) -> JobSubmissionClient:
address = ray_start_context.address_info["webui_url"]
assert wait_until_server_available(address)
yield JobSubmissionClient(format_web_url(address), headers=headers)
@pytest.fixture
def shutdown_only():
yield None
# The code after the yield will run as teardown code.
ray.shutdown()
def test_submit_job_with_resources(shutdown_only):
ctx = ray.init(
include_dashboard=True,
num_cpus=1,
num_gpus=1,
resources={"Custom": 1},
dashboard_port=8269,
_memory=4,
)
address = ctx.address_info["webui_url"]
client = JobSubmissionClient(format_web_url(address))
# Check the case of too many resources.
for kwargs in [
{"entrypoint_num_cpus": 2},
{"entrypoint_num_gpus": 2},
{"entrypoint_memory": 4},
{"entrypoint_resources": {"Custom": 2}},
]:
job_id = client.submit_job(entrypoint="echo hello", **kwargs)
data = client.get_job_info(job_id)
assert "waiting for resources" in data.message
# Check the case of sufficient resources.
job_id = client.submit_job(
entrypoint="echo hello",
entrypoint_num_cpus=1,
entrypoint_num_gpus=1,
entrypoint_memory=4,
entrypoint_resources={"Custom": 1},
)
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id, timeout=10)
@pytest.mark.parametrize("use_sdk", [True, False])
def test_list_jobs_empty(headers, use_sdk: bool):
# Create a cluster using `ray start` instead of `ray.init` to avoid creating a job
subprocess.check_output(["ray", "start", "--head"])
address = "http://127.0.0.1:8265"
try:
with set_env_var("RAY_ADDRESS", address):
client = JobSubmissionClient(format_web_url(address), headers=headers)
if use_sdk:
assert client.list_jobs() == []
else:
r = client._do_request(
"GET",
"/api/jobs/",
)
assert r.status_code == 200
assert json.loads(r.text) == []
finally:
subprocess.check_output(["ray", "stop", "--force"])
@pytest.mark.parametrize("use_sdk", [True, False])
def test_list_jobs(job_sdk_client: JobSubmissionClient, use_sdk: bool):
client = job_sdk_client
runtime_env = {"env_vars": {"TEST": "123"}}
metadata = {"foo": "bar"}
entrypoint = "echo hello"
submission_id = client.submit_job(
entrypoint=entrypoint, runtime_env=runtime_env, metadata=metadata
)
wait_for_condition(_check_job_succeeded, client=client, job_id=submission_id)
if use_sdk:
info: JobDetails = next(
job_info
for job_info in client.list_jobs()
if job_info.submission_id == submission_id
)
else:
r = client._do_request(
"GET",
"/api/jobs/",
)
assert r.status_code == 200
jobs_info_json = json.loads(r.text)
info_json = next(
job_info
for job_info in jobs_info_json
if job_info["submission_id"] == submission_id
)
info = JobDetails(**info_json)
assert info.entrypoint == entrypoint
assert info.status == JobStatus.SUCCEEDED
assert info.message is not None
assert info.end_time >= info.start_time
assert info.runtime_env == runtime_env
assert info.metadata == metadata
# Test get job status by job / driver id
status = client.get_job_status(info.submission_id)
assert status == JobStatus.SUCCEEDED
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}, info: {client.get_job_info(job_id)}"
)
assert status == JobStatus.SUCCEEDED
return True
def _check_job_failed(client: JobSubmissionClient, job_id: str) -> bool:
status = client.get_job_status(job_id)
return status == JobStatus.FAILED
def _check_job_stopped(client: JobSubmissionClient, job_id: str) -> bool:
status = client.get_job_status(job_id)
return status == JobStatus.STOPPED
@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}."
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")
client = job_sdk_client
job_id = client.submit_job(
entrypoint=runtime_env_option["entrypoint"],
runtime_env=runtime_env_option["runtime_env"],
)
try:
job_start_time = time.time()
wait_for_condition(
_check_job_succeeded, client=client, job_id=job_id, 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 client.get_job_status(job_id) == JobStatus.PENDING:
logs = client.get_job_logs(job_id)
info = client.get_job_info(job_id)
raise RuntimeError(
f"Job was stuck in PENDING.\nLogs: {logs}\nInfo: {info}"
) from e
logs = client.get_job_logs(job_id)
assert runtime_env_option["expected_logs"] in logs
def test_timeout(job_sdk_client):
client = job_sdk_client
job_id = client.submit_job(
entrypoint="echo hello",
# Assume pip packages take > 1s to download, or this test will spuriously fail.
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),
),
)
wait_for_condition(_check_job_failed, client=client, job_id=job_id, timeout=10)
data = 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
def test_per_task_runtime_env(job_sdk_client: JobSubmissionClient):
run_cmd = "python per_task_runtime_env.py"
job_id = job_sdk_client.submit_job(
entrypoint=run_cmd,
runtime_env={"working_dir": DRIVER_SCRIPT_DIR},
)
wait_for_condition(_check_job_succeeded, client=job_sdk_client, job_id=job_id)
def test_ray_tune_basic(job_sdk_client: JobSubmissionClient):
run_cmd = "python ray_tune_basic.py"
job_id = job_sdk_client.submit_job(
entrypoint=run_cmd,
runtime_env={"working_dir": DRIVER_SCRIPT_DIR},
)
wait_for_condition(
_check_job_succeeded, timeout=30, client=job_sdk_client, job_id=job_id
)
def test_http_bad_request(job_sdk_client):
"""
Send bad requests to job http server and ensure right return code and
error message is returned via http.
"""
client = job_sdk_client
# 400 - HTTPBadRequest
r = client._do_request(
"POST",
"/api/jobs/",
json_data={"key": "baaaad request"},
)
assert r.status_code == 400
assert "__init__() got an unexpected keyword argument" in r.text
def test_invalid_runtime_env(job_sdk_client):
client = job_sdk_client
with pytest.raises(ValueError, match="Only .zip, .tar.gz, and .tgz files"):
client.submit_job(
entrypoint="echo hello", runtime_env={"working_dir": "s3://not_a_zip"}
)
def test_runtime_env_setup_failure(job_sdk_client):
client = job_sdk_client
job_id = client.submit_job(
entrypoint="echo hello", runtime_env={"working_dir": "s3://does_not_exist.zip"}
)
wait_for_condition(_check_job_failed, client=client, job_id=job_id)
data = client.get_job_info(job_id)
assert "Failed to set up runtime environment" in data.message
def test_submit_job_with_exception_in_driver(job_sdk_client):
"""
Submit a job that's expected to throw exception while executing.
"""
client = job_sdk_client
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
driver_script = """
print('Hello !')
raise RuntimeError('Intentionally failed.')
"""
test_script_file = path / "test_script.py"
with open(test_script_file, "w+") as file:
file.write(driver_script)
job_id = client.submit_job(
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
)
wait_for_condition(_check_job_failed, client=client, job_id=job_id)
logs = client.get_job_logs(job_id)
assert "Hello !" in logs
assert "RuntimeError: Intentionally failed." in logs
def test_stop_long_running_job(job_sdk_client):
"""
Submit a job that runs for a while and stop it in the middle.
"""
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)
job_id = client.submit_job(
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
)
assert client.stop_job(job_id) is True
wait_for_condition(_check_job_stopped, client=client, job_id=job_id)
def test_delete_job(job_sdk_client, capsys):
"""
Submit a job and delete it.
"""
client: JobSubmissionClient = job_sdk_client
job_id = client.submit_job(entrypoint="sleep 300 && echo hello")
with pytest.raises(Exception, match="but it is in a non-terminal state"):
# This should fail because the job is not in a terminal state.
client.delete_job(job_id)
# Check that the job appears in list_jobs
jobs = client.list_jobs()
assert job_id in [job.submission_id for job in jobs]
finished_job_id = client.submit_job(entrypoint="echo hello")
wait_for_condition(_check_job_succeeded, client=client, job_id=finished_job_id)
deleted = client.delete_job(finished_job_id)
assert deleted is True
# Check that the job no longer appears in list_jobs
jobs = client.list_jobs()
assert finished_job_id not in [job.submission_id for job in jobs]
def test_job_metadata(job_sdk_client):
client = job_sdk_client
print_metadata_cmd = (
'python -c"'
"import ray;"
"ray.init();"
"job_config=ray._private.worker.global_worker.core_worker.get_job_config();"
"print(dict(sorted(job_config.metadata.items())))"
'"'
)
job_id = client.submit_job(
entrypoint=print_metadata_cmd, metadata={"key1": "val1", "key2": "val2"}
)
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
assert str(
{
"job_name": job_id,
"job_submission_id": job_id,
"key1": "val1",
"key2": "val2",
}
) in client.get_job_logs(job_id)
def test_pass_job_id(job_sdk_client):
client = job_sdk_client
job_id = "my_custom_id"
returned_id = client.submit_job(entrypoint="echo hello", job_id=job_id)
assert returned_id == job_id
wait_for_condition(_check_job_succeeded, client=client, job_id=returned_id)
# Test that a duplicate job_id is rejected.
with pytest.raises(Exception, match=f"{job_id} already exists"):
returned_id = client.submit_job(entrypoint="echo hello", job_id=job_id)
def test_nonexistent_job(job_sdk_client):
client = job_sdk_client
with pytest.raises(RuntimeError, match="nonexistent_job does not exist"):
client.get_job_status("nonexistent_job")
def test_submit_optional_args(job_sdk_client):
"""Check that job_id, runtime_env, and metadata are optional."""
client = job_sdk_client
r = client._do_request(
"POST",
"/api/jobs/",
json_data={"entrypoint": "ls"},
)
wait_for_condition(
_check_job_succeeded, client=client, job_id=r.json()["submission_id"]
)
def test_submit_still_accepts_job_id_or_submission_id(job_sdk_client):
"""Check that job_id, runtime_env, and metadata are optional."""
client = job_sdk_client
client._do_request(
"POST",
"/api/jobs/",
json_data={"entrypoint": "ls", "job_id": "raysubmit_12345"},
)
wait_for_condition(_check_job_succeeded, client=client, job_id="raysubmit_12345")
client._do_request(
"POST",
"/api/jobs/",
json_data={"entrypoint": "ls", "submission_id": "raysubmit_23456"},
)
wait_for_condition(_check_job_succeeded, client=client, job_id="raysubmit_23456")
def test_missing_resources(job_sdk_client):
"""Check that 404s are raised for resources that don't exist."""
client = job_sdk_client
conditions = [
("GET", "/api/jobs/fake_job_id"),
("GET", "/api/jobs/fake_job_id/logs"),
("POST", "/api/jobs/fake_job_id/stop"),
("GET", "/api/packages/fake_package_uri"),
]
for method, route in conditions:
assert client._do_request(method, route).status_code == 404
def test_version_endpoint(job_sdk_client):
client = job_sdk_client
r = client._do_request("GET", "/api/version")
assert r.status_code == 200
body = r.json()
assert body == {
"version": CURRENT_VERSION,
"ray_version": ray.__version__,
"ray_commit": ray.__commit__,
"session_name": body["session_name"],
}
def test_request_headers(job_sdk_client):
client = job_sdk_client
with patch("requests.request") as mock_request:
_ = client._do_request(
"POST",
"/api/jobs/",
json_data={"entrypoint": "ls"},
)
mock_request.assert_called_with(
"POST",
"http://127.0.0.1:8265/api/jobs/",
cookies=None,
data=None,
json={"entrypoint": "ls"},
headers={"Connection": "keep-alive", "Authorization": "TOK:<MY_TOKEN>"},
verify=True,
)
@pytest.mark.parametrize("scheme", ["http", "https", "fake_module"])
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "fake.dns.name"])
@pytest.mark.parametrize("port", [None, 8265, 10000])
def test_parse_cluster_info(scheme: str, host: str, port: Optional[int]):
address = f"{scheme}://{host}"
if port is not None:
address += f":{port}"
if scheme in {"http", "https"}:
assert parse_cluster_info(address, False) == ClusterInfo(
address=address,
cookies=None,
metadata=None,
headers=None,
)
else:
with pytest.raises(RuntimeError):
parse_cluster_info(address, False)
@pytest.mark.asyncio
async def test_tail_job_logs(job_sdk_client):
client = job_sdk_client
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
driver_script = """
import time
for i in range(100):
print("Hello", i)
time.sleep(0.1)
"""
test_script_file = path / "test_script.py"
with open(test_script_file, "w+") as f:
f.write(driver_script)
job_id = client.submit_job(
entrypoint="python test_script.py", runtime_env={"working_dir": tmp_dir}
)
st = time.time()
while time.time() - st <= 10:
try:
i = 0
async for lines in client.tail_job_logs(job_id):
print(lines, end="")
for line in lines.strip().split("\n"):
assert line.split(" ") == ["Hello", str(i)]
i += 1
except Exception as ex:
print("Exception:", ex)
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
def _hook(env):
with open(env["env_vars"]["TEMPPATH"], "w+") as f:
f.write(env["env_vars"]["TOKEN"])
return env
def test_jobs_env_hook(job_sdk_client: JobSubmissionClient):
client = job_sdk_client
_, path = tempfile.mkstemp()
runtime_env = {"env_vars": {"TEMPPATH": path, "TOKEN": "Ray rocks!"}}
run_job_script = """
import os
import ray
os.environ["RAY_RUNTIME_ENV_HOOK"] =\
"ray.dashboard.modules.job.tests.test_http_job_server._hook"
ray.init(address="auto")
"""
entrypoint = f"python -c '{run_job_script}'"
job_id = client.submit_job(entrypoint=entrypoint, runtime_env=runtime_env)
wait_for_condition(_check_job_succeeded, client=client, job_id=job_id)
with open(path) as f:
assert f.read().strip() == "Ray rocks!"
@pytest.mark.asyncio
async def test_get_upload_package(ray_start_context, tmp_path):
assert wait_until_server_available(ray_start_context["webui_url"])
webui_url = format_web_url(ray_start_context["webui_url"])
gcs_client = ray._private.worker.global_worker.gcs_client
url = webui_url + "/api/packages/{protocol}/{package_name}"
pkg_dir = tmp_path / "pkg"
pkg_dir.mkdir()
filename = "task.py"
file_content = b"Hello world"
with (pkg_dir / filename).open("wb") as f:
f.write(file_content)
package_uri = get_uri_for_file(str(pkg_dir / filename))
protocol, package_name = uri_to_http_components(package_uri)
package_file = tmp_path / package_name
create_package(str(pkg_dir), package_file, include_gitignore=True)
resp = requests.get(url.format(protocol=protocol, package_name=package_name))
assert resp.status_code == 404
resp = requests.put(
url.format(protocol=protocol, package_name=package_name),
data=package_file.read_bytes(),
)
assert resp.status_code == 200
resp = requests.get(url.format(protocol=protocol, package_name=package_name))
assert resp.status_code == 200
await download_and_unpack_package(package_uri, str(tmp_path), gcs_client)
assert (package_file.with_suffix("") / filename).read_bytes() == file_content
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,47 @@
import ssl
import sys
import pytest
import trustme
import ray
from ray.job_submission import JobSubmissionClient
@pytest.fixture(scope="session")
def ca():
return trustme.CA()
@pytest.fixture(scope="session")
def httpserver_ssl_context(ca):
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
localhost_cert = ca.issue_cert("localhost")
localhost_cert.configure_cert(context)
return context
@pytest.fixture(scope="session")
def httpclient_ssl_context(ca):
with ca.cert_pem.tempfile() as ca_temp_path:
return ssl.create_default_context(cafile=ca_temp_path)
def test_mock_https_connection(httpserver, ca):
"""Test connections to a mock HTTPS job submission server."""
httpserver.expect_request("/api/version").respond_with_json(
{"ray_version": ray.__version__}
)
mock_url = httpserver.url_for("/")
# Connection without SSL certificate should fail
with pytest.raises(ConnectionError):
JobSubmissionClient(mock_url)
# Connecton with SSL verification skipped should succeed
JobSubmissionClient(mock_url, verify=False)
# Connection with SSL verification should succeed
with ca.cert_pem.tempfile() as ca_temp_path:
JobSubmissionClient(mock_url, verify=ca_temp_path)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,692 @@
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__]))
@@ -0,0 +1,182 @@
import json
import sys
import time
import pytest
from ray.cluster_utils import Cluster
from ray.dashboard.consts import RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR
from ray.dashboard.modules.job.tests.conftest import _driver_script_path
from ray.dashboard.modules.job.tests.subprocess_driver_scripts.driver_runtime_env_inheritance import ( # noqa: E501
RUNTIME_ENV_LOG_LINE_PREFIX,
)
from ray.job_submission import JobStatus, JobSubmissionClient
def wait_until_status(client, job_id, status_to_wait_for, timeout_seconds=20):
start = time.time()
while time.time() - start <= timeout_seconds:
status = client.get_job_status(job_id)
print(f"status: {status}")
if status in status_to_wait_for:
return
time.sleep(1)
raise Exception
def wait(client, job_id):
wait_until_status(
client,
job_id,
{JobStatus.SUCCEEDED, JobStatus.STOPPED, JobStatus.FAILED},
timeout_seconds=60,
)
def get_runtime_env_from_logs(client, job_id):
wait(client, job_id)
logs = client.get_job_logs(job_id)
print(logs)
assert client.get_job_status(job_id) == JobStatus.SUCCEEDED
# Split logs by line, find the unique line that starts with
# RUNTIME_ENV_LOG_LINE_PREFIX, strip it and parse it as JSON.
lines = logs.strip().split("\n")
assert len(lines) > 0
for line in lines:
if line.startswith(RUNTIME_ENV_LOG_LINE_PREFIX):
return json.loads(line[len(RUNTIME_ENV_LOG_LINE_PREFIX) :])
def test_job_driver_inheritance():
try:
c = Cluster()
c.add_node(num_cpus=1)
# If using a remote cluster, replace 127.0.0.1 with the head node's IP address.
client = JobSubmissionClient("http://127.0.0.1:8265")
driver_script_path = _driver_script_path("driver_runtime_env_inheritance.py")
job_id = client.submit_job(
entrypoint=f"python {driver_script_path}",
runtime_env={
"env_vars": {"A": "1", "B": "2"},
"pip": ["requests"],
},
)
# Test key is merged
print("Test key merged")
runtime_env = get_runtime_env_from_logs(client, job_id)
assert runtime_env["env_vars"] == {"A": "1", "B": "2", "C": "1"}
assert runtime_env["pip"] == {"packages": ["requests"], "pip_check": False}
# Test worker process setuphook works.
print("Test key setup hook")
expected_str = "HELLOWORLD"
job_id = client.submit_job(
entrypoint=(
f"python {driver_script_path} "
f"--worker-process-setup-hook {expected_str}"
),
runtime_env={
"env_vars": {"A": "1", "B": "2"},
},
)
wait(client, job_id)
logs = client.get_job_logs(job_id)
assert expected_str in logs
# Test raise an exception upon key conflict
print("Test conflicting pip")
job_id = client.submit_job(
entrypoint=f"python {driver_script_path} --conflict=pip",
runtime_env={"pip": ["numpy"]},
)
wait(client, job_id)
status = client.get_job_status(job_id)
logs = client.get_job_logs(job_id)
assert status == JobStatus.FAILED
assert "Failed to merge the Job's runtime env" in logs
# Test raise an exception upon env var conflict
print("Test conflicting env vars")
job_id = client.submit_job(
entrypoint=f"python {driver_script_path} --conflict=env_vars",
runtime_env={
"env_vars": {"A": "1"},
},
)
wait(client, job_id)
status = client.get_job_status(job_id)
logs = client.get_job_logs(job_id)
assert status == JobStatus.FAILED
assert "Failed to merge the Job's runtime env" in logs
finally:
c.shutdown()
@pytest.mark.parametrize("stream_runtime_env_log", ["1", "0"])
def test_runtime_env_logs_streamed_to_job_driver_log(
monkeypatch, stream_runtime_env_log
):
monkeypatch.setenv(
RAY_STREAM_RUNTIME_ENV_LOG_TO_JOB_DRIVER_LOG_ENV_VAR, stream_runtime_env_log
)
try:
c = Cluster()
c.add_node(num_cpus=1)
client = JobSubmissionClient("http://127.0.0.1:8265")
job_id = client.submit_job(
entrypoint="echo hello world",
runtime_env={"pip": ["requests==2.25.1"]},
)
wait(client, job_id)
logs = client.get_job_logs(job_id)
if stream_runtime_env_log == "0":
assert "Creating virtualenv at" not in logs
else:
assert "Creating virtualenv at" in logs
finally:
c.shutdown()
def test_job_driver_inheritance_override(monkeypatch):
monkeypatch.setenv("RAY_OVERRIDE_JOB_RUNTIME_ENV", "1")
try:
c = Cluster()
c.add_node(num_cpus=1)
# If using a remote cluster, replace 127.0.0.1 with the head node's IP address.
client = JobSubmissionClient("http://127.0.0.1:8265")
driver_script_path = _driver_script_path("driver_runtime_env_inheritance.py")
job_id = client.submit_job(
entrypoint=f"python {driver_script_path}",
runtime_env={
"env_vars": {"A": "1", "B": "2"},
"pip": ["requests"],
},
)
# Test conflict resolution regular field
job_id = client.submit_job(
entrypoint=f"python {driver_script_path} --conflict=pip",
runtime_env={"pip": ["pip-install-test==0.5"]},
)
runtime_env = get_runtime_env_from_logs(client, job_id)
print(runtime_env)
assert runtime_env["pip"] == {"packages": ["numpy"], "pip_check": False}
# Test raise an exception upon env var conflict
job_id = client.submit_job(
entrypoint=f"python {driver_script_path} --conflict=env_vars",
runtime_env={
"env_vars": {"A": "2"},
},
)
runtime_env = get_runtime_env_from_logs(client, job_id)
print(runtime_env)
assert runtime_env["env_vars"]["A"] == "1"
finally:
c.shutdown()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
import sys
import pytest
from ray._common.test_utils import async_wait_for_condition
from ray.dashboard.modules.job.tests.conftest import (
_driver_script_path,
create_job_manager,
create_ray_cluster,
)
from ray.dashboard.modules.job.tests.test_job_manager import check_job_succeeded
@pytest.mark.asyncio
class TestRuntimeEnvStandalone:
"""NOTE: PLEASE READ CAREFULLY BEFORE MODIFYING
This test is extracted into a standalone module such that it can bootstrap its own
(standalone) Ray cluster while avoiding affecting the shared one used by other
JobManager tests
"""
@pytest.mark.parametrize(
"tracing_enabled",
[
False,
# TODO(issues/38633): local code loading is broken when tracing is enabled
# True,
],
)
async def test_user_provided_job_config_honored_by_worker(
self, tracing_enabled, tmp_path
):
"""Ensures that the JobConfig instance injected into ray.init in the driver
script is honored even in case when job is submitted via JobManager.submit_job
API (involving RAY_JOB_CONFIG_JSON_ENV_VAR being set in child process env)
"""
if tracing_enabled:
tracing_startup_hook = (
"ray.util.tracing.setup_local_tmp_tracing:setup_tracing"
)
else:
tracing_startup_hook = None
with create_ray_cluster(_tracing_startup_hook=tracing_startup_hook) as cluster:
job_manager = create_job_manager(cluster, tmp_path)
driver_script_path = _driver_script_path(
"check_code_search_path_is_propagated.py"
)
job_id = await job_manager.submit_job(
entrypoint=f"python {driver_script_path}",
# NOTE: We inject runtime_env in here, but also specify the JobConfig in
# the driver script: settings to JobConfig (other than the
# runtime_env) passed in via ray.init(...) have to be respected
# along with the runtime_env passed from submit_job API
runtime_env={"env_vars": {"TEST_SUBPROCESS_RANDOM_VAR": "0xDEEDDEED"}},
)
await async_wait_for_condition(
check_job_succeeded, job_manager=job_manager, job_id=job_id
)
logs = job_manager.get_job_logs(job_id)
assert "Code search path is propagated" in logs, logs
assert "0xDEEDDEED" in logs, logs
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,435 @@
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:<MY_TOKEN>"}
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__]))
@@ -0,0 +1,303 @@
import os
import sys
from tempfile import NamedTemporaryFile
import pytest
from ray.dashboard.modules.job.common import JobSubmitRequest
from ray.dashboard.modules.job.utils import (
fast_tail_last_n_lines,
file_tail_iterator,
parse_and_validate_request,
redact_url_password,
strip_keys_with_value_none,
)
# Polyfill anext() function for Python 3.9 compatibility
# May raise StopAsyncIteration.
async def anext_polyfill(iterator):
return await iterator.__anext__()
# Use the built-in anext() for Python 3.10+, otherwise use our polyfilled function
if sys.version_info < (3, 10):
anext = anext_polyfill
@pytest.fixture
def tmp():
with NamedTemporaryFile() as f:
yield f.name
def test_strip_keys_with_value_none():
d = {"a": 1, "b": None, "c": 3}
assert strip_keys_with_value_none(d) == {"a": 1, "c": 3}
d = {"a": 1, "b": 2, "c": 3}
assert strip_keys_with_value_none(d) == d
d = {"a": 1, "b": None, "c": None}
assert strip_keys_with_value_none(d) == {"a": 1}
def test_redact_url_password():
url = "http://user:password@host:port"
assert redact_url_password(url) == "http://user:<redacted>@host:port"
url = "http://user:password@host:port?query=1"
assert redact_url_password(url) == "http://user:<redacted>@host:port?query=1"
url = "http://user:password@host:port?query=1&password=2"
assert (
redact_url_password(url)
== "http://user:<redacted>@host:port?query=1&password=2"
)
url = "https://user:password@127.0.0.1:8080"
assert redact_url_password(url) == "https://user:<redacted>@127.0.0.1:8080"
url = "https://user:password@host:port?query=1"
assert redact_url_password(url) == "https://user:<redacted>@host:port?query=1"
url = "https://user:password@host:port?query=1&password=2"
assert (
redact_url_password(url)
== "https://user:<redacted>@host:port?query=1&password=2"
)
# Mock for aiohttp.web.Request, which should not be constructed directly.
class MockRequest:
def __init__(self, **kwargs):
self._json = kwargs
async def json(self):
return self._json
@pytest.mark.asyncio
async def test_mock_request():
request = MockRequest(a=1, b=2)
assert await request.json() == {"a": 1, "b": 2}
request = MockRequest(a=1, b=None)
assert await request.json() == {"a": 1, "b": None}
# async test
@pytest.mark.asyncio
class TestParseAndValidateRequest:
async def test_basic(self):
request = MockRequest(entrypoint="echo hi")
expected = JobSubmitRequest(entrypoint="echo hi")
assert await parse_and_validate_request(request, JobSubmitRequest) == expected
async def test_forward_compatibility(self):
request = MockRequest(entrypoint="echo hi", new_client_field=None)
expected = JobSubmitRequest(entrypoint="echo hi")
assert await parse_and_validate_request(request, JobSubmitRequest) == expected
class TestIterLine:
@pytest.mark.asyncio
async def test_invalid_type(self):
with pytest.raises(TypeError, match="path must be a string"):
await anext(file_tail_iterator(1))
@pytest.mark.asyncio
async def test_file_not_created(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
f.write("hi\n")
f.flush()
assert await anext(it) is not None
@pytest.mark.asyncio
async def test_wait_for_newline(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
f.write("no_newline_yet")
assert await anext(it) is None
f.write("\n")
f.flush()
assert await anext(it) == ["no_newline_yet\n"]
@pytest.mark.asyncio
async def test_multiple_lines(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
num_lines = 10
for i in range(num_lines):
s = f"{i}\n"
f.write(s)
f.flush()
assert await anext(it) == [s]
assert await anext(it) is None
@pytest.mark.asyncio
async def test_batching(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
# Write lines in batches of 10, check that we get them back in batches.
for _ in range(100):
num_lines = 10
for i in range(num_lines):
f.write(f"{i}\n")
f.flush()
assert await anext(it) == [f"{i}\n" for i in range(10)]
assert await anext(it) is None
@pytest.mark.asyncio
async def test_max_line_batching(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
# Write lines in batches of 50, check that we get them back in batches of 10.
for _ in range(100):
num_lines = 50
for i in range(num_lines):
f.write(f"{i}\n")
f.flush()
assert await anext(it) == [f"{i}\n" for i in range(10)]
assert await anext(it) == [f"{i}\n" for i in range(10, 20)]
assert await anext(it) == [f"{i}\n" for i in range(20, 30)]
assert await anext(it) == [f"{i}\n" for i in range(30, 40)]
assert await anext(it) == [f"{i}\n" for i in range(40, 50)]
assert await anext(it) is None
@pytest.mark.asyncio
async def test_max_char_batching(self, tmp):
it = file_tail_iterator(tmp)
assert await anext(it) is None
f = open(tmp, "w")
# Write a single line that is 60k characters
f.write(f"{'1234567890' * 6000}\n")
# Write a 4 lines that are 10k characters each
for _ in range(4):
f.write(f"{'1234567890' * 500}\n")
f.flush()
# First line will come in a batch of its own
assert await anext(it) == [f"{'1234567890' * 6000}\n"]
# Other 4 lines will be batched together
assert (
await anext(it)
== [
f"{'1234567890' * 500}\n",
]
* 4
)
assert await anext(it) is None
@pytest.mark.asyncio
async def test_delete_file(self):
with NamedTemporaryFile() as tmp:
it = file_tail_iterator(tmp.name)
f = open(tmp.name, "w")
assert await anext(it) is None
f.write("hi\n")
f.flush()
assert await anext(it) == ["hi\n"]
# Calls should continue returning None after file deleted.
assert await anext(it) is None
class TestFastTailLastNLines:
def test_nonexistent_path(self, tmp):
missing = tmp + ".missing"
assert not os.path.exists(missing)
with pytest.raises(FileNotFoundError):
fast_tail_last_n_lines(missing, num_lines=10, max_chars=1000)
def test_basic_last_n(self, tmp):
# Write 100 lines, check that we get the last 10 lines.
with open(tmp, "w") as f:
for i in range(100):
f.write(f"line-{i}\n")
out = fast_tail_last_n_lines(tmp, num_lines=10, max_chars=1000)
expected = "".join([f"line-{i}\n" for i in range(90, 100)])
assert out == expected
def test_truncate_max_chars(self, tmp):
# Construct a log file with two lines, each over max_chars,
# check that we truncate to max_chars.
with open(tmp, "w") as f:
f.write("x" * 5000 + "\n")
f.write("y" * 5000 + "\n")
out = fast_tail_last_n_lines(tmp, num_lines=2, max_chars=3000)
assert len(out) == 3000
# Check that we truncate to max_chars, and include the last line.
assert out.endswith("\n")
def test_partial_last_line(self, tmp):
# Write a log file with a partial last line, check that we include it.
with open(tmp, "w") as f:
f.write("a\n")
f.write("b\n")
f.write("partial_last_line") # No newline at end
out = fast_tail_last_n_lines(tmp, num_lines=3, max_chars=1000)
assert out == "a\nb\npartial_last_line"
def test_small_block_size(self, tmp):
# Write 30 lines, check that we can read a small block size and get the last N lines.
with open(tmp, "w") as f:
for i in range(30):
f.write(f"{i}\n")
out = fast_tail_last_n_lines(tmp, num_lines=5, max_chars=1000, block_size=16)
expected = "".join([f"{i}\n" for i in range(25, 30)])
assert out == expected
def test_mixed_long_lines(self, tmp):
# Write a log file with a mix of short and long lines, check that we get the last N lines.
with open(tmp, "w") as f:
f.write("short-1\n")
f.write("short-2\n")
f.write("long-" + ("Z" * 10000) + "\n")
f.write("short-3\n")
f.write("short-4\n")
out = fast_tail_last_n_lines(tmp, num_lines=3, max_chars=20000)
# Check that we get the last 3 lines, including the long line.
assert out.splitlines()[-1] == "short-4"
assert out.splitlines()[-2] == "short-3"
assert out.splitlines()[-3].startswith("long-Z")
def test_sparse_large_file_tail_max_chars(self, tmp):
"""Simulate ~8 GiB sparse file tail and verify max_chars=20000 truncation."""
size_8g = 8 * 1024 * 1024 * 1024
# Build tail of two extremely long lines
tail = "\n" + ("Q" * 25000 + "\n") + ("R" * 25000 + "\n")
tail_bytes = tail.encode("utf-8")
print("Start writing sparse file tail...")
# Create a sparse file: seek to near EOF then write only the tail.
with open(tmp, "wb") as f:
f.seek(size_8g - len(tail_bytes))
f.write(tail_bytes)
f.flush()
print("Finish writing sparse file tail.")
out = fast_tail_last_n_lines(tmp, num_lines=2, max_chars=20000)
print("Finish reading sparse file tail.")
assert len(out) == 20000
assert out.endswith("\n")
assert "R" * 100 in out # sampling check for last line content
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))