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__]))