Skip to content

Commit bc5f0ac

Browse files
authored
Add /api/instances/list (#2199)
Add an API method for listing instances with filtering and pagination. This method will be used in the new Instances page in the UI. It is similar to the deprecated `/api/pools/list_instances` method, except it allows filtering by fleet and not by pool. Also add the `fleet_id` and `fleet_name` fields to the instance model so that the Instances page in the UI can display fleet names and provide links to fleet details.
1 parent 91bdc80 commit bc5f0ac

File tree

11 files changed

+396
-16
lines changed

11 files changed

+396
-16
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class Instance(CoreModel):
2121
backend: Optional[BackendType] = None
2222
instance_type: Optional[InstanceType] = None
2323
name: str
24+
fleet_id: Optional[UUID] = None
25+
fleet_name: Optional[str] = None
2426
instance_num: int
2527
pool_name: Optional[str] = None
2628
job_name: Optional[str] = None

src/dstack/_internal/server/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
backends,
2525
fleets,
2626
gateways,
27+
instances,
2728
logs,
2829
metrics,
2930
pools,
@@ -169,6 +170,7 @@ def register_routes(app: FastAPI, ui: bool = True):
169170
app.include_router(backends.project_router)
170171
app.include_router(fleets.root_router)
171172
app.include_router(fleets.project_router)
173+
app.include_router(instances.root_router)
172174
app.include_router(repos.router)
173175
app.include_router(runs.root_router)
174176
app.include_router(runs.project_router)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from typing import List
2+
3+
from fastapi import APIRouter, Depends
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
import dstack._internal.server.services.pools as pools
7+
from dstack._internal.core.models.pools import Instance
8+
from dstack._internal.server.db import get_session
9+
from dstack._internal.server.models import UserModel
10+
from dstack._internal.server.schemas.instances import ListInstancesRequest
11+
from dstack._internal.server.security.permissions import Authenticated
12+
from dstack._internal.server.utils.routers import get_base_api_additional_responses
13+
14+
root_router = APIRouter(
15+
prefix="/api/instances",
16+
tags=["instances"],
17+
responses=get_base_api_additional_responses(),
18+
)
19+
20+
21+
@root_router.post("/list")
22+
async def list_instances(
23+
body: ListInstancesRequest,
24+
session: AsyncSession = Depends(get_session),
25+
user: UserModel = Depends(Authenticated()),
26+
) -> List[Instance]:
27+
"""
28+
Returns all instances visible to user sorted by descending `created_at`.
29+
`project_names` and `fleet_ids` can be specified as filters.
30+
31+
The results are paginated. To get the next page, pass `created_at` and `id` of
32+
the last instance from the previous page as `prev_created_at` and `prev_id`.
33+
"""
34+
return await pools.list_user_pool_instances(
35+
session=session,
36+
user=user,
37+
project_names=body.project_names,
38+
fleet_ids=body.fleet_ids,
39+
pool_name=None,
40+
only_active=body.only_active,
41+
prev_created_at=body.prev_created_at,
42+
prev_id=body.prev_id,
43+
limit=body.limit,
44+
ascending=body.ascending,
45+
)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ async def list_pool_instances(
4545
return await pools.list_user_pool_instances(
4646
session=session,
4747
user=user,
48-
project_name=body.project_name,
48+
project_names=[body.project_name] if body.project_name is not None else None,
49+
fleet_ids=None,
4950
pool_name=body.pool_name,
5051
only_active=body.only_active,
5152
prev_created_at=body.prev_created_at,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from datetime import datetime
2+
from typing import Optional
3+
from uuid import UUID
4+
5+
from dstack._internal.core.models.common import CoreModel
6+
7+
8+
class ListInstancesRequest(CoreModel):
9+
project_names: Optional[list[str]] = None
10+
fleet_ids: Optional[list[UUID]] = None
11+
only_active: bool = False
12+
prev_created_at: Optional[datetime] = None
13+
prev_id: Optional[UUID] = None
14+
limit: int = 1000
15+
ascending: bool = False

src/dstack/_internal/server/services/pools.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ipaddress
22
import uuid
3+
from collections.abc import Container, Iterable
34
from datetime import datetime, timezone
45
from typing import List, Optional
56

@@ -69,37 +70,54 @@ async def list_project_pools(session: AsyncSession, project: ProjectModel) -> Li
6970

7071

7172
async def get_pool(
72-
session: AsyncSession, project: ProjectModel, pool_name: str, select_deleted: bool = False
73+
session: AsyncSession,
74+
project: ProjectModel,
75+
pool_name: str,
76+
select_deleted: bool = False,
77+
load_instance_fleets: bool = False,
7378
) -> Optional[PoolModel]:
7479
filters = [
7580
PoolModel.name == pool_name,
7681
PoolModel.project_id == project.id,
7782
]
7883
if not select_deleted:
7984
filters.append(PoolModel.deleted == False)
80-
res = await session.scalars(select(PoolModel).where(*filters))
85+
query = select(PoolModel).where(*filters)
86+
if load_instance_fleets:
87+
query = query.options(joinedload(PoolModel.instances, InstanceModel.fleet))
88+
res = await session.scalars(query)
8189
return res.one_or_none()
8290

8391

8492
async def get_or_create_pool_by_name(
85-
session: AsyncSession, project: ProjectModel, pool_name: Optional[str]
93+
session: AsyncSession,
94+
project: ProjectModel,
95+
pool_name: Optional[str],
96+
load_instance_fleets: bool = False,
8697
) -> PoolModel:
8798
if pool_name is None:
8899
if project.default_pool_id is not None:
89-
return await get_default_pool_or_error(session, project)
90-
default_pool = await get_pool(session, project, DEFAULT_POOL_NAME)
100+
return await get_default_pool_or_error(session, project, load_instance_fleets)
101+
default_pool = await get_pool(
102+
session, project, DEFAULT_POOL_NAME, load_instance_fleets=load_instance_fleets
103+
)
91104
if default_pool is not None:
92105
await set_default_pool(session, project, DEFAULT_POOL_NAME)
93106
return default_pool
94107
return await create_pool(session, project, DEFAULT_POOL_NAME)
95-
pool = await get_pool(session, project, pool_name)
108+
pool = await get_pool(session, project, pool_name, load_instance_fleets=load_instance_fleets)
96109
if pool is not None:
97110
return pool
98111
return await create_pool(session, project, pool_name)
99112

100113

101-
async def get_default_pool_or_error(session: AsyncSession, project: ProjectModel) -> PoolModel:
102-
res = await session.execute(select(PoolModel).where(PoolModel.id == project.default_pool_id))
114+
async def get_default_pool_or_error(
115+
session: AsyncSession, project: ProjectModel, load_instance_fleets: bool = False
116+
) -> PoolModel:
117+
query = select(PoolModel).where(PoolModel.id == project.default_pool_id)
118+
if load_instance_fleets:
119+
query = query.options(joinedload(PoolModel.instances, InstanceModel.fleet))
120+
res = await session.execute(query)
103121
return res.scalar_one()
104122

105123

@@ -201,11 +219,13 @@ async def show_pool_instances(
201219
session: AsyncSession, project: ProjectModel, pool_name: Optional[str]
202220
) -> PoolInstances:
203221
if pool_name is not None:
204-
pool = await get_pool(session, project, pool_name)
222+
pool = await get_pool(session, project, pool_name, load_instance_fleets=True)
205223
if pool is None:
206224
raise ResourceNotExistsError("Pool not found")
207225
else:
208-
pool = await get_or_create_pool_by_name(session, project, pool_name)
226+
pool = await get_or_create_pool_by_name(
227+
session, project, pool_name, load_instance_fleets=True
228+
)
209229
pool_instances = get_pool_instances(pool)
210230
instances = list(map(instance_model_to_instance, pool_instances))
211231
return PoolInstances(
@@ -223,6 +243,8 @@ def instance_model_to_instance(instance_model: InstanceModel) -> Instance:
223243
id=instance_model.id,
224244
project_name=instance_model.project.name,
225245
name=instance_model.name,
246+
fleet_id=instance_model.fleet_id,
247+
fleet_name=instance_model.fleet.name if instance_model.fleet else None,
226248
instance_num=instance_model.instance_num,
227249
status=instance_model.status,
228250
unreachable=instance_model.unreachable,
@@ -478,6 +500,7 @@ def filter_pool_instances(
478500
async def list_pools_instance_models(
479501
session: AsyncSession,
480502
projects: List[ProjectModel],
503+
fleet_ids: Optional[Iterable[uuid.UUID]],
481504
pool: Optional[PoolModel],
482505
only_active: bool,
483506
prev_created_at: Optional[datetime],
@@ -488,6 +511,8 @@ async def list_pools_instance_models(
488511
filters: List = [
489512
InstanceModel.project_id.in_(p.id for p in projects),
490513
]
514+
if fleet_ids is not None:
515+
filters.append(InstanceModel.fleet_id.in_(fleet_ids))
491516
if pool is not None:
492517
filters.append(InstanceModel.pool_id == pool.id)
493518
if only_active:
@@ -533,7 +558,7 @@ async def list_pools_instance_models(
533558
.where(*filters)
534559
.order_by(*order_by)
535560
.limit(limit)
536-
.options(joinedload(InstanceModel.pool))
561+
.options(joinedload(InstanceModel.pool), joinedload(InstanceModel.fleet))
537562
)
538563
instance_models = list(res.scalars().all())
539564
return instance_models
@@ -542,7 +567,8 @@ async def list_pools_instance_models(
542567
async def list_user_pool_instances(
543568
session: AsyncSession,
544569
user: UserModel,
545-
project_name: Optional[str],
570+
project_names: Optional[Container[str]],
571+
fleet_ids: Optional[Iterable[uuid.UUID]],
546572
pool_name: Optional[str],
547573
only_active: bool,
548574
prev_created_at: Optional[datetime],
@@ -558,8 +584,8 @@ async def list_user_pool_instances(
558584
return []
559585

560586
pool = None
561-
if project_name is not None:
562-
projects = [proj for proj in projects if proj.name == project_name]
587+
if project_names is not None:
588+
projects = [proj for proj in projects if proj.name in project_names]
563589
if len(projects) == 0:
564590
return []
565591
if pool_name is not None:
@@ -573,6 +599,7 @@ async def list_user_pool_instances(
573599
instance_models = await list_pools_instance_models(
574600
session=session,
575601
projects=projects,
602+
fleet_ids=fleet_ids,
576603
pool=pool,
577604
only_active=only_active,
578605
prev_created_at=prev_created_at,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ async def create_instance(
480480
region: str = "eu-west",
481481
remote_connection_info: Optional[RemoteConnectionInfo] = None,
482482
job_provisioning_data: Optional[JobProvisioningData] = None,
483+
name: str = "test_instance",
483484
) -> InstanceModel:
484485
if instance_id is None:
485486
instance_id = uuid.uuid4()
@@ -544,7 +545,7 @@ async def create_instance(
544545

545546
im = InstanceModel(
546547
id=instance_id,
547-
name="test_instance",
548+
name=name,
548549
instance_num=instance_num,
549550
pool=pool,
550551
fleet=fleet,

src/tests/_internal/server/routers/test_fleets.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ async def test_creates_fleet(self, test_db, session: AsyncSession, client: Async
316316
"id": "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e",
317317
"project_name": project.name,
318318
"name": f"{spec.configuration.name}-0",
319+
"fleet_id": "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e",
320+
"fleet_name": spec.configuration.name,
319321
"instance_num": 0,
320322
"job_name": None,
321323
"hostname": None,
@@ -443,6 +445,8 @@ async def test_creates_ssh_fleet(self, test_db, session: AsyncSession, client: A
443445
},
444446
},
445447
"name": f"{spec.configuration.name}-0",
448+
"fleet_id": "1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e",
449+
"fleet_name": spec.configuration.name,
446450
"instance_num": 0,
447451
"pool_name": None,
448452
"job_name": None,

0 commit comments

Comments
 (0)