diff --git a/src/dstack/_internal/server/services/instances.py b/src/dstack/_internal/server/services/instances.py index 129aaf4c0..2b763c93a 100644 --- a/src/dstack/_internal/server/services/instances.py +++ b/src/dstack/_internal/server/services/instances.py @@ -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 diff --git a/src/dstack/_internal/server/services/offers.py b/src/dstack/_internal/server/services/offers.py index cb50fe044..8193ab086 100644 --- a/src/dstack/_internal/server/services/offers.py +++ b/src/dstack/_internal/server/services/offers.py @@ -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: @@ -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 diff --git a/src/dstack/_internal/server/services/requirements/combine.py b/src/dstack/_internal/server/services/requirements/combine.py index 090f9c89d..36043b211 100644 --- a/src/dstack/_internal/server/services/requirements/combine.py +++ b/src/dstack/_internal/server/services/requirements/combine.py @@ -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 @@ -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( @@ -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: @@ -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), ) diff --git a/src/tests/_internal/server/services/requirements/test_combine.py b/src/tests/_internal/server/services/requirements/test_combine.py index d9b96da47..8149b89a8 100644 --- a/src/tests/_internal/server/services/requirements/test_combine.py +++ b/src/tests/_internal/server/services/requirements/test_combine.py @@ -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, @@ -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, @@ -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): @@ -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)) diff --git a/src/tests/_internal/server/services/test_instances.py b/src/tests/_internal/server/services/test_instances.py index 7a9606d5f..defd1498d 100644 --- a/src/tests/_internal/server/services/test_instances.py +++ b/src/tests/_internal/server/services/test_instances.py @@ -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): diff --git a/src/tests/_internal/server/services/test_offers.py b/src/tests/_internal/server/services/test_offers.py index 225853cef..017d6aa81 100644 --- a/src/tests/_internal/server/services/test_offers.py +++ b/src/tests/_internal/server/services/test_offers.py @@ -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