Skip to content

Commit f5f5a05

Browse files
committed
Add more probe request configuration options
- Add `method`, `headers`, and `body` properties - Support interpolation from `${{ env.* }}` (but not `${{ secrets.* }}` yet)
1 parent 6c492df commit f5f5a05

File tree

9 files changed

+156
-7
lines changed

9 files changed

+156
-7
lines changed

docs/docs/concepts/services.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,34 @@ $ dstack ps --verbose
230230

231231
If multiple probes are configured for the service, their statuses are displayed in the order in which the probes appear in the configuration.
232232

233-
Probes are executed for each service replica while the replica is `running`. Probe statuses do not affect how `dstack` handles replicas, except during [rolling deployments](#rolling-deployment).
233+
Probes are executed for each service replica while the replica is `running`. A probe execution is considered successful if the replica responds with a `2xx` status code. Probe statuses do not affect how `dstack` handles replicas, except during [rolling deployments](#rolling-deployment).
234+
235+
??? info "HTTP request configuration"
236+
You can configure the HTTP request method, headers, and other properties. To include secret values in probe requests, use environment variable interpolation, which is enabled for the `url`, `headers[i].value`, and `body` properties.
237+
238+
<div editor-title="service.dstack.yml">
239+
240+
```yaml
241+
type: service
242+
name: my-service
243+
port: 80
244+
image: my-app:latest
245+
env:
246+
- PROBES_API_KEY
247+
probes:
248+
- type: http
249+
method: post
250+
url: /check-health
251+
headers:
252+
- name: X-API-Key
253+
value: ${{ env.PROBES_API_KEY }}
254+
- name: Content-Type
255+
value: application/json
256+
body: '{"level": 2}'
257+
timeout: 20s
258+
```
259+
260+
</div>
234261

235262
See the [reference](../reference/dstack.yml/service.md#probes) for more probe configuration options.
236263

docs/docs/reference/dstack.yml/service.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,17 @@ The `service` configuration type allows running [services](../../concepts/servic
116116
type:
117117
required: true
118118

119+
##### `probes[n].headers`
120+
121+
###### `probes[n].headers[m]`
122+
123+
#SCHEMA# dstack._internal.core.models.configurations.HTTPHeaderSpec
124+
overrides:
125+
show_root_heading: false
126+
type:
127+
required: true
128+
129+
119130
### `retry`
120131

121132
#SCHEMA# dstack._internal.core.models.profiles.ProfileRetry

src/dstack/_internal/cli/services/configurators/run.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,14 @@ def interpolate_env(self, conf: BaseRunConfiguration):
341341
username=interpolator.interpolate_or_error(conf.registry_auth.username),
342342
password=interpolator.interpolate_or_error(conf.registry_auth.password),
343343
)
344+
if isinstance(conf, ServiceConfiguration):
345+
for probe in conf.probes:
346+
for header in probe.headers:
347+
header.value = interpolator.interpolate_or_error(header.value)
348+
if probe.url:
349+
probe.url = interpolator.interpolate_or_error(probe.url)
350+
if probe.body:
351+
probe.body = interpolator.interpolate_or_error(probe.body)
344352
except InterpolatorError as e:
345353
raise ConfigurationError(e.args[0])
346354

src/dstack/_internal/core/models/configurations.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from dstack._internal.core.models.services import AnyModel, OpenAIChatModel
2020
from dstack._internal.core.models.unix import UnixUser
2121
from dstack._internal.core.models.volumes import MountPoint, VolumeConfiguration, parse_mount_point
22+
from dstack._internal.utils.common import has_duplicates
2223
from dstack._internal.utils.json_utils import (
2324
pydantic_orjson_dumps_with_indent,
2425
)
@@ -38,6 +39,7 @@
3839
DEFAULT_PROBE_TIMEOUT = 10
3940
DEFAULT_PROBE_INTERVAL = 15
4041
DEFAULT_PROBE_READY_AFTER = 1
42+
DEFAULT_PROBE_METHOD = "get"
4143
MAX_PROBE_URL_LEN = 2048
4244

4345

@@ -169,11 +171,54 @@ class RateLimit(CoreModel):
169171
] = 0
170172

171173

174+
HTTPMethod = Literal["get", "post", "put", "delete", "patch", "head"]
175+
176+
177+
class HTTPHeaderSpec(CoreModel):
178+
name: Annotated[
179+
str,
180+
Field(
181+
description="The name of the HTTP header",
182+
min_length=1,
183+
max_length=256,
184+
),
185+
]
186+
value: Annotated[
187+
str,
188+
Field(
189+
description="The value of the HTTP header",
190+
min_length=1,
191+
max_length=2048,
192+
),
193+
]
194+
195+
172196
class ProbeConfig(CoreModel):
173197
type: Literal["http"] # expect other probe types in the future, namely `exec`
174198
url: Annotated[
175199
Optional[str], Field(description=f"The URL to request. Defaults to `{DEFAULT_PROBE_URL}`")
176200
] = None
201+
method: Annotated[
202+
Optional[HTTPMethod],
203+
Field(
204+
description=(
205+
"The HTTP method to use for the probe (e.g., `get`, `post`, etc.)."
206+
f" Defaults to `{DEFAULT_PROBE_METHOD}`"
207+
)
208+
),
209+
] = None
210+
headers: Annotated[
211+
list[HTTPHeaderSpec],
212+
Field(description="A list of HTTP headers to include in the request", max_items=16),
213+
] = []
214+
body: Annotated[
215+
Optional[str],
216+
Field(
217+
description="The HTTP request body to send with the probe",
218+
min_length=1,
219+
max_length=2048,
220+
),
221+
] = None
177222
timeout: Annotated[
178223
Optional[Union[int, str]],
179224
Field(
@@ -203,9 +248,6 @@ class ProbeConfig(CoreModel):
203248
),
204249
] = None
205250

206-
class Config:
207-
frozen = True
208-
209251
@validator("timeout")
210252
def parse_timeout(cls, v: Optional[Union[int, str]]) -> Optional[int]:
211253
if v is None:
@@ -236,6 +278,13 @@ def validate_url(cls, v: Optional[str]) -> Optional[str]:
236278
raise ValueError("Cannot contain non-printable characters")
237279
return v
238280

281+
@root_validator
282+
def validate_body_matches_method(cls, values):
283+
method: HTTPMethod = values["method"]
284+
if values["body"] is not None and method in ["get", "head"]:
285+
raise ValueError(f"Cannot set request body for the `{method}` method")
286+
return values
287+
239288

240289
class BaseRunConfiguration(CoreModel):
241290
type: Literal["none"]
@@ -592,7 +641,7 @@ def validate_rate_limits(cls, v: list[RateLimit]) -> list[RateLimit]:
592641

593642
@validator("probes")
594643
def validate_probes(cls, v: list[ProbeConfig]) -> list[ProbeConfig]:
595-
if len(v) != len(set(v)):
644+
if has_duplicates(v):
596645
# Using a custom validator instead of Field(unique_items=True) to avoid Pydantic bug:
597646
# https://github.com/pydantic/pydantic/issues/3765
598647
# Because of the bug, our gen_schema_reference.py fails to determine the type of

src/dstack/_internal/core/models/runs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
from dstack._internal.core.models.backends.base import BackendType
99
from dstack._internal.core.models.common import ApplyAction, CoreModel, NetworkMode, RegistryAuth
1010
from dstack._internal.core.models.configurations import (
11+
DEFAULT_PROBE_METHOD,
1112
DEFAULT_REPO_DIR,
1213
AnyRunConfiguration,
14+
HTTPHeaderSpec,
15+
HTTPMethod,
1316
RunConfiguration,
1417
ServiceConfiguration,
1518
)
@@ -226,6 +229,9 @@ class JobSSHKey(CoreModel):
226229
class ProbeSpec(CoreModel):
227230
type: Literal["http"] # expect other probe types in the future, namely `exec`
228231
url: str
232+
method: HTTPMethod = DEFAULT_PROBE_METHOD
233+
headers: list[HTTPHeaderSpec] = []
234+
body: Optional[str] = None
229235
timeout: int
230236
interval: int
231237
ready_after: int

src/dstack/_internal/server/background/tasks/process_probes.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,13 @@ async def _execute_probe(probe: ProbeModel, probe_spec: ProbeSpec) -> bool:
116116

117117
try:
118118
async with _get_service_replica_client(probe.job) as client:
119-
resp = await client.get(
120-
"http://dstack" + probe_spec.url,
119+
resp = await client.request(
120+
method=probe_spec.method,
121+
url="http://dstack" + probe_spec.url,
122+
headers=[(h.name, h.value) for h in probe_spec.headers],
123+
data=probe_spec.body,
121124
timeout=probe_spec.timeout,
125+
follow_redirects=False,
122126
)
123127
logger.debug("%s: probe status code: %s", fmt(probe), resp.status_code)
124128
return resp.is_success

src/dstack/_internal/server/services/jobs/configurators/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from dstack._internal.core.models.common import RegistryAuth
1313
from dstack._internal.core.models.configurations import (
1414
DEFAULT_PROBE_INTERVAL,
15+
DEFAULT_PROBE_METHOD,
1516
DEFAULT_PROBE_READY_AFTER,
1617
DEFAULT_PROBE_TIMEOUT,
1718
DEFAULT_PROBE_URL,
@@ -372,6 +373,9 @@ def _probe_config_to_spec(c: ProbeConfig) -> ProbeSpec:
372373
timeout=c.timeout if c.timeout is not None else DEFAULT_PROBE_TIMEOUT,
373374
interval=c.interval if c.interval is not None else DEFAULT_PROBE_INTERVAL,
374375
ready_after=c.ready_after if c.ready_after is not None else DEFAULT_PROBE_READY_AFTER,
376+
method=c.method if c.method is not None else DEFAULT_PROBE_METHOD,
377+
headers=c.headers,
378+
body=c.body,
375379
)
376380

377381

src/dstack/_internal/utils/common.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,21 @@ def remove_prefix(text: str, prefix: str) -> str:
222222
return text
223223

224224

225+
def has_duplicates(iterable: Iterable[Any]) -> bool:
226+
"""
227+
Checks if there are any duplicate items in the given iterable.
228+
229+
O(n^2) implementation, but works with iterables with unhashable items.
230+
For iterables with hashable items, prefer len(set(iterable)) != len(iterable).
231+
"""
232+
seen = []
233+
for item in iterable:
234+
if item in seen:
235+
return True
236+
seen.append(item)
237+
return False
238+
239+
225240
T = TypeVar("T")
226241

227242

src/tests/_internal/utils/test_common.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
batched,
99
concat_url_path,
1010
format_duration_multiunit,
11+
has_duplicates,
1112
local_time,
1213
make_proxy_url,
1314
parse_memory,
@@ -124,6 +125,30 @@ def test_forbids_negative(self) -> None:
124125
format_duration_multiunit(-1)
125126

126127

128+
@pytest.mark.parametrize(
129+
"iterable, expected",
130+
[
131+
([1, 2, 3, 4], False),
132+
([1, 2, 3, 4, 2], True),
133+
(iter([1, 2, 3, 4]), False),
134+
(iter([1, 2, 3, 4, 2]), True),
135+
("abcde", False),
136+
("hello", True),
137+
([1, "a"], False),
138+
([1, "a", 1], True),
139+
([[1, 2], [3, 4]], False),
140+
([[1, 2], [1, 2]], True),
141+
([{"a": "b"}, {"a": "c"}], False),
142+
([{"a": "b"}, {"a": "b"}], True),
143+
([{}, {}], True),
144+
([], False),
145+
([1], False),
146+
],
147+
)
148+
def test_has_duplicates(iterable, expected):
149+
assert has_duplicates(iterable) == expected
150+
151+
127152
class TestParseMemory:
128153
@pytest.mark.parametrize(
129154
"memory,as_units,expected",

0 commit comments

Comments
 (0)