475 lines
15 KiB
Python
475 lines
15 KiB
Python
"""Integration tests for token-based authentication in Ray (part 1).
|
|
|
|
Cluster lifecycle, connection, and end-to-end operation tests.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
from ray._common.test_utils import wait_for_condition
|
|
from ray._private.test_utils import client_test_enabled
|
|
|
|
try:
|
|
from ray._raylet import AuthenticationTokenLoader
|
|
|
|
_RAYLET_AVAILABLE = True
|
|
except ImportError:
|
|
_RAYLET_AVAILABLE = False
|
|
AuthenticationTokenLoader = None
|
|
|
|
from ray._private.authentication_test_utils import (
|
|
clear_auth_token_sources,
|
|
reset_auth_token_state,
|
|
set_auth_mode,
|
|
set_env_auth_token,
|
|
)
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not _RAYLET_AVAILABLE,
|
|
reason="Authentication tests require ray._raylet (not available in minimal installs)",
|
|
)
|
|
|
|
|
|
def _run_ray_start_and_verify_status(
|
|
args: list, env: dict, expect_success: bool = True, timeout: int = 30
|
|
) -> subprocess.CompletedProcess:
|
|
"""Helper to run ray start command with proper error handling."""
|
|
result = subprocess.run(
|
|
["ray", "start"] + args,
|
|
env={"RAY_ENABLE_WINDOWS_OR_OSX_CLUSTER": "1", **env},
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
|
|
if expect_success:
|
|
assert result.returncode == 0, (
|
|
f"ray start should have succeeded. "
|
|
f"stdout: {result.stdout}, stderr: {result.stderr}"
|
|
)
|
|
else:
|
|
assert result.returncode != 0, (
|
|
f"ray start should have failed but succeeded. "
|
|
f"stdout: {result.stdout}, stderr: {result.stderr}"
|
|
)
|
|
# Check that error message mentions token
|
|
error_output = result.stdout + result.stderr
|
|
assert (
|
|
"authentication token" in error_output.lower()
|
|
or "token" in error_output.lower()
|
|
), f"Error message should mention token. Got: {error_output}"
|
|
|
|
return result
|
|
|
|
|
|
def _cleanup_ray_start(env: Optional[dict] = None):
|
|
"""Helper to clean up ray start processes."""
|
|
# Ensure any ray.init() connection is closed first
|
|
if ray.is_initialized():
|
|
ray.shutdown()
|
|
|
|
# Stop with a longer timeout
|
|
subprocess.run(
|
|
["ray", "stop", "--force"],
|
|
env=env,
|
|
capture_output=True,
|
|
timeout=60, # Increased timeout for flaky cleanup
|
|
check=False, # Don't raise on non-zero exit
|
|
)
|
|
|
|
# Wait for ray processes to actually stop
|
|
def ray_stopped():
|
|
result = subprocess.run(
|
|
["ray", "status"],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
# ray status returns non-zero when no cluster is running
|
|
return result.returncode != 0
|
|
|
|
try:
|
|
wait_for_condition(ray_stopped, timeout=10, retry_interval_ms=500)
|
|
except Exception:
|
|
# Best effort - don't fail the test if we can't verify it stopped
|
|
pass
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _auto_clean_token_sources(clean_token_sources):
|
|
"""Opt in to the shared clean_token_sources fixture for every test."""
|
|
yield
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
client_test_enabled(),
|
|
reason="This test is for starting a new local cluster, not compatible with client mode",
|
|
)
|
|
def test_local_cluster_generates_token():
|
|
"""Test ray.init() generates token for local cluster when auth_mode=token is set."""
|
|
# Ensure no token exists
|
|
default_token_path = Path.home() / ".ray" / "auth_token"
|
|
assert (
|
|
not default_token_path.exists()
|
|
), f"Token file already exists at {default_token_path}"
|
|
|
|
# Enable token auth via environment variable
|
|
set_auth_mode("token")
|
|
reset_auth_token_state()
|
|
|
|
# Initialize Ray with token auth
|
|
ray.init()
|
|
|
|
try:
|
|
# Verify token file was created
|
|
assert default_token_path.exists(), (
|
|
f"Token file was not created at {default_token_path}. "
|
|
f"HOME={os.environ.get('HOME')}, "
|
|
f"Files in {default_token_path.parent}: {list(default_token_path.parent.iterdir()) if default_token_path.parent.exists() else 'directory does not exist'}"
|
|
)
|
|
token = default_token_path.read_text().strip()
|
|
assert len(token) == 64
|
|
assert all(c in "0123456789abcdef" for c in token)
|
|
|
|
# Verify cluster is working
|
|
assert ray.is_initialized()
|
|
|
|
finally:
|
|
ray.shutdown()
|
|
|
|
|
|
def test_connect_without_token_raises_error(setup_cluster_with_token_auth):
|
|
"""Test ray.init(address=...) without token fails when auth_mode=token is set."""
|
|
cluster_info = setup_cluster_with_token_auth
|
|
cluster = cluster_info["cluster"]
|
|
|
|
# Disconnect the current driver session and drop token state before retrying.
|
|
ray.shutdown()
|
|
set_auth_mode("disabled")
|
|
clear_auth_token_sources(remove_default=True)
|
|
reset_auth_token_state()
|
|
|
|
# Ensure no token exists
|
|
token_loader = AuthenticationTokenLoader.instance()
|
|
assert not token_loader.has_token()
|
|
|
|
# Try to connect to the cluster without a token - should raise RuntimeError
|
|
with pytest.raises(ConnectionError):
|
|
ray.init(address=cluster.address)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"token,expected_status",
|
|
[
|
|
(None, 401), # No token -> Unauthorized
|
|
("wrong_token", 403), # Wrong token -> Forbidden
|
|
],
|
|
ids=["no_token", "wrong_token"],
|
|
)
|
|
def test_state_api_auth_failure(token, expected_status, setup_cluster_with_token_auth):
|
|
"""Test that state API calls fail with missing or incorrect token."""
|
|
import requests
|
|
|
|
cluster_info = setup_cluster_with_token_auth
|
|
dashboard_url = cluster_info["dashboard_url"]
|
|
|
|
# Make direct HTTP request to state API endpoint
|
|
headers = {}
|
|
if token is not None:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
|
|
response = requests.get(f"{dashboard_url}/api/v0/actors", headers=headers)
|
|
|
|
assert response.status_code == expected_status, (
|
|
f"State API should return {expected_status}, got {response.status_code}: "
|
|
f"{response.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("tokens_match", [True, False])
|
|
def test_cluster_token_authentication(tokens_match, setup_cluster_with_token_auth):
|
|
"""Test cluster authentication with matching and non-matching tokens."""
|
|
cluster_info = setup_cluster_with_token_auth
|
|
cluster = cluster_info["cluster"]
|
|
cluster_token = cluster_info["token"]
|
|
|
|
# Reconfigure the driver token state to simulate fresh connections.
|
|
ray.shutdown()
|
|
set_auth_mode("token")
|
|
|
|
if tokens_match:
|
|
client_token = cluster_token # Same token - should succeed
|
|
else:
|
|
client_token = "b" * 64 # Different token - should fail
|
|
|
|
set_env_auth_token(client_token)
|
|
reset_auth_token_state()
|
|
|
|
if tokens_match:
|
|
# Should succeed - test gRPC calls work
|
|
ray.init(address=cluster.address)
|
|
|
|
obj_ref = ray.put("test_data")
|
|
result = ray.get(obj_ref)
|
|
assert result == "test_data"
|
|
|
|
@ray.remote
|
|
def test_func():
|
|
return "success"
|
|
|
|
result = ray.get(test_func.remote())
|
|
assert result == "success"
|
|
|
|
ray.shutdown()
|
|
|
|
else:
|
|
# Should fail - connection or gRPC calls should fail
|
|
with pytest.raises((ConnectionError, RuntimeError)):
|
|
ray.init(address=cluster.address)
|
|
try:
|
|
ray.put("test")
|
|
finally:
|
|
ray.shutdown()
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
client_test_enabled(),
|
|
reason="Uses subprocess ray start, not compatible with client mode",
|
|
)
|
|
@pytest.mark.parametrize("is_head", [True, False])
|
|
def test_ray_start_without_token_raises_error(is_head, request):
|
|
"""Test that ray start fails when auth_mode=token but no token exists."""
|
|
# Set up environment with token auth enabled but no token
|
|
env = os.environ.copy()
|
|
env["RAY_AUTH_MODE"] = "token"
|
|
env.pop("RAY_AUTH_TOKEN", None)
|
|
env.pop("RAY_AUTH_TOKEN_PATH", None)
|
|
|
|
# Ensure no default token file exists (already cleaned by fixture)
|
|
default_token_path = Path.home() / ".ray" / "auth_token"
|
|
assert not default_token_path.exists()
|
|
|
|
# When specifying an address, we need a head node to connect to
|
|
cluster_info = None
|
|
if not is_head:
|
|
cluster_info = request.getfixturevalue("setup_cluster_with_token_auth")
|
|
cluster = cluster_info["cluster"]
|
|
ray.shutdown()
|
|
|
|
# Prepare arguments
|
|
if is_head:
|
|
args = ["--head", "--port=0"]
|
|
else:
|
|
args = [f"--address={cluster.address}"]
|
|
|
|
# Try to start node - should fail
|
|
_run_ray_start_and_verify_status(args, env, expect_success=False)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
client_test_enabled(),
|
|
reason="Uses subprocess ray start, not compatible with client mode",
|
|
)
|
|
def test_ray_start_head_with_token_succeeds():
|
|
"""Test that ray start --head succeeds when token auth is enabled with a valid token."""
|
|
# Set up environment with token auth and a valid token
|
|
test_token = "a" * 64
|
|
env = os.environ.copy()
|
|
env["RAY_AUTH_TOKEN"] = test_token
|
|
env["RAY_AUTH_MODE"] = "token"
|
|
|
|
try:
|
|
# Start head node - should succeed
|
|
_run_ray_start_and_verify_status(
|
|
["--head", "--port=0"], env, expect_success=True
|
|
)
|
|
|
|
# Verify we can connect to the cluster with ray.init()
|
|
set_env_auth_token(test_token)
|
|
set_auth_mode("token")
|
|
reset_auth_token_state()
|
|
|
|
# Wait for cluster to be ready
|
|
def cluster_ready():
|
|
try:
|
|
ray.init(address="auto")
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
wait_for_condition(cluster_ready, timeout=10)
|
|
assert ray.is_initialized()
|
|
|
|
# Test basic operations work
|
|
@ray.remote
|
|
def test_func():
|
|
return "success"
|
|
|
|
result = ray.get(test_func.remote())
|
|
assert result == "success"
|
|
|
|
finally:
|
|
# Cleanup handles ray.shutdown() internally
|
|
_cleanup_ray_start(env)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
client_test_enabled(),
|
|
reason="Uses subprocess ray start, not compatible with client mode",
|
|
)
|
|
@pytest.mark.parametrize("token_match", ["correct", "incorrect"])
|
|
def test_ray_start_address_with_token(token_match, setup_cluster_with_token_auth):
|
|
"""Test ray start --address=... with correct or incorrect token."""
|
|
cluster_info = setup_cluster_with_token_auth
|
|
cluster = cluster_info["cluster"]
|
|
cluster_token = cluster_info["token"]
|
|
|
|
# Reset the driver connection to reuse the fixture-backed cluster.
|
|
ray.shutdown()
|
|
set_auth_mode("token")
|
|
|
|
# Set up environment for worker
|
|
env = os.environ.copy()
|
|
env["RAY_AUTH_MODE"] = "token"
|
|
|
|
if token_match == "correct":
|
|
env["RAY_AUTH_TOKEN"] = cluster_token
|
|
expect_success = True
|
|
else:
|
|
env["RAY_AUTH_TOKEN"] = "b" * 64
|
|
expect_success = False
|
|
|
|
# Start worker node
|
|
_run_ray_start_and_verify_status(
|
|
[f"--address={cluster.address}", "--num-cpus=1"],
|
|
env,
|
|
expect_success=expect_success,
|
|
)
|
|
|
|
if token_match == "correct":
|
|
try:
|
|
# Connect and verify the cluster has 2 nodes (head + worker)
|
|
set_env_auth_token(cluster_token)
|
|
reset_auth_token_state()
|
|
ray.init(address=cluster.address)
|
|
|
|
def worker_joined():
|
|
return len(ray.nodes()) >= 2
|
|
|
|
wait_for_condition(worker_joined, timeout=10)
|
|
|
|
nodes = ray.nodes()
|
|
assert (
|
|
len(nodes) >= 2
|
|
), f"Expected at least 2 nodes, got {len(nodes)}: {nodes}"
|
|
|
|
finally:
|
|
if ray.is_initialized():
|
|
ray.shutdown()
|
|
_cleanup_ray_start(env)
|
|
|
|
|
|
def test_e2e_operations_with_token_auth(setup_cluster_with_token_auth):
|
|
"""Test that e2e operations work with token authentication enabled.
|
|
|
|
This verifies that with token auth enabled:
|
|
1. Tasks execute successfully
|
|
2. Actors can be created and called
|
|
3. State API works (list_nodes, list_actors, list_tasks)
|
|
4. Job submission works
|
|
"""
|
|
cluster_info = setup_cluster_with_token_auth
|
|
|
|
# Test 1: Submit a simple task
|
|
@ray.remote
|
|
def simple_task(x):
|
|
return x + 1
|
|
|
|
result = ray.get(simple_task.remote(41))
|
|
assert result == 42, f"Task should return 42, got {result}"
|
|
|
|
# Test 2: Create and use an actor
|
|
@ray.remote
|
|
class SimpleActor:
|
|
def __init__(self):
|
|
self.value = 0
|
|
|
|
def increment(self):
|
|
self.value += 1
|
|
return self.value
|
|
|
|
actor = SimpleActor.remote()
|
|
result = ray.get(actor.increment.remote())
|
|
assert result == 1, f"Actor method should return 1, got {result}"
|
|
|
|
# Test 3: State API operations (uses HTTP with auth headers)
|
|
from ray.util.state import list_actors, list_nodes, list_tasks
|
|
|
|
# List nodes - should include at least the head node
|
|
wait_for_condition(lambda: len(list_nodes()) >= 1)
|
|
|
|
# List actors - should include our SimpleActor
|
|
def check_actors():
|
|
actors = list_actors()
|
|
if len(actors) < 1:
|
|
return False
|
|
return "SimpleActor" in actors[0].class_name
|
|
|
|
wait_for_condition(check_actors)
|
|
|
|
# List tasks - should include completed tasks
|
|
wait_for_condition(lambda: len(list_tasks()) >= 1)
|
|
|
|
# Test 4: Submit a job and wait for completion
|
|
from ray.job_submission import JobSubmissionClient
|
|
|
|
# Create job submission client (uses HTTP with auth headers)
|
|
client = JobSubmissionClient(address=cluster_info["dashboard_url"])
|
|
|
|
# Submit a simple job
|
|
job_id = client.submit_job(
|
|
entrypoint="echo 'Hello from job'",
|
|
)
|
|
|
|
# Wait for job to complete
|
|
def job_finished():
|
|
status = client.get_job_status(job_id)
|
|
return status in ["SUCCEEDED", "FAILED", "STOPPED"]
|
|
|
|
wait_for_condition(job_finished, timeout=30)
|
|
|
|
final_status = client.get_job_status(job_id)
|
|
assert (
|
|
final_status == "SUCCEEDED"
|
|
), f"Job should succeed, got status: {final_status}"
|
|
|
|
|
|
def test_logs_api_with_token_auth(setup_cluster_with_token_auth):
|
|
"""Test that log APIs work with token authentication enabled."""
|
|
from ray.util.state import get_log, list_logs
|
|
|
|
# Get node ID for log queries
|
|
node_id = ray.nodes()[0]["NodeID"]
|
|
|
|
# Test list_logs() with valid auth
|
|
logs = list_logs(node_id=node_id)
|
|
assert isinstance(logs, dict), f"list_logs should return a dict, got {type(logs)}"
|
|
|
|
# Test get_log() with valid auth (fetch raylet.out which will always exist)
|
|
chunks_received = 0
|
|
for chunk in get_log(filename="raylet.out", node_id=node_id, tail=10):
|
|
assert isinstance(chunk, str), f"get_log chunk should be str, got {type(chunk)}"
|
|
chunks_received += 1
|
|
break
|
|
|
|
assert chunks_received > 0, "Should have received at least one log chunk"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-vv", __file__]))
|