Skip to content

Commit 8fb1a4f

Browse files
Only reject start-time options the caller actually declared
The declarative paths hand serve_start a fully-defaulted dump of the config, so every untouched schema default read as a requested change: HTTPOptionsSchema.host defaults to 0.0.0.0 while HTTPOptions.host defaults to the loopback, so a config with no http_options section rejected every apply to a Serve instance that was started from Python, and ServeDeploySchema.proxy_location always carries a value so placement was always requested too. Compare only what the config declared (declared_start_time_options), and have serve start pass only the flags it was given. Placement is now diffed against the placement the caller asked for rather than the one in effect: direct-ingress mode overrides proxy_location to HeadOnly, so re-applying the very config that started Serve was rejected, and the error told the user to restart, which reapplies the same override. Also redact secrets from the error (it reaches an HTTP 400 body), surface it as a CLI error instead of a traceback from serve run/serve start, document the 400 on the REST reference and serve.start, and update the pre-existing standalone test that asserted the warning this replaced. Signed-off-by: john.taylor <john.taylor@anyscale.com>
1 parent db340b7 commit 8fb1a4f

10 files changed

Lines changed: 251 additions & 82 deletions

File tree

doc/source/serve/api/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ The Serve REST API is exposed at the same port as the Ray Dashboard. The Dashboa
201201

202202
Declaratively deploys a list of Serve applications. If Serve is already running on the Ray cluster, removes all applications not listed in the new config. If Serve is not running on the Ray cluster, starts Serve. See [multi-app config schema](serve-rest-api-config-schema) for the request's JSON schema.
203203

204+
`proxy_location`, `http_options`, and `grpc_options` are global to the cluster and fixed when Serve starts. If Serve is already running and the config sets any of them to a different value, the whole request is rejected with a `400` naming each field, and no applications are deployed. Omitting a field is not a request to change it.
205+
204206
**Example Request**:
205207

