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

1020 lines
36 KiB
Python

import json
import os
import subprocess
import sys
import time
from copy import deepcopy
from tempfile import NamedTemporaryFile
from typing import Dict, List, Optional
import httpx
import pytest
import yaml
import ray
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.serve._private.common import DeploymentID, ReplicaState
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.serve.scripts import remove_ansi_escape_sequences
from ray.util.state import list_actors
def assert_deployments_live(ids: List[DeploymentID]):
"""Checks if all deployments named in names have at least 1 living replica."""
running_actor_names = [actor["name"] for actor in list_actors()]
for deployment_id in ids:
prefix = f"{deployment_id.app_name}#{deployment_id.name}"
msg = f"Deployment {deployment_id} is not live"
assert any(prefix in actor_name for actor_name in running_actor_names), msg
def check_http_response(
expected_text: str,
json: Optional[Dict] = None,
app_name: str = SERVE_DEFAULT_APP_NAME,
):
url = get_application_url(app_name=app_name)
resp = httpx.post(f"{url}/", json=json)
assert resp.text == expected_text
return True
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_config_default_num_replicas_no_replica_restart(
serve_instance, tmp_path
):
"""Explicitly setting default num_replicas via CLI config should not roll replicas."""
config = {
"applications": [
{
"name": SERVE_DEFAULT_APP_NAME,
"import_path": "ray.serve.tests.test_config_files.pid.node",
}
]
}
success_message_fragment = b"Sent deploy request successfully."
config_file_name = str(tmp_path / "serve_config.yaml")
def write_config() -> None:
with open(config_file_name, "w") as config_file:
yaml.safe_dump(config, config_file)
def check_running_with_one_replica() -> bool:
status = serve.status()
app_status = status.applications.get(SERVE_DEFAULT_APP_NAME)
if app_status is None or app_status.status != "RUNNING":
return False
deployment_status = app_status.deployments.get("f")
if deployment_status is None:
return False
return deployment_status.replica_states.get("RUNNING", 0) == 1
def get_pid() -> int:
url = get_application_url()
return httpx.get(url).json()[0]
write_config()
deploy_response = subprocess.check_output(["serve", "deploy", config_file_name])
assert success_message_fragment in deploy_response
wait_for_condition(check_running_with_one_replica, timeout=30)
initial_pid = get_pid()
config["applications"][0]["deployments"] = [
{
"name": "f",
"num_replicas": 1,
}
]
write_config()
deploy_response = subprocess.check_output(["serve", "deploy", config_file_name])
assert success_message_fragment in deploy_response
wait_for_condition(check_running_with_one_replica, timeout=30)
observed_pids = [get_pid() for _ in range(5)]
assert observed_pids == [initial_pid] * len(observed_pids)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_basic(serve_instance):
"""Deploys some valid config files and checks that the deployments work."""
# Create absolute file names to YAML config files
pizza_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "pizza.yaml"
)
arithmetic_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "arithmetic.yaml"
)
success_message_fragment = b"Sent deploy request successfully."
# Ensure the CLI is idempotent
num_iterations = 2
for iteration in range(1, num_iterations + 1):
print(f"*** Starting Iteration {iteration}/{num_iterations} ***\n")
print("Deploying pizza config.")
deploy_response = subprocess.check_output(["serve", "deploy", pizza_file_name])
assert success_message_fragment in deploy_response
print("Deploy request sent successfully.")
wait_for_condition(
check_http_response,
json=["ADD", 2],
expected_text="3 pizzas please!",
timeout=15,
)
wait_for_condition(
check_http_response,
json=["MUL", 2],
expected_text="-4 pizzas please!",
timeout=15,
)
print("Deployments are reachable over HTTP.")
deployments = [
DeploymentID(name="Router"),
DeploymentID(name="Multiplier"),
DeploymentID(name="Adder"),
]
assert_deployments_live(deployments)
print("All deployments are live.\n")
print("Deploying arithmetic config.")
deploy_response = subprocess.check_output(
["serve", "deploy", arithmetic_file_name, "-a", "http://localhost:8265/"]
)
assert success_message_fragment in deploy_response
print("Deploy request sent successfully.")
wait_for_condition(
check_http_response,
json=["ADD", 0],
expected_text="1",
timeout=15,
)
wait_for_condition(
check_http_response,
json=["SUB", 5],
expected_text="3",
timeout=15,
)
print("Deployments are reachable over HTTP.")
deployments = [
DeploymentID(name="Router"),
DeploymentID(name="Add"),
DeploymentID(name="Subtract"),
]
assert_deployments_live(deployments)
print("All deployments are live.\n")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_multi_app_basic(serve_instance):
"""Deploys some valid config files and checks that the deployments work."""
# Create absolute file names to YAML config files
two_pizzas = os.path.join(
os.path.dirname(__file__), "test_config_files", "two_pizzas.yaml"
)
pizza_world = os.path.join(
os.path.dirname(__file__), "test_config_files", "pizza_world.yaml"
)
success_message_fragment = b"Sent deploy request successfully."
# Ensure the CLI is idempotent
num_iterations = 2
for iteration in range(1, num_iterations + 1):
print(f"*** Starting Iteration {iteration}/{num_iterations} ***\n")
print("Deploying two pizzas config.")
deploy_response = subprocess.check_output(["serve", "deploy", two_pizzas])
assert success_message_fragment in deploy_response
print("Deploy request sent successfully.")
# Test add and mul for each of the two apps
wait_for_condition(
lambda: httpx.post(
f"{get_application_url(app_name='app1')}", json=["ADD", 2]
).text
== "3 pizzas please!",
timeout=15,
)
wait_for_condition(
lambda: httpx.post(
f"{get_application_url(app_name='app1')}", json=["MUL", 2]
).text
== "2 pizzas please!",
timeout=15,
)
print('Application "app1" is reachable over HTTP.')
wait_for_condition(
lambda: httpx.post(
f"{get_application_url(app_name='app2')}", json=["ADD", 2]
).text
== "5 pizzas please!",
timeout=15,
)
wait_for_condition(
lambda: httpx.post(
f"{get_application_url(app_name='app2')}", json=["MUL", 2]
).text
== "4 pizzas please!",
timeout=15,
)
print('Application "app2" is reachable over HTTP.')
deployment_names = [
DeploymentID(name="Router", app_name="app1"),
DeploymentID(name="Multiplier", app_name="app1"),
DeploymentID(name="Adder", app_name="app1"),
DeploymentID(name="Router", app_name="app2"),
DeploymentID(name="Multiplier", app_name="app2"),
DeploymentID(name="Adder", app_name="app2"),
]
assert_deployments_live(deployment_names)
print("All deployments are live.\n")
print("Deploying pizza world config.")
deploy_response = subprocess.check_output(["serve", "deploy", pizza_world])
assert success_message_fragment in deploy_response
print("Deploy request sent successfully.")
# Test app1 (simple wonderful world) and app2 (add + mul)
wait_for_condition(
lambda: httpx.post(f"{get_application_url(app_name='app1')}").text
== "wonderful world",
timeout=15,
)
print('Application "app1" is reachable over HTTP.')
wait_for_condition(
lambda: httpx.post(
f"{get_application_url(app_name='app2')}", json=["ADD", 2]
).text
== "12 pizzas please!",
timeout=15,
)
wait_for_condition(
lambda: httpx.post(
f"{get_application_url(app_name='app2')}", json=["MUL", 2]
).text
== "20 pizzas please!",
timeout=15,
)
print('Application "app2" is reachable over HTTP.')
deployment_names = [
DeploymentID(name="BasicDriver", app_name="app1"),
DeploymentID(name="f", app_name="app1"),
DeploymentID(name="Router", app_name="app2"),
DeploymentID(name="Multiplier", app_name="app2"),
DeploymentID(name="Adder", app_name="app2"),
]
assert_deployments_live(deployment_names)
print("All deployments are live.\n")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_duplicate_apps(serve_instance):
"""If a config with duplicate app names is deployed, `serve deploy` should fail.
The response should clearly indicate a validation error.
"""
config_file = os.path.join(
os.path.dirname(__file__), "test_config_files", "duplicate_app_names.yaml"
)
with pytest.raises(subprocess.CalledProcessError) as e:
subprocess.check_output(
["serve", "deploy", config_file], stderr=subprocess.STDOUT
)
assert "ValidationError" in e.value.output.decode("utf-8")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_duplicate_routes(serve_instance):
"""If a config with duplicate routes is deployed, the PUT request should fail.
The response should clearly indicate a validation error.
"""
config_file = os.path.join(
os.path.dirname(__file__), "test_config_files", "duplicate_app_routes.yaml"
)
with pytest.raises(subprocess.CalledProcessError) as e:
subprocess.check_output(
["serve", "deploy", config_file], stderr=subprocess.STDOUT
)
assert "ValidationError" in e.value.output.decode("utf-8")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_bad_v2_config(serve_instance):
"""Deploy a bad config with field applications, should try to parse as v2 config."""
config_file = os.path.join(
os.path.dirname(__file__), "test_config_files", "bad_multi_config.yaml"
)
with pytest.raises(subprocess.CalledProcessError) as e:
subprocess.check_output(
["serve", "deploy", config_file], stderr=subprocess.STDOUT
)
output = e.value.output.decode("utf-8")
assert "ValidationError" in output, output
assert "ServeDeploySchema" in output, output
assert "Please ensure each application's route_prefix is unique" in output
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_multi_app_builder_with_args(serve_instance):
"""Deploys a config file containing multiple applications that take arguments."""
# Create absolute file names to YAML config file.
apps_with_args = os.path.join(
os.path.dirname(__file__), "test_config_files", "apps_with_args.yaml"
)
subprocess.check_output(["serve", "deploy", apps_with_args])
wait_for_condition(
lambda: httpx.post(get_application_url(app_name="untyped_default")).text
== "DEFAULT",
timeout=10,
)
wait_for_condition(
lambda: httpx.post(get_application_url(app_name="untyped_hello")).text
== "hello",
timeout=10,
)
wait_for_condition(
lambda: httpx.post(get_application_url(app_name="typed_default")).text
== "DEFAULT",
timeout=10,
)
wait_for_condition(
lambda: httpx.post(get_application_url(app_name="typed_hello")).text == "hello",
timeout=10,
)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_config_multi_app(serve_instance):
"""Deploys multi-app config and checks output of `serve config`."""
# Check that `serve config` works even if no Serve app is running
subprocess.check_output(["serve", "config"])
# Deploy config
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "pizza_world.yaml"
)
with open(config_file_name, "r") as config_file:
config = yaml.safe_load(config_file)
subprocess.check_output(["serve", "deploy", config_file_name])
# Config should be immediately ready
info_response = subprocess.check_output(["serve", "config"])
fetched_configs = list(yaml.safe_load_all(info_response))
assert config["applications"][0] == fetched_configs[0]
assert config["applications"][1] == fetched_configs[1]
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_cli_without_config_deploy(serve_instance):
"""Deploys application with serve.run instead of a config, and check that cli
still works as expected.
"""
@serve.deployment
def fn():
return "hi"
serve.run(fn.bind())
def check_cli():
info_response = subprocess.check_output(["serve", "config"]).decode("utf-8")
config_response_for_absent_app = subprocess.check_output(
["serve", "config", "-n", "absent_app"]
).decode("utf-8")
status_response = subprocess.check_output(["serve", "status"])
fetched_status = yaml.safe_load(status_response)["applications"][
SERVE_DEFAULT_APP_NAME
]
assert info_response == "No configuration was found.\n"
assert (
config_response_for_absent_app
== 'No config has been deployed for application "absent_app".\n'
)
assert fetched_status["status"] == "RUNNING"
assert fetched_status["deployments"]["fn"]["status"] == "HEALTHY"
return True
wait_for_condition(check_cli)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_config_with_deleting_app(serve_instance):
"""Test that even if one or more apps is deleting, serve config still works"""
config_json1 = {
"applications": [
{
"name": "app1",
"route_prefix": "/app1",
"import_path": "ray.serve.tests.test_config_files.world.DagNode",
},
{
"name": "app2",
"route_prefix": "/app2",
"import_path": "ray.serve.tests.test_config_files.delete_blocked.app",
},
]
}
config_json2 = deepcopy(config_json1)
del config_json2["applications"][1]
def check_cli(expected_configs: List, expected_statuses: int):
info_response = subprocess.check_output(["serve", "config"])
status_response = subprocess.check_output(["serve", "status"])
fetched_configs = list(yaml.safe_load_all(info_response))
statuses = yaml.safe_load(status_response)
return (
len(
[
s
for s in statuses["applications"].values()
if s["status"] == "RUNNING"
]
)
== expected_statuses
and fetched_configs == expected_configs
)
with NamedTemporaryFile(mode="w+", suffix=".yaml") as tmp:
tmp.write(yaml.safe_dump(config_json1))
tmp.flush()
subprocess.check_output(["serve", "deploy", tmp.name])
print("Deployed config with app1 and app2.")
wait_for_condition(
check_cli, expected_configs=config_json1["applications"], expected_statuses=2
)
print("`serve status` and `serve config` are returning expected responses.")
with NamedTemporaryFile(mode="w+", suffix=".yaml") as tmp:
tmp.write(yaml.safe_dump(config_json2))
tmp.flush()
subprocess.check_output(["serve", "deploy", tmp.name])
print("Redeployed config with app2 removed.")
wait_for_condition(
check_cli, expected_configs=config_json2["applications"], expected_statuses=1
)
print("`serve status` and `serve config` are returning expected responses.")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_status_basic(serve_instance):
"""Deploys a config file and checks its status."""
# Check that `serve status` works even if no Serve app is running
subprocess.check_output(["serve", "status"])
# Deploy config
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "pizza.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def num_live_deployments(app_name):
status_response = subprocess.check_output(["serve", "status"])
serve_status = yaml.safe_load(status_response)
return len(serve_status["applications"][app_name]["deployments"])
wait_for_condition(
lambda: num_live_deployments(SERVE_DEFAULT_APP_NAME) == 3, timeout=15
)
status_response = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
serve_status = yaml.safe_load(status_response)
default_app = serve_status["applications"][SERVE_DEFAULT_APP_NAME]
expected_deployments = {
"Multiplier",
"Adder",
"Router",
}
for name, status in default_app["deployments"].items():
expected_deployments.remove(name)
assert status["status"] in {"HEALTHY", "UPDATING"}
assert status["status_trigger"] in {
"CONFIG_UPDATE_COMPLETED",
"CONFIG_UPDATE_STARTED",
}
assert status["replica_states"].get("RUNNING", 0) in {0, 1}
assert "message" in status
assert len(expected_deployments) == 0
assert default_app["status"] in {"DEPLOYING", "RUNNING"}
wait_for_condition(
lambda: time.time() > default_app["last_deployed_time_s"],
timeout=2,
)
def proxy_healthy():
status_response = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
proxy_status = yaml.safe_load(status_response)["proxies"]
return len(proxy_status) and all(p == "HEALTHY" for p in proxy_status.values())
wait_for_condition(proxy_healthy)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_status_error_msg_format(serve_instance):
"""Deploys a faulty config file and checks its status."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "deployment_fail.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def check_for_failed_deployment():
cli_output = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
cli_status = yaml.safe_load(cli_output)["applications"][SERVE_DEFAULT_APP_NAME]
api_status = serve.status().applications[SERVE_DEFAULT_APP_NAME]
assert cli_status["status"] == "DEPLOY_FAILED"
assert remove_ansi_escape_sequences(cli_status["message"]) in api_status.message
deployment_status = cli_status["deployments"]["A"]
assert deployment_status["status"] == "DEPLOY_FAILED"
assert deployment_status["status_trigger"] == "REPLICA_STARTUP_FAILED"
return True
wait_for_condition(check_for_failed_deployment)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_status_invalid_runtime_env(serve_instance):
"""Deploys a config file with invalid runtime env and checks status.
get_status() should not throw error (meaning REST API returned 200 status code) and
the status be deploy failed."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "bad_runtime_env.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def check_for_failed_deployment():
cli_output = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
cli_status = yaml.safe_load(cli_output)["applications"][SERVE_DEFAULT_APP_NAME]
assert cli_status["status"] == "DEPLOY_FAILED"
assert "Failed to set up runtime environment" in cli_status["message"]
return True
wait_for_condition(check_for_failed_deployment, timeout=15)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_status_syntax_error(serve_instance):
"""Deploys Serve app with syntax error, checks error message has traceback."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "syntax_error.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def check_for_failed_deployment():
cli_output = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
status = yaml.safe_load(cli_output)["applications"][SERVE_DEFAULT_APP_NAME]
assert status["status"] == "DEPLOY_FAILED"
assert "Traceback (most recent call last)" in status["message"]
assert "x = (1 + 2" in status["message"]
return True
wait_for_condition(check_for_failed_deployment)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_status_constructor_error(serve_instance):
"""Deploys Serve deployment that errors out in constructor, checks that the
traceback is surfaced.
"""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "deployment_fail.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def check_for_failed_deployment():
cli_output = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
status = yaml.safe_load(cli_output)["applications"][SERVE_DEFAULT_APP_NAME]
assert status["status"] == "DEPLOY_FAILED"
deployment_status = status["deployments"]["A"]
assert deployment_status["status"] == "DEPLOY_FAILED"
assert deployment_status["status_trigger"] == "REPLICA_STARTUP_FAILED"
assert "ZeroDivisionError" in deployment_status["message"]
return True
wait_for_condition(check_for_failed_deployment)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_status_constructor_retry_error(serve_instance):
"""Deploys Serve deployment that errors out in constructor, checks that the
retry message is surfaced.
"""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "deployment_fail_2.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def check_for_failed_deployment():
cli_output = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
status = yaml.safe_load(cli_output)["applications"][SERVE_DEFAULT_APP_NAME]
assert status["status"] == "DEPLOYING"
deployment_status = status["deployments"]["A"]
assert deployment_status["status"] == "UPDATING"
assert deployment_status["status_trigger"] == "CONFIG_UPDATE_STARTED"
assert "ZeroDivisionError" in deployment_status["message"]
return True
wait_for_condition(check_for_failed_deployment)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_status_package_unavailable_in_controller(serve_instance):
"""Test that exceptions raised from packages that are installed on deployment actors
but not on controller is serialized and surfaced properly.
"""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "sqlalchemy.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def check_for_failed_deployment():
cli_output = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
status = yaml.safe_load(cli_output)["applications"][SERVE_DEFAULT_APP_NAME]
assert status["status"] == "DEPLOY_FAILED"
assert "some_wrong_url" in status["deployments"]["TestDeployment"]["message"]
return True
wait_for_condition(check_for_failed_deployment, timeout=40)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_max_replicas_per_node(serve_instance):
"""Test that max_replicas_per_node can be set via config file."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "max_replicas_per_node.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def check_application_status():
cli_output = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
status = yaml.safe_load(cli_output)["applications"]
assert (
status["valid"]["status"] == "RUNNING"
and status["invalid"]["status"] == "DEPLOY_FAILED"
)
return True
wait_for_condition(check_application_status, timeout=15)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_replica_placement_group_options(serve_instance):
"""Test that placement group options can be set via config file."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "replica_placement_groups.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
def check_application_status():
cli_output = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
status = yaml.safe_load(cli_output)["applications"]
assert (
status["valid"]["status"] == "RUNNING"
and status["invalid_bundles"]["status"] == "DEPLOY_FAILED"
and status["invalid_strategy"]["status"] == "DEPLOY_FAILED"
)
return True
wait_for_condition(check_application_status, timeout=15)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_from_import_path(serve_instance):
"""Test that `deploy` works from an import path."""
import_path = "ray.serve.tests.test_config_files.arg_builders.build_echo_app"
subprocess.check_output(["serve", "deploy", import_path])
wait_for_condition(
check_http_response,
expected_text="DEFAULT",
timeout=15,
)
subprocess.check_output(["serve", "deploy", import_path, "message=redeployed!"])
wait_for_condition(
check_http_response,
expected_text="redeployed!",
timeout=15,
)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_status_multi_app(serve_instance):
"""Deploys a multi-app config file and checks their status."""
# Check that `serve status` works even if no Serve app is running
subprocess.check_output(["serve", "status"])
print("Confirmed `serve status` works when nothing has been deployed.")
# Deploy config
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "pizza_world.yaml"
)
subprocess.check_output(["serve", "deploy", config_file_name])
print("Deployed config successfully.")
def num_live_deployments():
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)["applications"]
return len(status["app1"]["deployments"]) and len(status["app2"]["deployments"])
wait_for_condition(lambda: num_live_deployments() == 3, timeout=15)
print("All deployments are live.")
status_response = subprocess.check_output(
["serve", "status", "-a", "http://localhost:8265/"]
)
statuses = yaml.safe_load(status_response)["applications"]
expected_deployments_1 = {"f", "BasicDriver"}
expected_deployments_2 = {
"Multiplier",
"Adder",
"Router",
}
for deployment_name, deployment in statuses["app1"]["deployments"].items():
expected_deployments_1.remove(deployment_name)
assert deployment["status"] in {"HEALTHY", "UPDATING"}
assert "message" in deployment
for deployment_name, deployment in statuses["app2"]["deployments"].items():
expected_deployments_2.remove(deployment_name)
assert deployment["status"] in {"HEALTHY", "UPDATING"}
assert "message" in deployment
assert len(expected_deployments_1) == 0
assert len(expected_deployments_2) == 0
print("All expected deployments are present in the status output.")
for status in statuses.values():
assert status["status"] in {"DEPLOYING", "RUNNING"}
assert time.time() > status["last_deployed_time_s"]
print("Verified status and deployment timestamp of both apps.")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deployment_contains_utils(serve_instance):
"""Test when deployment contains utils module, it can be deployed successfully.
When the deployment contains utils module, running serve deploy should successfully
deployment the application and return the correct response.
"""
config_file = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"deployment_uses_utils_module.yaml",
)
subprocess.check_output(["serve", "deploy", config_file], stderr=subprocess.STDOUT)
wait_for_condition(
lambda: httpx.post(f"{get_application_url()}/").text == "hello_from_utils"
)
@pytest.mark.skipif(
RAY_SERVE_ENABLE_HA_PROXY,
reason=(
"Custom ingress request routers are currently only available with Ray "
"Serve LLM and RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING."
),
)
def test_deploy_use_custom_request_router(serve_instance):
"""Test that the custom request router is initialized and used correctly."""
config_file = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"use_custom_request_router.yaml",
)
subprocess.check_output(["serve", "deploy", config_file], stderr=subprocess.STDOUT)
wait_for_condition(
lambda: httpx.post(f"{get_application_url(app_name='app1')}/").text
== "hello_from_custom_request_router"
)
def test_deploy_use_custom_autoscaling(serve_instance):
"""Test that the custom autoscaling is initialized correctly."""
config_file = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"use_custom_autoscaling.yaml",
)
subprocess.check_output(["serve", "deploy", config_file], stderr=subprocess.STDOUT)
wait_for_condition(
lambda: httpx.post(f"{get_application_url(app_name='app1')}/").text
== "hello_from_custom_autoscaling_policy"
)
def test_deploy_gang_scheduling(serve_instance):
"""Test that gang scheduling config can be deployed via YAML config."""
config_file = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"gang_scheduling.yaml",
)
subprocess.check_output(["serve", "deploy", config_file], stderr=subprocess.STDOUT)
wait_for_condition(
lambda: httpx.post(f"{get_application_url(app_name='gang_app')}/").status_code
== 200
)
@pytest.mark.parametrize(
"initial_replicas, final_replicas",
[
(4, 2), # Downscale: 2 gangs -> 1 gang
(2, 4), # Upscale: 1 gang -> 2 gangs
],
)
def test_deploy_gang_scaling(serve_instance, initial_replicas, final_replicas):
"""Test gang-aware scaling via serve deploy preserves existing gangs."""
gang_size = 2
deployment_id = DeploymentID(name="GangApp", app_name="gang_app")
controller = serve.context._get_global_client()._controller
config_files = {
replicas: os.path.join(
os.path.dirname(__file__),
"test_config_files",
f"gang_scheduling_{replicas}_replicas.yaml",
)
for replicas in (initial_replicas, final_replicas)
}
def collect_gang_ids(num_replicas):
# Query the controller directly for replica gang contexts. Probing
# via HTTP is unreliable for observing every replica: under direct
# ingress, get_application_url returns a single replica's port, so
# all requests land on one replica.
collected = []
def _ready():
replicas = ray.get(
controller._dump_replica_states_for_testing.remote(deployment_id)
)
running = replicas.get([ReplicaState.RUNNING])
if len(running) != num_replicas:
return False
collected.append({r.gang_context.gang_id for r in running})
return True
wait_for_condition(_ready, timeout=30)
return collected[-1]
subprocess.check_output(["serve", "deploy", config_files[initial_replicas]])
initial_num_gangs = initial_replicas // gang_size
initial_gang_ids = collect_gang_ids(initial_replicas)
assert len(initial_gang_ids) == initial_num_gangs
subprocess.check_output(["serve", "deploy", config_files[final_replicas]])
final_num_gangs = final_replicas // gang_size
final_gang_ids = collect_gang_ids(final_replicas)
assert len(final_gang_ids) == final_num_gangs
# The smaller set of gangs must be a subset of the larger set,
# proving that existing gangs were preserved during rescaling.
smaller, larger = sorted([initial_gang_ids, final_gang_ids], key=len)
assert smaller.issubset(larger)
def test_controller_health(serve_instance):
# Wait for control loops to run
time.sleep(1)
# Test YAML output (default)
# Note: We don't capture stderr because Ray connection logs would be mixed in
output = subprocess.check_output(
["serve", "controller-health"],
)
output_str = output.decode("utf-8")
# Verify expected fields are present in YAML output
expected_fields = [
"timestamp:",
"controller_start_time:",
"uptime_s:",
"num_control_loops:",
"loop_duration_s:",
"loops_per_second:",
"num_asyncio_tasks:",
"process_memory_mb:",
]
for field in expected_fields:
assert field in output_str, f"Missing field in YAML output: {field}"
# Verify DurationStats structure is present
assert "mean:" in output_str
assert "std:" in output_str
assert "min:" in output_str
assert "max:" in output_str
# Test JSON output
json_output = subprocess.check_output(
["serve", "controller-health", "--json"],
)
metrics = json.loads(json_output.decode("utf-8"))
# Verify it's a valid dictionary with expected fields
assert isinstance(metrics, dict)
assert metrics["timestamp"] > 0
assert metrics["controller_start_time"] > 0
assert metrics["num_control_loops"] > 0
assert metrics["loop_duration_s"]["mean"] > 0
assert "handle_metrics_delay_ms" in metrics
assert "replica_metrics_delay_ms" in metrics
def test_controller_health_serve_not_running(start_and_shutdown_ray_cli_module):
"""Test that controller-health shows a helpful error when Serve isn't running."""
with pytest.raises(subprocess.CalledProcessError) as exc_info:
subprocess.check_output(
["serve", "controller-health"],
stderr=subprocess.STDOUT,
)
output = exc_info.value.output.decode("utf-8")
# Verify the error message contains the helpful RayServeException message
assert "no serve instance running" in output.lower()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))