Skip to content

Commit 9c787b7

Browse files
committed
Fleet sharing main mechanisms
- DB schema for resource exports and imports - Submitting jobs to imported fleets - Viewing imported fleets and instances in API, CLI, UI - Filtering events by imported fleets and instances Currently testable through unit tests and through exports and imports manually created in the DB.
1 parent 9eea926 commit 9c787b7

25 files changed

Lines changed: 1685 additions & 47 deletions

File tree

frontend/src/pages/Fleets/List/hooks.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export const useFilters = (localStorePrefix = 'fleet-list-page') => {
182182
return {
183183
...params,
184184
only_active: onlyActive,
185+
include_imported: true,
185186
} as Partial<TFleetListRequestParams>;
186187
}, [propertyFilterQuery, onlyActive]);
187188

frontend/src/pages/Instances/List/hooks/useFilters.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const useFilters = (localStorePrefix = 'instances-list-page') => {
8383
return {
8484
...params,
8585
only_active: onlyActive,
86+
include_imported: true,
8687
} as Partial<TInstanceListRequestParams>;
8788
}, [propertyFilterQuery, onlyActive]);
8889

frontend/src/types/fleet.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ declare type TSpotPolicy = 'spot' | 'on-demand' | 'auto';
33
declare type TFleetListRequestParams = TBaseRequestListParams & {
44
project_name?: string;
55
only_active?: boolean;
6+
include_imported?: boolean;
67
};
78

