Skip to content

Commit fffb036

Browse files
authored
Merge pull request #85 from verda-cloud/fix/volume-mounts-decode
Fix/volume mounts decode
2 parents a18ec94 + 365263f commit fffb036

File tree

3 files changed

+107
-3
lines changed

3 files changed

+107
-3
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [1.22.0] - 2026-03-20
10+
### Fixed
11+
12+
- Fixed volume mount fields (`volume_id`, `secret_name`, `file_names`, `size_in_mb`) being silently dropped during deserialization, causing deployment updates to fail with `volume_mounts.*.volume_id should not be null or undefined`
13+
14+
## [1.23.0] - 2026-03-20
1115

1216
### Added
1317

tests/unit_tests/job_deployments/test_job_deployments.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import json
2+
from dataclasses import replace
23

34
import pytest
45
import responses # https://github.com/getsentry/responses
56

67
from verda.containers import ComputeResource, Container, ContainerRegistrySettings
8+
from verda.containers._containers import (
9+
GeneralStorageMount,
10+
MemoryMount,
11+
SecretMount,
12+
SharedFileSystemMount,
13+
)
714
from verda.exceptions import APIException
815
from verda.job_deployments import (
916
JobDeployment,
@@ -207,3 +214,71 @@ def test_purge_job_deployment_queue(self, service, endpoint):
207214
service.purge_queue(JOB_NAME)
208215

209216
assert responses.assert_call_count(url, 1) is True
217+
218+
@responses.activate
219+
def test_update_preserves_volume_mounts_round_trip(self, service, endpoint):
220+
"""Regression test: volume mount subclass fields (volume_id, secret_name, etc.)
221+
must survive a get → update round trip without being dropped during deserialization."""
222+
volume_id = '550e8400-e29b-41d4-a716-446655440000'
223+
api_payload = {
224+
'name': JOB_NAME,
225+
'containers': [
226+
{
227+
'name': CONTAINER_NAME,
228+
'image': 'busybox:latest',
229+
'exposed_port': 8080,
230+
'env': [],
231+
'volume_mounts': [
232+
{'type': 'scratch', 'mount_path': '/data'},
233+
{'type': 'shared', 'mount_path': '/sfs', 'volume_id': volume_id},
234+
{
235+
'type': 'secret',
236+
'mount_path': '/secrets',
237+
'secret_name': 'my-secret',
238+
'file_names': ['key.pem'],
239+
},
240+
{'type': 'memory', 'mount_path': '/dev/shm', 'size_in_mb': 512},
241+
],
242+
}
243+
],
244+
'endpoint_base_url': 'https://test-job.datacrunch.io',
245+
'created_at': '2024-01-01T00:00:00Z',
246+
'compute': {'name': 'H100', 'size': 1},
247+
'container_registry_settings': {'is_private': False, 'credentials': None},
248+
}
249+
250+
get_url = f'{endpoint}/{JOB_NAME}'
251+
responses.add(responses.GET, get_url, json=api_payload, status=200)
252+
responses.add(responses.PATCH, get_url, json=api_payload, status=200)
253+
254+
# Simulate the user's flow: get → modify image → update
255+
deployment = service.get_by_name(JOB_NAME)
256+
257+
# Verify deserialization produced the correct subclasses
258+
vms = deployment.containers[0].volume_mounts
259+
assert isinstance(vms[0], GeneralStorageMount)
260+
assert isinstance(vms[1], SharedFileSystemMount)
261+
assert vms[1].volume_id == volume_id
262+
assert isinstance(vms[2], SecretMount)
263+
assert vms[2].secret_name == 'my-secret'
264+
assert vms[2].file_names == ['key.pem']
265+
assert isinstance(vms[3], MemoryMount)
266+
assert vms[3].size_in_mb == 512
267+
268+
# Update only the image (exactly what the reported user script does)
269+
containers = list(deployment.containers)
270+
containers[0] = replace(containers[0], image='busybox:v2')
271+
updated_deployment = replace(deployment, containers=containers)
272+
273+
service.update(JOB_NAME, updated_deployment)
274+
275+
# Verify the PATCH request body still contains volume_id
276+
request_body = json.loads(responses.calls[1].request.body.decode('utf-8'))
277+
sent_vms = request_body['containers'][0]['volume_mounts']
278+
assert sent_vms[0]['type'] == 'scratch'
279+
assert sent_vms[1]['type'] == 'shared'
280+
assert sent_vms[1]['volume_id'] == volume_id
281+
assert sent_vms[2]['type'] == 'secret'
282+
assert sent_vms[2]['secret_name'] == 'my-secret'
283+
assert sent_vms[3]['type'] == 'memory'
284+
assert sent_vms[3]['size_in_mb'] == 512

verda/containers/_containers.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from enum import Enum
1111
from typing import Any
1212

13-
from dataclasses_json import Undefined, dataclass_json # type: ignore
13+
from dataclasses_json import Undefined, config, dataclass_json # type: ignore
1414

1515
from verda.http_client import HTTPClient
1616
from verda.inference_client import InferenceClient, InferenceResponse
@@ -203,6 +203,29 @@ def __init__(self, mount_path: str, volume_id: str):
203203
self.volume_id = volume_id
204204

205205

206+
def _decode_volume_mount(data: dict) -> VolumeMount:
207+
"""Decode a volume mount dict into the correct VolumeMount subclass based on type."""
208+
mount_type = data.get('type')
209+
if mount_type == VolumeMountType.SHARED or mount_type == 'shared':
210+
return SharedFileSystemMount(mount_path=data['mount_path'], volume_id=data['volume_id'])
211+
if mount_type == VolumeMountType.SECRET or mount_type == 'secret':
212+
return SecretMount(
213+
mount_path=data['mount_path'],
214+
secret_name=data['secret_name'],
215+
file_names=data.get('file_names'),
216+
)
217+
if mount_type == VolumeMountType.MEMORY or mount_type == 'memory':
218+
return MemoryMount(size_in_mb=data['size_in_mb'])
219+
return GeneralStorageMount(mount_path=data['mount_path'])
220+
221+
222+
def _decode_volume_mounts(data: list[dict] | None) -> list[VolumeMount] | None:
223+
"""Decode a list of volume mount dicts into the correct VolumeMount subclasses."""
224+
if not data:
225+
return None
226+
return [_decode_volume_mount(v) for v in data]
227+
228+
206229
@dataclass_json
207230
@dataclass
208231
class Container:
@@ -224,7 +247,9 @@ class Container:
224247
healthcheck: HealthcheckSettings | None = None
225248
entrypoint_overrides: EntrypointOverridesSettings | None = None
226249
env: list[EnvVar] | None = None
227-
volume_mounts: list[VolumeMount] | None = None
250+
volume_mounts: list[VolumeMount] | None = field(
251+
default=None, metadata=config(decoder=_decode_volume_mounts)
252+
)
228253

229254

230255
@dataclass_json

0 commit comments

Comments
 (0)