"""runtime_env tests that require their own custom fixture. The other runtime_env tests use a shared Ray instance across the test module to reduce overheads & overall test runtime. """ import fnmatch import logging import os import sys import time from pathlib import Path from typing import List import pytest import ray from ray._common.test_utils import wait_for_condition from ray._private.runtime_env.context import RuntimeEnvContext from ray._private.runtime_env.plugin import RuntimeEnvPlugin from ray._private.test_utils import ( get_error_message, get_log_sources, ) from ray.exceptions import RuntimeEnvSetupError from ray.job_submission import JobStatus, JobSubmissionClient from ray.runtime_env import RuntimeEnv @pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.") def test_no_spurious_worker_startup(shutdown_only, monkeypatch): """Test that no extra workers start up during a long env installation.""" # Causes agent to sleep for 15 seconds to simulate creating a runtime env. monkeypatch.setenv("RAY_RUNTIME_ENV_SLEEP_FOR_TESTING_S", "15") ray.init(num_cpus=1) @ray.remote class Counter(object): def __init__(self): self.value = 0 def get(self): return self.value # Set a nonempty runtime env so that the runtime env setup hook is called. runtime_env = RuntimeEnv(env_vars={"a": "b"}) # Instantiate an actor that requires the long runtime env installation. a = Counter.options(runtime_env=runtime_env).remote() assert ray.get(a.get.remote()) == 0 # Check "debug_state.txt" to ensure no extra workers were started. session_dir = ray._private.worker.global_worker.node.address_info["session_dir"] session_path = Path(session_dir) debug_state_path = session_path / "logs" / "debug_state.txt" def get_num_workers(): with open(debug_state_path) as f: for line in f.readlines(): num_workers_prefix = "- num PYTHON workers: " if num_workers_prefix in line: return int(line[len(num_workers_prefix) :]) return None # Wait for "debug_state.txt" to be updated to reflect the started worker. start = time.time() wait_for_condition(lambda: get_num_workers() is not None and get_num_workers() > 0) time_waited = time.time() - start print(f"Waited {time_waited} for debug_state.txt to be updated") # If any workers were unnecessarily started during the initial env # installation, they will bypass the runtime env setup hook (because the # created env will have been cached) and should be added to num_workers # within a few seconds. Adjusting the default update period for # debut_state.txt via this cluster_utils pytest fixture seems to be broken, # so just check it for the next 10 seconds (the default period). start = time.time() got_num_workers = False while time.time() - start < 10: # Check that no more than one extra worker is started. We add one # because Ray will prestart an idle worker for the one available CPU. num_workers = get_num_workers() if num_workers is not None: got_num_workers = True assert num_workers <= 2 time.sleep(0.1) assert got_num_workers, "failed to read num workers for 10 seconds" @pytest.fixture def runtime_env_local_dev_env_var(monkeypatch): monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1") yield @pytest.mark.skipif(sys.platform == "win32", reason="very slow on Windows.") def test_runtime_env_no_spurious_resource_deadlock_msg( runtime_env_local_dev_env_var, ray_start_regular, error_pubsub ): p = error_pubsub runtime_env = RuntimeEnv(pip=["tensorflow", "torch"]) @ray.remote(runtime_env=runtime_env) def f(): pass # Check no warning printed. ray.get(f.remote()) errors = get_error_message( p, 5, ray._private.ray_constants.RESOURCE_DEADLOCK_ERROR, timeout=5 ) assert len(errors) == 0 RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_CLASS_PATH = ( "ray.tests.test_runtime_env_standalone.RtEnvAgentSlowStartupPlugin" # noqa ) RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_NAME = "RtEnvAgentSlowStartupPlugin" RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_CLASS_PATH = ( "ray.tests.test_runtime_env_standalone.RtEnvAgentSlowStartupPlugin" ) class RtEnvAgentSlowStartupPlugin(RuntimeEnvPlugin): name = RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_NAME def __init__(self): # This happens in Runtime Env Agent start up process. Make it slow. time.sleep(5) print("starting...") @pytest.mark.parametrize( "set_runtime_env_plugins", [ '[{"class":"' + RT_ENV_AGENT_SLOW_STARTUP_PLUGIN_CLASS_PATH + '"}]', ], indirect=True, ) def test_slow_runtime_env_agent_startup_on_task_pressure( shutdown_only, set_runtime_env_plugins ): """ Starts nodes with runtime env agent and a slow plugin. Then when the runtime env agent is still starting up, we submit a lot of tasks to the cluster. The tasks should wait for the runtime env agent to start up and then run. https://github.com/ray-project/ray/issues/45353 """ @ray.remote(num_cpus=0.1) def get_foo(): return os.environ.get("foo") print("Submitting 20 tasks...") # Each task has a different runtime env to ensure the agent is invoked for each. vals = ray.get( [ get_foo.options(runtime_env={"env_vars": {"foo": f"bar{i}"}}).remote() for i in range(20) ] ) print("20 tasks done.") assert vals == [f"bar{i}" for i in range(20)] MY_PLUGIN_CLASS_PATH = "ray.tests.test_runtime_env_standalone.MyPlugin" MY_PLUGIN_NAME = "MyPlugin" success_retry_number = 3 runtime_env_retry_times = 0 # This plugin can make runtime env creation failed before the retry times # increased to `success_retry_number`. class MyPlugin(RuntimeEnvPlugin): name = MY_PLUGIN_NAME @staticmethod def validate(runtime_env_dict: dict) -> str: return runtime_env_dict[MY_PLUGIN_NAME] @staticmethod def modify_context( uris: List[str], runtime_env: dict, ctx: RuntimeEnvContext, logger: logging.Logger, ) -> None: global runtime_env_retry_times runtime_env_retry_times += 1 if runtime_env_retry_times != success_retry_number: raise ValueError(f"Fault injection {runtime_env_retry_times}") pass @pytest.mark.parametrize( "set_runtime_env_retry_times", [ str(success_retry_number - 1), str(success_retry_number), ], indirect=True, ) @pytest.mark.parametrize( "set_runtime_env_plugins", [ '[{"class":"' + MY_PLUGIN_CLASS_PATH + '"}]', ], indirect=True, ) def test_runtime_env_retry( set_runtime_env_retry_times, set_runtime_env_plugins, ray_start_regular ): @ray.remote def f(): return "ok" runtime_env_retry_times = int(set_runtime_env_retry_times) if runtime_env_retry_times >= success_retry_number: # Enough retry times output = ray.get( f.options(runtime_env={MY_PLUGIN_NAME: {"key": "value"}}).remote() ) assert output == "ok" else: # No enough retry times with pytest.raises( RuntimeEnvSetupError, match=f"Fault injection {runtime_env_retry_times}" ): ray.get(f.options(runtime_env={MY_PLUGIN_NAME: {"key": "value"}}).remote()) @pytest.fixture def enable_dev_mode(local_env_var_enabled, monkeypatch): enabled = "1" if local_env_var_enabled else "0" monkeypatch.setenv("RAY_RUNTIME_ENV_LOG_TO_DRIVER_ENABLED", enabled) yield @pytest.mark.skipif( sys.platform == "win32", reason="conda in runtime_env unsupported on Windows." ) @pytest.mark.parametrize("local_env_var_enabled", [False, True]) def test_runtime_env_log_msg( local_env_var_enabled, enable_dev_mode, ray_start_cluster_head, log_pubsub, ): p = log_pubsub @ray.remote def f(): pass good_env = RuntimeEnv(pip=["requests"]) ray.get(f.options(runtime_env=good_env).remote()) sources = get_log_sources(p, 5) if local_env_var_enabled: assert "runtime_env" in sources else: assert "runtime_env" not in sources def assert_no_user_info_in_logs(user_info: str, file_whitelist: List[str] = None): """Assert that the user info is not in the logs, except for any file that glob pattern matches a file in the whitelist. """ if file_whitelist is None: file_whitelist = [] node = ray._private.worker.global_worker.node log_dir = os.path.join(node.get_session_dir_path(), "logs") for root, dirs, files in os.walk(log_dir): for file in files: if any(fnmatch.fnmatch(file, pattern) for pattern in file_whitelist): continue # Some lines contain hex IDs, so ignore the UTF decoding errors. with open(os.path.join(root, file), "r", errors="ignore") as f: for line in f: assert user_info not in line, (file, user_info, line) class TestNoUserInfoInLogs: """Test that no user info (e.g. runtime env env vars) show up in the logs.""" def test_assert_no_user_info_in_logs(self, shutdown_only): """Test assert_no_user_info_in_logs does not spuriously pass.""" ray.init() with pytest.raises(AssertionError): assert_no_user_info_in_logs("ray") assert_no_user_info_in_logs("ray", file_whitelist=["*"]) def test_basic(self, tmp_path, shutdown_only): """Test that no user info shows up in the logs.""" # Runtime env logs may still appear in debug logs. Check the debug flag is off. assert os.getenv("RAY_BACKEND_LOG_LEVEL") != "debug" # Reuse the same "secret" for working_dir, pip, env_vars for convenience. USER_SECRET = "pip-install-test" working_dir = tmp_path / USER_SECRET working_dir.mkdir() runtime_env = { "working_dir": str(working_dir), "pip": [USER_SECRET], "env_vars": {USER_SECRET: USER_SECRET}, } ray.init(runtime_env=runtime_env, include_dashboard=True) # Run a function to ensure the runtime env is set up. @ray.remote def f(): return os.environ.get(USER_SECRET) assert USER_SECRET in ray.get(f.remote()) @ray.remote class Foo: def __init__(self): self.x = os.environ.get(USER_SECRET) def get_x(self): return self.x foo = Foo.remote() assert USER_SECRET in ray.get(foo.get_x.remote()) # Generate runtime env failure logs too. bad_runtime_env = { "pip": ["pkg-which-sadly-does-not-exist"], "env_vars": {USER_SECRET: USER_SECRET}, } with pytest.raises(Exception): ray.get(f.options(runtime_env=bad_runtime_env).remote()) with pytest.raises(Exception): foo2 = Foo.options(runtime_env=bad_runtime_env).remote() ray.get(foo2.get_x.remote()) # Test Ray Jobs API codepath. # Skip for Windows because Dashboard and Ray Jobs are not tested on Windows. if sys.platform != "win32": client = JobSubmissionClient() job_id_good_runtime_env = client.submit_job( entrypoint="echo 'hello world'", runtime_env=runtime_env ) job_id_bad_runtime_env = client.submit_job( entrypoint="echo 'hello world'", runtime_env=bad_runtime_env ) def job_succeeded(job_id): job_status = client.get_job_status(job_id) return job_status == JobStatus.SUCCEEDED def job_failed(job_id): job_status = client.get_job_status(job_id) return job_status == JobStatus.FAILED wait_for_condition(lambda: job_succeeded(job_id_good_runtime_env)) wait_for_condition(lambda: job_failed(job_id_bad_runtime_env), timeout=30) with pytest.raises(AssertionError): assert_no_user_info_in_logs(USER_SECRET) assert_no_user_info_in_logs( USER_SECRET, file_whitelist=["runtime_env*.log", "event_EXPORT*.log"] ) @pytest.mark.skipif(sys.platform == "win32", reason="Hangs on windows.") def test_failed_job_env_no_hang(shutdown_only): """Test that after a failed job-level env, tasks can still be run.""" runtime_env_for_init = RuntimeEnv(pip=["ray-doesnotexist-123"]) ray.init(runtime_env=runtime_env_for_init) @ray.remote def f(): import pip_install_test # noqa: F401 return True runtime_env_for_f = RuntimeEnv(pip=["pip-install-test==0.5"]) assert ray.get(f.options(runtime_env=runtime_env_for_f).remote()) # Task with no runtime env should inherit the bad job env. with pytest.raises(RuntimeEnvSetupError): ray.get(f.remote()) if __name__ == "__main__": sys.exit(pytest.main(["-sv", __file__]))