scripts.py 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018
  1. #!/usr/bin/env python
  2. import json
  3. import os
  4. import pathlib
  5. import re
  6. import sys
  7. import time
  8. import traceback
  9. from dataclasses import asdict
  10. from enum import Enum
  11. from typing import Any, Dict, List, Optional, Tuple
  12. import click
  13. import watchfiles
  14. import yaml
  15. import ray
  16. from ray import serve
  17. from ray._common.utils import import_attr
  18. from ray.autoscaler._private.cli_logger import cli_logger
  19. from ray.dashboard.modules.dashboard_sdk import parse_runtime_env_args
  20. from ray.dashboard.modules.serve.sdk import ServeSubmissionClient
  21. from ray.serve._private import api as _private_api
  22. from ray.serve._private.build_app import BuiltApplication, build_app
  23. from ray.serve._private.constants import (
  24. DEFAULT_GRPC_PORT,
  25. DEFAULT_HTTP_HOST,
  26. DEFAULT_HTTP_PORT,
  27. SERVE_DEFAULT_APP_NAME,
  28. SERVE_NAMESPACE,
  29. )
  30. from ray.serve.config import (
  31. DeploymentMode,
  32. ProxyLocation,
  33. gRPCOptions,
  34. )
  35. from ray.serve.context import _get_global_client
  36. from ray.serve.deployment import Application, deployment_to_schema
  37. from ray.serve.exceptions import RayServeException
  38. from ray.serve.schema import (
  39. LoggingConfig,
  40. ServeApplicationSchema,
  41. ServeDeploySchema,
  42. ServeInstanceDetails,
  43. )
  44. APP_DIR_HELP_STR = (
  45. "Local directory to look for the IMPORT_PATH (will be inserted into "
  46. "PYTHONPATH). Defaults to '.', meaning that an object in ./main.py "
  47. "can be imported as 'main.object'. Not relevant if you're importing "
  48. "from an installed module."
  49. )
  50. RAY_INIT_ADDRESS_HELP_STR = (
  51. "Address to use for ray.init(). Can also be set using "
  52. "the RAY_ADDRESS environment variable."
  53. )
  54. RAY_DASHBOARD_ADDRESS_HELP_STR = (
  55. "Address for the Ray dashboard. Defaults to http://localhost:8265. "
  56. "Can also be set using the RAY_DASHBOARD_ADDRESS environment variable."
  57. )
  58. # See https://stackoverflow.com/a/33300001/11162437
  59. def str_presenter(dumper: yaml.Dumper, data):
  60. """
  61. A custom representer to write multi-line strings in block notation using a literal
  62. style.
  63. Ensures strings with newline characters print correctly.
  64. """
  65. if len(data.splitlines()) > 1:
  66. return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
  67. return dumper.represent_scalar("tag:yaml.org,2002:str", data)
  68. # See https://stackoverflow.com/a/14693789/11162437
  69. def remove_ansi_escape_sequences(input: str):
  70. """Removes ANSI escape sequences in a string"""
  71. ansi_escape = re.compile(
  72. r"""
  73. \x1B # ESC
  74. (?: # 7-bit C1 Fe (except CSI)
  75. [@-Z\\-_]
  76. | # or [ for CSI, followed by a control sequence
  77. \[
  78. [0-?]* # Parameter bytes
  79. [ -/]* # Intermediate bytes
  80. [@-~] # Final byte
  81. )
  82. """,
  83. re.VERBOSE,
  84. )
  85. return ansi_escape.sub("", input)
  86. def process_dict_for_yaml_dump(data):
  87. """
  88. Removes ANSI escape sequences recursively for all strings in dict.
  89. We often need to use yaml.dump() to print dictionaries that contain exception
  90. tracebacks, which can contain ANSI escape sequences that color printed text. However
  91. yaml.dump() will format the tracebacks incorrectly if ANSI escape sequences are
  92. present, so we need to remove them before dumping.
  93. """
  94. for k, v in data.items():
  95. if isinstance(v, dict):
  96. data[k] = process_dict_for_yaml_dump(v)
  97. if isinstance(v, list):
  98. data[k] = [process_dict_for_yaml_dump(item) for item in v]
  99. elif isinstance(v, str):
  100. data[k] = remove_ansi_escape_sequences(v)
  101. return data
  102. def convert_args_to_dict(args: Tuple[str]) -> Dict[str, str]:
  103. args_dict = dict()
  104. for arg in args:
  105. split = arg.split("=", maxsplit=1)
  106. if len(split) != 2 or len(split[1]) == 0:
  107. raise click.ClickException(
  108. f"Invalid application argument '{arg}', "
  109. "must be of the form '<key>=<val>'."
  110. )
  111. args_dict[split[0]] = split[1]
  112. return args_dict
  113. def warn_if_agent_address_set():
  114. if "RAY_AGENT_ADDRESS" in os.environ:
  115. cli_logger.warning(
  116. "The `RAY_AGENT_ADDRESS` env var has been deprecated in favor of "
  117. "the `RAY_DASHBOARD_ADDRESS` env var. The `RAY_AGENT_ADDRESS` is "
  118. "ignored."
  119. )
  120. @click.group(
  121. help="CLI for managing Serve applications on a Ray cluster.",
  122. context_settings=dict(help_option_names=["--help", "-h"]),
  123. )
  124. def cli():
  125. pass
  126. @cli.command(help="Start Serve on the Ray cluster.")
  127. @click.option(
  128. "--address",
  129. "-a",
  130. default=os.environ.get("RAY_ADDRESS", "auto"),
  131. required=False,
  132. type=str,
  133. help=RAY_INIT_ADDRESS_HELP_STR,
  134. )
  135. @click.option(
  136. "--http-host",
  137. default=DEFAULT_HTTP_HOST,
  138. required=False,
  139. type=str,
  140. help="Host for HTTP proxies to listen on. " f"Defaults to {DEFAULT_HTTP_HOST}.",
  141. )
  142. @click.option(
  143. "--http-port",
  144. default=DEFAULT_HTTP_PORT,
  145. required=False,
  146. type=int,
  147. help="Port for HTTP proxies to listen on. " f"Defaults to {DEFAULT_HTTP_PORT}.",
  148. )
  149. @click.option(
  150. "--http-location",
  151. default=DeploymentMode.HeadOnly,
  152. required=False,
  153. type=click.Choice(list(DeploymentMode)),
  154. help="DEPRECATED: Use `--proxy-location` instead.",
  155. )
  156. @click.option(
  157. "--proxy-location",
  158. default=ProxyLocation.EveryNode,
  159. required=False,
  160. type=click.Choice(list(ProxyLocation)),
  161. help="Location of the proxies. Defaults to EveryNode.",
  162. )
  163. @click.option(
  164. "--grpc-port",
  165. default=DEFAULT_GRPC_PORT,
  166. required=False,
  167. type=int,
  168. help="Port for gRPC proxies to listen on. " f"Defaults to {DEFAULT_GRPC_PORT}.",
  169. )
  170. @click.option(
  171. "--grpc-servicer-functions",
  172. default=[],
  173. required=False,
  174. multiple=True,
  175. help="Servicer function for adding the method handler to the gRPC server. "
  176. "Defaults to an empty list and no gRPC server is started.",
  177. )
  178. def start(
  179. address,
  180. http_host,
  181. http_port,
  182. http_location,
  183. proxy_location,
  184. grpc_port,
  185. grpc_servicer_functions,
  186. ):
  187. if http_location != DeploymentMode.HeadOnly:
  188. cli_logger.warning(
  189. "The `--http-location` flag to `serve start` is deprecated, "
  190. "use `--proxy-location` instead."
  191. )
  192. proxy_location = http_location
  193. ray.init(
  194. address=address,
  195. namespace=SERVE_NAMESPACE,
  196. )
  197. serve.start(
  198. proxy_location=proxy_location,
  199. http_options=dict(
  200. host=http_host,
  201. port=http_port,
  202. ),
  203. grpc_options=gRPCOptions(
  204. port=grpc_port,
  205. grpc_servicer_functions=grpc_servicer_functions,
  206. ),
  207. )
  208. def _generate_config_from_file_or_import_path(
  209. config_or_import_path: str,
  210. *,
  211. name: Optional[str],
  212. arguments: Dict[str, str],
  213. runtime_env: Optional[Dict[str, Any]],
  214. ) -> ServeDeploySchema:
  215. """Generates a deployable config schema for the passed application(s)."""
  216. if pathlib.Path(config_or_import_path).is_file():
  217. config_path = config_or_import_path
  218. cli_logger.print(f"Deploying from config file: '{config_path}'.")
  219. if len(arguments) > 0:
  220. raise click.ClickException(
  221. "Application arguments cannot be specified for a config file."
  222. )
  223. # TODO(edoakes): should we enable overriding?
  224. with open(config_path, "r") as config_file:
  225. if runtime_env and len(runtime_env) > 0:
  226. cli_logger.warning(
  227. "Passed in runtime_env is ignored when using config file"
  228. )
  229. if name is not None:
  230. cli_logger.warning("Passed in name is ignored when using config file")
  231. config_dict = yaml.safe_load(config_file)
  232. config = ServeDeploySchema.parse_obj(config_dict)
  233. else:
  234. # TODO(edoakes): should we default to --working-dir="." for this?
  235. import_path = config_or_import_path
  236. cli_logger.print(f"Deploying from import path: '{import_path}'.")
  237. app = ServeApplicationSchema(
  238. import_path=import_path,
  239. runtime_env=runtime_env,
  240. args=arguments,
  241. )
  242. if name is not None:
  243. app.name = name
  244. config = ServeDeploySchema(applications=[app])
  245. return config
  246. @cli.command(
  247. short_help="Deploy an application or group of applications.",
  248. help=(
  249. "Deploy an application from an import path (e.g., main:app) "
  250. "or a group of applications from a YAML config file.\n\n"
  251. "Passed import paths must point to an Application object or "
  252. "a function that returns one. If a function is used, arguments can be "
  253. "passed to it in 'key=val' format after the import path, for example:\n\n"
  254. "serve deploy main:app model_path='/path/to/model.pkl' num_replicas=5\n\n"
  255. "This command makes a REST API request to a running Ray cluster."
  256. ),
  257. )
  258. @click.argument("config_or_import_path")
  259. @click.argument("arguments", nargs=-1, required=False)
  260. @click.option(
  261. "--runtime-env",
  262. type=str,
  263. default=None,
  264. required=False,
  265. help=(
  266. "Path to a local YAML file containing a runtime_env definition. Ignored "
  267. "when deploying from a config file."
  268. ),
  269. )
  270. @click.option(
  271. "--runtime-env-json",
  272. type=str,
  273. default=None,
  274. required=False,
  275. help=(
  276. "JSON-serialized runtime_env dictionary. Ignored when deploying from a "
  277. "config file."
  278. ),
  279. )
  280. @click.option(
  281. "--working-dir",
  282. type=str,
  283. default=None,
  284. required=False,
  285. help=(
  286. "Directory containing files that your application(s) will run in. This must "
  287. "be a remote URI to a .zip file (e.g., S3 bucket). This overrides the "
  288. "working_dir in --runtime-env if both are specified. Ignored when deploying "
  289. "from a config file."
  290. ),
  291. )
  292. @click.option(
  293. "--name",
  294. required=False,
  295. default=None,
  296. type=str,
  297. help="Custom name for the application. Ignored when deploying from a config file.",
  298. )
  299. @click.option(
  300. "--address",
  301. "-a",
  302. default=os.environ.get("RAY_DASHBOARD_ADDRESS", "http://localhost:8265"),
  303. required=False,
  304. type=str,
  305. help=RAY_DASHBOARD_ADDRESS_HELP_STR,
  306. )
  307. def deploy(
  308. config_or_import_path: str,
  309. arguments: Tuple[str],
  310. runtime_env: str,
  311. runtime_env_json: str,
  312. working_dir: str,
  313. name: Optional[str],
  314. address: str,
  315. ):
  316. args_dict = convert_args_to_dict(arguments)
  317. final_runtime_env = parse_runtime_env_args(
  318. runtime_env=runtime_env,
  319. runtime_env_json=runtime_env_json,
  320. working_dir=working_dir,
  321. )
  322. config = _generate_config_from_file_or_import_path(
  323. config_or_import_path,
  324. name=name,
  325. arguments=args_dict,
  326. runtime_env=final_runtime_env,
  327. )
  328. ServeSubmissionClient(address).deploy_applications(
  329. config.dict(exclude_unset=True),
  330. )
  331. cli_logger.success(
  332. "\nSent deploy request successfully.\n "
  333. "* Use `serve status` to check applications' statuses.\n "
  334. "* Use `serve config` to see the current application config(s).\n"
  335. )
  336. @cli.command(
  337. short_help="Run an application or group of applications.",
  338. help=(
  339. "Run an application from an import path (e.g., my_script:"
  340. "app) or a group of applications from a YAML config file.\n\n"
  341. "Passed import paths must point to an Application object or "
  342. "a function that returns one. If a function is used, arguments can be "
  343. "passed to it in 'key=val' format after the import path, for example:\n\n"
  344. "serve run my_script:app model_path='/path/to/model.pkl' num_replicas=5\n\n"
  345. "If passing a YAML config, existing applications with no code changes will not "
  346. "be updated.\n\n"
  347. "By default, this will block and stream logs to the console. If you "
  348. "Ctrl-C the command, it will shut down Serve on the cluster."
  349. ),
  350. )
  351. @click.argument("config_or_import_path")
  352. @click.argument("arguments", nargs=-1, required=False)
  353. @click.option(
  354. "--runtime-env",
  355. type=str,
  356. default=None,
  357. required=False,
  358. help="Path to a local YAML file containing a runtime_env definition. "
  359. "This will be passed to ray.init() as the default for deployments.",
  360. )
  361. @click.option(
  362. "--runtime-env-json",
  363. type=str,
  364. default=None,
  365. required=False,
  366. help="JSON-serialized runtime_env dictionary. This will be passed to "
  367. "ray.init() as the default for deployments.",
  368. )
  369. @click.option(
  370. "--working-dir",
  371. type=str,
  372. default=None,
  373. required=False,
  374. help=(
  375. "Directory containing files that your application(s) will run in. Can be a "
  376. "local directory or a remote URI to a .zip file (S3, GS, HTTP). "
  377. "This overrides the working_dir in --runtime-env if both are "
  378. "specified. This will be passed to ray.init() as the default for "
  379. "deployments."
  380. ),
  381. )
  382. @click.option(
  383. "--app-dir",
  384. "-d",
  385. default=".",
  386. type=str,
  387. help=APP_DIR_HELP_STR,
  388. )
  389. @click.option(
  390. "--address",
  391. "-a",
  392. default=os.environ.get("RAY_ADDRESS", None),
  393. required=False,
  394. type=str,
  395. help=RAY_INIT_ADDRESS_HELP_STR,
  396. )
  397. @click.option(
  398. "--blocking/--non-blocking",
  399. default=True,
  400. help=(
  401. "Whether or not this command should be blocking. If blocking, it "
  402. "will loop and log status until Ctrl-C'd, then clean up the app."
  403. ),
  404. )
  405. @click.option(
  406. "--reload",
  407. "-r",
  408. is_flag=True,
  409. help=(
  410. "This is an experimental feature - Listens for changes to files in the working directory, "
  411. "--working-dir or the working_dir in the --runtime-env, and automatically redeploys "
  412. "the application. This will block until Ctrl-C'd, then clean up the "
  413. "app."
  414. ),
  415. )
  416. @click.option(
  417. "--route-prefix",
  418. required=False,
  419. type=str,
  420. default="/",
  421. help=(
  422. "Route prefix for the application. This should only be used "
  423. "when running an application specified by import path and "
  424. "will be ignored if running a config file."
  425. ),
  426. )
  427. @click.option(
  428. "--name",
  429. required=False,
  430. default=SERVE_DEFAULT_APP_NAME,
  431. type=str,
  432. help=(
  433. "Name of the application. This should only be used "
  434. "when running an application specified by import path and "
  435. "will be ignored if running a config file."
  436. ),
  437. )
  438. def run(
  439. config_or_import_path: str,
  440. arguments: Tuple[str],
  441. runtime_env: str,
  442. runtime_env_json: str,
  443. working_dir: str,
  444. app_dir: str,
  445. address: str,
  446. blocking: bool,
  447. reload: bool,
  448. route_prefix: str,
  449. name: str,
  450. ):
  451. sys.path.insert(0, app_dir)
  452. args_dict = convert_args_to_dict(arguments)
  453. final_runtime_env = parse_runtime_env_args(
  454. runtime_env=runtime_env,
  455. runtime_env_json=runtime_env_json,
  456. working_dir=working_dir,
  457. )
  458. if pathlib.Path(config_or_import_path).is_file():
  459. if len(args_dict) > 0:
  460. cli_logger.warning(
  461. "Application arguments are ignored when running a config file."
  462. )
  463. is_config = True
  464. config_path = config_or_import_path
  465. cli_logger.print(f"Running config file: '{config_path}'.")
  466. with open(config_path, "r") as config_file:
  467. config_dict = yaml.safe_load(config_file)
  468. config = ServeDeploySchema.parse_obj(config_dict)
  469. else:
  470. is_config = False
  471. import_path = config_or_import_path
  472. cli_logger.print(f"Running import path: '{import_path}'.")
  473. app = _private_api.call_user_app_builder_with_args_if_necessary(
  474. import_attr(import_path), args_dict
  475. )
  476. # Only initialize ray if it has not happened yet.
  477. if not ray.is_initialized():
  478. # Setting the runtime_env here will set defaults for the deployments.
  479. ray.init(
  480. address=address, namespace=SERVE_NAMESPACE, runtime_env=final_runtime_env
  481. )
  482. elif (
  483. address is not None
  484. and address != "auto"
  485. and address != ray.get_runtime_context().gcs_address
  486. ):
  487. # Warning users the address they passed is different from the existing ray
  488. # instance.
  489. ray_address = ray.get_runtime_context().gcs_address
  490. cli_logger.warning(
  491. "An address was passed to `serve run` but the imported module also "
  492. f"connected to Ray at a different address: '{ray_address}'. You do not "
  493. "need to call `ray.init` in your code when using `serve run`."
  494. )
  495. http_options = {"location": "EveryNode"}
  496. grpc_options = gRPCOptions()
  497. # Merge http_options and grpc_options with the ones on ServeDeploySchema.
  498. if is_config and isinstance(config, ServeDeploySchema):
  499. http_options["location"] = ProxyLocation._to_deployment_mode(
  500. config.proxy_location
  501. ).value
  502. config_http_options = config.http_options.dict()
  503. http_options = {**config_http_options, **http_options}
  504. grpc_options = gRPCOptions(**config.grpc_options.dict())
  505. client = _private_api.serve_start(
  506. http_options=http_options,
  507. grpc_options=grpc_options,
  508. )
  509. try:
  510. if is_config:
  511. client.deploy_apps(config, _blocking=False)
  512. cli_logger.success("Submitted deploy config successfully.")
  513. if blocking:
  514. while True:
  515. # Block, letting Ray print logs to the terminal.
  516. time.sleep(10)
  517. else:
  518. # This should not block if reload is true so the watchfiles can be triggered
  519. should_block = blocking and not reload
  520. serve.run(app, blocking=should_block, name=name, route_prefix=route_prefix)
  521. if reload:
  522. if not blocking:
  523. raise click.ClickException(
  524. "The --non-blocking option conflicts with the --reload option."
  525. )
  526. if working_dir:
  527. watch_dir = working_dir
  528. else:
  529. watch_dir = app_dir
  530. for changes in watchfiles.watch(
  531. watch_dir,
  532. rust_timeout=10000,
  533. yield_on_timeout=True,
  534. ):
  535. if changes:
  536. try:
  537. # The module needs to be reloaded with `importlib` in order to
  538. # pick up any changes.
  539. app = _private_api.call_user_app_builder_with_args_if_necessary(
  540. import_attr(import_path, reload_module=True), args_dict
  541. )
  542. serve.run(
  543. target=app,
  544. blocking=False,
  545. name=name,
  546. route_prefix=route_prefix,
  547. )
  548. except Exception:
  549. traceback.print_exc()
  550. cli_logger.error(
  551. "Deploying the latest version of the application failed."
  552. )
  553. except KeyboardInterrupt:
  554. cli_logger.info("Got KeyboardInterrupt, shutting down...")
  555. serve.shutdown()
  556. sys.exit()
  557. except Exception:
  558. traceback.print_exc()
  559. cli_logger.error(
  560. "Received unexpected error, see console logs for more details. Shutting "
  561. "down..."
  562. )
  563. serve.shutdown()
  564. sys.exit()
  565. @cli.command(help="Gets the current configs of Serve applications on the cluster.")
  566. @click.option(
  567. "--address",
  568. "-a",
  569. default=os.environ.get("RAY_DASHBOARD_ADDRESS", "http://localhost:8265"),
  570. required=False,
  571. type=str,
  572. help=RAY_DASHBOARD_ADDRESS_HELP_STR,
  573. )
  574. @click.option(
  575. "--name",
  576. "-n",
  577. required=False,
  578. type=str,
  579. help=(
  580. "Name of an application. Only applies to multi-application mode. If set, this "
  581. "will only fetch the config for the specified application."
  582. ),
  583. )
  584. def config(address: str, name: Optional[str]):
  585. warn_if_agent_address_set()
  586. serve_details = ServeInstanceDetails(
  587. **ServeSubmissionClient(address).get_serve_details()
  588. )
  589. applications = serve_details.applications
  590. # Fetch app configs for all live applications on the cluster
  591. if name is None:
  592. configs = [
  593. yaml.dump(
  594. app.deployed_app_config.dict(exclude_unset=True),
  595. Dumper=ServeDeploySchemaDumper,
  596. sort_keys=False,
  597. )
  598. for app in applications.values()
  599. if app.deployed_app_config is not None
  600. ]
  601. if configs:
  602. print("\n---\n\n".join(configs), end="")
  603. else:
  604. print("No configuration was found.")
  605. # Fetch a specific app config by name.
  606. else:
  607. app = applications.get(name)
  608. if app is None or app.deployed_app_config is None:
  609. print(f'No config has been deployed for application "{name}".')
  610. else:
  611. config = app.deployed_app_config.dict(exclude_unset=True)
  612. print(
  613. yaml.dump(config, Dumper=ServeDeploySchemaDumper, sort_keys=False),
  614. end="",
  615. )
  616. @cli.command(
  617. short_help="Get the current status of all Serve applications on the cluster.",
  618. help=(
  619. "Prints status information about all applications on the cluster.\n\n"
  620. "An application may be:\n\n"
  621. "- NOT_STARTED: the application does not exist.\n"
  622. "- DEPLOYING: the deployments in the application are still deploying and "
  623. "haven't reached the target number of replicas.\n"
  624. "- RUNNING: all deployments are healthy.\n"
  625. "- DEPLOY_FAILED: the application failed to deploy or reach a running state.\n"
  626. "- DELETING: the application is being deleted, and the deployments in the "
  627. "application are being teared down.\n\n"
  628. "The deployments within each application may be:\n\n"
  629. "- HEALTHY: all replicas are acting normally and passing their health checks.\n"
  630. "- UNHEALTHY: at least one replica is not acting normally and may not be "
  631. "passing its health check.\n"
  632. "- UPDATING: the deployment is updating."
  633. ),
  634. )
  635. @click.option(
  636. "--address",
  637. "-a",
  638. default=os.environ.get("RAY_DASHBOARD_ADDRESS", "http://localhost:8265"),
  639. required=False,
  640. type=str,
  641. help=RAY_DASHBOARD_ADDRESS_HELP_STR,
  642. )
  643. @click.option(
  644. "--name",
  645. "-n",
  646. default=None,
  647. required=False,
  648. type=str,
  649. help=(
  650. "Name of an application. If set, this will display only the status of the "
  651. "specified application."
  652. ),
  653. )
  654. def status(address: str, name: Optional[str]):
  655. warn_if_agent_address_set()
  656. serve_details = ServeInstanceDetails(
  657. **ServeSubmissionClient(address).get_serve_details()
  658. )
  659. status = asdict(serve_details._get_status())
  660. # Ensure multi-line strings in app_status is dumped/printed correctly
  661. if name is None:
  662. print(
  663. yaml.dump(
  664. # Ensure exception traceback in app_status are printed correctly
  665. process_dict_for_yaml_dump(status),
  666. Dumper=ServeDeploySchemaDumper,
  667. default_flow_style=False,
  668. sort_keys=False,
  669. ),
  670. end="",
  671. )
  672. else:
  673. if name not in serve_details.applications:
  674. cli_logger.error(f'Application "{name}" does not exist.')
  675. else:
  676. print(
  677. yaml.dump(
  678. # Ensure exception tracebacks in app_status are printed correctly
  679. process_dict_for_yaml_dump(status["applications"][name]),
  680. Dumper=ServeDeploySchemaDumper,
  681. default_flow_style=False,
  682. sort_keys=False,
  683. ),
  684. end="",
  685. )
  686. @cli.command(
  687. help="Shuts down Serve on the cluster, deleting all applications.",
  688. )
  689. @click.option(
  690. "--address",
  691. "-a",
  692. default=os.environ.get("RAY_DASHBOARD_ADDRESS", "http://localhost:8265"),
  693. required=False,
  694. type=str,
  695. help=RAY_DASHBOARD_ADDRESS_HELP_STR,
  696. )
  697. @click.option("--yes", "-y", is_flag=True, help="Bypass confirmation prompt.")
  698. def shutdown(address: str, yes: bool):
  699. warn_if_agent_address_set()
  700. # check if the address is a valid Ray address
  701. try:
  702. # see what applications are deployed on the cluster
  703. serve_details = ServeInstanceDetails(
  704. **ServeSubmissionClient(address).get_serve_details()
  705. )
  706. if serve_details.controller_info.node_id is None:
  707. cli_logger.warning(
  708. f"No Serve instance found running on the cluster at {address}."
  709. )
  710. return
  711. except Exception as e:
  712. cli_logger.error(
  713. f"Unable to shutdown Serve on the cluster at address {address}: {e}"
  714. )
  715. return
  716. if not yes:
  717. click.confirm(
  718. f"This will shut down Serve on the cluster at address "
  719. f'"{address}" and delete all applications there. Do you '
  720. "want to continue?",
  721. abort=True,
  722. )
  723. ServeSubmissionClient(address).delete_applications()
  724. cli_logger.success(
  725. "Sent shutdown request; applications will be deleted asynchronously."
  726. )
  727. @cli.command(
  728. name="controller-health",
  729. short_help="Display health metrics for the Serve controller.",
  730. help=(
  731. "Display health metrics for the Ray Serve controller.\n\n"
  732. "Shows performance indicators that help diagnose controller issues, "
  733. "especially as cluster size increases. Metrics include control loop "
  734. "duration statistics, event loop health, component update times, and "
  735. "autoscaling metrics latency."
  736. ),
  737. )
  738. @click.option(
  739. "--address",
  740. "-a",
  741. default=os.environ.get("RAY_ADDRESS", "auto"),
  742. required=False,
  743. type=str,
  744. help=RAY_INIT_ADDRESS_HELP_STR,
  745. )
  746. @click.option(
  747. "--json",
  748. "output_json",
  749. is_flag=True,
  750. help="Output metrics as JSON instead of formatted YAML.",
  751. )
  752. def controller_health(address: str, output_json: bool):
  753. if not ray.is_initialized():
  754. # Connect to existing cluster only, don't start a new one
  755. try:
  756. ray.init(
  757. address=address,
  758. namespace=SERVE_NAMESPACE,
  759. )
  760. except ConnectionError:
  761. cli_logger.error(
  762. f"Could not connect to Ray cluster at address '{address}'. "
  763. "Make sure a Ray cluster is running."
  764. )
  765. sys.exit(1)
  766. try:
  767. # Get the controller handle
  768. controller = _get_global_client()._controller
  769. # Fetch health metrics
  770. metrics = ray.get(controller.get_health_metrics.remote())
  771. if output_json:
  772. print(json.dumps(metrics, indent=2))
  773. else:
  774. print(
  775. yaml.dump(
  776. metrics,
  777. default_flow_style=False,
  778. sort_keys=False,
  779. ),
  780. end="",
  781. )
  782. except RayServeException as e:
  783. cli_logger.error(str(e))
  784. sys.exit(1)
  785. except Exception:
  786. cli_logger.error(
  787. "Failed to get controller health metrics, "
  788. "see the controller logs for more details."
  789. )
  790. sys.exit(1)
  791. @cli.command(
  792. short_help="Generate a config file for the specified applications.",
  793. help=(
  794. "Imports the applications at IMPORT_PATHS and generates a structured, multi-"
  795. "application config for them. If the flag --single-app is set, accepts one "
  796. "application and generates a single-application config. Config "
  797. "outputted from this command can be used by `serve deploy` or the REST API. "
  798. ),
  799. )
  800. @click.argument("import_paths", nargs=-1, required=True)
  801. @click.option(
  802. "--app-dir",
  803. "-d",
  804. default=".",
  805. type=str,
  806. help=APP_DIR_HELP_STR,
  807. )
  808. @click.option(
  809. "--output-path",
  810. "-o",
  811. default=None,
  812. type=str,
  813. help=(
  814. "Local path where the output config will be written in YAML format. "
  815. "If not provided, the config will be printed to STDOUT."
  816. ),
  817. )
  818. @click.option(
  819. "--grpc-servicer-functions",
  820. default=[],
  821. required=False,
  822. multiple=True,
  823. help="Servicer function for adding the method handler to the gRPC server. "
  824. "Defaults to an empty list and no gRPC server is started.",
  825. )
  826. def build(
  827. import_paths: Tuple[str],
  828. app_dir: str,
  829. output_path: Optional[str],
  830. grpc_servicer_functions: List[str],
  831. ):
  832. sys.path.insert(0, app_dir)
  833. def build_app_config(import_path: str, name: str = None):
  834. app: Application = import_attr(import_path)
  835. if not isinstance(app, Application):
  836. raise TypeError(
  837. f"Expected '{import_path}' to be an Application but got {type(app)}."
  838. )
  839. built_app: BuiltApplication = build_app(app, name=name)
  840. schema = ServeApplicationSchema(
  841. name=name,
  842. route_prefix="/" if len(import_paths) == 1 else f"/{name}",
  843. import_path=import_path,
  844. runtime_env={},
  845. deployments=[deployment_to_schema(d) for d in built_app.deployments],
  846. )
  847. return schema.dict(exclude_unset=True)
  848. config_str = (
  849. "# This file was generated using the `serve build` command "
  850. f"on Ray v{ray.__version__}.\n\n"
  851. )
  852. app_configs = []
  853. for app_index, import_path in enumerate(import_paths):
  854. app_configs.append(build_app_config(import_path, name=f"app{app_index + 1}"))
  855. deploy_config = {
  856. "proxy_location": "EveryNode",
  857. "http_options": {
  858. "host": "0.0.0.0",
  859. "port": 8000,
  860. },
  861. "grpc_options": {
  862. "port": DEFAULT_GRPC_PORT,
  863. "grpc_servicer_functions": grpc_servicer_functions,
  864. },
  865. "logging_config": LoggingConfig().dict(),
  866. "applications": app_configs,
  867. }
  868. # Parse + validate the set of application configs
  869. ServeDeploySchema.parse_obj(deploy_config)
  870. config_str += yaml.dump(
  871. deploy_config,
  872. Dumper=ServeDeploySchemaDumper,
  873. default_flow_style=False,
  874. sort_keys=False,
  875. width=80, # Set width to avoid folding long lines
  876. indent=2, # Use 2-space indentation for more compact configuration
  877. )
  878. cli_logger.info(
  879. "The auto-generated application names default to `app1`, `app2`, ... etc. "
  880. "Rename as necessary.\n",
  881. )
  882. # Ensure file ends with only one newline
  883. config_str = config_str.rstrip("\n") + "\n"
  884. with open(output_path, "w") if output_path else sys.stdout as f:
  885. f.write(config_str)
  886. class ServeDeploySchemaDumper(yaml.SafeDumper):
  887. """YAML dumper object with custom formatting for ServeDeploySchema.
  888. Reformat config to follow this spacing with appropriate line breaks:
  889. ---------------------------------------------------------------
  890. proxy_location: EveryNode
  891. http_options:
  892. host: 0.0.0.0
  893. port: 8000
  894. grpc_options:
  895. port: 9000
  896. grpc_servicer_functions: []
  897. logging_config:
  898. # ...
  899. applications:
  900. - name: app1
  901. import_path: app1.path
  902. # ...
  903. """
  904. def write_line_break(self, data=None):
  905. # https://github.com/yaml/pyyaml/issues/127#issuecomment-525800484
  906. super().write_line_break(data)
  907. # Only add extra line breaks between top-level keys
  908. if len(self.indents) == 1:
  909. super().write_line_break()
  910. def enum_representer(dumper: yaml.Dumper, data: Enum):
  911. """Custom representer for Enum objects to serialize as their string values.
  912. This tells PyYAML when it encounters an Enum object, serialize it as
  913. a string scalar using its .value attribute."""
  914. return dumper.represent_scalar("tag:yaml.org,2002:str", str(data.value))
  915. # Register Enum representer with SafeDumper to handle enum serialization
  916. # in all YAML dumps (config, status, build commands).
  917. # Since ServeDeploySchemaDumper extends SafeDumper, this also covers build command.
  918. ServeDeploySchemaDumper.add_multi_representer(Enum, enum_representer)
  919. ServeDeploySchemaDumper.add_representer(str, str_presenter)
  920. if __name__ == "__main__":
  921. cli()