Skip to content

Commit 28561b2

Browse files
GujiasshPangjiping
authored andcommitted
fix(k8s): 前移共享 PVC 只读一致性校验
1 parent 21ebe34 commit 28561b2

4 files changed

Lines changed: 89 additions & 14 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: 26 additions & 7 deletions
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,9 +62,10 @@ 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] = {}
43-
pvc_to_read_only: Dict[str, bool] = {}
4469

4570
for vol in volumes:
4671
vol_name = vol.name
@@ -63,13 +88,7 @@ def apply_volumes_to_pod_spec(
6388
},
6489
})
6590
pvc_to_volume_name[pvc_claim_name] = vol_name
66-
pvc_to_read_only[pvc_claim_name] = vol.read_only
6791
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-
)
7392

7493
mount = {
7594
"name": pvc_to_volume_name[pvc_claim_name],

server/tests/k8s/test_batchsandbox_provider.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,9 @@ def test_create_workload_builds_correct_manifest(self, mock_k8s_client):
140140
expires_at=expires_at,
141141
execd_image="execd:latest",
142142
)
143-
143+
144144
assert result == {"name": "test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
145-
145+
146146
# Verify API call
147147
call_args = mock_k8s_client.create_custom_object.call_args
148148
body = call_args.kwargs["body"]
@@ -870,7 +870,7 @@ def test_create_workload_updates_informer_cache(self, mock_k8s_client):
870870
)
871871

872872
assert result == {"name": "test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
873-
873+
874874
# ===== Workload List Tests =====
875875

876876
def test_list_workloads_returns_items(self, mock_k8s_client, mock_batchsandbox_list_response):
@@ -1325,7 +1325,7 @@ def test_create_workload_poolref_ignores_image_spec(self, mock_k8s_client):
13251325

13261326
# Should succeed and return workload info
13271327
assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
1328-
1328+
13291329
# Verify poolRef is used
13301330
body = mock_k8s_client.create_custom_object.call_args.kwargs["body"]
13311331
assert body["spec"]["poolRef"] == "my-pool"
@@ -1357,7 +1357,7 @@ def test_create_workload_poolref_ignores_resource_limits(self, mock_k8s_client):
13571357

13581358
# Should succeed and return workload info
13591359
assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
1360-
1360+
13611361
# Verify poolRef is used
13621362
body = mock_k8s_client.create_custom_object.call_args.kwargs["body"]
13631363
assert body["spec"]["poolRef"] == "my-pool"
@@ -1385,9 +1385,9 @@ def test_create_workload_poolref_allows_entrypoint_and_env(self, mock_k8s_client
13851385
execd_image="execd:latest",
13861386
extensions={"poolRef": "my-pool"},
13871387
)
1388-
1388+
13891389
assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"}
1390-
1390+
13911391
# Verify the call
13921392
body = mock_k8s_client.create_custom_object.call_args.kwargs["body"]
13931393
assert body["spec"]["poolRef"] == "my-pool"

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)