Skip to content

Commit bfe44d3

Browse files
authored
Fleet sharing main mechanisms (#3629)
- 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 98fc29b commit bfe44d3

File tree

25 files changed

+1798
-47
lines changed

25 files changed

+1798
-47
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export const useFilters = () => {
180180
return {
181181
...params,
182182
only_active: onlyActive,
183+
include_imported: true,
183184
} as Partial<TFleetListRequestParams>;
184185
}, [propertyFilterQuery, onlyActive]);
185186

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const useFilters = () => {
7676
return {
7777
...params,
7878
only_active: onlyActive,
79+
include_imported: true,
7980
} as Partial<TInstanceListRequestParams>;
8081
}, [propertyFilterQuery, onlyActive]);
8182

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, current_project=self.api.project, verbose=args.verbose)
9999
return
@@ -107,7 +107,7 @@ def _list(self, args: argparse.Namespace):
107107
)
108108
)
109109
time.sleep(LIVE_TABLE_PROVISION_INTERVAL_SECS)
110-
fleets = self.api.client.fleets.list(self.api.project)
110+
fleets = self.api.client.fleets.list(self.api.project, include_imported=True)
111111
except KeyboardInterrupt:
112112
pass
113113

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Add exports
2+
3+
Revision ID: 5e8c7a9202bc
4+
Revises: 46150101edec
5+
Create Date: 2026-03-04 22:21:54.971260+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 = "5e8c7a9202bc"
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+
"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_exports_project_id_projects"),
36+
ondelete="CASCADE",
37+
),
38+
sa.PrimaryKeyConstraint("id", name=op.f("pk_exports")),
39+
sa.UniqueConstraint("project_id", "name", name="uq_exports_project_id_name"),
40+
)
41+
with op.batch_alter_table("exports", schema=None) as batch_op:
42+
batch_op.create_index(batch_op.f("ix_exports_project_id"), ["project_id"], unique=False)
43+
44+
op.create_table(
45+
"exported_fleets",
46+
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
47+
sa.Column("export_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
48+
sa.Column("fleet_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
49+
sa.ForeignKeyConstraint(
50+
["export_id"],
51+
["exports.id"],
52+
name=op.f("fk_exported_fleets_export_id_exports"),
53+
ondelete="CASCADE",
54+
),
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.PrimaryKeyConstraint("id", name=op.f("pk_exported_fleets")),
62+
sa.UniqueConstraint("export_id", "fleet_id", name="uq_exported_fleets_export_id_fleet_id"),
63+
)
64+
with op.batch_alter_table("exported_fleets", schema=None) as batch_op:
65+
batch_op.create_index(
66+
batch_op.f("ix_exported_fleets_export_id"), ["export_id"], unique=False
67+
)
68+
batch_op.create_index(
69+
batch_op.f("ix_exported_fleets_fleet_id"), ["fleet_id"], unique=False
70+
)
71+
72+
op.create_table(
73+
"imports",
74+
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
75+
sa.Column(
76+
"project_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False
77+
),
78+
sa.Column("export_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
79+
sa.Column("created_at", dstack._internal.server.models.NaiveDateTime(), nullable=False),
80+
sa.ForeignKeyConstraint(
81+
["export_id"],
82+
["exports.id"],
83+
name=op.f("fk_imports_export_id_exports"),
84+
ondelete="CASCADE",
85+
),
86+
sa.ForeignKeyConstraint(
87+
["project_id"],
88+
["projects.id"],
89+
name=op.f("fk_imports_project_id_projects"),
90+
ondelete="CASCADE",
91+
),
92+
sa.PrimaryKeyConstraint("id", name=op.f("pk_imports")),
93+
sa.UniqueConstraint("project_id", "export_id", name="uq_imports_project_id_export_id"),
94+
)
95+
with op.batch_alter_table("imports", schema=None) as batch_op:
96+
batch_op.create_index(batch_op.f("ix_imports_export_id"), ["export_id"], unique=False)
97+
batch_op.create_index(batch_op.f("ix_imports_project_id"), ["project_id"], unique=False)
98+
99+
# ### end Alembic commands ###
100+
101+
102+
def downgrade() -> None:
103+
# ### commands auto generated by Alembic - please adjust! ###
104+
with op.batch_alter_table("imports", schema=None) as batch_op:
105+
batch_op.drop_index(batch_op.f("ix_imports_project_id"))
106+
batch_op.drop_index(batch_op.f("ix_imports_export_id"))
107+
108+
op.drop_table("imports")
109+
with op.batch_alter_table("exported_fleets", schema=None) as batch_op:
110+
batch_op.drop_index(batch_op.f("ix_exported_fleets_fleet_id"))
111+
batch_op.drop_index(batch_op.f("ix_exported_fleets_export_id"))
112+
113+
op.drop_table("exported_fleets")
114+
with op.batch_alter_table("exports", schema=None) as batch_op:
115+
batch_op.drop_index(batch_op.f("ix_exports_project_id"))
116+
117+
op.drop_table("exports")
118+
# ### end Alembic commands ###

src/dstack/_internal/server/models.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,3 +978,63 @@ 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 ExportModel(BaseModel):
984+
__tablename__ = "exports"
985+
__table_args__ = (UniqueConstraint("project_id", "name", name="uq_exports_project_id_name"),)
986+
987+
id: Mapped[uuid.UUID] = mapped_column(
988+
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
989+
)
990+
name: Mapped[str] = mapped_column(String(100))
991+
project_id: Mapped[uuid.UUID] = mapped_column(
992+
ForeignKey("projects.id", ondelete="CASCADE"), index=True
993+
)
994+
project: Mapped["ProjectModel"] = relationship()
995+
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
996+
imports: Mapped[List["ImportModel"]] = relationship(back_populates="export")
997+
exported_fleets: Mapped[List["ExportedFleetModel"]] = relationship(back_populates="export")
998+
999+
1000+
class ImportModel(BaseModel):
1001+
__tablename__ = "imports"
1002+
__table_args__ = (
1003+
UniqueConstraint(
1004+
"project_id",
1005+
"export_id",
1006+
name="uq_imports_project_id_export_id",
1007+
),
1008+
)
1009+
1010+
id: Mapped[uuid.UUID] = mapped_column(
1011+
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
1012+
)
1013+
project_id: Mapped[uuid.UUID] = mapped_column(
1014+
ForeignKey("projects.id", ondelete="CASCADE"), index=True
1015+
)
1016+
project: Mapped["ProjectModel"] = relationship()
1017+
export_id: Mapped[uuid.UUID] = mapped_column(
1018+
ForeignKey("exports.id", ondelete="CASCADE"), index=True
1019+
)
1020+
export: Mapped["ExportModel"] = relationship()
1021+
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
1022+
1023+
1024+
class ExportedFleetModel(BaseModel):
1025+
__tablename__ = "exported_fleets"
1026+
__table_args__ = (
1027+
UniqueConstraint("export_id", "fleet_id", name="uq_exported_fleets_export_id_fleet_id"),
1028+
)
1029+
1030+
id: Mapped[uuid.UUID] = mapped_column(
1031+
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
1032+
)
1033+
export_id: Mapped[uuid.UUID] = mapped_column(
1034+
ForeignKey("exports.id", ondelete="CASCADE"), index=True
1035+
)
1036+
export: Mapped["ExportModel"] = relationship()
1037+
fleet_id: Mapped[uuid.UUID] = mapped_column(
1038+
ForeignKey("fleets.id", ondelete="CASCADE"), index=True
1039+
)
1040+
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
)

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,38 @@
44

55
from pydantic import Field
66

7+
from dstack._internal.core.errors import ServerClientError
78
from dstack._internal.core.models.common import CoreModel
89
from dstack._internal.core.models.fleets import ApplyFleetPlanInput, FleetSpec
10+
from dstack._internal.utils.common import EntityID, EntityName, EntityNameOrID
911

1012

1113
class ListFleetsRequest(CoreModel):
1214
project_name: Optional[str] = None
1315
only_active: bool = False
16+
include_imported: bool = False
1417
prev_created_at: Optional[datetime] = None
1518
prev_id: Optional[UUID] = None
1619
limit: int = Field(100, ge=0, le=100)
1720
ascending: bool = False
1821

1922

23+
class ListProjectFleetsRequest(CoreModel):
24+
include_imported: bool = False
25+
26+
2027
class GetFleetRequest(CoreModel):
2128
name: Optional[str]
2229
id: Optional[UUID] = None
2330

31+
def get_name_or_id(self) -> EntityNameOrID:
32+
if self.id is not None:
33+
return EntityID(id=self.id)
34+
elif self.name is not None:
35+
return EntityName(name=self.name)
36+
else:
37+
raise ServerClientError("name or id must be specified")
38+
2439

2540
class GetFleetPlanRequest(CoreModel):
2641
spec: FleetSpec

0 commit comments

Comments
 (0)