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 src/dstack/_internal/cli/commands/offer.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def _list_offers(self, args: argparse.Namespace) -> None:
max_offers=args.max_offers,
full_offers=args.full_offers,
unallocated_resources=args.unallocated,
for_offers_only=True,
)
job_plan = run_plan.job_plans[0]
if args.format == "plain":
Expand Down
4 changes: 3 additions & 1 deletion src/dstack/_internal/cli/services/presets/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,9 @@ def _print_fleet_offers(api: Client, allowed_fleets: tuple[str, ...]) -> None:
offer_configuration.fleets = list(allowed_fleets)
run_spec = RunSpec(configuration=offer_configuration, profile=None)
with console.status("Getting offers..."):
run_plan = api.client.runs.get_plan(api.project, run_spec, max_offers=10)
run_plan = api.client.runs.get_plan(
api.project, run_spec, max_offers=10, for_offers_only=True
)
props = Table(box=None, show_header=False)
props.add_column(no_wrap=True)
props.add_column()
Expand Down
18 changes: 18 additions & 0 deletions src/dstack/_internal/server/compatibility/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,21 @@ def patch_run_spec(run_spec: RunSpec, client_version: Optional[Version]) -> None
and isinstance(run_spec.configuration.gateway, EntityReference)
):
run_spec.configuration.gateway = run_spec.configuration.gateway.format()


def is_run_plan_for_offers_only(
run_spec: RunSpec, for_offers_only: bool, client_version: Optional[Version]
) -> bool:
"""
Clients < 0.21.0 don't support `for_offers_only` argument and rely on a magic configuration
that triggers "offer collection only" path.

TODO: Drop once clients < 0.21.0 are no longer supported.

NOTE: A real task with `commands == [":"]` would also match this special `dstack offer` path.
"""
if for_offers_only:
return True
if client_version is not None and client_version < Version("0.21.0"):
return run_spec.configuration.type == "task" and run_spec.configuration.commands == [":"]
return False
11 changes: 10 additions & 1 deletion src/dstack/_internal/server/routers/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

