Skip to content

Commit f3686d6

Browse files
peterschmidt85Andrey Cheptsov
andauthored
Support --fleet in dstack offer (#3774)
* Support --fleet in dstack offer Keep plain dstack offer global when no fleets are specified, but respect explicitly selected fleets. For a single --fleet, return the same per-fleet offers that would be considered if that fleet were the chosen candidate during apply planning. For multiple --fleet values, collect offers for each selected fleet and merge them instead of picking one best fleet. * Simplify fleet offer helpers Inline the temporary dstack offer dispatcher helper, rename the remaining fleet offer helper to match its behavior, and document the single-fleet vs multi-fleet semantics in docstrings. * Avoid asyncio.run in offer CLI test The new offer CLI test helper used asyncio.run(), which clears the current event loop on Python 3.9/3.10 and made a later test_event_loop assertion fail in GitHub Actions. Use a private event loop in the helper instead so the test stays isolated. * Document asyncio isolation in offer CLI test Add a short comment explaining why the helper uses a private event loop instead of asyncio.run(): the latter clears the current loop on Python 3.9/3.10 and can break later tests in the same worker. * Tighten Python version note in offer CLI test The isolated-loop comment should not claim the same Queue construction failure on Python 3.10. Python 3.9 still constructs Queue via get_event_loop(), while 3.10 removed the loop parameter and defers loop binding until use. * Deduplicate identical backend offers across fleets * Document fleet-scoped dstack offer behavior * Clarify uncapped multi-fleet offer behavior * Clarify max_offers_per_fleet TODO --------- Co-authored-by: Andrey Cheptsov <andrey.cheptsov@github.com>
1 parent 73bac33 commit f3686d6

15 files changed

Lines changed: 782 additions & 32 deletions

File tree

docs/docs/concepts/fleets.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,20 @@ Fleet my-gcp-fleet deleted
517517
Alternatively, you can delete a fleet by passing the fleet name to `dstack fleet delete`.
518518
To terminate and delete specific instances from a fleet, pass `-i INSTANCE_NUM`.
519519

520+
### List offers
521+
522+
To inspect offers available through a fleet, pass `--fleet` to `dstack offer`.
523+
524+
<div class="termy">
525+
526+
```shell
527+
$ dstack offer --gpu H100 --fleet my-fleet
528+
```
529+
530+
</div>
531+
532+
Use `--group-by gpu,backend` to aggregate offers.
533+
520534
!!! info "What's next?"
521535
1. Check [dev environments](dev-environments.md), [tasks](tasks.md), and
522536
[services](services.md)

docs/docs/guides/protips.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,9 @@ Getting offers...
458458

459459
</div>
460460

461+
By default, `dstack offer` ignores fleet configurations and shows all available offers that match the request.
462+
To inspect offers available through a specific fleet, pass `--fleet NAME`.
463+
461464
??? info "Grouping offers"
462465
Use `--group-by` to aggregate offers. Accepted values: `gpu`, `backend`, `region`, and `count`.
463466

docs/docs/guides/troubleshooting.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,15 @@ If you run `dstack apply` and don't see any instance offers, it means that
5454
`dstack` could not find instances that match the requirements in your configuration.
5555
Below are some of the reasons why this might happen.
5656

57-
> Feel free to use `dstack offer` to view available offers.
57+
Feel free to use `dstack offer` to inspect available offers:
58+
59+
```shell
60+
# All matching offers, ignoring fleet configurations
61+
$ dstack offer --gpu H100
62+
63+
# Offers available through a specific fleet
64+
$ dstack offer --gpu H100 --fleet my-fleet
65+
```
5866

5967
#### Cause 1: No backends
6068

docs/docs/reference/cli/dstack/offer.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ Displays available offers (hardware configurations) from configured backends or
44

55
The output shows backend, region, instance type, resources, spot availability, and pricing.
66

7-
!!! info "Experimental"
8-
`dstack offer` command is currently an experimental feature. Backward compatibility is not guaranteed across releases.
9-
107
## Usage
118

129
This command accepts most of the same arguments as [`dstack apply`](apply.md).
@@ -20,9 +17,28 @@ $ dstack offer --help
2017

2118
</div>
2219

20+
## Fleet offers
21+
22+
By default, `dstack offer` ignores fleet configurations and shows all available offers that match the request.
23+
24+
Use `--fleet` to inspect offers available through specific fleets. With one `--fleet`,
25+
`dstack offer` shows offers available through that fleet. With multiple `--fleet`, it
26+
combines offers available through the selected fleets.
27+
28+
<div class="termy">
29+
30+
```shell
31+
$ dstack offer --gpu H100 --fleet my-fleet
32+
```
33+
34+
</div>
35+
36+
The same fleet filtering applies to `--group-by` output, e.g. `--group-by gpu,backend`
37+
or `--group-by gpu,backend,region`.
38+
2339
## Examples
2440

25-
### Filtering offers
41+
### Filtering offers { #list-gpu-offers }
2642

2743
The `--gpu` flag accepts the same specification format as the `gpu` property in [`dev environment`](../../../concepts/dev-environments.md), [`task`](../../../concepts/tasks.md),
2844
[`service`](../../../concepts/services.md), and [`fleet`](../../../concepts/fleets.md) configurations.

skills/dstack/SKILL.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ dstack stop my-run-name --abort
459459

460460
### List offers
461461

462-
Offers represent available instance configurations available for provisioning across backends. `dstack offer` lists offers regardless of configured fleets.
462+
Offers represent available instance configurations available for provisioning across backends. By default, `dstack offer` ignores fleet configurations and shows all available offers that match the request. Use `--fleet` to inspect offers available through specific fleets.
463463

464464
```bash
465465
# Filter by specific backend
@@ -474,10 +474,18 @@ dstack offer --gpu 24GB..80GB
474474
# Combine filters
475475
dstack offer --backend aws --gpu A100:80GB
476476
477+
# Limit to a specific fleet
478+
dstack offer --fleet my-fleet
479+
480+
# Combine offers from multiple fleets
481+
dstack offer --fleet my-fleet --fleet other-fleet
482+
477483
# JSON output (for troubleshooting/scripting)
478484
dstack offer --json
479485
```
480486

487+
With one `--fleet`, `dstack offer` shows offers available through that fleet. With multiple `--fleet`, it combines offers available through the selected fleets. Identical backend offers are shown once, while matching existing instances stay separate.
488+
481489
**Max offers:** By default, `dstack offer` returns first N offers (output also includes the total number). Use `--max-offers N` to increase the limit.
482490
**Grouping:** Prefer `--group-by gpu` (other supported values: `gpu,backend`, `gpu,backend,region`) for aggregated output across all offers, not `--max-offers`.
483491

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

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,8 @@
1515
from dstack._internal.core.models.configurations import ApplyConfigurationType, TaskConfiguration
1616
from dstack._internal.core.models.gpus import GpuGroup
1717
from dstack._internal.core.models.runs import RunSpec
18-
from dstack._internal.utils.logging import get_logger
1918
from dstack.api.utils import load_profile
2019

21-
logger = get_logger(__name__)
22-
2320

2421
class OfferConfigurator(BaseRunConfigurator):
2522
TYPE = ApplyConfigurationType.TASK
@@ -77,11 +74,6 @@ def _register(self):
7774

7875
def _command(self, args: argparse.Namespace):
7976
super()._command(args)
80-
if args.fleets:
81-
logger.warning(
82-
"Specifying `--fleet` in `dstack offer` has no defined effect"
83-
" and may be disallowed in a future release"
84-
)
8577
# Set image and user so that the server (a) does not default gpu.vendor
8678
# to nvidia — `dstack offer` should show all vendors, and (b) does not
8779
# attempt to pull image config from the Docker registry.
@@ -114,7 +106,11 @@ def _command(self, args: argparse.Namespace):
114106
run_spec,
115107
max_offers=args.max_offers,
116108
)
117-
print_run_plan(run_plan, include_run_properties=False)
109+
print_run_plan(
110+
run_plan,
111+
include_run_properties=False,
112+
show_offer_fleet_hint=run_spec.merged_profile.fleets is None,
113+
)
118114
else:
119115
if args.group_by:
120116
gpus = self._list_gpus(args, run_spec)

