Skip to content

Commit fe08e54

Browse files
author
Andrey Cheptsov
committed
Show offers for elastic container fleets
Use run-capable offer lookup for cloud fleets with nodes.min=0 and nodes.target=0, while keeping create-instance filtering for non-elastic fleets.\n\nAdds router tests for elastic container backend offers and preserves no-offers behavior for non-elastic container fleets.
1 parent 4c90f9f commit fe08e54

File tree

2 files changed

+102
-7
lines changed

2 files changed

+102
-7
lines changed

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

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -442,13 +442,25 @@ async def get_plan(
442442

443443
offers = []
444444
if effective_spec.configuration.ssh_config is None:
445-
offers_with_backends = await get_create_instance_offers(
446-
project=project,
447-
profile=effective_spec.merged_profile,
448-
requirements=get_fleet_requirements(effective_spec),
449-
fleet_spec=effective_spec,
450-
blocks=effective_spec.configuration.blocks,
451-
)
445+
requirements = get_fleet_requirements(effective_spec)
446+
if _is_elastic_cloud_fleet_spec(effective_spec):
447+
offers_with_backends = await offers_services.get_offers_by_requirements(
448+
project=project,
449+
profile=effective_spec.merged_profile,
450+
requirements=requirements,
451+
multinode=(
452+
effective_spec.configuration.placement == InstanceGroupPlacement.CLUSTER
453+
),
454+
blocks=effective_spec.configuration.blocks,
455+
)
456+
else:
457+
offers_with_backends = await get_create_instance_offers(
458+
project=project,
459+
profile=effective_spec.merged_profile,
460+
requirements=requirements,
461+
fleet_spec=effective_spec,
462+
blocks=effective_spec.configuration.blocks,
463+
)
452464
offers = [offer for _, offer in offers_with_backends]
453465

454466
_remove_fleet_spec_sensitive_info(effective_spec)
@@ -468,6 +480,16 @@ async def get_plan(
468480
return plan
469481

470482

483+
def _is_elastic_cloud_fleet_spec(fleet_spec: FleetSpec) -> bool:
484+
nodes = fleet_spec.configuration.nodes
485+
return (
486+
fleet_spec.configuration.ssh_config is None
487+
and nodes is not None
488+
and nodes.min == 0
489+
and nodes.target == 0
490+
)
491+
492+
471493
async def get_create_instance_offers(
472494
project: ProjectModel,
473495
profile: Profile,

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from dstack._internal.core.models.common import EntityReference
1515
from dstack._internal.core.models.fleets import (
1616
FleetConfiguration,
17+
FleetNodesSpec,
1718
FleetStatus,
1819
InstanceGroupPlacement,
1920
SSHHostParams,
@@ -2028,6 +2029,78 @@ async def test_returns_create_plan_for_new_fleet(
20282029
"action": "create",
20292030
}
20302031

2032+
@pytest.mark.asyncio
2033+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
2034+
async def test_returns_offers_for_elastic_container_backend_fleet(
2035+
self, test_db, session: AsyncSession, client: AsyncClient
2036+
):
2037+
user = await create_user(session=session, global_role=GlobalRole.USER)
2038+
project = await create_project(session=session, owner=user)
2039+
await add_project_member(
2040+
session=session, project=project, user=user, project_role=ProjectRole.USER
2041+
)
2042+
offer = get_instance_offer_with_availability(
2043+
backend=BackendType.RUNPOD,
2044+
region="US-OR-1",
2045+
price=0.7185,
2046+
)
2047+
spec = get_fleet_spec(
2048+
conf=get_fleet_configuration(nodes=FleetNodesSpec(min=0, target=0, max=1))
2049+
)
2050+
with patch("dstack._internal.server.services.backends.get_project_backends") as m:
2051+
backend_mock = Mock()
2052+
m.return_value = [backend_mock]
2053+
backend_mock.TYPE = BackendType.RUNPOD
2054+
backend_mock.compute.return_value.get_offers.return_value = [offer]
2055+
response = await client.post(
2056+
f"/api/project/{project.name}/fleets/get_plan",
2057+
headers=get_auth_headers(user.token),
2058+
json={"spec": spec.dict()},
2059+
)
2060+
backend_mock.compute.return_value.get_offers.assert_called_once()
2061+
2062+
response_json = response.json()
2063+
assert response.status_code == 200, response_json
2064+
assert response_json["offers"] == [json.loads(offer.json())]
2065+
assert response_json["total_offers"] == 1
2066+
assert response_json["max_offer_price"] == offer.price
2067+
2068+
@pytest.mark.asyncio
2069+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
2070+
async def test_returns_no_offers_for_non_elastic_container_backend_fleet(
2071+
self, test_db, session: AsyncSession, client: AsyncClient
2072+
):
2073+
user = await create_user(session=session, global_role=GlobalRole.USER)
2074+
project = await create_project(session=session, owner=user)
2075+
await add_project_member(
2076+
session=session, project=project, user=user, project_role=ProjectRole.USER
2077+
)
2078+
offer = get_instance_offer_with_availability(
2079+
backend=BackendType.RUNPOD,
2080+
region="US-OR-1",
2081+
price=0.7185,
2082+
)
2083+
spec = get_fleet_spec(
2084+
conf=get_fleet_configuration(nodes=FleetNodesSpec(min=0, target=1, max=1))
2085+
)
2086+
with patch("dstack._internal.server.services.backends.get_project_backends") as m:
2087+
backend_mock = Mock()
2088+
m.return_value = [backend_mock]
2089+
backend_mock.TYPE = BackendType.RUNPOD
2090+
backend_mock.compute.return_value.get_offers.return_value = [offer]
2091+
response = await client.post(
2092+
f"/api/project/{project.name}/fleets/get_plan",
2093+
headers=get_auth_headers(user.token),
2094+
json={"spec": spec.dict()},
2095+
)
2096+
backend_mock.compute.return_value.get_offers.assert_called_once()
2097+
2098+
response_json = response.json()
2099+
assert response.status_code == 200, response_json
2100+
assert response_json["offers"] == []
2101+
assert response_json["total_offers"] == 0
2102+
assert response_json["max_offer_price"] is None
2103+
20312104
@pytest.mark.asyncio
20322105
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
20332106
async def test_returns_update_plan_for_existing_fleet(

0 commit comments

Comments
 (0)