from dstack._internal.core.errors import ResourceNotExistsError
from dstack._internal.core.models.runs import Run, RunPlan
from dstack._internal.server.compatibility.runs import patch_run, patch_run_plan
from dstack._internal.server.compatibility.runs import (
is_run_plan_for_offers_only,
patch_run,
patch_run_plan,
)
from dstack._internal.server.db import get_session
from dstack._internal.server.models import ProjectModel, UserModel
from dstack._internal.server.schemas.runs import (
Expand Down Expand Up @@ -133,6 +137,10 @@ async def get_plan(
user, project = user_project
if not user.ssh_public_key and not body.run_spec.ssh_key_pub:
await users.refresh_ssh_key(session=session, actor=user)
# TODO: Use body.for_offers_only directly once clients < 0.21.0 are no longer supported
for_offers_only = is_run_plan_for_offers_only(
run_spec=body.run_spec, for_offers_only=body.for_offers_only, client_version=client_version
)
run_plan = await runs.get_plan(
session=session,
project=project,
Expand All @@ -142,6 +150,7 @@ async def get_plan(
full_offers=body.full_offers,
unallocated_resources=body.unallocated_resources,
legacy_repo_dir=legacy_repo_dir,
for_offers_only=for_offers_only,
)
patch_run_plan(run_plan, client_version)
return CustomJSONResponse(run_plan)
Expand Down
6 changes: 6 additions & 0 deletions src/dstack/_internal/server/schemas/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ class GetRunPlanRequest(CoreModel):
bool,
Field(description="Subtract allocated resources to return only unallocated resources"),
] = False
for_offers_only: Annotated[
bool,
Field(
description="Set to True if the run plan is requested for offer collection only, not a real run submission"
),
] = False


class SubmitRunRequest(CoreModel):
Expand Down
2 changes: 2 additions & 0 deletions src/dstack/_internal/server/services/runs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ async def get_plan(
max_offers: Optional[int],
full_offers: bool,
unallocated_resources: bool,
for_offers_only: bool,
legacy_repo_dir: bool = False,
) -> RunPlan:
effective_run_spec = RunSpec.model_validate(run_spec.model_dump())
Expand Down Expand Up @@ -574,6 +575,7 @@ async def get_plan(
max_offers=max_offers,
full_offers=full_offers,
unallocated_resources=unallocated_resources,
for_offers_only=for_offers_only,
)
run_plan = RunPlan(
project_name=project.name,
Expand Down
27 changes: 3 additions & 24 deletions src/dstack/_internal/server/services/runs/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,14 @@ async def get_job_plans(
max_offers: Optional[int],
full_offers: bool,
unallocated_resources: bool,
for_offers_only: bool,
) -> list[JobPlan]:
"""
Returns job plans for the given run spec.

Normal run planning (`dstack apply`) selects the best fleet candidate for each planned job
and builds offers from that path. `dstack offer` without `--group-by` uses the same
`/runs/get_plan` API, but its synthetic run spec is detected by
`_should_select_best_fleet_candidate()`. In that case, planning skips
`/runs/get_plan` API but with `for_offers_only=True`. In that case, planning skips
best-fleet-candidate selection and collects offers directly: global offers when no fleets
are specified, or offers from the selected fleets when `--fleet` is used.

Expand All @@ -119,7 +119,7 @@ async def get_job_plans(
job_num=0,
)

if _should_select_best_fleet_candidate(run_spec) and run_spec.merged_profile.instances is None:
if not for_offers_only and run_spec.merged_profile.instances is None:
candidate_fleet_models = await _select_candidate_fleet_models(
session=session,
project=project,
Expand Down Expand Up @@ -1016,27 +1016,6 @@ def _get_job_plan(
)


def _should_select_best_fleet_candidate(run_spec: RunSpec) -> bool:
"""
Returns ``True`` for normal run planning and ``False`` for `dstack offer` without
`--group-by`.

Both `dstack apply` and `dstack offer` without `--group-by` call `/runs/get_plan`. The
current way to recognize `dstack offer` without `--group-by` is the synthetic task spec
that the CLI sends with `type == "task"` and `commands == [":"]`.
TODO: Replace this command-shape hack with an explicit request/API signal for
`dstack offer` without `--group-by`.

When this function returns ``False``, the planner skips best-fleet-candidate selection
and goes directly to the special `dstack offer` collection path:
global offers when no fleets are specified, or offers from the selected fleets when
`--fleet` is used.

A real task with `commands == [":"]` would also match this special `dstack offer` path.
"""
return not (run_spec.configuration.type == "task" and run_spec.configuration.commands == [":"])


def _get_offers_from_instances(
instances: list[InstanceModel],
) -> list[tuple[InstanceModel, InstanceOfferWithAvailability]]:
Expand Down
4 changes: 4 additions & 0 deletions src/dstack/api/_public/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ def get_run_plan(
max_offers: Optional[int] = None,
full_offers: bool = False,
unallocated_resources: bool = False,
for_offers_only: bool = False,
) -> RunPlan:
"""
Get a run plan.
Expand All @@ -498,6 +499,8 @@ def get_run_plan(
full_offers: Return full offers not adjusted by requirements.
unallocated_resources: Subtract allocated resources to return only unallocated
resources.
for_offers_only: Set to True if the run plan is requested for offer collection only,
not a real run submission.

Returns:
Run plan.
Expand Down Expand Up @@ -554,6 +557,7 @@ def get_run_plan(
max_offers=max_offers,
full_offers=full_offers,
unallocated_resources=unallocated_resources,
for_offers_only=for_offers_only,
)
return run_plan

Expand Down
2 changes: 2 additions & 0 deletions src/dstack/api/server/_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@ def get_plan(
max_offers: Optional[int] = None,
full_offers: bool = False,
unallocated_resources: bool = False,
for_offers_only: bool = False,
) -> RunPlan:
body = GetRunPlanRequest(
run_spec=run_spec,
max_offers=max_offers,
full_offers=full_offers,
unallocated_resources=unallocated_resources,
for_offers_only=for_offers_only,
)
body = copy.deepcopy(body)
patch_run_spec(body.run_spec)
Expand Down
81 changes: 81 additions & 0 deletions src/tests/_internal/server/compatibility/test_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from typing import Optional

import pytest
from packaging.version import Version

from dstack._internal.core.models.configurations import (
AnyRunConfiguration,
DevEnvironmentConfiguration,
TaskConfiguration,
)
from dstack._internal.server.compatibility.runs import is_run_plan_for_offers_only
from dstack._internal.server.testing.common import get_run_spec

_OFFER_CLI_CONFIGURATION = TaskConfiguration(commands=[":"], image="scratch", user="root")


class TestIsRunPlanForOffersOnly:
@pytest.mark.parametrize(
("configuration", "for_offers_only", "client_version", "expected"),
[
pytest.param(_OFFER_CLI_CONFIGURATION, True, Version("0.21.0"), True, id="flag-set"),
pytest.param(
DevEnvironmentConfiguration(),
True,
Version("0.21.0"),
True,
id="flag-set-for-any-configuration",
),
pytest.param(
_OFFER_CLI_CONFIGURATION,
False,
Version("0.20.30"),
True,
id="old-client-sends-offer-cli-configuration",
),
pytest.param(
TaskConfiguration(commands=["echo"], image="scratch"),
False,
Version("0.20.30"),
False,
id="old-client-sends-regular-task",
),
pytest.param(
DevEnvironmentConfiguration(),
False,
Version("0.20.30"),
False,
id="old-client-sends-configuration-without-commands",
),
pytest.param(
_OFFER_CLI_CONFIGURATION,
False,
Version("0.21.0"),
False,
id="new-client-does-not-rely-on-offer-cli-configuration",
),
pytest.param(
_OFFER_CLI_CONFIGURATION,
False,
None,
False,
id="dev-client-does-not-rely-on-offer-cli-configuration",
),
],
)
def test_returns_expected(
self,
configuration: AnyRunConfiguration,
for_offers_only: bool,
client_version: Optional[Version],
expected: bool,
) -> None:
run_spec = get_run_spec(repo_id="test-repo", configuration=configuration)
assert (
is_run_plan_for_offers_only(
run_spec=run_spec,
for_offers_only=for_offers_only,
client_version=client_version,
)
is expected
)
Loading
Loading