src/dstack/_internal/cli/services/profile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def register_profile_args(parser: argparse.ArgumentParser):
7070
action="append",
7171
metavar="NAME",
7272
dest="fleets",
73-
help="Consider only instances from the specified fleet(s) for reuse",
73+
help="Consider only the specified fleet(s)",
7474
)
7575
fleets_group_exc = fleets_group.add_mutually_exclusive_group()
7676
fleets_group_exc.add_argument(

src/dstack/_internal/cli/utils/run.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ class RunWaitStatus(str, Enum):
5555
WAITING_FOR_SCHEDULE = "waiting for schedule"
5656

5757

58+
_OFFER_FLEET_HINT = (
59+
"Hint: Existing fleets are ignored, and all available offers are shown."
60+
" To filter by fleet, pass --fleet NAME."
61+
)
62+
63+
5864
def print_offers_json(run_plan: RunPlan, run_spec):
5965
"""Print offers information in JSON format."""
6066
job_plan = run_plan.job_plans[0]
@@ -92,6 +98,7 @@ def print_run_plan(
9298
include_run_properties: bool = True,
9399
no_fleets: bool = False,
94100
verbose: bool = False,
101+
show_offer_fleet_hint: bool = False,
95102
):
96103
run_spec = run_plan.get_effective_run_spec()
97104
job_plan = run_plan.job_plans[0]
@@ -171,9 +178,9 @@ def th(s: str) -> str:
171178
offers.add_column("PRICE", style="grey58", ratio=1)
172179
offers.add_column()
173180

174-
job_plan.offers = job_plan.offers[:max_offers] if max_offers else job_plan.offers
181+
displayed_offers = job_plan.offers[:max_offers] if max_offers else job_plan.offers
175182

176-
for i, offer in enumerate(job_plan.offers, start=1):
183+
for i, offer in enumerate(displayed_offers, start=1):
177184
r = offer.instance.resources
178185

179186
instance = offer.instance.name
@@ -188,19 +195,32 @@ def th(s: str) -> str:
188195
format_instance_availability(offer.availability),
189196
style=None if i == 1 or not include_run_properties else "secondary",
190197
)
191-
if job_plan.total_offers > len(job_plan.offers):
198+
if job_plan.total_offers > len(displayed_offers):
192199
offers.add_row("", "...", style="secondary")
193200

