Skip to content

Commit 979de8e

Browse files
author
Andrey Cheptsov
committed
Add server access to tasks and dev environments
1 parent 1e9f5f0 commit 979de8e

23 files changed

Lines changed: 861 additions & 7 deletions

File tree

mkdocs/docs/concepts/dev-environments.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,26 @@ The `schedule` property can be combined with `max_duration` or `utilization_poli
642642
By default, `dstack` uses on-demand instances. However, you can change that
643643
via the [`spot_policy`](../reference/dstack.yml/dev-environment.md#spot_policy) property. It accepts `spot`, `on-demand`, and `auto`.
644644

645+
### Server access
646+
647+
Set `server` to `true` when a dev environment needs to use the dstack CLI. dstack configures the
648+
server and current project automatically. To run authenticated commands, pass `DSTACK_TOKEN`
649+
explicitly.
650+
651+
<div editor-title=".dstack.yml">
652+
653+
```yaml
654+
type: dev-environment
655+
image: dstackai/dstack
656+
server: true
657+
env:
658+
- DSTACK_TOKEN
659+
init:
660+
- dstack ps
661+
```
662+
663+
</div>
664+
645665
--8<-- "docs/concepts/snippets/manage-fleets.ext"
646666

647667
!!! info "Reference"

mkdocs/docs/concepts/tasks.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,25 @@ schedule:
864864
By default, `dstack` uses on-demand instances. However, you can change that
865865
via the [`spot_policy`](../reference/dstack.yml/task.md#spot_policy) property. It accepts `spot`, `on-demand`, and `auto`.
866866

867+
### Server access
868+
869+
Set `server` to `true` when a task needs to use the dstack CLI. dstack configures the server and
870+
current project automatically. To run authenticated commands, pass `DSTACK_TOKEN` explicitly.
871+
872+
<div editor-title=".dstack.yml">
873+
874+
```yaml
875+
type: task
876+
image: dstackai/dstack
877+
server: true
878+
env:
879+
- DSTACK_TOKEN
880+
commands:
881+
- dstack ps
882+
```
883+
884+
</div>
885+
867886
--8<-- "docs/concepts/snippets/manage-fleets.ext"
868887

869888
!!! info "Reference"

mkdocs/docs/reference/env.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,17 @@ slows down processing and may cause CPU spikes due to frequent SSH-connection es
168168

169169
The following environment variables are supported by the CLI.
170170

171+
- `DSTACK_TOKEN`{ #DSTACK_TOKEN } – The user token used by the CLI. Set `DSTACK_TOKEN`,
172+
`DSTACK_SERVER_URL`, and `DSTACK_PROJECT` together to use the CLI without a project in
173+
`~/.dstack/config.yml`, or to override the configured server, project, and user.
174+
175+
```shell
176+
DSTACK_SERVER_URL=https://server.example.com \
177+
DSTACK_PROJECT=main \
178+
DSTACK_TOKEN=your-token \
179+
dstack ps
180+
```
181+
171182
- `DSTACK_CLI_LOG_LEVEL`{ #DSTACK_CLI_LOG_LEVEL } – Sets the logging level for CLI output to stdout. Defaults to `INFO`.
172183

173184
Example:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ classifiers = [
1313
dependencies = [
1414
"pyyaml",
1515
"requests",
16+
"requests-unixsocket>=0.4.1",
1617
"typing-extensions>=4.0.0",
1718
"cryptography",
1819
"packaging",
@@ -187,7 +188,6 @@ server = [
187188
"aiorwlock",
188189
"aiocache",
189190
"httpx>=0.28.0",
190-
"requests-unixsocket>=0.4.1",
191191
"jinja2",
192192
"watchfiles",
193193
"sqlalchemy[asyncio]>=2.0.0",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
from urllib.parse import quote
2+
13
# shim (runs on the host) HTTP API port
24
DSTACK_SHIM_HTTP_PORT = 10998
35
# runner (runs inside a container) HTTP API port
46
DSTACK_RUNNER_HTTP_PORT = 10999
57
# ssh server (runs alongside the runner inside a container) listen port
68
DSTACK_RUNNER_SSH_PORT = 10022
9+
# Private socket created inside jobs that request access to the dstack server.
10+
DSTACK_RUN_SERVER_SOCKET_PATH = "/run/dstack/server.sock"
11+
DSTACK_RUN_SERVER_URL = f"http+unix://{quote(DSTACK_RUN_SERVER_SOCKET_PATH, safe='')}"
12+
DSTACK_PROJECT_ENV = "DSTACK_PROJECT"
13+
DSTACK_SERVER_URL_ENV = "DSTACK_SERVER_URL"
14+
DSTACK_TOKEN_ENV = "DSTACK_TOKEN"
715
# legacy AWS, Azure, GCP, and OCI image for older GPUs
816
DSTACK_OS_IMAGE_WITH_PROPRIETARY_NVIDIA_KERNEL_MODULES = "0.10"

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,18 @@ def check_image_or_commands_present(cls, values):
693693
return values
694694

695695

696+
class ConfigurationWithServerParams(CoreModel):
697+
server: Annotated[
698+
bool,
699+
Field(
700+
description=(
701+
"Make the dstack server accessible inside the run. "
702+
"No authentication credentials are provided"
703+
)
704+
),
705+
] = False
706+
707+
696708
class DevEnvironmentConfigurationParams(CoreModel):
697709
ide: Annotated[
698710
Optional[Union[Literal["vscode"], Literal["cursor"], Literal["windsurf"], Literal["zed"]]],
@@ -762,6 +774,7 @@ class DevEnvironmentConfiguration(
762774
ProfileParams,
763775
BaseRunConfiguration,
764776
ConfigurationWithPortsParams,
777+
ConfigurationWithServerParams,
765778
DevEnvironmentConfigurationParams,
766779
generate_dual_core_model(DevEnvironmentConfigurationConfig),
767780
):
@@ -793,6 +806,7 @@ class TaskConfiguration(
793806
BaseRunConfiguration,
794807
ConfigurationWithCommandsParams,
795808
ConfigurationWithPortsParams,
809+
ConfigurationWithServerParams,
796810
TaskConfigurationParams,
797811
generate_dual_core_model(TaskConfigurationConfig),
798812
):

src/dstack/_internal/core/services/api_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
1+
import os
12
from typing import Optional, Tuple
23

34
import dstack._internal.core.services.configs as configs
5+
from dstack._internal.core.consts import (
6+
DSTACK_PROJECT_ENV,
7+
DSTACK_SERVER_URL_ENV,
8+
DSTACK_TOKEN_ENV,
9+
)
410
from dstack._internal.core.errors import ConfigurationError
511
from dstack.api.server import APIClient
612

713

814
def get_api_client(project_name: Optional[str] = None) -> Tuple[APIClient, str]:
15+
env_project_name = project_name or os.getenv(DSTACK_PROJECT_ENV)
16+
server_url = os.getenv(DSTACK_SERVER_URL_ENV)
17+
token = os.getenv(DSTACK_TOKEN_ENV)
18+
if env_project_name is not None and server_url is not None and token is not None:
19+
return APIClient(server_url, token), env_project_name
20+
921
config = configs.ConfigManager()
1022
project = config.get_project_config(project_name)
1123
if project is None:

src/dstack/_internal/server/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
)
5454
from dstack._internal.server.services.config import ServerConfigManager
5555
from dstack._internal.server.services.gateways import gateway_connections_pool
56+
from dstack._internal.server.services.jobs.server_connection import job_server_connections_pool
5657
from dstack._internal.server.services.locking import advisory_lock_ctx
5758
from dstack._internal.server.services.projects import get_or_create_default_project
5859
from dstack._internal.server.services.proxy.deps import ServerProxyDependencyInjector
@@ -213,6 +214,7 @@ async def lifespan(app: FastAPI):
213214
if pipeline_manager is not None:
214215
await pipeline_manager.drain()
215216
await gateway_connections_pool.remove_all()
217+
await job_server_connections_pool.remove_all()
216218
service_conn_pool = await get_injector_from_app(app).get_service_connection_pool()
217219
await service_conn_pool.remove_all()
218220
if settings.SERVER_SSH_POOL_ENABLED:

src/dstack/_internal/server/background/pipeline_tasks/jobs_running.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@
9191
is_master_job,
9292
job_model_to_job_submission,
9393
)
94+
from dstack._internal.server.services.jobs.server_connection import (
95+
job_server_connections_pool,
96+
)
9497
from dstack._internal.server.services.locking import get_locker
9598
from dstack._internal.server.services.logging import fmt
9699
from dstack._internal.server.services.metrics import get_job_metrics
@@ -474,6 +477,11 @@ async def _process_running_job(context: _ProcessContext) -> _ProcessResult:
474477
context=context, startup_context=startup_context, result=result
475478
)
476479
elif context.job_model.status == JobStatus.RUNNING:
480+
if _server_access_enabled(context):
481+
await job_server_connections_pool.ensure(
482+
context.job_model,
483+
context.job_submission.job_runtime_data,
484+
)
477485
await _process_running_status(context=context, result=result)
478486

479487
if _get_result_status(context.job_model, result) == JobStatus.RUNNING:
@@ -485,6 +493,8 @@ async def _process_running_job(context: _ProcessContext) -> _ProcessResult:
485493
)
486494
await _maybe_register_replica(context=context, result=result)
487495
await _check_gpu_utilization(context=context, result=result)
496+
elif _server_access_enabled(context):
497+
await job_server_connections_pool.remove(context.job_model.id)
488498
return result
489499

490500

@@ -614,6 +624,7 @@ async def _refetch_locked_job_model(
614624
JobModel.lock_token == item.lock_token,
615625
)
616626
.options(joinedload(JobModel.instance).joinedload(InstanceModel.project))
627+
.options(joinedload(JobModel.project))
617628
.options(joinedload(JobModel.probes).load_only(ProbeModel.success_streak))
618629
.options(
619630
joinedload(JobModel.run).load_only(RunModel.id, RunModel.run_spec, RunModel.status)
@@ -780,6 +791,8 @@ async def _process_provisioning_status(
780791
None,
781792
)
782793
if runner_availability == _RunnerAvailability.AVAILABLE:
794+
if not await _ensure_job_server_connection(context, result):
795+
return
783796
file_archives = await _get_job_file_archives(
784797
archive_mappings=context.job.job_spec.file_archives,
785798
user=context.run_model.user,
@@ -891,6 +904,8 @@ async def _process_pulling_status(
891904
return
892905

893906
if runner_availability == _RunnerAvailability.AVAILABLE:
907+
if not await _ensure_job_server_connection(context, result):
908+
return
894909
file_archives = await _get_job_file_archives(
895910
archive_mappings=context.job.job_spec.file_archives,
896911
user=context.run_model.user,
@@ -959,6 +974,36 @@ async def _process_running_status(
959974
_handle_instance_unreachable(context, result, job_provisioning_data)
960975

961976

977+
async def _ensure_job_server_connection(
978+
context: _ProcessContext,
979+
result: _ProcessResult,
980+
) -> bool:
981+
if not _server_access_enabled(context):
982+
return True
983+
connected = await job_server_connections_pool.ensure(
984+
context.job_model,
985+
_get_result_job_runtime_data(context.job_model, result),
986+
)
987+
if connected:
988+
return True
989+
990+
if job_server_connections_pool.retry_timed_out(
991+
context.job_model.id,
992+
JOB_DISCONNECTED_RETRY_TIMEOUT.total_seconds(),
993+
):
994+
_terminate_job(
995+
job_model=context.job_model,
996+
job_update_map=result.job_update_map,
997+
termination_reason=JobTerminationReason.TERMINATED_BY_SERVER,
998+
termination_reason_message="Could not establish dstack server access",
999+
)
1000+
return False
1001+
1002+
1003+
def _server_access_enabled(context: _ProcessContext) -> bool:
1004+
return bool(getattr(context.run.run_spec.configuration, "server", False))
1005+
1006+
9621007
async def _apply_process_result(
9631008
item: JobRunningPipelineItem,
9641009
job_model: JobModel,

src/dstack/_internal/server/background/pipeline_tasks/jobs_terminating.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@
6464
get_job_spec,
6565
stop_runner,
6666
)
67+
from dstack._internal.server.services.jobs.server_connection import (
68+
job_server_connections_pool,
69+
)
6770
from dstack._internal.server.services.locking import get_locker
6871
from dstack._internal.server.services.logging import fmt
6972
from dstack._internal.server.services.pipelines import PipelineHinterProtocol
@@ -268,6 +271,7 @@ async def process(self, item: JobTerminatingPipelineItem):
268271
return
269272

270273
if job_model.volumes_detached_at is None:
274+
await job_server_connections_pool.remove(job_model.id)
271275
result = await _process_terminating_job(
272276
job_model=job_model,
273277
instance_model=instance_model,

0 commit comments

Comments
 (0)