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.
- dstack/_internal/cli/utils/volume.py +9 -0
- dstack/_internal/core/backends/aws/compute.py +2 -1
- dstack/_internal/core/backends/gcp/compute.py +2 -1
- dstack/_internal/core/models/runs.py +3 -3
- dstack/_internal/core/models/volumes.py +23 -0
- dstack/_internal/server/background/tasks/process_instances.py +2 -3
- dstack/_internal/server/background/tasks/process_running_jobs.py +4 -0
- dstack/_internal/server/background/tasks/process_submitted_jobs.py +12 -7
- dstack/_internal/server/background/tasks/process_terminating_jobs.py +13 -2
- dstack/_internal/server/background/tasks/process_volumes.py +11 -1
- dstack/_internal/server/migrations/versions/a751ef183f27_move_attachment_data_to_volumes_.py +34 -0
- dstack/_internal/server/models.py +17 -19
- dstack/_internal/server/services/fleets.py +5 -1
- dstack/_internal/server/services/jobs/__init__.py +4 -4
- dstack/_internal/server/services/offers.py +7 -7
- dstack/_internal/server/services/pools.py +3 -3
- dstack/_internal/server/services/runner/client.py +8 -5
- dstack/_internal/server/services/volumes.py +68 -9
- dstack/_internal/server/testing/common.py +13 -9
- dstack/version.py +1 -1
- {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/METADATA +1 -1
- {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/RECORD +33 -31
- tests/_internal/server/background/tasks/test_process_running_jobs.py +1 -0
- tests/_internal/server/background/tasks/test_process_submitted_jobs.py +5 -3
- tests/_internal/server/background/tasks/test_process_terminating_jobs.py +11 -6
- tests/_internal/server/routers/test_volumes.py +9 -2
- tests/_internal/server/services/runner/test_client.py +22 -3
- tests/_internal/server/services/test_offers.py +167 -0
- tests/_internal/server/services/test_pools.py +105 -1
- {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/LICENSE.md +0 -0
- {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/WHEEL +0 -0
- {dstack-0.18.41.dist-info → dstack-0.18.42.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|