194201
console.print(props)
195202
console.print()
196-
if len(job_plan.offers) > 0:
203+
if len(displayed_offers) > 0:
204+
show_offer_fleet_hint_before_table = (
205+
show_offer_fleet_hint
206+
and job_plan.total_offers <= len(displayed_offers)
207+
and len(displayed_offers) < 3
208+
)
209+
show_offer_fleet_hint_after_table = (
210+
show_offer_fleet_hint and not show_offer_fleet_hint_before_table
211+
)
212+
if show_offer_fleet_hint_before_table:
213+
console.print(f"[secondary]{_OFFER_FLEET_HINT}[/]")
214+
console.print()
197215
console.print(offers)
198-
if job_plan.total_offers > len(job_plan.offers):
216+
if job_plan.total_offers > len(displayed_offers):
199217
console.print(
200-
f"[secondary] Shown {len(job_plan.offers)} of {job_plan.total_offers} offers, "
218+
f"[secondary] Shown {len(displayed_offers)} of {job_plan.total_offers} offers, "
201219
f"${job_plan.max_price:3f}".rstrip("0").rstrip(".")
202220
+ "max[/]"
203221
)
222+
if show_offer_fleet_hint_after_table:
223+
console.print(f"[secondary]{_OFFER_FLEET_HINT}[/]")
204224
console.print()
205225
else:
206226
console.print(NO_FLEETS_WARNING if no_fleets else NO_OFFERS_WARNING)

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from fastapi import APIRouter, Depends
44
from packaging.version import Version
5+
from sqlalchemy.ext.asyncio import AsyncSession
56

