Files
ray-project--ray/python/ray/serve/tests/test_cli_3.py
T
2026-07-13 13:17:40 +08:00

614 lines
20 KiB
Python

import json
import os
import signal
import subprocess
import sys
import time
from typing import Union
import httpx
import pytest
import yaml
from pydantic import BaseModel
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME
from ray.serve.handle import DeploymentHandle
from ray.serve.tests.common.remote_uris import (
TEST_DAG_PINNED_URI,
TEST_DEPLOY_GROUP_PINNED_URI,
)
CONNECTION_ERROR_MSG = "connection error"
def ping_endpoint(endpoint: str, params: str = ""):
endpoint = endpoint.lstrip("/")
try:
return httpx.get(f"http://localhost:8000/{endpoint}{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")
@serve.deployment
def parrot(request):
return request.query_params["sound"]
parrot_node = parrot.bind()
@serve.deployment
class MetalDetector:
def __call__(self, *args):
return os.environ.get("buried_item", "no dice")
metal_detector_node = MetalDetector.bind()
@serve.deployment
class ConstructorFailure:
def __init__(self):
raise RuntimeError("Intentionally failing.")
constructor_failure_node = ConstructorFailure.bind()
@serve.deployment
class Macaw:
def __init__(self, color, name="Mulligan", surname=None):
self.color = color
self.name = name
self.surname = surname
def __call__(self):
if self.surname is not None:
return f"{self.name} {self.surname} is {self.color}!"
else:
return f"{self.name} is {self.color}!"
molly_macaw = Macaw.bind("green", name="Molly")
@serve.deployment
def global_f(*args):
return "wonderful world"
@serve.deployment
class NoArgDriver:
def __init__(self, h: DeploymentHandle):
self._h = h
async def __call__(self):
return await self._h.remote()
TestBuildFNode = global_f.bind()
TestBuildDagNode = NoArgDriver.bind(TestBuildFNode)
TestApp1Node = global_f.options(name="app1").bind()
TestApp2Node = NoArgDriver.options(name="app2").bind(global_f.bind())
@serve.deployment
class Echo:
def __init__(self, message: str):
print("Echo message:", message)
self._message = message
def __call__(self, *args):
return self._message
echo_app = Echo.bind("hello")
def build_echo_app(args):
return Echo.bind(args.get("message", "DEFAULT"))
class TypedArgs(BaseModel):
message: str = "DEFAULT"
def build_echo_app_typed(args: TypedArgs):
return Echo.bind(args.message)
k8sFNode = global_f.options(
num_replicas=2, ray_actor_options={"num_cpus": 2, "num_gpus": 1}
).bind()
class TestRun:
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
@pytest.mark.parametrize(
"proxy_location,expected",
[
(
None,
"EveryNode",
), # default ProxyLocation `EveryNode` is used as http_options.location is not specified
("EveryNode", "EveryNode"),
("HeadOnly", "HeadOnly"),
("Disabled", "Disabled"),
],
)
def test_proxy_location(self, ray_start_stop, tmp_path, proxy_location, expected):
# when the `serve run` cli command is executed
# without serve already running (for the first time)
# `proxy_location` should be set from the config file if specified
def is_proxy_location_correct(expected_proxy_location: str) -> bool:
try:
response = httpx.get(
"http://localhost:8265/api/serve/applications/"
).text
response_json = json.loads(response)
print("response_json")
print(response_json)
return response_json["proxy_location"] == expected_proxy_location
except httpx.HTTPError:
return False
def arithmetic_config(with_proxy_location: Union[str, None]) -> str:
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "arithmetic.yaml"
)
with open(config_file_name, "r") as config_file:
arithmetic_config_dict = yaml.safe_load(config_file)
config_path = tmp_path / "config.yaml"
if with_proxy_location:
arithmetic_config_dict["proxy_location"] = with_proxy_location
with open(config_path, "w") as f:
yaml.dump(arithmetic_config_dict, f)
return str(config_path)
config_path = arithmetic_config(with_proxy_location=proxy_location)
p = subprocess.Popen(["serve", "run", config_path])
wait_for_condition(
lambda: is_proxy_location_correct(expected_proxy_location=expected),
timeout=10,
)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.parametrize("number_of_kill_signals", (1, 2))
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_application(self, ray_start_stop, number_of_kill_signals):
"""Deploys valid config file and import path via `serve run`."""
# Deploy via config file
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "arithmetic.yaml"
)
print('Running config file "arithmetic.yaml".')
p = subprocess.Popen(["serve", "run", "--address=auto", config_file_name])
wait_for_condition(
lambda: httpx.post("http://localhost:8000/", json=["ADD", 0]).json() == 1,
timeout=15,
)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/", json=["SUB", 5]).json() == 3,
timeout=15,
)
print(
"Run successful! Deployments are live and reachable over HTTP. Killing run."
)
for _ in range(number_of_kill_signals):
p.send_signal(signal.SIGINT)
# Mimic realistic human Ctrl-C timing. Without a gap, two
# back-to-back SIGINTs can land before the KeyboardInterrupt
# handler in `serve run` has a chance to run, aborting the
# process before graceful shutdown begins.
time.sleep(0.1)
p.wait()
with pytest.raises(httpx.HTTPError):
httpx.post("http://localhost:8000/", json=["ADD", 0]).json()
print("Kill successful! Deployments are not reachable over HTTP.")
print('Running node at import path "ray.serve.tests.test_cli_3.parrot_node".')
# Deploy via import path
p = subprocess.Popen(
["serve", "run", "--address=auto", "ray.serve.tests.test_cli_3.parrot_node"]
)
wait_for_condition(
lambda: ping_endpoint("/", params="?sound=squawk") == "squawk"
)
print(
"Run successful! Deployment is live and reachable over HTTP. Killing run."
)
p.send_signal(signal.SIGINT) # Equivalent to ctrl-C
p.wait()
assert ping_endpoint("/", params="?sound=squawk") == CONNECTION_ERROR_MSG
print("Kill successful! Deployment is not reachable over HTTP.")
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_multi_app(self, ray_start_stop):
"""Deploys valid multi-app config file via `serve run`."""
# Deploy via config file
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "pizza_world.yaml"
)
print('Running config file "pizza_world.yaml".')
p = subprocess.Popen(["serve", "run", "--address=auto", config_file_name])
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").text == "wonderful world",
timeout=15,
)
print('Application "app1" is reachable over HTTP.')
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app2", json=["ADD", 2]).text
== "12 pizzas please!",
timeout=15,
)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app2", json=["MUL", 2]).text
== "20 pizzas please!",
timeout=15,
)
print(
"Run successful! Deployments are live and reachable over HTTP. Killing run."
)
p.send_signal(signal.SIGINT) # Equivalent to ctrl-C
p.wait()
with pytest.raises(httpx.HTTPError):
_ = httpx.post("http://localhost:8000/app1").text
with pytest.raises(httpx.HTTPError):
_ = httpx.post("http://localhost:8000/app2", json=["ADD", 0]).text
print("Kill successful! Deployments are not reachable over HTTP.")
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_deployment_node(self, ray_start_stop):
"""Test `serve run` with bound args and kwargs."""
# Deploy via import path
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"ray.serve.tests.test_cli_3.molly_macaw",
]
)
wait_for_condition(lambda: ping_endpoint("/") == "Molly is green!", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
assert ping_endpoint("/") == CONNECTION_ERROR_MSG
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
@pytest.mark.parametrize(
"import_path",
[
"ray.serve.tests.test_cli_3.build_echo_app",
"ray.serve.tests.test_cli_3.build_echo_app_typed",
],
)
def test_run_builder_with_args(self, ray_start_stop, import_path: str):
"""Test `serve run` with args passed into a builder function.
Tests both the untyped and typed args cases.
"""
# First deploy without any arguments, should get default response.
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
import_path,
]
)
wait_for_condition(lambda: ping_endpoint("") == "DEFAULT", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
assert ping_endpoint("/") == CONNECTION_ERROR_MSG
# Now deploy passing a message as an argument, should get passed message.
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
import_path,
"message=hello world",
]
)
wait_for_condition(lambda: ping_endpoint("") == "hello world", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
assert ping_endpoint("/") == CONNECTION_ERROR_MSG
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_runtime_env(self, ray_start_stop):
"""Test `serve run` with runtime_env passed in."""
# With import path
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"ray.serve.tests.test_cli_3.metal_detector_node",
"--runtime-env-json",
('{"env_vars": {"buried_item": "lucky coin"} }'),
]
)
wait_for_condition(
lambda: ping_endpoint("MetalDetector") == "lucky coin", timeout=10
)
p.send_signal(signal.SIGINT)
p.wait()
# With config
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
os.path.join(
os.path.dirname(__file__),
"test_config_files",
"missing_runtime_env.yaml",
),
"--runtime-env-json",
json.dumps(
{
"py_modules": [TEST_DEPLOY_GROUP_PINNED_URI],
"working_dir": "http://nonexistentlink-q490123950ni34t",
}
),
"--working-dir",
TEST_DAG_PINNED_URI,
]
)
wait_for_condition(lambda: ping_endpoint("") == "wonderful world", timeout=15)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
@pytest.mark.parametrize("config_file", ["basic_graph.yaml", "basic_multi.yaml"])
def test_run_config_port1(self, ray_start_stop, config_file):
"""Test that `serve run` defaults to port 8000."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", config_file
)
p = subprocess.Popen(["serve", "run", config_file_name])
wait_for_condition(
lambda: httpx.post("http://localhost:8000/").text == "wonderful world",
timeout=15,
)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
@pytest.mark.parametrize(
"config_file", ["basic_graph_http.yaml", "basic_multi_http.yaml"]
)
def test_run_config_port2(self, ray_start_stop, config_file):
"""If config file specifies a port, the default port value should not be used."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", config_file
)
p = subprocess.Popen(["serve", "run", config_file_name])
wait_for_condition(
lambda: httpx.post("http://localhost:8005/").text == "wonderful world",
timeout=15,
)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_teardown(self, ray_start_stop):
"""Consecutive serve runs should tear down controller so logs can always be seen."""
logs = subprocess.check_output(
["serve", "run", "ray.serve.tests.test_cli_3.constructor_failure_node"],
stderr=subprocess.STDOUT,
timeout=30,
).decode()
assert "Intentionally failing." in logs
logs = subprocess.check_output(
["serve", "run", "ray.serve.tests.test_cli_3.constructor_failure_node"],
stderr=subprocess.STDOUT,
timeout=30,
).decode()
assert "Intentionally failing." in logs
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_route_prefix_and_name_default(self, ray_start_stop):
"""Test `serve run` without route_prefix and name options."""
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"ray.serve.tests.test_cli_3.echo_app",
]
)
wait_for_condition(check_app_running, app_name=SERVE_DEFAULT_APP_NAME)
assert ping_endpoint("/") == "hello"
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_route_prefix_and_name_override(self, ray_start_stop):
"""Test `serve run` with route prefix option."""
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"--route-prefix=/hello",
"--name=hello_app",
"ray.serve.tests.test_cli_3.echo_app",
],
)
wait_for_condition(check_app_running, app_name="hello_app")
assert "Path '/' not found" in ping_endpoint("/")
assert ping_endpoint("/hello") == "hello"
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_config_request_timeout(self, ray_start_stop):
"""Test running serve with request timeout in http_options.
The config file has 0.1s as the `request_timeout_s` in the `http_options`. First
case checks that when the query runs longer than the 0.1s, the deployment returns a
task failed message. The second case checks that when the query takes less than
0.1s, the deployment returns a success message.
"""
config_file_name = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"http_option_request_timeout_s.yaml",
)
p = subprocess.Popen(["serve", "run", config_file_name])
# Ensure the http request is killed and failed when the deployment runs longer than
# the 0.1 request_timeout_s set in in the config yaml
wait_for_condition(
lambda: httpx.get("http://localhost:8000/app1?sleep_s=0.11").status_code
== 408,
)
# Ensure the http request returned the correct response when the deployment runs
# shorter than the 0.1 request_timeout_s set up in the config yaml
wait_for_condition(
lambda: httpx.get("http://localhost:8000/app1?sleep_s=0.09").text
== "Task Succeeded!",
)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_reload_basic(self, ray_start_stop, tmp_path):
"""Test `serve run` with reload."""
code_template = """
from ray import serve
@serve.deployment
class MessageDeployment:
def __init__(self, msg):
{invalid_suffix}
self.msg = msg
def __call__(self):
return self.msg
msg_app = MessageDeployment.bind("Hello {message}!")
"""
def write_file(message: str, invalid_suffix: str = ""):
with open(os.path.join(tmp_path, "reload_serve.py"), "w") as f:
code = code_template.format(
invalid_suffix=invalid_suffix, message=message
)
print(f"Writing updated code:\n{code}")
f.write(code)
f.flush()
write_file("World")
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"--app-dir",
tmp_path,
"--reload",
"reload_serve:msg_app",
]
)
wait_for_condition(lambda: ping_endpoint("") == "Hello World!", timeout=10)
# Sleep to ensure the `serve run` command is in the file watching loop when we
# write the change, else it won't be picked up.
time.sleep(5)
# Write the file: an update should be auto-triggered.
write_file("Updated")
wait_for_condition(lambda: ping_endpoint("") == "Hello Updated!", timeout=10)
# Ensure a bad change doesn't shut down serve and serve reports deploy failed.
write_file(message="update1", invalid_suffix="foobar")
wait_for_condition(
condition_predictor=check_app_status,
app_name="default",
expected_status="DEPLOY_FAILED",
)
# Ensure the following reload happens as expected.
write_file("Updated2")
wait_for_condition(lambda: ping_endpoint("") == "Hello Updated2!", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
assert ping_endpoint("") == CONNECTION_ERROR_MSG
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))