Skip to content

Commit 21ebe34

Browse files
GujiasshPangjiping
authored andcommitted
fix(k8s): validate shared PVC readonly policy
1 parent b432b06 commit 21ebe34

2 files changed

Lines changed: 97 additions & 58 deletions

File tree

server/opensandbox_server/services/k8s/volume_helper.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def apply_volumes_to_pod_spec(
4040

4141
existing_volume_names = {v.get("name") for v in pod_volumes if isinstance(v, dict)}
4242
pvc_to_volume_name: Dict[str, str] = {}
43+
pvc_to_read_only: Dict[str, bool] = {}
4344

4445
for vol in volumes:
4546
vol_name = vol.name
@@ -54,17 +55,21 @@ def apply_volumes_to_pod_spec(
5455
pvc_claim_name = vol.pvc.claim_name
5556

5657
if pvc_claim_name not in pvc_to_volume_name:
57-
pod_volumes.append(
58-
{
59-
"name": vol_name,
60-
"persistentVolumeClaim": {
61-
"claimName": pvc_claim_name,
62-
"readOnly": vol.read_only,
63-
},
64-
}
65-
)
58+
pod_volumes.append({
59+
"name": vol_name,
60+
"persistentVolumeClaim": {
61+
"claimName": pvc_claim_name,
62+
"readOnly": vol.read_only,
63+
},
64+
})
6665
pvc_to_volume_name[pvc_claim_name] = vol_name
66+
pvc_to_read_only[pvc_claim_name] = vol.read_only
6767
existing_volume_names.add(vol_name)
68+
elif pvc_to_read_only[pvc_claim_name] != vol.read_only:
69+
raise ValueError(
70+
f"PVC claim '{pvc_claim_name}' is mounted with mixed read_only values. "
71+
"All mounts sharing the same PVC must use the same read_only policy."
72+
)
6873

6974
mount = {
7075
"name": pvc_to_volume_name[pvc_claim_name],
@@ -76,20 +81,22 @@ def apply_volumes_to_pod_spec(
7681
mounts.append(mount)
7782

7883
logger.info(
79-
f"Added PVC volume '{vol_name}' (claim: {pvc_claim_name}) mounted at '{vol.mount_path}' for sandbox"
84+
"Added PVC volume '%s' (claim: %s, read_only=%s) mounted at '%s' for sandbox",
85+
pvc_to_volume_name[pvc_claim_name],
86+
pvc_claim_name,
87+
vol.read_only,
88+
vol.mount_path,
8089
)
8190
elif vol.host is not None:
8291
host_path = vol.host.path
8392

84-
pod_volumes.append(
85-
{
86-
"name": vol_name,
87-
"hostPath": {
88-
"path": host_path,
89-
"type": "DirectoryOrCreate",
90-
},
91-
}
92-
)
93+
pod_volumes.append({
94+
"name": vol_name,
95+
"hostPath": {
96+
"path": host_path,
97+
"type": "DirectoryOrCreate",
98+
},
99+
})
93100

94101
mount = {
95102
"name": vol_name,

server/tests/k8s/test_batchsandbox_provider.py

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,7 @@ def test_create_workload_builds_correct_manifest(self, mock_k8s_client):
141141
execd_image="execd:latest",
142142
)
143143

144-
assert result == {
145-
"name": "test-id",
146-
"uid": "test-uid",
147-
"apiVersion": "sandbox.opensandbox.io/v1alpha1",
148-
"kind": "BatchSandbox",
149-
}
144+
assert result == {"name": "test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
150145

151146
# Verify API call
152147
call_args = mock_k8s_client.create_custom_object.call_args
@@ -874,12 +869,7 @@ def test_create_workload_updates_informer_cache(self, mock_k8s_client):
874869
execd_image="execd:latest",
875870
)
876871

877-
assert result == {
878-
"name": "test-id",
879-
"uid": "test-uid",
880-
"apiVersion": "sandbox.opensandbox.io/v1alpha1",
881-
"kind": "BatchSandbox",
882-
}
872+
assert result == {"name": "test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
883873

884874
# ===== Workload List Tests =====
885875

@@ -1334,12 +1324,7 @@ def test_create_workload_poolref_ignores_image_spec(self, mock_k8s_client):
13341324
)
13351325

13361326
# Should succeed and return workload info
1337-
assert result == {
1338-
"name": "sandbox-test-id",
1339-
"uid": "test-uid",
1340-
"apiVersion": "sandbox.opensandbox.io/v1alpha1",
1341-
"kind": "BatchSandbox",
1342-
}
1327+
assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
13431328

13441329
# Verify poolRef is used
13451330
body = mock_k8s_client.create_custom_object.call_args.kwargs["body"]
@@ -1371,12 +1356,7 @@ def test_create_workload_poolref_ignores_resource_limits(self, mock_k8s_client):
13711356
)
13721357

13731358
# Should succeed and return workload info
1374-
assert result == {
1375-
"name": "sandbox-test-id",
1376-
"uid": "test-uid",
1377-
"apiVersion": "sandbox.opensandbox.io/v1alpha1",
1378-
"kind": "BatchSandbox",
1379-
}
1359+
assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
13801360

13811361
# Verify poolRef is used
13821362
body = mock_k8s_client.create_custom_object.call_args.kwargs["body"]
@@ -1406,12 +1386,7 @@ def test_create_workload_poolref_allows_entrypoint_and_env(self, mock_k8s_client
14061386
extensions={"poolRef": "my-pool"},
14071387
)
14081388

1409-
assert result == {
1410-
"name": "sandbox-test-id",
1411-
"uid": "test-uid",
1412-
"apiVersion": "sandbox.opensandbox.io/v1alpha1",
1413-
"kind": "BatchSandbox",
1414-
}
1389+
assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
14151390

14161391
# Verify the call
14171392
body = mock_k8s_client.create_custom_object.call_args.kwargs["body"]
@@ -1645,9 +1620,7 @@ def test_create_workload_poolref_default_entrypoint_with_env_includes_task_templ
16451620
task_template = body["spec"]["taskTemplate"]
16461621
assert task_template["spec"]["process"]["env"] == [{"name": "VERSION", "value": "11"}]
16471622

1648-
def test_create_workload_poolref_none_entrypoint_no_env_omits_task_template(
1649-
self, mock_k8s_client
1650-
):
1623+
def test_create_workload_poolref_none_entrypoint_no_env_omits_task_template(self, mock_k8s_client):
16511624
"""When entrypoint is None and env is empty, taskTemplate is omitted.
16521625
16531626
SDK pool mode callers omit entrypoint entirely (None), expecting the pool's
@@ -2664,12 +2637,7 @@ def test_create_workload_with_pvc_volume(self, mock_k8s_client):
26642637
volumes=volumes,
26652638
)
26662639

2667-
assert result == {
2668-
"name": "test-id",
2669-
"uid": "test-uid",
2670-
"apiVersion": "sandbox.opensandbox.io/v1alpha1",
2671-
"kind": "BatchSandbox",
2672-
}
2640+
assert result == {"name": "test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
26732641

26742642
def test_create_workload_poolref_rejects_platform(self, mock_k8s_client):
26752643
provider = BatchSandboxProvider(mock_k8s_client)
@@ -3009,3 +2977,67 @@ def test_apply_volumes_to_pod_spec_same_pvc_multiple_mounts(self, mock_k8s_clien
30092977
assert by_path["/path/to/skills"].get("subPath") == "skill-hub/publish"
30102978
assert by_path["/path/to/draft"]["name"] == "skills"
30112979
assert by_path["/path/to/draft"].get("subPath") == "skill-hub/draft"
2980+
2981+
def test_apply_volumes_to_pod_spec_same_pvc_multiple_mounts_readwrite(self, mock_k8s_client):
2982+
"""Shared PVC mounts with read_only=False should keep source-level readOnly false."""
2983+
from opensandbox_server.api.schema import Volume, PVC
2984+
2985+
pod_spec = {
2986+
"containers": [{"name": "main", "volumeMounts": []}],
2987+
"volumes": [],
2988+
}
2989+
volumes = [
2990+
Volume(
2991+
name="skills",
2992+
pvc=PVC(claim_name="oss-pvc-rw"),
2993+
mount_path="/path/to/skills",
2994+
sub_path="skill-hub/publish",
2995+
read_only=False,
2996+
),
2997+
Volume(
2998+
name="draft",
2999+
pvc=PVC(claim_name="oss-pvc-rw"),
3000+
mount_path="/path/to/draft",
3001+
sub_path="skill-hub/draft",
3002+
read_only=False,
3003+
),
3004+
]
3005+
3006+
apply_volumes_to_pod_spec(pod_spec, volumes)
3007+
3008+
assert len(pod_spec["volumes"]) == 1
3009+
shared_volume = next((v for v in pod_spec["volumes"] if v["name"] == "skills"), None)
3010+
assert shared_volume is not None
3011+
assert shared_volume["persistentVolumeClaim"]["claimName"] == "oss-pvc-rw"
3012+
assert shared_volume["persistentVolumeClaim"]["readOnly"] is False
3013+
3014+
mounts = pod_spec["containers"][0]["volumeMounts"]
3015+
assert len(mounts) == 2
3016+
assert all(mount["name"] == "skills" for mount in mounts)
3017+
assert all(mount["readOnly"] is False for mount in mounts)
3018+
3019+
def test_apply_volumes_to_pod_spec_same_pvc_mixed_read_only_raises(self, mock_k8s_client):
3020+
"""Shared PVC mounts with mixed read_only values should fail fast."""
3021+
from opensandbox_server.api.schema import Volume, PVC
3022+
3023+
pod_spec = {
3024+
"containers": [{"name": "main", "volumeMounts": []}],
3025+
"volumes": [],
3026+
}
3027+
volumes = [
3028+
Volume(
3029+
name="skills",
3030+
pvc=PVC(claim_name="oss-pvc-mixed"),
3031+
mount_path="/path/to/skills",
3032+
read_only=False,
3033+
),
3034+
Volume(
3035+
name="draft",
3036+
pvc=PVC(claim_name="oss-pvc-mixed"),
3037+
mount_path="/path/to/draft",
3038+
read_only=True,
3039+
),
3040+
]
3041+
3042+
with pytest.raises(ValueError, match="mixed read_only values"):
3043+
apply_volumes_to_pod_spec(pod_spec, volumes)

0 commit comments

Comments
 (0)