Skip to content

Commit 36e21f9

Browse files
authored
Cross-project fleet references in CLI and YAML (#3677)
Allow to reference imported fleets in the `fleets` property in run and profile configurations: ```yaml type: dev-environment ide: vscode fleets: - local - main/shared-1 - project: main # verbose syntax name: shared-2 ``` As well as in the `--fleet` CLI option: ```shell $ dstack apply -f run.dstack.yml --fleet main/shared-1 ``` Fleets specified without `project` are local to the run's project.
1 parent 3ceddfd commit 36e21f9

18 files changed

Lines changed: 632 additions & 48 deletions

File tree

docs/docs/concepts/exports.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ $ dstack fleet list
134134

135135
Imported fleets can be used for runs just like the project's own fleets.
136136

137+
<div editor-title=".dstack.yml">
138+
139+
```yaml
140+
type: dev-environment
141+
ide: vscode
142+
143+
fleets:
144+
- my-local-fleet
145+
- team-a/my-fleet
146+
```
147+
148+
</div>
149+
137150
!!! info "Tenant isolation"
138151
Exported fleets share the same access model as regular fleets. See [Tenant isolation](fleets.md#tenant-isolation) for details.
139152
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from dstack._internal.core.models.common import EntityReference
2+
from dstack._internal.core.models.profiles import ProfileParams
3+
4+
5+
def patch_profile_params(params: ProfileParams) -> None:
6+
# If there are no project-prefixed fleets, replace all EntityReference with str
7+
# for compatibility with pre-0.20.14 servers that don't support EntityReference.
8+
if params.fleets is not None and all(
9+
EntityReference.parse(f).project is None for f in params.fleets
10+
):
11+
params.fleets = [
12+
fleet_ref.format() if isinstance(fleet_ref, EntityReference) else fleet_ref
13+
for fleet_ref in params.fleets
14+
]

src/dstack/_internal/core/compatibility/fleets.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from typing import Optional
22

3-
from dstack._internal.core.models.common import IncludeExcludeDictType, IncludeExcludeSetType
3+
from dstack._internal.core.compatibility.common import patch_profile_params
4+
from dstack._internal.core.models.common import (
5+
IncludeExcludeDictType,
6+
IncludeExcludeSetType,
7+
)
48
from dstack._internal.core.models.fleets import ApplyFleetPlanInput, FleetSpec
59

610

@@ -56,3 +60,7 @@ def get_fleet_spec_excludes(fleet_spec: FleetSpec) -> Optional[IncludeExcludeDic
5660
if spec_excludes:
5761
return spec_excludes
5862
return None
63+
64+
65+
def patch_fleet_spec(spec: FleetSpec) -> None:
66+
patch_profile_params(spec.profile)

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from typing import Optional
22

3-
from dstack._internal.core.models.common import IncludeExcludeDictType, IncludeExcludeSetType
3+
from dstack._internal.core.compatibility.common import patch_profile_params
4+
from dstack._internal.core.models.common import (
5+
IncludeExcludeDictType,
6+
IncludeExcludeSetType,
7+
)
48
from dstack._internal.core.models.configurations import ServiceConfiguration
59
from dstack._internal.core.models.routers import SGLangServiceRouterConfig
610
from dstack._internal.core.models.runs import (
@@ -138,3 +142,9 @@ def get_job_submission_excludes(job_submissions: list[JobSubmission]) -> Include
138142
submission_excludes["job_runtime_data"] = jrd_excludes
139143

140144
return submission_excludes
145+
146+
147+
def patch_run_spec(run_spec: RunSpec) -> None:
148+
patch_profile_params(run_spec.configuration)
149+
if run_spec.profile is not None:
150+
patch_profile_params(run_spec.profile)

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,31 @@ class ApplyAction(str, Enum):
143143
class NetworkMode(str, Enum):
144144
HOST = "host"
145145
BRIDGE = "bridge"
146+
147+
148+
class EntityReference(CoreModel):
149+
"""
150+
Cross-project entity reference.
151+
"""
152+
153+
project: Annotated[
154+
Optional[str],
155+
Field(description="The project name. If unspecified, refers to the current project"),
156+
]
157+
name: Annotated[str, Field(description="The entity name")]
158+
159+
@classmethod
160+
def parse(cls, v: Union[str, "EntityReference"]) -> "EntityReference":
161+
if isinstance(v, EntityReference):
162+
return v
163+
parts = v.split("/")
164+
if len(parts) == 1:
165+
return cls(project=None, name=parts[0])
166+
if len(parts) == 2:
167+
return cls(project=parts[0], name=parts[1])
168+
raise ValueError("Invalid entity reference. Only `<project>/<name>` format is allowed")
169+
170+
def format(self) -> str:
171+
if self.project is None:
172+
return self.name
173+
return f"{self.project}/{self.name}"

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CoreConfig,
1111
CoreModel,
1212
Duration,
13+
EntityReference,
1314
generate_dual_core_model,
1415
)
1516
from dstack._internal.utils.common import list_enum_values_for_annotation
@@ -360,7 +361,21 @@ class ProfileParams(CoreModel):
360361
Field(description=("The schedule for starting the run at specified time")),
361362
] = None
362363
fleets: Annotated[
363-
Optional[list[str]], Field(description="The fleets considered for reuse")
364+
Optional[
365+
list[
366+
Union[
367+
EntityReference,
368+
str, # For server response compatibility with pre-0.20.14 clients
369+
]
370+
]
371+
],
372+
Field(
373+
description=(
374+
"The fleets considered for reuse."
375+
" For fleets owned by the current project, specify fleet names."
376+
" For imported fleets, specify `<project name>/<fleet name>`"
377+
),
378+
),
364379
] = None
365380
tags: Annotated[
366381
Optional[Dict[str, str]],
@@ -382,6 +397,7 @@ class ProfileParams(CoreModel):
382397
_validate_idle_duration = validator("idle_duration", pre=True, allow_reuse=True)(
383398
parse_idle_duration
384399
)
400+
_validate_fleets = validator("fleets", allow_reuse=True, each_item=True)(EntityReference.parse)
385401
_validate_tags = validator("tags", pre=True, allow_reuse=True)(tags_validator)
386402

387403

src/dstack/_internal/server/background/scheduled_tasks/submitted_jobs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,7 @@ async def _refetch_fleet_models_with_instances(
975975
) -> list[FleetModel]:
976976
res = await session.execute(
977977
select(FleetModel)
978+
.join(FleetModel.project) # can be referenced by fleet_filters
978979
.outerjoin(FleetModel.instances)
979980
.where(
980981
FleetModel.id.in_(fleets_ids),

src/dstack/_internal/server/compatibility/common.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,23 @@
22

33
from packaging.version import Version
44

5+
from dstack._internal.core.models.common import EntityReference
56
from dstack._internal.core.models.instances import (
67
InstanceAvailability,
78
InstanceOfferWithAvailability,
89
)
10+
from dstack._internal.core.models.profiles import ProfileParams
11+
12+
13+
def patch_profile_params(params: ProfileParams, client_version: Optional[Version]) -> None:
14+
if client_version is None:
15+
return
16+
# Clients prior to 0.20.14 only support `list[str]` in `fleets`
17+
if client_version < Version("0.20.14") and params.fleets is not None:
18+
params.fleets = [
19+
fleet_ref.format() if isinstance(fleet_ref, EntityReference) else fleet_ref
20+
for fleet_ref in params.fleets
21+
]
922

1023

1124
def patch_offers_list(
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Optional
2+
3+
from packaging.version import Version
4+
5+
from dstack._internal.core.models.fleets import Fleet, FleetPlan, FleetSpec
6+
from dstack._internal.server.compatibility.common import patch_offers_list, patch_profile_params
7+
8+
9+
def patch_fleet_plan(fleet_plan: FleetPlan, client_version: Optional[Version]) -> None:
10+
patch_fleet_spec(fleet_plan.spec, client_version)
11+
if fleet_plan.effective_spec is not None:
12+
patch_fleet_spec(fleet_plan.effective_spec, client_version)
13+
if fleet_plan.current_resource is not None:
14+
patch_fleet(fleet_plan.current_resource, client_version)
15+
patch_offers_list(fleet_plan.offers, client_version)
16+
17+
18+
def patch_fleet(fleet: Fleet, client_version: Optional[Version]) -> None:
19+
patch_fleet_spec(fleet.spec, client_version)
20+
21+
22+
def patch_fleet_spec(fleet_spec: FleetSpec, client_version: Optional[Version]) -> None:
23+
patch_profile_params(fleet_spec.profile, client_version)

src/dstack/_internal/server/compatibility/runs.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from dstack._internal.core.models.configurations import SERVICE_HTTPS_DEFAULT, ServiceConfiguration
66
from dstack._internal.core.models.runs import Run, RunPlan, RunSpec
7-
from dstack._internal.server.compatibility.common import patch_offers_list
7+
from dstack._internal.server.compatibility.common import patch_offers_list, patch_profile_params
88

99

1010
def patch_run_plan(run_plan: RunPlan, client_version: Optional[Version]) -> None:
@@ -41,3 +41,6 @@ def patch_run_spec(run_spec: RunSpec, client_version: Optional[Version]) -> None
4141
and run_spec.configuration.https is None
4242
):
4343
run_spec.configuration.https = SERVICE_HTTPS_DEFAULT
44+
patch_profile_params(run_spec.configuration, client_version)
45+
if run_spec.profile is not None:
46+
patch_profile_params(run_spec.profile, client_version)

0 commit comments

Comments
 (0)