Files
2026-07-13 13:17:40 +08:00

378 lines
14 KiB
Python

import json
import os
import re
import signal
import subprocess
import sys
from typing import Dict, Optional, Pattern
import httpx
import pytest
import yaml
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_HA_PROXY,
SERVE_DEFAULT_APP_NAME,
)
from ray.serve._private.test_utils import get_application_url
from ray.util.state import list_actors
CONNECTION_ERROR_MSG = "connection error"
def ping_endpoint(app_name: str = SERVE_DEFAULT_APP_NAME, params: str = ""):
try:
url = get_application_url("HTTP", app_name=app_name)
return httpx.get(f"{url}/{params}").text
except httpx.HTTPError:
return CONNECTION_ERROR_MSG
def check_app_status(app_name: str, expected_status: str):
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)["applications"]
assert status[app_name]["status"] == expected_status
return True
def check_app_running(app_name: str):
return check_app_status(app_name, "RUNNING")
def check_http_response(expected_text: str, json: Optional[Dict] = None):
url = get_application_url("HTTP")
resp = httpx.post(url, json=json)
assert resp.text == expected_text
return True
def test_start_shutdown(ray_start_stop):
subprocess.check_output(["serve", "start"])
# deploy a simple app
import_path = "ray.serve.tests.test_config_files.arg_builders.build_echo_app"
deploy_response = subprocess.check_output(["serve", "deploy", import_path])
assert b"Sent deploy request successfully." in deploy_response
wait_for_condition(
check_http_response,
expected_text="DEFAULT",
timeout=15,
)
ret = subprocess.check_output(["serve", "shutdown", "-y"])
assert b"Sent shutdown request; applications will be deleted asynchronously" in ret
def check_no_apps():
status = subprocess.check_output(["serve", "status"])
return b"applications: {}" in status
wait_for_condition(check_no_apps, timeout=15)
# Test shutdown when no Serve instance is running
ret = subprocess.check_output(["serve", "shutdown", "-y"], stderr=subprocess.STDOUT)
assert b"No Serve instance found running" in ret
def test_start_shutdown_without_serve_running(ray_start_stop):
# Test shutdown when no Serve instance is running
ret = subprocess.check_output(["serve", "shutdown", "-y"], stderr=subprocess.STDOUT)
assert b"No Serve instance found running" in ret
# def test_start_shutdown_without_ray_running():
# # Test shutdown when Ray is not running
# ret = subprocess.check_output(["serve", "shutdown", "-y"], stderr=subprocess.STDOUT)
# assert b"Unable to shutdown Serve on the cluster" in ret
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_shutdown(ray_start_stop):
"""Deploys a config file and shuts down the Serve application."""
# Check that `serve shutdown` works even if no Serve app is running
subprocess.check_output(["serve", "shutdown", "-y"])
def num_live_deployments():
status_response = subprocess.check_output(["serve", "status"])
serve_status = yaml.safe_load(status_response)["applications"][
SERVE_DEFAULT_APP_NAME
]
return len(serve_status["deployments"])
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "basic_graph.yaml"
)
# Check idempotence
num_iterations = 2
for iteration in range(1, num_iterations + 1):
print(f"*** Starting Iteration {iteration}/{num_iterations} ***\n")
print("Deploying config.")
subprocess.check_output(["serve", "deploy", config_file_name])
wait_for_condition(lambda: num_live_deployments() == 2, timeout=15)
print("Deployment successful. Deployments are live.")
# `serve config` and `serve status` should print non-empty schemas
config_response = subprocess.check_output(["serve", "config"])
yaml.safe_load(config_response)
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)
assert len(status["applications"])
print("`serve config` and `serve status` print non-empty responses.\n")
print("Deleting Serve app.")
subprocess.check_output(["serve", "shutdown", "-y"])
# `serve config` and `serve status` should print messages indicating
# nothing is deployed
def serve_config_empty_warning():
config_response = subprocess.check_output(["serve", "config"]).decode(
"utf-8"
)
return config_response == "No configuration was found.\n"
def serve_status_empty():
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)
return len(status["applications"]) == 0
wait_for_condition(serve_config_empty_warning)
wait_for_condition(serve_status_empty)
print("`serve config` and `serve status` print empty responses.\n")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_with_http_options(ray_start_stop):
"""Deploys config with host and port options specified"""
f1 = os.path.join(
os.path.dirname(__file__), "test_config_files", "basic_graph_http.yaml"
)
success_message_fragment = b"Sent deploy request successfully."
with open(f1, "r") as config_file:
config = yaml.safe_load(config_file)
deploy_response = subprocess.check_output(["serve", "deploy", f1])
assert success_message_fragment in deploy_response
wait_for_condition(
lambda: httpx.post("http://localhost:8005/", json=None).text
== "wonderful world",
timeout=15,
)
# Config should contain matching host and port options
info_response = subprocess.check_output(["serve", "config"])
info = yaml.safe_load(info_response)
# TODO(zcin): the assertion should just be `info == config` here but the output
# formatting removes a lot of info.
assert info == config["applications"][0]
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
@pytest.mark.parametrize("use_command", [True, False])
def test_idempotence_after_controller_death(ray_start_stop, use_command: bool):
"""Check that CLI is idempotent even if controller dies."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "basic_graph.yaml"
)
success_message_fragment = b"Sent deploy request successfully."
deploy_response = subprocess.check_output(["serve", "deploy", config_file_name])
assert success_message_fragment in deploy_response
expected_num_actors = 4
if RAY_SERVE_ENABLE_HA_PROXY:
# fallback proxy
expected_num_actors += 1
serve.start()
wait_for_condition(
lambda: len(list_actors(filters=[("state", "=", "ALIVE")]))
== expected_num_actors,
timeout=15,
)
# Kill controller
if use_command:
subprocess.check_output(["serve", "shutdown", "-y"])
else:
serve.shutdown()
status_response = subprocess.check_output(["serve", "status"])
status_info = yaml.safe_load(status_response)
assert len(status_info["applications"]) == 0
deploy_response = subprocess.check_output(["serve", "deploy", config_file_name])
assert success_message_fragment in deploy_response
# Restore testing controller
serve.start()
wait_for_condition(
lambda: len(list_actors(filters=[("state", "=", "ALIVE")]))
== expected_num_actors,
timeout=15,
)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_control_c_shutdown_serve_components(ray_start_stop):
"""Test ctrl+c after `serve run` shuts down serve components."""
p = subprocess.Popen(["serve", "run", "ray.serve.tests.test_cli_3.echo_app"])
# Make sure Serve components are up and running
wait_for_condition(check_app_running, app_name=SERVE_DEFAULT_APP_NAME)
assert httpx.get("http://localhost:8000/-/healthz").text == "success"
assert json.loads(httpx.get("http://localhost:8000/-/routes").text) == {
"/": "default"
}
assert httpx.get("http://localhost:8000/").text == "hello"
# Send ctrl+c to shutdown Serve components
p.send_signal(signal.SIGINT)
p.wait()
# Make sure Serve components are shutdown
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)
assert status == {"applications": {}, "proxies": {}, "target_capacity": None}
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
@pytest.mark.parametrize(
"ray_start_stop_in_specific_directory",
[
os.path.join(os.path.dirname(__file__), "test_config_files"),
],
indirect=True,
)
def test_deploy_with_access_to_current_directory(ray_start_stop_in_specific_directory):
"""Test serve deploy using modules in the current directory succeeds.
There was an issue where dashboard client doesn't add the current directory to
the sys.path and failed to deploy a Serve app defined in the directory. This
test ensures that files in the current directory can be accessed and deployed.
See: https://github.com/ray-project/ray/issues/43889
"""
# Deploy Serve application with a config in the current directory.
subprocess.check_output(["serve", "deploy", "use_current_working_directory.yaml"])
# Ensure serve deploy eventually succeeds.
def check_deploy_successfully():
status_response = subprocess.check_output(["serve", "status"])
assert b"RUNNING" in status_response
return True
wait_for_condition(check_deploy_successfully, timeout=5)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
class TestRayReinitialization:
@pytest.fixture
def import_file_name(self) -> str:
return "ray.serve.tests.test_config_files.ray_already_initialized:app"
@pytest.fixture
def pattern(self) -> Pattern:
return re.compile(r"Connecting to existing Ray cluster at address: (.*)\.\.\.")
@pytest.fixture
def ansi_escape(self) -> Pattern:
return re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def test_run_without_address(self, import_file_name, ray_start_stop):
"""Test serve run with ray already initialized and run without address argument.
When the imported file already initialized a ray instance and serve doesn't run
with address argument, then serve does not reinitialize another ray instance and
cause error.
"""
p = subprocess.Popen(["serve", "run", import_file_name])
wait_for_condition(lambda: ping_endpoint() == "foobar", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
def test_run_with_address_same_address(self, import_file_name, ray_start_stop):
"""Test serve run with ray already initialized and run with address argument
that has the same address as existing ray instance.
When the imported file already initialized a ray instance and serve runs with
address argument same as the ray instance, then serve does not reinitialize
another ray instance and cause error.
"""
p = subprocess.Popen(
["serve", "run", "--address=127.0.0.1:6379", import_file_name]
)
wait_for_condition(lambda: ping_endpoint() == "foobar", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
def test_run_with_address_different_address(
self, import_file_name, pattern, ansi_escape, ray_start_stop
):
"""Test serve run with ray already initialized and run with address argument
that has the different address as existing ray instance.
When the imported file already initialized a ray instance and serve runs with
address argument different as the ray instance, then serve does not reinitialize
another ray instance and cause error and logs warning to the user.
"""
p = subprocess.Popen(
["serve", "run", "--address=ray://123.45.67.89:50005", import_file_name],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
wait_for_condition(lambda: ping_endpoint() == "foobar", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
process_output, _ = p.communicate()
logs = process_output.decode("utf-8").strip()
ray_address = ansi_escape.sub("", pattern.search(logs).group(1))
expected_warning_message = (
"An address was passed to `serve run` but the imported module also "
f"connected to Ray at a different address: '{ray_address}'. You do not "
"need to call `ray.init` in your code when using `serve run`."
)
assert expected_warning_message in logs
def test_run_with_auto_address(
self, import_file_name, pattern, ansi_escape, ray_start_stop
):
"""Test serve run with ray already initialized and run with "auto" address
argument.
When the imported file already initialized a ray instance and serve runs with
address argument same as the ray instance, then serve does not reinitialize
another ray instance and cause error.
"""
p = subprocess.Popen(
["serve", "run", "--address=auto", import_file_name],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
wait_for_condition(lambda: ping_endpoint() == "foobar", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
process_output, _ = p.communicate()
logs = process_output.decode("utf-8").strip()
ray_address = ansi_escape.sub("", pattern.search(logs).group(1))
expected_warning_message = (
"An address was passed to `serve run` but the imported module also "
f"connected to Ray at a different address: '{ray_address}'. You do not "
"need to call `ray.init` in your code when using `serve run`."
)
assert expected_warning_message not in logs
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))