Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/pages/Fleets/List/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export const useFilters = (localStorePrefix = 'fleet-list-page') => {
return {
...params,
only_active: onlyActive,
include_imported: true,
} as Partial<TFleetListRequestParams>;
}, [propertyFilterQuery, onlyActive]);

Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/Instances/List/hooks/useFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const useFilters = (localStorePrefix = 'instances-list-page') => {
return {
...params,
only_active: onlyActive,
include_imported: true,
} as Partial<TInstanceListRequestParams>;
}, [propertyFilterQuery, onlyActive]);

Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/fleet.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ declare type TSpotPolicy = 'spot' | 'on-demand' | 'auto';
declare type TFleetListRequestParams = TBaseRequestListParams & {
project_name?: string;
only_active?: boolean;
include_imported?: boolean;
};

declare interface ISSHHostParamsRequest {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/instance.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ declare type TInstanceListRequestParams = TBaseRequestListParams & {
project_names?: string[];
fleet_ids?: string[];
only_active?: boolean;
include_imported?: boolean;
};

declare type TInstanceStatus =
Expand Down
4 changes: 2 additions & 2 deletions src/dstack/_internal/cli/commands/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def _command(self, args: argparse.Namespace):
args.subfunc(args)

def _list(self, args: argparse.Namespace):
fleets = self.api.client.fleets.list(self.api.project)
fleets = self.api.client.fleets.list(self.api.project, include_imported=True)
if not args.watch:
print_fleets_table(fleets, verbose=args.verbose)
return
Expand All @@ -103,7 +103,7 @@ def _list(self, args: argparse.Namespace):
while True:
live.update(get_fleets_table(fleets, verbose=args.verbose))
time.sleep(LIVE_TABLE_PROVISION_INTERVAL_SECS)
fleets = self.api.client.fleets.list(self.api.project)
fleets = self.api.client.fleets.list(self.api.project, include_imported=True)
except KeyboardInterrupt:
pass

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Add exports

Revision ID: 5e8c7a9202bc
Revises: 46150101edec
Create Date: 2026-03-04 22:21:54.971260+00:00

"""

import sqlalchemy as sa
import sqlalchemy_utils
from alembic import op

import dstack._internal.server.models

# revision identifiers, used by Alembic.
revision = "5e8c7a9202bc"
down_revision = "46150101edec"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"exports",
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column(
"project_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False
),
sa.Column("created_at", dstack._internal.server.models.NaiveDateTime(), nullable=False),
sa.ForeignKeyConstraint(
["project_id"],
["projects.id"],
name=op.f("fk_exports_project_id_projects"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_exports")),
sa.UniqueConstraint("project_id", "name", name="uq_exports_project_id_name"),
)
with op.batch_alter_table("exports", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_exports_project_id"), ["project_id"], unique=False)

op.create_table(
"exported_fleets",
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
sa.Column("export_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
sa.Column("fleet_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
sa.ForeignKeyConstraint(
["export_id"],
["exports.id"],
name=op.f("fk_exported_fleets_export_id_exports"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["fleet_id"],
["fleets.id"],
name=op.f("fk_exported_fleets_fleet_id_fleets"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_exported_fleets")),
sa.UniqueConstraint("export_id", "fleet_id", name="uq_exported_fleets_export_id_fleet_id"),
)
with op.batch_alter_table("exported_fleets", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_exported_fleets_export_id"), ["export_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_exported_fleets_fleet_id"), ["fleet_id"], unique=False
)

op.create_table(
"imports",
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
sa.Column(
"project_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False
),
sa.Column("export_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
sa.Column("created_at", dstack._internal.server.models.NaiveDateTime(), nullable=False),
sa.ForeignKeyConstraint(
["export_id"],
["exports.id"],
name=op.f("fk_imports_export_id_exports"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["project_id"],
["projects.id"],
name=op.f("fk_imports_project_id_projects"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_imports")),
sa.UniqueConstraint("project_id", "export_id", name="uq_imports_project_id_export_id"),
)
with op.batch_alter_table("imports", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_imports_export_id"), ["export_id"], unique=False)
batch_op.create_index(batch_op.f("ix_imports_project_id"), ["project_id"], unique=False)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("imports", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_imports_project_id"))
batch_op.drop_index(batch_op.f("ix_imports_export_id"))

op.drop_table("imports")
with op.batch_alter_table("exported_fleets", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_exported_fleets_fleet_id"))
batch_op.drop_index(batch_op.f("ix_exported_fleets_export_id"))

op.drop_table("exported_fleets")
with op.batch_alter_table("exports", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_exports_project_id"))

op.drop_table("exports")
# ### end Alembic commands ###
60 changes: 60 additions & 0 deletions src/dstack/_internal/server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,3 +978,63 @@ class EventTargetModel(BaseModel):
)
entity_id: Mapped[uuid.UUID] = mapped_column(UUIDType(binary=False), index=True)
entity_name: Mapped[str] = mapped_column(String(200))


class ExportModel(BaseModel):
__tablename__ = "exports"
__table_args__ = (UniqueConstraint("project_id", "name", name="uq_exports_project_id_name"),)

id: Mapped[uuid.UUID] = mapped_column(
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100))
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
project: Mapped["ProjectModel"] = relationship()
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
imports: Mapped[List["ImportModel"]] = relationship(back_populates="export")
exported_fleets: Mapped[List["ExportedFleetModel"]] = relationship(back_populates="export")


class ImportModel(BaseModel):
__tablename__ = "imports"
__table_args__ = (
UniqueConstraint(
"project_id",
"export_id",
name="uq_imports_project_id_export_id",
),
)

id: Mapped[uuid.UUID] = mapped_column(
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
project: Mapped["ProjectModel"] = relationship()
export_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("exports.id", ondelete="CASCADE"), index=True
)
export: Mapped["ExportModel"] = relationship()
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)


class ExportedFleetModel(BaseModel):
__tablename__ = "exported_fleets"
__table_args__ = (
UniqueConstraint("export_id", "fleet_id", name="uq_exported_fleets_export_id_fleet_id"),
)

id: Mapped[uuid.UUID] = mapped_column(
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
)
export_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("exports.id", ondelete="CASCADE"), index=True
)
export: Mapped["ExportModel"] = relationship()
fleet_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("fleets.id", ondelete="CASCADE"), index=True
)
fleet: Mapped["FleetModel"] = relationship()
27 changes: 22 additions & 5 deletions src/dstack/_internal/server/routers/fleets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from dstack._internal.core.models.fleets import Fleet, FleetPlan
from dstack._internal.server.compatibility.common import patch_offers_list
from dstack._internal.server.db import get_session
from dstack._internal.server.deps import Project
from dstack._internal.server.models import ProjectModel, UserModel
from dstack._internal.server.schemas.fleets import (
ApplyFleetPlanRequest,
Expand All @@ -18,8 +19,13 @@
GetFleetPlanRequest,
GetFleetRequest,
ListFleetsRequest,
ListProjectFleetsRequest,
)
from dstack._internal.server.security.permissions import (
Authenticated,
ProjectMember,
check_can_access_fleet,
)
from dstack._internal.server.security.permissions import Authenticated, ProjectMember
from dstack._internal.server.utils.routers import (
CustomORJSONResponse,
get_base_api_additional_responses,
Expand Down Expand Up @@ -58,6 +64,7 @@ async def list_fleets(
user=user,
project_name=body.project_name,
only_active=body.only_active,
include_imported=body.include_imported,
prev_created_at=body.prev_created_at,
prev_id=body.prev_id,
limit=body.limit,
Expand All @@ -68,6 +75,7 @@ async def list_fleets(

@project_router.post("/list", response_model=List[Fleet])
async def list_project_fleets(
body: Optional[ListProjectFleetsRequest] = None,
session: AsyncSession = Depends(get_session),
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
):
Expand All @@ -76,25 +84,34 @@ async def list_project_fleets(
Includes only active fleet instances. To list all fleet instances, use `/api/instances/list`.
"""
_, project = user_project
if body is None:
body = ListProjectFleetsRequest()
return CustomORJSONResponse(
await fleets_services.list_project_fleets(session=session, project=project)
await fleets_services.list_project_fleets(
session=session,
project=project,
include_imported=body.include_imported,
)
)


@project_router.post("/get", response_model=Fleet)
async def get_fleet(
body: GetFleetRequest,
session: AsyncSession = Depends(get_session),
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
user: UserModel = Depends(Authenticated()),
project: ProjectModel = Depends(Project()),
):
"""
Returns a fleet given `name` or `id`.
If given `name`, does not return deleted fleets.
If given `id`, returns deleted fleets.
"""
_, project = user_project
await check_can_access_fleet(
session=session, user=user, fleet_project=project, fleet_name_or_id=body.get_name_or_id()
)
fleet = await fleets_services.get_fleet(
session=session, project=project, name=body.name, fleet_id=body.id
session=session, project=project, name_or_id=body.get_name_or_id()
)
if fleet is None:
raise ResourceNotExistsError()
Expand Down
15 changes: 12 additions & 3 deletions src/dstack/_internal/server/routers/instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@
from dstack._internal.core.errors import ResourceNotExistsError
from dstack._internal.core.models.instances import Instance
from dstack._internal.server.db import get_session
from dstack._internal.server.deps import Project
from dstack._internal.server.models import ProjectModel, UserModel
from dstack._internal.server.schemas.instances import (
GetInstanceHealthChecksRequest,
GetInstanceHealthChecksResponse,
GetInstanceRequest,
ListInstancesRequest,
)
from dstack._internal.server.security.permissions import Authenticated, ProjectMember
from dstack._internal.server.security.permissions import (
Authenticated,
ProjectMember,
check_can_access_instance,
)
from dstack._internal.server.utils.routers import (
CustomORJSONResponse,
get_base_api_additional_responses,
Expand Down Expand Up @@ -52,6 +57,7 @@ async def list_instances(
project_names=body.project_names,
fleet_ids=body.fleet_ids,
only_active=body.only_active,
include_imported=body.include_imported,
prev_created_at=body.prev_created_at,
prev_id=body.prev_id,
limit=body.limit,
Expand Down Expand Up @@ -83,12 +89,15 @@ async def get_instance_health_checks(
async def get_instance(
body: GetInstanceRequest,
session: Annotated[AsyncSession, Depends(get_session)],
user_project: Annotated[tuple[UserModel, ProjectModel], Depends(ProjectMember())],
user: Annotated[UserModel, Depends(Authenticated())],
project: Annotated[ProjectModel, Depends(Project())],
):
"""
Returns an instance given its ID.
"""
_, project = user_project
await check_can_access_instance(
session=session, user=user, instance_project=project, instance_id=body.id
)
instance = await instances_services.get_instance(
session=session, project=project, instance_id=body.id
)
Expand Down
15 changes: 15 additions & 0 deletions src/dstack/_internal/server/schemas/fleets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,38 @@

from pydantic import Field

from dstack._internal.core.errors import ServerClientError
from dstack._internal.core.models.common import CoreModel
from dstack._internal.core.models.fleets import ApplyFleetPlanInput, FleetSpec
from dstack._internal.utils.common import EntityID, EntityName, EntityNameOrID


class ListFleetsRequest(CoreModel):
project_name: Optional[str] = None
only_active: bool = False
include_imported: bool = False
prev_created_at: Optional[datetime] = None
prev_id: Optional[UUID] = None
limit: int = Field(100, ge=0, le=100)
ascending: bool = False


class ListProjectFleetsRequest(CoreModel):
include_imported: bool = False


class GetFleetRequest(CoreModel):
name: Optional[str]
id: Optional[UUID] = None

def get_name_or_id(self) -> EntityNameOrID:
if self.id is not None:
return EntityID(id=self.id)
elif self.name is not None:
return EntityName(name=self.name)
else:
raise ServerClientError("name or id must be specified")


class GetFleetPlanRequest(CoreModel):
spec: FleetSpec
Expand Down
Loading