67
from dstack._internal.server.compatibility.gpus import patch_list_gpus_response
8+
from dstack._internal.server.db import get_session
79
from dstack._internal.server.models import ProjectModel, UserModel
810
from dstack._internal.server.schemas.gpus import ListGpusRequest, ListGpusResponse
911
from dstack._internal.server.security.permissions import ProjectMember
@@ -23,10 +25,16 @@
2325
@project_router.post("/list", response_model=ListGpusResponse, response_model_exclude_none=True)
2426
async def list_gpus(
2527
body: ListGpusRequest,
28+
session: Annotated[AsyncSession, Depends(get_session)],
2629
client_version: Annotated[Optional[Version], Depends(get_client_version)],
2730
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
2831
) -> ListGpusResponse:
2932
_, project = user_project
30-
resp = await list_gpus_grouped(project=project, run_spec=body.run_spec, group_by=body.group_by)
33+
resp = await list_gpus_grouped(
34+
session=session,
35+
project=project,
36+
run_spec=body.run_spec,
37+
group_by=body.group_by,
38+
)
3139
patch_list_gpus_response(resp, client_version)
3240
return resp

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import Dict, List, Literal, Optional, Tuple
22

3+
from sqlalchemy.ext.asyncio import AsyncSession
4+
35
from dstack._internal.core.backends.base.backend import Backend
46
from dstack._internal.core.errors import ServerClientError
57
from dstack._internal.core.models.backends.base import BackendType
@@ -10,17 +12,22 @@
1012
from dstack._internal.core.models.runs import Requirements, RunSpec, get_policy_map
1113
from dstack._internal.server.models import ProjectModel
1214
from dstack._internal.server.schemas.gpus import ListGpusResponse
15+
from dstack._internal.server.services.jobs import get_jobs_from_run_spec
1316
from dstack._internal.server.services.offers import get_offers_by_requirements
17+
from dstack._internal.server.services.runs.plan import (
18+
get_backend_offers_in_run_candidate_fleets,
19+
)
1420
from dstack._internal.utils.common import get_or_error
1521

1622

1723
async def list_gpus_grouped(
24+
session: AsyncSession,
1825
project: ProjectModel,
1926
run_spec: RunSpec,
2027
group_by: Optional[List[Literal["backend", "region", "count"]]] = None,
2128
) -> ListGpusResponse:
2229
"""Retrieves available GPU specifications based on a run spec, with optional grouping."""
23-
offers = await _get_gpu_offers(project=project, run_spec=run_spec)
30+
offers = await _get_gpu_offers(session=session, project=project, run_spec=run_spec)
2431
backend_gpus = _process_offers_into_backend_gpus(offers)
2532
group_by_set = set(group_by) if group_by else set()
2633
if "region" in group_by_set and "backend" not in group_by_set:
@@ -47,10 +54,24 @@ async def list_gpus_grouped(
4754

4855

4956
async def _get_gpu_offers(
50-
project: ProjectModel, run_spec: RunSpec
57+
session: AsyncSession,
58+
project: ProjectModel,
59+
run_spec: RunSpec,
5160
) -> List[Tuple[Backend, InstanceOfferWithAvailability]]:
5261
"""Fetches all available instance offers that match the run spec's GPU requirements."""
5362
profile = run_spec.merged_profile
63+
if profile.fleets is not None:
64+
jobs = await get_jobs_from_run_spec(run_spec=run_spec, secrets={}, replica_num=0)
65+
if len(jobs) == 0:
66+
return []
67+
return await get_backend_offers_in_run_candidate_fleets(
68+
session=session,
69+
project=project,
70+
run_spec=run_spec,
71+
job=jobs[0],
72+
volumes=None,
73+
max_offers_per_fleet=None,
74+
)
5475
requirements = Requirements(
5576
resources=run_spec.configuration.resources,
5677
max_price=profile.max_price,

0 commit comments

Comments
 (0)