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
2 changes: 1 addition & 1 deletion src/dstack/_internal/server/services/instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ def instance_matches_constraints(
if (
jpd.availability_zone is not None
and zones is not None
and jpd.availability_zone not in zones
and jpd.availability_zone.lower() not in [z.lower() for z in zones]
):
return False

Expand Down
4 changes: 3 additions & 1 deletion src/dstack/_internal/server/services/offers.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ def _filter_offers(
regions = [r.lower() for r in regions]
if instance_types is not None:
instance_types = [i.lower() for i in instance_types]
if availability_zones is not None:
availability_zones = [z.lower() for z in availability_zones]

for b, offer in offers:
if backend_types is not None and offer.backend not in backend_types:
Expand All @@ -234,7 +236,7 @@ def _filter_offers(
continue
new_offer = offer.model_copy()
new_offer.availability_zones = [
z for z in offer.availability_zones if z in availability_zones
z for z in offer.availability_zones if z.lower() in availability_zones
]
if not new_offer.availability_zones:
continue
Expand Down
28 changes: 20 additions & 8 deletions src/dstack/_internal/server/services/requirements/combine.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from dstack._internal.utils.combine import (
CombineError,
combine_optional,
get_max_optional,
get_single_value_optional,
)
from dstack._internal.utils.typing import SupportsRichComparison
Expand All @@ -31,11 +32,13 @@ def combine_fleet_and_run_profiles(
try:
return Profile(
backends=_intersect_lists_optional(fleet_profile.backends, run_profile.backends),
regions=_intersect_lists_optional(fleet_profile.regions, run_profile.regions),
availability_zones=_intersect_lists_optional(
regions=_intersect_lists_case_insensitive_optional(
fleet_profile.regions, run_profile.regions
),
availability_zones=_intersect_lists_case_insensitive_optional(
fleet_profile.availability_zones, run_profile.availability_zones
),
instance_types=_intersect_lists_optional(
instance_types=_intersect_lists_case_insensitive_optional(
fleet_profile.instance_types, run_profile.instance_types
),
reservation=get_single_value_optional(
Expand Down Expand Up @@ -121,12 +124,21 @@ def _intersect_lists_optional(
return [x for x in list1 if x in list2]


def _get_min(value1: _CompT, value2: _CompT) -> _CompT:
return min(value1, value2)
def _intersect_lists_case_insensitive_optional(
list1: Optional[list[str]], list2: Optional[list[str]]
) -> Optional[list[str]]:
if list1 is None:
if list2 is None:
return None
return list2.copy()
if list2 is None:
return list1.copy()
list2_lowered = {x.lower() for x in list2}
return [x for x in list1 if x.lower() in list2_lowered]


def _get_min_optional(value1: Optional[_CompT], value2: Optional[_CompT]) -> Optional[_CompT]:
return combine_optional(value1, value2, _get_min)
return combine_optional(value1, value2, min)


def _combine_spot_policy(value1: SpotPolicy, value2: SpotPolicy) -> SpotPolicy:
Expand Down Expand Up @@ -199,11 +211,11 @@ def _combine_shm_size_optional(
def _combine_gpu(value1: GPUSpec, value2: GPUSpec) -> GPUSpec:
return GPUSpec(
vendor=get_single_value_optional(value1.vendor, value2.vendor),
name=_intersect_lists_optional(value1.name, value2.name),
name=_intersect_lists_case_insensitive_optional(value1.name, value2.name),
count=_combine_range(value1.count, value2.count),
memory=_combine_range_optional(value1.memory, value2.memory),
total_memory=_combine_range_optional(value1.total_memory, value2.total_memory),
compute_capability=_get_min_optional(value1.compute_capability, value2.compute_capability),
compute_capability=get_max_optional(value1.compute_capability, value2.compute_capability),
)


Expand Down
75 changes: 74 additions & 1 deletion src/tests/_internal/server/services/requirements/test_combine.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
_combine_idle_duration_optional,
_combine_resources,
_combine_spot_policy_optional,
_intersect_lists_case_insensitive_optional,
_intersect_lists_optional,
combine_fleet_and_run_profiles,
combine_fleet_and_run_requirements,
Expand Down Expand Up @@ -96,6 +97,24 @@ def test_prefers_finite_idle_duration_over_off(self):
),
id="compatible_profiles",
),
pytest.param(
Profile(
regions=["US-East-1"],
availability_zones=["US-East-1a"],
instance_types=["P4d.24xlarge"],
),
Profile(
regions=["us-east-1"],
availability_zones=["us-east-1a"],
instance_types=["p4d.24xlarge"],
),
Profile(
regions=["US-East-1"],
availability_zones=["US-East-1a"],
instance_types=["P4d.24xlarge"],
),
id="locations_differing_in_case",
),
pytest.param(
Profile(
spot_policy=SpotPolicy.SPOT,
Expand Down Expand Up @@ -276,6 +295,44 @@ def test_intersection_with_duplicates(self):
result = _intersect_lists_optional(list1, list2)
assert result == ["a", "a", "c"]

def test_intersection_is_case_sensitive(self):
assert _intersect_lists_optional(["A"], ["a"]) == []


class TestIntersectListsCaseInsensitive:
def test_both_none_returns_none(self):
assert _intersect_lists_case_insensitive_optional(None, None) is None

def test_first_none_returns_copy_of_second(self):
list2 = ["a", "b", "c"]
result = _intersect_lists_case_insensitive_optional(None, list2)
assert result == list2
assert result is not list2 # Should be a copy

def test_second_none_returns_copy_of_first(self):
list1 = ["x", "y", "z"]
result = _intersect_lists_case_insensitive_optional(list1, None)
assert result == list1
assert result is not list1 # Should be a copy

def test_intersection_ignores_case(self):
list1 = ["us-east-1", "EU-WEST-1", "ap-south-1"]
list2 = ["US-EAST-1", "eu-west-1"]
result = _intersect_lists_case_insensitive_optional(list1, list2)
assert result == ["us-east-1", "EU-WEST-1"]

def test_intersection_of_non_overlapping_lists(self):
result = _intersect_lists_case_insensitive_optional(["a", "b"], ["c", "d"])
assert result == []

def test_intersection_preserves_order_from_first_list(self):
result = _intersect_lists_case_insensitive_optional(["C", "A", "B"], ["a", "b", "c"])
assert result == ["C", "A", "B"]

def test_intersection_with_duplicates(self):
result = _intersect_lists_case_insensitive_optional(["a", "b", "A", "c"], ["A", "c", "d"])
assert result == ["a", "A", "c"]


class TestCombineOptionalIdleDuration:
def test_both_none_returns_none(self):
Expand Down Expand Up @@ -454,9 +511,25 @@ def test_combines_compatible_gpu_specs(self):
name=["V100"],
count=Range(min=2, max=3),
memory=Range(min=Memory(16), max=Memory(24)),
compute_capability=ComputeCapability((7, 0)),
compute_capability=ComputeCapability((7, 8)),
)

def test_intersects_names_case_insensitively(self):
gpu1 = GPUSpec(name=["MI300X", "H100"], count=Range(min=1, max=1))
gpu2 = GPUSpec(name=["mi300x"], count=Range(min=1, max=1))
result = _combine_gpu_optional(gpu1, gpu2)
assert result is not None
assert result.name == ["MI300X"]

def test_takes_the_highest_compute_capability(self):
# compute_capability is a lower bound, so the stricter of the two must win.
higher = GPUSpec(count=Range(min=1, max=1), compute_capability=ComputeCapability((8, 0)))
lower = GPUSpec(count=Range(min=1, max=1), compute_capability=ComputeCapability((7, 0)))
for gpu1, gpu2 in [(higher, lower), (lower, higher)]:
result = _combine_gpu_optional(gpu1, gpu2)
assert result is not None
assert result.compute_capability == ComputeCapability((8, 0))

def test_incompatible_vendors_raises_error(self):
gpu1 = GPUSpec(vendor=gpuhunt.AcceleratorVendor.NVIDIA, count=Range(min=1, max=2))
gpu2 = GPUSpec(vendor=gpuhunt.AcceleratorVendor.AMD, count=Range(min=1, max=2))
Expand Down
27 changes: 27 additions & 0 deletions src/tests/_internal/server/services/test_instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,33 @@ async def test_returns_multinode_instances(self, test_db, session: AsyncSession)
)
assert res == [aws_instance]

@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
async def test_returns_instances_matching_zone_ignoring_case(
self, test_db, session: AsyncSession
):
user = await create_user(session=session)
project = await create_project(session=session, owner=user)
matching_instance = await create_instance(
session=session,
project=project,
backend=BackendType.AWS,
region="eu-west-1",
availability_zone="eu-west-1a",
)
other_instance = await create_instance(
session=session,
project=project,
backend=BackendType.AWS,
region="eu-west-1",
availability_zone="eu-west-1b",
)
res = instances_services.filter_instances(
instances=[matching_instance, other_instance],
profile=Profile(name="test", availability_zones=["EU-West-1a"]),
)
assert res == [matching_instance]

@pytest.mark.asyncio
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
async def test_returns_volume_instances(self, test_db, session: AsyncSession):
Expand Down
22 changes: 22 additions & 0 deletions src/tests/_internal/server/services/test_offers.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,28 @@ async def test_returns_az_offers(self):
m.assert_awaited_once()
assert res == [(aws_backend_mock, aws_offer1), (aws_backend_mock, expected_aws_offer3)]

@pytest.mark.asyncio
async def test_returns_az_offers_ignoring_case(self):
profile = Profile(name="test", availability_zones=["AZ1"])
requirements = Requirements(resources=ResourcesSpec())
with patch("dstack._internal.server.services.backends.get_project_backends") as m:
aws_backend_mock = Mock()
aws_backend_mock.TYPE = BackendType.AWS
aws_offer = get_instance_offer_with_availability(
backend=BackendType.AWS, availability_zones=["az1", "az2"]
)
# The offer keeps the zone spelling reported by the backend.
expected_aws_offer = aws_offer.model_copy()
expected_aws_offer.availability_zones = ["az1"]
aws_backend_mock.compute.return_value.get_offers.return_value = [aws_offer]
m.return_value = [aws_backend_mock]
res = await get_offers_by_requirements(
project=Mock(),
profile=profile,
requirements=requirements,
)
assert res == [(aws_backend_mock, expected_aws_offer)]

@pytest.mark.asyncio
async def test_returns_no_offers_for_multinode_instance_mounts_and_non_multinode_backend(self):
# Regression test for https://github.com/dstackai/dstack/issues/2211
Expand Down
Loading