Skip to content

Commit 5b3afaa

Browse files
authored
Merge pull request #1246 from Gujiassh/fix/k8s-propagate-pvc-readonly-545
fix(k8s): propagate pvc readonly flag
2 parents 34653f7 + 28561b2 commit 5b3afaa

4 files changed

Lines changed: 159 additions & 2 deletions

File tree

server/opensandbox_server/services/k8s/kubernetes_service.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from opensandbox_server.services.k8s.k8s_diagnostics import K8sDiagnosticsMixin
6161
from opensandbox_server.services.k8s.endpoint_resolver import _attach_egress_auth_headers, _attach_secure_access_headers
6262
from opensandbox_server.services.k8s.list_helpers import _build_list_sandboxes_response
63+
from opensandbox_server.services.k8s.volume_helper import ensure_shared_pvc_read_only_policy
6364
from opensandbox_server.services.k8s.status_helpers import (
6465
_is_unschedulable_status,
6566
_normalize_create_status,
@@ -803,6 +804,11 @@ async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxRe
803804
if has_pool_ref and pool_ref != POOL_AUTO_ASSIGN_REF:
804805
await asyncio.to_thread(self._ensure_pool_ref_exists, pool_ref)
805806

807+
# Kubernetes applies PVC readOnly at the source volume level, so
808+
# shared PVC mounts must agree before any auto-provisioning side effect.
809+
if request.volumes:
810+
ensure_shared_pvc_read_only_policy(request.volumes)
811+
806812
# Auto-create PVCs that don't exist yet
807813
if request.volumes:
808814
managed_pvcs_may_exist = True

server/opensandbox_server/services/k8s/volume_helper.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,30 @@
2424
logger = logging.getLogger(__name__)
2525

2626

27+
def _raise_mixed_pvc_read_only_policy(pvc_claim_name: str) -> None:
28+
raise ValueError(
29+
f"PVC claim '{pvc_claim_name}' is mounted with mixed read_only values. "
30+
"All mounts sharing the same PVC must use the same read_only policy."
31+
)
32+
33+
34+
def ensure_shared_pvc_read_only_policy(volumes: List[Volume]) -> None:
35+
"""Ensure every mount that references the same PVC uses the same read_only policy."""
36+
pvc_to_read_only: Dict[str, bool] = {}
37+
38+
for vol in volumes:
39+
if vol.pvc is None:
40+
continue
41+
42+
pvc_claim_name = vol.pvc.claim_name
43+
if pvc_claim_name in pvc_to_read_only:
44+
if pvc_to_read_only[pvc_claim_name] != vol.read_only:
45+
_raise_mixed_pvc_read_only_policy(pvc_claim_name)
46+
continue
47+
48+
pvc_to_read_only[pvc_claim_name] = vol.read_only
49+
50+
2751
def apply_volumes_to_pod_spec(
2852
pod_spec: Dict[str, Any],
2953
volumes: List[Volume],
@@ -38,6 +62,8 @@ def apply_volumes_to_pod_spec(
3862
mounts = main_container.get("volumeMounts", [])
3963
pod_volumes = pod_spec.get("volumes", [])
4064

65+
ensure_shared_pvc_read_only_policy(volumes)
66+
4167
existing_volume_names = {v.get("name") for v in pod_volumes if isinstance(v, dict)}
4268
pvc_to_volume_name: Dict[str, str] = {}
4369

@@ -58,6 +84,7 @@ def apply_volumes_to_pod_spec(
5884
"name": vol_name,
5985
"persistentVolumeClaim": {
6086
"claimName": pvc_claim_name,
87+
"readOnly": vol.read_only,
6188
},
6289
})
6390
pvc_to_volume_name[pvc_claim_name] = vol_name
@@ -73,7 +100,11 @@ def apply_volumes_to_pod_spec(
73100
mounts.append(mount)
74101

75102
logger.info(
76-
f"Added PVC volume '{vol_name}' (claim: {pvc_claim_name}) mounted at '{vol.mount_path}' for sandbox"
103+
"Added PVC volume '%s' (claim: %s, read_only=%s) mounted at '%s' for sandbox",
104+
pvc_to_volume_name[pvc_claim_name],
105+
pvc_claim_name,
106+
vol.read_only,
107+
vol.mount_path,
77108
)
78109
elif vol.host is not None:
79110
host_path = vol.host.path

server/tests/k8s/test_batchsandbox_provider.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2698,8 +2698,11 @@ def test_create_workload_with_pvc_volume_readonly(self, mock_k8s_client):
26982698
main_container = pod_spec["containers"][0]
26992699
mounts = main_container.get("volumeMounts", [])
27002700
models_mount = next((m for m in mounts if m["name"] == "models-volume"), None)
2701+
models_volume = next((v for v in pod_spec["volumes"] if v["name"] == "models-volume"), None)
27012702
assert models_mount is not None
27022703
assert models_mount["readOnly"] is True
2704+
assert models_volume is not None
2705+
assert models_volume["persistentVolumeClaim"]["readOnly"] is True
27032706

27042707
def test_create_workload_with_pvc_volume_subpath(self, mock_k8s_client):
27052708
"""
@@ -2961,7 +2964,10 @@ def test_apply_volumes_to_pod_spec_same_pvc_multiple_mounts(self, mock_k8s_clien
29612964
# One volume definition for the shared PVC (first Volume name used)
29622965
assert len(pod_spec["volumes"]) == 1
29632966
assert pod_spec["volumes"][0]["name"] == "skills"
2964-
assert pod_spec["volumes"][0]["persistentVolumeClaim"]["claimName"] == "oss-pvc-r"
2967+
shared_volume = next((v for v in pod_spec["volumes"] if v["name"] == "skills"), None)
2968+
assert shared_volume is not None
2969+
assert shared_volume["persistentVolumeClaim"]["claimName"] == "oss-pvc-r"
2970+
assert shared_volume["persistentVolumeClaim"]["readOnly"] is True
29652971

29662972
# Two volumeMounts, both referencing the same volume name
29672973
mounts = pod_spec["containers"][0]["volumeMounts"]
@@ -2971,3 +2977,67 @@ def test_apply_volumes_to_pod_spec_same_pvc_multiple_mounts(self, mock_k8s_clien
29712977
assert by_path["/path/to/skills"].get("subPath") == "skill-hub/publish"
29722978
assert by_path["/path/to/draft"]["name"] == "skills"
29732979
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)

server/tests/k8s/test_kubernetes_service.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,56 @@ async def test_pool_ref_with_volumes_rejected_before_pvc_creation(
15411541
k8s_service.k8s_client.create_pvc.assert_not_called()
15421542
k8s_service.workload_provider.create_workload.assert_not_called()
15431543

1544+
@pytest.mark.asyncio
1545+
async def test_shared_pvc_mixed_read_only_rejected_before_pvc_creation(
1546+
self, k8s_service
1547+
):
1548+
from opensandbox_server.api.schema import (
1549+
CreateSandboxRequest,
1550+
ImageSpec,
1551+
PVC,
1552+
ResourceLimits,
1553+
Volume,
1554+
)
1555+
1556+
request = CreateSandboxRequest(
1557+
image=ImageSpec(uri="python:3.11"),
1558+
entrypoint=["/bin/bash", "-c", "sleep 3600"],
1559+
timeout=3600,
1560+
resourceLimits=ResourceLimits(root={"cpu": "1", "memory": "1Gi"}),
1561+
volumes=[
1562+
Volume(
1563+
name="shared-rw",
1564+
mountPath="/data/rw",
1565+
readOnly=False,
1566+
pvc=PVC(
1567+
claimName="auto-data",
1568+
createIfNotExists=True,
1569+
deleteOnSandboxTermination=True,
1570+
),
1571+
),
1572+
Volume(
1573+
name="shared-ro",
1574+
mountPath="/data/ro",
1575+
readOnly=True,
1576+
pvc=PVC(
1577+
claimName="auto-data",
1578+
createIfNotExists=True,
1579+
deleteOnSandboxTermination=True,
1580+
),
1581+
),
1582+
],
1583+
)
1584+
1585+
with pytest.raises(HTTPException) as exc_info:
1586+
await k8s_service.create_sandbox(request)
1587+
1588+
assert exc_info.value.status_code == 400
1589+
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
1590+
assert "mixed read_only values" in exc_info.value.detail["message"]
1591+
k8s_service.k8s_client.create_pvc.assert_not_called()
1592+
k8s_service.workload_provider.create_workload.assert_not_called()
1593+
15441594
@pytest.mark.asyncio
15451595
async def test_pvc_cleanup_runs_when_wait_for_ready_rollback_reports_not_found(
15461596
self, k8s_service, mock_workload

0 commit comments

Comments
 (0)