89
declare interface ISSHHostParamsRequest {

frontend/src/types/instance.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ declare type TInstanceListRequestParams = TBaseRequestListParams & {
22
project_names?: string[];
33
fleet_ids?: string[];
44
only_active?: boolean;
5+
include_imported?: boolean;
56
};
67

78
declare type TInstanceStatus =

src/dstack/_internal/cli/commands/fleet.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def _command(self, args: argparse.Namespace):
9393
args.subfunc(args)
9494

9595
def _list(self, args: argparse.Namespace):
96-
fleets = self.api.client.fleets.list(self.api.project)
96+
fleets = self.api.client.fleets.list(self.api.project, include_imported=True)
9797
if not args.watch:
9898
print_fleets_table(fleets, verbose=args.verbose)
9999
return
@@ -103,7 +103,7 @@ def _list(self, args: argparse.Namespace):
103103
while True:
104104
live.update(get_fleets_table(fleets, verbose=args.verbose))
105105
time.sleep(LIVE_TABLE_PROVISION_INTERVAL_SECS)
106-
fleets = self.api.client.fleets.list(self.api.project)
106+
fleets = self.api.client.fleets.list(self.api.project, include_imported=True)
107107
except KeyboardInterrupt:
108108
pass
109109

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Add resource exports imports
2+
3+
Revision ID: ea7cca121fcb
4+
Revises: 46150101edec
5+
Create Date: 2026-03-02 13:45:57.118841+00:00
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
import sqlalchemy_utils
11+
from alembic import op
12+
13+
import dstack._internal.server.models
14+
15+
# revision identifiers, used by Alembic.
16+
revision = "ea7cca121fcb"
17+
down_revision = "46150101edec"
18+
branch_labels = None
19+
depends_on = None
20+
21+
22+
def upgrade() -> None:
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table(
25+
"resource_exports",
26+
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
27+
sa.Column("name", sa.String(length=100), nullable=False),
28+
sa.Column(
29+
"project_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False
30+
),
31+
sa.Column("created_at", dstack._internal.server.models.NaiveDateTime(), nullable=False),
32+
sa.ForeignKeyConstraint(
33+
["project_id"],
34+
["projects.id"],
35+
name=op.f("fk_resource_exports_project_id_projects"),
36+
ondelete="CASCADE",
37+
),
38+
sa.PrimaryKeyConstraint("id", name=op.f("pk_resource_exports")),
39+
sa.UniqueConstraint("project_id", "name", name="uq_resource_exports_project_id_name"),
40+
)
41+
with op.batch_alter_table("resource_exports", schema=None) as batch_op:
42+
batch_op.create_index(
43+
batch_op.f("ix_resource_exports_project_id"), ["project_id"], unique=False
44+
)
45+
46+
op.create_table(
47+
"exported_fleets",
48+
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
49+
sa.Column(
50+
"resource_export_id",
51+
sqlalchemy_utils.types.uuid.UUIDType(binary=False),
52+
nullable=False,
53+
),
54+
sa.Column("fleet_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
55+
sa.ForeignKeyConstraint(
56+
["fleet_id"],
57+
["fleets.id"],
58+
name=op.f("fk_exported_fleets_fleet_id_fleets"),
59+
ondelete="CASCADE",
60+
),
61+
sa.ForeignKeyConstraint(
62+
["resource_export_id"],
63+
["resource_exports.id"],
64+
name=op.f("fk_exported_fleets_resource_export_id_resource_exports"),
65+
ondelete="CASCADE",
66+
),
67+
sa.PrimaryKeyConstraint("id", name=op.f("pk_exported_fleets")),
68+
sa.UniqueConstraint(
69+
"resource_export_id", "fleet_id", name="uq_exported_fleets_resource_export_id_fleet_id"
70+
),
71+
)
72+
with op.batch_alter_table("exported_fleets", schema=None) as batch_op:
73+
batch_op.create_index(
74+
batch_op.f("ix_exported_fleets_fleet_id"), ["fleet_id"], unique=False
75+
)
76+
batch_op.create_index(
77+
batch_op.f("ix_exported_fleets_resource_export_id"),
78+
["resource_export_id"],
79+
unique=False,
80+
)
81+
82+
op.create_table(
83+
"resource_imports",
84+
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
85+
sa.Column(
86+
"project_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False
87+
),
88+
sa.Column(
89+
"resource_export_id",
90+
sqlalchemy_utils.types.uuid.UUIDType(binary=False),
91+
nullable=False,
92+
),
93+
sa.Column("created_at", dstack._internal.server.models.NaiveDateTime(), nullable=False),
94+
sa.ForeignKeyConstraint(
95+
["project_id"],
96+
["projects.id"],
97+
name=op.f("fk_resource_imports_project_id_projects"),
98+
ondelete="CASCADE",
99+
),
100+
sa.ForeignKeyConstraint(
101+
["resource_export_id"],
102+
["resource_exports.id"],
103+
name=op.f("fk_resource_imports_resource_export_id_resource_exports"),
104+
ondelete="CASCADE",
105+
),
106+
sa.PrimaryKeyConstraint("id", name=op.f("pk_resource_imports")),
107+
sa.UniqueConstraint(
108+
"project_id",
109+
"resource_export_id",
110+
name="uq_resource_imports_project_id_resource_export_id",
111+
),
112+
)
113+
with op.batch_alter_table("resource_imports", schema=None) as batch_op:
114+
batch_op.create_index(
115+
batch_op.f("ix_resource_imports_project_id"), ["project_id"], unique=False
116+
)
117+
batch_op.create_index(
118+
batch_op.f("ix_resource_imports_resource_export_id"),
119+
["resource_export_id"],
120+
unique=False,
121+
)
122+
123+
# ### end Alembic commands ###
124+
125+
126+
def downgrade() -> None:
127+
# ### commands auto generated by Alembic - please adjust! ###
128+
with op.batch_alter_table("resource_imports", schema=None) as batch_op:
129+
batch_op.drop_index(batch_op.f("ix_resource_imports_resource_export_id"))
130+
batch_op.drop_index(batch_op.f("ix_resource_imports_project_id"))
131+
132+
op.drop_table("resource_imports")
133+
with op.batch_alter_table("exported_fleets", schema=None) as batch_op:
134+
batch_op.drop_index(batch_op.f("ix_exported_fleets_resource_export_id"))
135+
batch_op.drop_index(batch_op.f("ix_exported_fleets_fleet_id"))
136+
137+
op.drop_table("exported_fleets")
138+
with op.batch_alter_table("resource_exports", schema=None) as batch_op:
139+
batch_op.drop_index(batch_op.f("ix_resource_exports_project_id"))
140+
141+
op.drop_table("resource_exports")
142+
# ### end Alembic commands ###

src/dstack/_internal/server/models.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,3 +978,71 @@ class EventTargetModel(BaseModel):
978978
)
979979
entity_id: Mapped[uuid.UUID] = mapped_column(UUIDType(binary=False), index=True)
980980
entity_name: Mapped[str] = mapped_column(String(200))
981+
982+
983+
class ResourceExportModel(BaseModel):
984+
__tablename__ = "resource_exports"
985+
__table_args__ = (
986+
UniqueConstraint("project_id", "name", name="uq_resource_exports_project_id_name"),
987+
)
988+
989+
id: Mapped[uuid.UUID] = mapped_column(
990+
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
991+
)
992+
name: Mapped[str] = mapped_column(String(100))
993+
project_id: Mapped[uuid.UUID] = mapped_column(
994+
ForeignKey("projects.id", ondelete="CASCADE"), index=True
995+
)
996+
project: Mapped["ProjectModel"] = relationship()
997+
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
998+
resource_imports: Mapped[List["ResourceImportModel"]] = relationship(
999+
back_populates="resource_export"
1000+
)
1001+
exported_fleets: Mapped[List["ExportedFleetModel"]] = relationship(
1002+
back_populates="resource_export"
1003+
)
1004+
1005+
1006+
class ResourceImportModel(BaseModel):
1007+
__tablename__ = "resource_imports"
1008+
__table_args__ = (
1009+
UniqueConstraint(
1010+
"project_id",
1011+
"resource_export_id",
1012+
name="uq_resource_imports_project_id_resource_export_id",
1013+
),
1014+
)
1015+
1016+
id: Mapped[uuid.UUID] = mapped_column(
1017+
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
1018+
)
1019+
project_id: Mapped[uuid.UUID] = mapped_column(
1020+
ForeignKey("projects.id", ondelete="CASCADE"), index=True
1021+
)
1022+
project: Mapped["ProjectModel"] = relationship()
1023+
resource_export_id: Mapped[uuid.UUID] = mapped_column(
1024+
ForeignKey("resource_exports.id", ondelete="CASCADE"), index=True
1025+
)
1026+
resource_export: Mapped["ResourceExportModel"] = relationship()
1027+
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
1028+
1029+
1030+
class ExportedFleetModel(BaseModel):
1031+
__tablename__ = "exported_fleets"
1032+
__table_args__ = (
1033+
UniqueConstraint(
1034+
"resource_export_id", "fleet_id", name="uq_exported_fleets_resource_export_id_fleet_id"
1035+
),
1036+
)
1037+
1038+
id: Mapped[uuid.UUID] = mapped_column(
1039+
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
1040+
)
1041+
resource_export_id: Mapped[uuid.UUID] = mapped_column(
1042+
ForeignKey("resource_exports.id", ondelete="CASCADE"), index=True
1043+
)
1044+
resource_export: Mapped["ResourceExportModel"] = relationship()
1045+
fleet_id: Mapped[uuid.UUID] = mapped_column(
1046+
ForeignKey("fleets.id", ondelete="CASCADE"), index=True
1047+
)
1048+
fleet: Mapped["FleetModel"] = relationship()

src/dstack/_internal/server/routers/fleets.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from dstack._internal.core.models.fleets import Fleet, FleetPlan
1010
from dstack._internal.server.compatibility.common import patch_offers_list
1111
from dstack._internal.server.db import get_session
12+
from dstack._internal.server.deps import Project
1213
from dstack._internal.server.models import ProjectModel, UserModel
1314
from dstack._internal.server.schemas.fleets import (
1415
ApplyFleetPlanRequest,
@@ -18,8 +19,13 @@
1819
GetFleetPlanRequest,
1920
GetFleetRequest,
2021
ListFleetsRequest,
22+
ListProjectFleetsRequest,
23+
)
24+
from dstack._internal.server.security.permissions import (
25+
Authenticated,
26+
ProjectMember,
27+
check_can_access_fleet,
2128
)
22-
from dstack._internal.server.security.permissions import Authenticated, ProjectMember
2329
from dstack._internal.server.utils.routers import (
2430
CustomORJSONResponse,
2531
get_base_api_additional_responses,
@@ -58,6 +64,7 @@ async def list_fleets(
5864
user=user,
5965
project_name=body.project_name,
6066
only_active=body.only_active,
67+
include_imported=body.include_imported,
6168
prev_created_at=body.prev_created_at,
6269
prev_id=body.prev_id,
6370
limit=body.limit,
@@ -68,6 +75,7 @@ async def list_fleets(
6875

6976
@project_router.post("/list", response_model=List[Fleet])
7077
async def list_project_fleets(
78+
body: Optional[ListProjectFleetsRequest] = None,
7179
session: AsyncSession = Depends(get_session),
7280
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
7381
):
@@ -76,25 +84,34 @@ async def list_project_fleets(
7684
Includes only active fleet instances. To list all fleet instances, use `/api/instances/list`.
7785
"""
7886
_, project = user_project
87+
if body is None:
88+
body = ListProjectFleetsRequest()
7989
return CustomORJSONResponse(
80-
await fleets_services.list_project_fleets(session=session, project=project)
90+
await fleets_services.list_project_fleets(
91+
session=session,
92+
project=project,
93+
include_imported=body.include_imported,
94+
)
8195
)
8296

8397

8498
@project_router.post("/get", response_model=Fleet)
8599
async def get_fleet(
86100
body: GetFleetRequest,
87101
session: AsyncSession = Depends(get_session),
88-
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
102+
user: UserModel = Depends(Authenticated()),
103+
project: ProjectModel = Depends(Project()),
89104
):
90105
"""
91106
Returns a fleet given `name` or `id`.
92107
If given `name`, does not return deleted fleets.
93108
If given `id`, returns deleted fleets.
94109
"""
95-
_, project = user_project
110+
await check_can_access_fleet(
111+
session=session, user=user, fleet_project=project, fleet_name_or_id=body.get_name_or_id()
112+
)
96113
fleet = await fleets_services.get_fleet(
97-
session=session, project=project, name=body.name, fleet_id=body.id
114+
session=session, project=project, name_or_id=body.get_name_or_id()
98115
)
99116
if fleet is None:
100117
raise ResourceNotExistsError()

src/dstack/_internal/server/routers/instances.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@
77
from dstack._internal.core.errors import ResourceNotExistsError
88
from dstack._internal.core.models.instances import Instance
99
from dstack._internal.server.db import get_session
10+
from dstack._internal.server.deps import Project
1011
from dstack._internal.server.models import ProjectModel, UserModel
1112
from dstack._internal.server.schemas.instances import (
1213
GetInstanceHealthChecksRequest,
1314
GetInstanceHealthChecksResponse,
1415
GetInstanceRequest,
1516
ListInstancesRequest,
1617
)
17-
from dstack._internal.server.security.permissions import Authenticated, ProjectMember
18+
from dstack._internal.server.security.permissions import (
19+
Authenticated,
20+
ProjectMember,
21+
check_can_access_instance,
22+
)
1823
from dstack._internal.server.utils.routers import (
1924
CustomORJSONResponse,
2025
get_base_api_additional_responses,
@@ -52,6 +57,7 @@ async def list_instances(
5257
project_names=body.project_names,
5358
fleet_ids=body.fleet_ids,
5459
only_active=body.only_active,
60+
include_imported=body.include_imported,
5561
prev_created_at=body.prev_created_at,
5662
prev_id=body.prev_id,
5763
limit=body.limit,
@@ -83,12 +89,15 @@ async def get_instance_health_checks(
8389
async def get_instance(
8490
body: GetInstanceRequest,
8591
session: Annotated[AsyncSession, Depends(get_session)],
86-
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectMember())],
92+
user: Annotated[UserModel, Depends(Authenticated())],
93+
project: Annotated[ProjectModel, Depends(Project())],
8794
):
8895
"""
8996
Returns an instance given its ID.
9097
"""
91-
_, project = user_project
98+
await check_can_access_instance(
99+
session=session, user=user, instance_project=project, instance_id=body.id
100+
)
92101
instance = await instances_services.get_instance(
93102
session=session, project=project, instance_id=body.id
94103
)

0 commit comments

Comments
 (0)