dstack 0.18.41__py3-none-any.whl → 0.18.42__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. dstack/_internal/cli/utils/volume.py +9 -0
  2. dstack/_internal/core/backends/aws/compute.py +2 -1
  3. dstack/_internal/core/backends/gcp/compute.py +2 -1
  4. dstack/_internal/core/models/runs.py +3 -3
  5. dstack/_internal/core/models/volumes.py +23 -0
  6. dstack/_internal/server/background/tasks/process_instances.py +2 -3
  7. dstack/_internal/server/background/tasks/process_running_jobs.py +4 -0
  8. dstack/_internal/server/background/tasks/process_submitted_jobs.py +12 -7
  9. dstack/_internal/server/background/tasks/process_terminating_jobs.py +13 -2
  10. dstack/_internal/server/background/tasks/process_volumes.py +11 -1
  11. dstack/_internal/server/migrations/versions/a751ef183f27_move_attachment_data_to_volumes_.py +34 -0
  12. dstack/_internal/server/models.py +17 -19
  13. dstack/_internal/server/services/fleets.py +5 -1
  14. dstack/_internal/server/services/jobs/__init__.py +4 -4
  15. dstack/_internal/server/services/offers.py +7 -7
  16. dstack/_internal/server/services/pools.py +3 -3
  17. dstack/_internal/server/services/runner/client.py +8 -5
  18. dstack/_internal/server/services/volumes.py +68 -9
  19. dstack/_internal/server/testing/common.py +13 -9
  20. dstack/version.py +1 -1
  21. {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/METADATA +1 -1
  22. {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/RECORD +33 -31
  23. tests/_internal/server/background/tasks/test_process_running_jobs.py +1 -0
  24. tests/_internal/server/background/tasks/test_process_submitted_jobs.py +5 -3
  25. tests/_internal/server/background/tasks/test_process_terminating_jobs.py +11 -6
  26. tests/_internal/server/routers/test_volumes.py +9 -2
  27. tests/_internal/server/services/runner/test_client.py +22 -3
  28. tests/_internal/server/services/test_offers.py +167 -0
  29. tests/_internal/server/services/test_pools.py +105 -1
  30. {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/LICENSE.md +0 -0
  31. {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/WHEEL +0 -0
  32. {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/entry_points.txt +0 -0
  33. {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/top_level.txt +0 -0
@@ -9,7 +9,13 @@ from dstack._internal.core.consts import DSTACK_SHIM_HTTP_PORT
9
9
  from dstack._internal.core.models.backends.base import BackendType
10
10
  from dstack._internal.core.models.common import NetworkMode
11
11
  from dstack._internal.core.models.resources import Memory
12
- from dstack._internal.core.models.volumes import InstanceMountPoint, VolumeMountPoint
12
+ from dstack._internal.core.models.volumes import (
13
+ InstanceMountPoint,
14
+ VolumeAttachment,
15
+ VolumeAttachmentData,
16
+ VolumeInstance,
17
+ VolumeMountPoint,
18
+ )
13
19
  from dstack._internal.server.schemas.runner import (
14
20
  HealthcheckResponse,
15
21
  JobResult,
@@ -133,7 +139,12 @@ class TestShimClientV1(BaseShimClientTest):
133
139
  volume_id="vol-id",
134
140
  configuration=get_volume_configuration(backend=BackendType.GCP),
135
141
  external=False,
136
- device_name="/dev/sdv",
142
+ attachments=[
143
+ VolumeAttachment(
144
+ instance=VolumeInstance(name="instance", instance_num=0, instance_id="i-1"),
145
+ attachment_data=VolumeAttachmentData(device_name="/dev/sdv"),
146
+ )
147
+ ],
137
148
  )
138
149
 
139
150
  submitted = client.submit(
@@ -150,6 +161,7 @@ class TestShimClientV1(BaseShimClientTest):
150
161
  mounts=[VolumeMountPoint(name="vol", path="/vol")],
151
162
  volumes=[volume],
152
163
  instance_mounts=[InstanceMountPoint(instance_path="/mnt/nfs/home", path="/home")],
164
+ instance_id="i-1",
153
165
  )
154
166
 
155
167
  assert submitted is True
@@ -198,6 +210,7 @@ class TestShimClientV1(BaseShimClientTest):
198
210
  mounts=[],
199
211
  volumes=[],
200
212
  instance_mounts=[],
213
+ instance_id="",
201
214
  )
202
215
 
203
216
  assert submitted is False
@@ -294,7 +307,12 @@ class TestShimClientV2(BaseShimClientTest):
294
307
  volume_id="vol-id",
295
308
  configuration=get_volume_configuration(backend=BackendType.GCP),
296
309
  external=False,
297
- device_name="/dev/sdv",
310
+ attachments=[
311
+ VolumeAttachment(
312
+ instance=VolumeInstance(name="instance", instance_num=0, instance_id="i-1"),
313
+ attachment_data=VolumeAttachmentData(device_name="/dev/sdv"),
314
+ )
315
+ ],
298
316
  )
299
317
 
300
318
  client.submit_task(
@@ -316,6 +334,7 @@ class TestShimClientV2(BaseShimClientTest):
316
334
  host_ssh_user="dstack",
317
335
  host_ssh_keys=["host_key"],
318
336
  container_ssh_keys=["project_key", "user_key"],
337
+ instance_id="i-1",
319
338
  )
320
339
 
321
340
  assert adapter.call_count == 2
@@ -0,0 +1,167 @@
1
+ from unittest.mock import Mock, patch
2
+
3
+ import pytest
4
+
5
+ from dstack._internal.core.models.backends.base import BackendType
6
+ from dstack._internal.core.models.profiles import Profile
7
+ from dstack._internal.core.models.resources import ResourcesSpec
8
+ from dstack._internal.core.models.runs import Requirements
9
+ from dstack._internal.server.services.offers import get_offers_by_requirements
10
+ from dstack._internal.server.testing.common import (
11
+ get_instance_offer_with_availability,
12
+ get_volume,
13
+ get_volume_configuration,
14
+ )
15
+
16
+
17
+ class TestGetOffersByRequirements:
18
+ @pytest.mark.asyncio
19
+ async def test_returns_all_offers(self):
20
+ profile = Profile(name="test")
21
+ requirements = Requirements(resources=ResourcesSpec())
22
+ with patch("dstack._internal.server.services.backends.get_project_backends") as m:
23
+ aws_backend_mock = Mock()
24
+ aws_backend_mock.TYPE = BackendType.AWS
25
+ aws_offer = get_instance_offer_with_availability(backend=BackendType.AWS)
26
+ aws_backend_mock.compute.return_value.get_offers_cached.return_value = [aws_offer]
27
+ runpod_backend_mock = Mock()
28
+ runpod_backend_mock.TYPE = BackendType.RUNPOD
29
+ runpod_offer = get_instance_offer_with_availability(backend=BackendType.RUNPOD)
30
+ runpod_backend_mock.compute.return_value.get_offers_cached.return_value = [
31
+ runpod_offer
32
+ ]
33
+ m.return_value = [aws_backend_mock, runpod_backend_mock]
34
+ res = await get_offers_by_requirements(
35
+ project=Mock(),
36
+ profile=profile,
37
+ requirements=requirements,
38
+ )
39
+ m.assert_awaited_once()
40
+ assert res == [(aws_backend_mock, aws_offer), (runpod_backend_mock, runpod_offer)]
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_returns_multinode_offers(self):
44
+ profile = Profile(name="test")
45
+ requirements = Requirements(resources=ResourcesSpec())
46
+ with patch("dstack._internal.server.services.backends.get_project_backends") as m:
47
+ aws_backend_mock = Mock()
48
+ aws_backend_mock.TYPE = BackendType.AWS
49
+ aws_offer = get_instance_offer_with_availability(backend=BackendType.AWS)
50
+ aws_backend_mock.compute.return_value.get_offers_cached.return_value = [aws_offer]
51
+ runpod_backend_mock = Mock()
52
+ runpod_backend_mock.TYPE = BackendType.RUNPOD
53
+ runpod_offer = get_instance_offer_with_availability(backend=BackendType.RUNPOD)
54
+ runpod_backend_mock.compute.return_value.get_offers_cached.return_value = [
55
+ runpod_offer
56
+ ]
57
+ m.return_value = [aws_backend_mock, runpod_backend_mock]
58
+ res = await get_offers_by_requirements(
59
+ project=Mock(),
60
+ profile=profile,
61
+ requirements=requirements,
62
+ multinode=True,
63
+ )
64
+ m.assert_awaited_once()
65
+ assert res == [(aws_backend_mock, aws_offer)]
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_returns_volume_offers(self):
69
+ profile = Profile(name="test")
70
+ requirements = Requirements(resources=ResourcesSpec())
71
+ with patch("dstack._internal.server.services.backends.get_project_backends") as m:
72
+ aws_backend_mock = Mock()
73
+ aws_backend_mock.TYPE = BackendType.AWS
74
+ aws_offer = get_instance_offer_with_availability(backend=BackendType.AWS)
75
+ aws_backend_mock.compute.return_value.get_offers_cached.return_value = [aws_offer]
76
+ runpod_backend_mock = Mock()
77
+ runpod_backend_mock.TYPE = BackendType.RUNPOD
78
+ runpod_offer1 = get_instance_offer_with_availability(
79
+ backend=BackendType.RUNPOD, region="eu"
80
+ )
81
+ runpod_offer2 = get_instance_offer_with_availability(
82
+ backend=BackendType.RUNPOD, region="us"
83
+ )
84
+ runpod_backend_mock.compute.return_value.get_offers_cached.return_value = [
85
+ runpod_offer1,
86
+ runpod_offer2,
87
+ ]
88
+ m.return_value = [aws_backend_mock, runpod_backend_mock]
89
+ res = await get_offers_by_requirements(
90
+ project=Mock(),
91
+ profile=profile,
92
+ requirements=requirements,
93
+ volumes=[
94
+ [
95
+ get_volume(
96
+ configuration=get_volume_configuration(
97
+ backend=BackendType.RUNPOD, region="us"
98
+ )
99
+ )
100
+ ]
101
+ ],
102
+ )
103
+ m.assert_awaited_once()
104
+ assert res == [(runpod_backend_mock, runpod_offer2)]
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_returns_az_offers(self):
108
+ profile = Profile(name="test", availability_zones=["az1", "az3"])
109
+ requirements = Requirements(resources=ResourcesSpec())
110
+ with patch("dstack._internal.server.services.backends.get_project_backends") as m:
111
+ aws_backend_mock = Mock()
112
+ aws_backend_mock.TYPE = BackendType.AWS
113
+ aws_offer1 = get_instance_offer_with_availability(
114
+ backend=BackendType.AWS, availability_zones=["az1"]
115
+ )
116
+ aws_offer2 = get_instance_offer_with_availability(
117
+ backend=BackendType.AWS, availability_zones=["az2"]
118
+ )
119
+ aws_offer3 = get_instance_offer_with_availability(
120
+ backend=BackendType.AWS, availability_zones=["az2", "az3"]
121
+ )
122
+ expected_aws_offer3 = aws_offer3.copy()
123
+ expected_aws_offer3.availability_zones = ["az3"]
124
+ aws_offer4 = get_instance_offer_with_availability(
125
+ backend=BackendType.AWS, availability_zones=None
126
+ )
127
+ aws_backend_mock.compute.return_value.get_offers_cached.return_value = [
128
+ aws_offer1,
129
+ aws_offer2,
130
+ aws_offer3,
131
+ aws_offer4,
132
+ ]
133
+ m.return_value = [aws_backend_mock]
134
+ res = await get_offers_by_requirements(
135
+ project=Mock(),
136
+ profile=profile,
137
+ requirements=requirements,
138
+ )
139
+ m.assert_awaited_once()
140
+ assert res == [(aws_backend_mock, aws_offer1), (aws_backend_mock, expected_aws_offer3)]
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_returns_no_offers_for_multinode_instance_mounts_and_non_multinode_backend(self):
144
+ # Regression test for https://github.com/dstackai/dstack/issues/2211
145
+ profile = Profile(name="test", backends=[BackendType.RUNPOD])
146
+ requirements = Requirements(resources=ResourcesSpec())
147
+ with patch("dstack._internal.server.services.backends.get_project_backends") as m:
148
+ aws_backend_mock = Mock()
149
+ aws_backend_mock.TYPE = BackendType.AWS
150
+ aws_offer = get_instance_offer_with_availability(backend=BackendType.AWS)
151
+ aws_backend_mock.compute.return_value.get_offers_cached.return_value = [aws_offer]
152
+ runpod_backend_mock = Mock()
153
+ runpod_backend_mock.TYPE = BackendType.RUNPOD
154
+ runpod_offer = get_instance_offer_with_availability(backend=BackendType.RUNPOD)
155
+ runpod_backend_mock.compute.return_value.get_offers_cached.return_value = [
156
+ runpod_offer
157
+ ]
158
+ m.return_value = [aws_backend_mock, runpod_backend_mock]
159
+ res = await get_offers_by_requirements(
160
+ project=Mock(),
161
+ profile=profile,
162
+ requirements=requirements,
163
+ multinode=True,
164
+ instance_mounts=True,
165
+ )
166
+ m.assert_awaited_once()
167
+ assert res == []
@@ -7,11 +7,115 @@ import dstack._internal.server.services.pools as services_pools
7
7
  from dstack._internal.core.models.backends.base import BackendType
8
8
  from dstack._internal.core.models.instances import InstanceStatus, InstanceType, Resources
9
9
  from dstack._internal.core.models.pools import Instance
10
+ from dstack._internal.core.models.profiles import Profile
10
11
  from dstack._internal.server.models import InstanceModel
11
- from dstack._internal.server.testing.common import create_project, create_user
12
+ from dstack._internal.server.testing.common import (
13
+ create_instance,
14
+ create_pool,
15
+ create_project,
16
+ create_user,
17
+ get_volume,
18
+ get_volume_configuration,
19
+ )
12
20
  from dstack._internal.utils.common import get_current_datetime
13
21
 
14
22
 
23
+ class TestFilterPoolInstances:
24
+ # TODO: Refactor filter_pool_instances to not depend on InstanceModel and simplify tests
25
+ @pytest.mark.asyncio
26
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
27
+ async def test_returns_all_instances(self, test_db, session: AsyncSession):
28
+ user = await create_user(session=session)
29
+ project = await create_project(session=session, owner=user)
30
+ pool = await create_pool(session=session, project=project)
31
+ aws_instance = await create_instance(
32
+ session=session,
33
+ project=project,
34
+ pool=pool,
35
+ backend=BackendType.AWS,
36
+ )
37
+ runpod_instance = await create_instance(
38
+ session=session,
39
+ project=project,
40
+ pool=pool,
41
+ backend=BackendType.RUNPOD,
42
+ )
43
+ instances = [aws_instance, runpod_instance]
44
+ res = services_pools.filter_pool_instances(
45
+ pool_instances=instances,
46
+ profile=Profile(name="test"),
47
+ )
48
+ assert res == instances
49
+
50
+ @pytest.mark.asyncio
51
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
52
+ async def test_returns_multinode_instances(self, test_db, session: AsyncSession):
53
+ user = await create_user(session=session)
54
+ project = await create_project(session=session, owner=user)
55
+ pool = await create_pool(session=session, project=project)
56
+ aws_instance = await create_instance(
57
+ session=session,
58
+ project=project,
59
+ pool=pool,
60
+ backend=BackendType.AWS,
61
+ )
62
+ runpod_instance = await create_instance(
63
+ session=session,
64
+ project=project,
65
+ pool=pool,
66
+ backend=BackendType.RUNPOD,
67
+ )
68
+ instances = [aws_instance, runpod_instance]
69
+ res = services_pools.filter_pool_instances(
70
+ pool_instances=instances,
71
+ profile=Profile(name="test"),
72
+ multinode=True,
73
+ )
74
+ assert res == [aws_instance]
75
+
76
+ @pytest.mark.asyncio
77
+ @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
78
+ async def test_returns_volume_instances(self, test_db, session: AsyncSession):
79
+ user = await create_user(session=session)
80
+ project = await create_project(session=session, owner=user)
81
+ pool = await create_pool(session=session, project=project)
82
+ aws_instance = await create_instance(
83
+ session=session,
84
+ project=project,
85
+ pool=pool,
86
+ backend=BackendType.AWS,
87
+ )
88
+ runpod_instance1 = await create_instance(
89
+ session=session,
90
+ project=project,
91
+ pool=pool,
92
+ backend=BackendType.RUNPOD,
93
+ region="eu",
94
+ )
95
+ runpod_instance2 = await create_instance(
96
+ session=session,
97
+ project=project,
98
+ pool=pool,
99
+ backend=BackendType.RUNPOD,
100
+ region="us",
101
+ )
102
+ instances = [aws_instance, runpod_instance1, runpod_instance2]
103
+ res = services_pools.filter_pool_instances(
104
+ pool_instances=instances,
105
+ profile=Profile(name="test"),
106
+ volumes=[
107
+ [
108
+ get_volume(
109
+ configuration=get_volume_configuration(
110
+ backend=BackendType.RUNPOD, region="us"
111
+ )
112
+ )
113
+ ]
114
+ ],
115
+ )
116
+ assert res == [runpod_instance2]
117
+
118
+
15
119
  class TestGenerateInstanceName:
16
120
  @pytest.mark.asyncio
17
121
  @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)