206208
```http

python/ray/dashboard/modules/serve/serve_head.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ async def delete_serve_applications(self, req: Request) -> Response:
146146
@validate_endpoint()
147147
async def put_all_applications(self, req: Request) -> Response:
148148
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
149-
from ray.serve._private.api import serve_start_async
149+
from ray.serve._private.api import (
150+
declared_start_time_options,
151+
serve_start_async,
152+
)
150153
from ray.serve.exceptions import RayServeConfigException
151154
from ray.serve.schema import ServeDeploySchema
152155

@@ -171,6 +174,7 @@ async def put_all_applications(self, req: Request) -> Response:
171174
grpc_options=grpc_options,
172175
global_logging_config=config.logging_config,
173176
controller_options=config.controller_options,
177+
declared_options=declared_start_time_options(config),
174178
)
175179
except RayServeConfigException as e:
176180
# Reject the whole config: applying the applications while dropping the

python/ray/dashboard/modules/serve/tests/test_serve_dashboard_2.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ray import serve
1515
from ray._common.test_utils import wait_for_condition
1616
from ray._private.test_utils import generate_system_config_map
17+
from ray.serve.config import ProxyLocation
1718
from ray.serve.generated import serve_pb2, serve_pb2_grpc
1819
from ray.serve.schema import HTTPOptionsSchema, ServeInstanceDetails
1920
from ray.serve.tests.conftest import * # noqa: F401 F403
@@ -65,15 +66,15 @@ def test_serve_namespace(ray_start_stop):
6566

6667

6768
@pytest.mark.parametrize(
68-
"option,override",
69+
"option,override,changed_field",
6970
[
70-
("proxy_location", "HeadOnly"),
71-
("http_options", {"host": "127.0.0.2"}),
72-
("http_options", {"port": 8000}),
73-
("http_options", {"root_path": "/serve_updated"}),
71+
("proxy_location", "HeadOnly", "proxy_location"),
72+
("http_options", {"host": "127.0.0.2"}, "http_options.host"),
73+
("http_options", {"port": 8001}, "http_options.port"),
74+
("http_options", {"root_path": "/serve_updated"}, "http_options.root_path"),
7475
],
7576
)
76-
def test_put_with_http_options(ray_start_stop, option, override):
77+
def test_put_with_http_options(ray_start_stop, option, override, changed_field):
7778
"""Submits a config with HTTP options specified.
7879
7980
Trying to submit a config to the serve agent with the HTTP options modified:
@@ -127,6 +128,10 @@ def test_put_with_http_options(ray_start_stop, option, override):
127128
)
128129
assert put_response.status_code == 400
129130
assert "can't be updated at runtime" in put_response.text
131+
# Only the field the config actually changed may be reported: naming a field the
132+
# config left alone is how a fully-defaulted dump rejects an unchanged config.
133+
assert changed_field in put_response.text
134+
assert put_response.text.count("->") == 1
130135

131136
# Fetch Serve status and confirm that HTTP options are unchanged
132137
get_response = requests.get(SERVE_HEAD_URL, timeout=5)
@@ -147,6 +152,40 @@ def test_put_with_http_options(ray_start_stop, option, override):
147152
assert requests.post("http://localhost:8000/serve/app2").text == "wonderful world"
148153

149154

155+
def test_put_omitting_global_options(ray_start_stop):
156+
"""A config that declares no global options isn't asking to change them.
157+
158+
The schema fills in every default, so this is the case that regresses into a
159+
rejection if the apply is diffed against the full dump instead of what was sent.
160+
"""
161+
world_import_path = "ray.serve.tests.test_config_files.world.DagNode"
162+
app = {"name": "app1", "route_prefix": "/app1", "import_path": world_import_path}
163+
deploy_config_multi_app(
164+
{
165+
"proxy_location": "HeadOnly",
166+
"http_options": {"host": "127.0.0.1", "port": 8000, "root_path": "/serve"},
167+
"applications": [app],
168+
},
169+
SERVE_HEAD_URL,
170+
)
171+
wait_for_condition(
172+
lambda: requests.post("http://localhost:8000/serve/app1").text
173+
== "wonderful world",
174+
timeout=15,
175+
)
176+
177+
put_response = requests.put(SERVE_HEAD_URL, json={"applications": [app]}, timeout=5)
178+
assert put_response.status_code == 200, put_response.text
179+
180+
# The running options are untouched, not reset to the schema defaults.
181+
serve_details = ServeInstanceDetails.model_validate(
182+
requests.get(SERVE_HEAD_URL, timeout=5).json()
183+
)
184+
assert serve_details.http_options.host == "127.0.0.1"
185+
assert serve_details.http_options.root_path == "/serve"
186+
assert serve_details.proxy_location == ProxyLocation.HeadOnly
187+
188+
150189
def test_put_with_grpc_options(ray_start_stop):
151190
"""Submits a config with gRPC options specified.
152191

python/ray/serve/_private/api.py

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@
3535
)
3636
from ray.serve.deployment import Application
3737
from ray.serve.exceptions import RayServeConfigException, RayServeException
38-
from ray.serve.schema import LoggingConfig, TracingConfig
38+
from ray.serve.schema import LoggingConfig, ServeDeploySchema, TracingConfig
3939

4040
logger = logging.getLogger(SERVE_LOGGER_NAME)
4141

42+
# Reported as "<redacted>" instead of their value in the start-time config error.
43+
_REDACTED_FIELDS = frozenset({"http_options.ssl_keyfile_password"})
44+
4245

4346
def _coerce_controller_options(
4447
controller_options: Union[None, dict, ControllerOptions],
@@ -92,8 +95,13 @@ def _diff_start_time_options(
9295
}
9396

9497

95-
def _describe(value: Any) -> str:
96-
"""Render a config value for an error message, unwrapping enums."""
98+
def _describe(field: str, value: Any) -> str:
99+
"""Render a config value for an error message, unwrapping enums.
100+
101+
Secrets are redacted because the message reaches an HTTP 400 body and user logs.
102+
"""
103+
if field in _REDACTED_FIELDS:
104+
return "<redacted>"
97105
return repr(value.value if isinstance(value, Enum) else value)
98106

99107

@@ -149,15 +157,19 @@ def _check_start_time_config_unchanged(
149157
)
150158
)
151159

160+
# Compared against the placement the caller originally asked for, not the one
161+
# in effect: direct-ingress mode overrides it, and the override isn't a change
162+
# any config can request or avoid.
163+
current_location = client.requested_proxy_location
152164
requested_location = _requested_proxy_location(http_options, proxy_location)
153-
if requested_location is not None and requested_location != client.proxy_location:
154-
diff["proxy_location"] = (client.proxy_location, requested_location)
165+
if requested_location is not None and requested_location != current_location:
166+
diff["proxy_location"] = (current_location, requested_location)
155167

156168
if not diff:
157169
return
158170

159171
changes = ", ".join(
160-
f"{field}: {_describe(current)} -> {_describe(requested)}"
172+
f"{field}: {_describe(field, current)} -> {_describe(field, requested)}"
161173
for field, (current, requested) in sorted(diff.items())
162174
)
163175
raise RayServeConfigException(
@@ -170,6 +182,44 @@ def _check_start_time_config_unchanged(
170182
)
171183

172184

185+
def declared_start_time_options(config: ServeDeploySchema) -> Dict[str, Any]:
186+
"""The start-time options a declarative config explicitly asked for.
187+
188+
The declarative paths send a fully-defaulted dump to start Serve with, so
189+
without this every untouched schema default would read as a requested change.
190+
"""
191+
return {
192+
"http_options": config.http_options.model_dump(exclude_unset=True),
193+
"grpc_options": config.grpc_options.model_dump(exclude_unset=True),
194+
"proxy_location": (
195+
config.proxy_location
196+
if "proxy_location" in config.model_fields_set
197+
else None
198+
),
199+
}
200+
201+
202+
def _connect_to_existing_client(
203+
client: ServeControllerClient,
204+
http_options: Union[None, dict, HTTPOptions],
205+
grpc_options: Union[None, dict, gRPCOptions],
206+
proxy_location: Union[None, str, ProxyLocation],
207+
declared_options: Optional[Dict[str, Any]],
208+
) -> None:
209+
"""Log the connection and reject changes to config fixed at controller startup."""
210+
if declared_options is None:
211+
declared_options = {
212+
"http_options": http_options,
213+
"grpc_options": grpc_options,
214+
"proxy_location": proxy_location,
215+
}
216+
logger.info(
217+
f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".'
218+
" New controller_options will not be applied."
219+
)
220+
_check_start_time_config_unchanged(client, **declared_options)
221+
222+
173223
def _create_controller_and_proxy_refs(
174224
http_options: Union[None, dict, HTTPOptions],
175225
grpc_options: Union[None, dict, gRPCOptions],
@@ -254,6 +304,7 @@ async def serve_start_async(
254304
global_tracing_config: Union[None, dict, TracingConfig] = None,
255305
controller_options: Union[None, dict, ControllerOptions] = None,
256306
proxy_location: Union[None, str, ProxyLocation] = None,
307+
declared_options: Optional[Dict[str, Any]] = None,
257308
**kwargs,
258309
) -> ServeControllerClient:
259310
"""Initialize a serve instance asynchronously.
@@ -279,15 +330,8 @@ async def serve_start_async(
279330
except RayServeException:
280331
client = None
281332
if client is not None:
282-
logger.info(
283-
f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".'
284-
" New controller_options will not be applied."
285-
)
286-
_check_start_time_config_unchanged(
287-
client,
288-
http_options=http_options,
289-
grpc_options=grpc_options,
290-
proxy_location=proxy_location,
333+
_connect_to_existing_client(
334+
client, http_options, grpc_options, proxy_location, declared_options
291335
)
292336
return client
293337

@@ -334,6 +378,7 @@ def serve_start(
334378
global_tracing_config: Union[None, dict, TracingConfig] = None,
335379
controller_options: Union[None, dict, ControllerOptions] = None,
336380
proxy_location: Union[None, str, ProxyLocation] = None,
381+
declared_options: Optional[Dict[str, Any]] = None,
337382
**kwargs,
338383
) -> ServeControllerClient:
339384
"""Initialize a serve instance.
@@ -388,6 +433,11 @@ def serve_start(
388433
the cluster. See ``ProxyLocation`` for supported options. Defaults
389434
to ``EveryNode`` when unspecified. An explicit (deprecated)
390435
``HTTPOptions.location`` overrides this.
436+
declared_options: The subset of the options above that the caller
437+
explicitly asked for, checked against an already-running instance
438+
instead of the values passed here. Declarative callers must pass
439+
this (see ``declared_start_time_options``) because they send a
440+
fully-defaulted config to start Serve with.
391441
**kwargs: Reserved for forwarding to internal controller-start hooks;
392442
no public keys are currently supported and unknown keys may raise.
393443
@@ -407,15 +457,8 @@ def serve_start(
407457
except RayServeException:
408458
client = None
409459
if client is not None:
410-
logger.info(
411-
f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".'
412-
" New controller_options will not be applied."
413-
)
414-
_check_start_time_config_unchanged(
415-
client,
416-
http_options=http_options,
417-
grpc_options=grpc_options,
418-
proxy_location=proxy_location,
460+
_connect_to_existing_client(
461+
client, http_options, grpc_options, proxy_location, declared_options
419462
)
420463
return client
421464

python/ray/serve/_private/client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ def __init__(
7070
[
7171
controller.get_http_config.remote(),
7272
controller.get_grpc_config.remote(),
73-
controller.get_proxy_location.remote(),
73+
controller.get_requested_proxy_location.remote(),
7474
controller.get_root_url.remote(),
7575
]
7676
)
7777
self._http_config: HTTPOptions = configs[0]
7878
self._grpc_config: gRPCOptions = configs[1]
79-
self._proxy_location: Optional[ProxyLocation] = configs[2]
79+
self._requested_proxy_location: ProxyLocation = configs[2]
8080
self._root_url = configs[3]
8181

8282
# Each handle has the overhead of long poll client, therefore cached.
@@ -96,8 +96,8 @@ def grpc_config(self):
9696
return self._grpc_config
9797

9898
@property
99-
def proxy_location(self):
100-
return self._proxy_location
99+
def requested_proxy_location(self):
100+
return self._requested_proxy_location
101101

102102
def __reduce__(self):
103103
raise RayServeException(("Ray Serve client cannot be serialized."))

python/ray/serve/_private/controller.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ async def __init__( # type: ignore[misc]
208208

209209
self._ha_proxy_enabled = RAY_SERVE_ENABLE_HA_PROXY
210210
self._direct_ingress_enabled = RAY_SERVE_ENABLE_DIRECT_INGRESS
211+
# Captured before the mode-specific overrides below so a later config apply
212+
# is diffed against what the caller asked for, not what we forced.
213+
self._requested_proxy_location = (
214+
http_options.location or proxy_location or ProxyLocation.EveryNode
215+
)
211216
# Last full set of ingress-port tuples fed to update_ports (for the per-tick set-diff).
212217
self._last_ingress_port_tuples: set = set()
213218
# Last ingress membership version seen; -1 forces the first tick to run.
@@ -989,6 +994,10 @@ def get_proxy_location(self) -> Optional[ProxyLocation]:
989994
return None
990995
return self.proxy_state_manager.get_proxy_location()
991996

997+
def get_requested_proxy_location(self) -> ProxyLocation:
998+
"""Return the placement the caller asked for, before any mode override."""
999+
return self._requested_proxy_location
1000+
9921001
def get_grpc_config(self) -> gRPCOptions:
9931002
"""Return the gRPC proxy configuration."""
9941003
if self.proxy_state_manager is None:

python/ray/serve/api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ class See `gRPCOptions` for supported options.
122122
running in this Ray cluster.
123123
**kwargs: Reserved for forward-compatibility; passed through to the
124124
internal Serve start helper.
125+
126+
Raises:
127+
RayServeConfigException: If Serve is already running and this call asks to
128+
change ``proxy_location``, ``http_options``, or ``grpc_options``.
125129
"""
126130
_private_api.serve_start(
127131
http_options=http_options,

0 commit comments

Comments
 (0)