Skip to content

Commit 98088bd

Browse files
committed
fix(docker): return sandbox extensions in lifecycle responses
Docker provider was missing extensions support added in PR #1112. Store opensandbox.extensions.* keys as container labels and return them in CreateSandboxResponse, get_sandbox, and list_sandboxes. Also rename apply_extensions_to_annotations/extract_extensions_from_annotations to apply_extensions_to_mapping/extract_extensions_from_mapping since these functions operate on generic mappings, not just k8s annotations.
1 parent 7780dac commit 98088bd

9 files changed

Lines changed: 179 additions & 25 deletions

File tree

server/opensandbox_server/extensions/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818

1919
from opensandbox_server.extensions.codec import (
2020
apply_access_renew_extend_seconds_to_mapping,
21-
apply_extensions_to_annotations,
22-
extract_extensions_from_annotations,
21+
apply_extensions_to_mapping,
22+
extract_extensions_from_mapping,
2323
)
2424
from opensandbox_server.extensions.keys import (
2525
ACCESS_RENEW_EXTEND_SECONDS_KEY,
@@ -42,8 +42,8 @@
4242
"ACCESS_RENEW_EXTEND_SECONDS_MAX",
4343
"validate_extensions",
4444
"apply_access_renew_extend_seconds_to_mapping",
45-
"apply_extensions_to_annotations",
46-
"extract_extensions_from_annotations",
45+
"apply_extensions_to_mapping",
46+
"extract_extensions_from_mapping",
4747
"EXTENSIONS_ANNOTATION_PREFIX",
4848
"ANNOTATION_METADATA_PREFIX",
4949
"BOOTSTRAP_EXECD_ISOLATION_KEY",

server/opensandbox_server/extensions/codec.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def apply_access_renew_extend_seconds_to_mapping(
4646
mapping[metadata_key] = s
4747

4848

49-
def apply_extensions_to_annotations(
49+
def apply_extensions_to_mapping(
5050
annotations: MutableMapping[str, str],
5151
extensions: Optional[Dict[str, str]],
5252
) -> None:
@@ -70,7 +70,7 @@ def apply_extensions_to_annotations(
7070
annotations[annotation_key] = value
7171

7272

73-
def extract_extensions_from_annotations(
73+
def extract_extensions_from_mapping(
7474
annotations: Optional[Mapping[str, str]],
7575
) -> Optional[Dict[str, str]]:
7676
"""

server/opensandbox_server/services/docker/container_ops.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
from opensandbox_server.extensions import (
3333
apply_access_renew_extend_seconds_to_mapping,
34+
apply_extensions_to_mapping,
3435
)
3536

3637
from opensandbox_server.api.schema import (
@@ -304,6 +305,7 @@ def _build_labels_and_env(
304305
labels[SANDBOX_SNAPSHOT_ID_LABEL] = request.snapshot_id
305306

306307
apply_access_renew_extend_seconds_to_mapping(labels, request.extensions)
308+
apply_extensions_to_mapping(labels, request.extensions)
307309

308310
env_dict = request.env or {}
309311
environment = []

server/opensandbox_server/services/docker/docker_service.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@
4141
ACCESS_RENEW_EXTEND_SECONDS_METADATA_KEY,
4242
BOOTSTRAP_EXECD_ISOLATION_KEY,
4343
ISOLATION_UPPER_MOUNT_PATH,
44+
extract_extensions_from_mapping,
4445
)
46+
from opensandbox_server.extensions.keys import EXTENSIONS_ANNOTATION_PREFIX
4547
from opensandbox_server.api.schema import (
4648
CreateSandboxRequest,
4749
CreateSandboxResponse,
@@ -594,6 +596,7 @@ def _container_to_sandbox(self, container, sandbox_id: Optional[str] = None) ->
594596
platform=platform_spec,
595597
status=status_info,
596598
metadata=metadata,
599+
extensions=extract_extensions_from_mapping(labels),
597600
entrypoint=entrypoint,
598601
expiresAt=expires_at,
599602
createdAt=created_at,
@@ -762,13 +765,18 @@ def _pending_to_sandbox(sandbox_id: str, pending: PendingSandbox) -> Sandbox:
762765
snapshot_id = getattr(pending.request, "snapshot_id", None)
763766
if not isinstance(snapshot_id, str) or not snapshot_id:
764767
snapshot_id = None
768+
extensions = {
769+
k: v for k, v in (pending.request.extensions or {}).items()
770+
if k.startswith(EXTENSIONS_ANNOTATION_PREFIX)
771+
} or None
765772
return Sandbox(
766773
id=sandbox_id,
767774
image=None if snapshot_id else pending.request.image,
768775
snapshotId=snapshot_id,
769776
platform=pending.request.platform,
770777
status=pending.status,
771778
metadata=pending.request.metadata,
779+
extensions=extensions,
772780
entrypoint=pending.request.entrypoint,
773781
expiresAt=pending.expires_at,
774782
createdAt=pending.created_at,
@@ -1029,6 +1037,7 @@ def _provision_sandbox(
10291037
id=sandbox_id,
10301038
status=status_info,
10311039
metadata=request.metadata,
1040+
extensions=extract_extensions_from_mapping(labels),
10321041
platform=effective_platform or request.platform,
10331042
expiresAt=expires_at,
10341043
createdAt=created_at,

server/opensandbox_server/services/k8s/kubernetes_service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030

3131
from opensandbox_server.extensions import (
3232
apply_access_renew_extend_seconds_to_mapping,
33-
apply_extensions_to_annotations,
34-
extract_extensions_from_annotations,
33+
apply_extensions_to_mapping,
34+
extract_extensions_from_mapping,
3535
)
3636
from opensandbox_server.extensions.keys import ACCESS_RENEW_EXTEND_SECONDS_METADATA_KEY
3737
from opensandbox_server.api.schema import (
@@ -677,7 +677,7 @@ async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxRe
677677
secure_access_token_factory=generate_secure_access_token,
678678
)
679679
apply_access_renew_extend_seconds_to_mapping(context.annotations, request.extensions)
680-
apply_extensions_to_annotations(context.annotations, request.extensions)
680+
apply_extensions_to_mapping(context.annotations, request.extensions)
681681

682682
ensure_volumes_valid(
683683
request.volumes,
@@ -832,7 +832,7 @@ async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxRe
832832
created_at=created_at,
833833
expires_at=context.expires_at,
834834
metadata=request.metadata,
835-
extensions=extract_extensions_from_annotations(annotations),
835+
extensions=extract_extensions_from_mapping(annotations),
836836
entrypoint=request.entrypoint,
837837
platform=effective_platform or request.platform,
838838
)

server/opensandbox_server/services/k8s/workload_mapper.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from typing import Any, Optional
1818

1919
from opensandbox_server.api.schema import ImageSpec, PlatformSpec, Sandbox, SandboxStatus
20-
from opensandbox_server.extensions import extract_extensions_from_annotations
20+
from opensandbox_server.extensions import extract_extensions_from_mapping
2121
from opensandbox_server.services.constants import SANDBOX_ID_LABEL, SANDBOX_SNAPSHOT_ID_LABEL
2222

2323

@@ -78,7 +78,7 @@ def _build_sandbox_from_workload(workload: Any, workload_provider: Any) -> Sandb
7878
created_at=creation_timestamp,
7979
expires_at=expires_at,
8080
metadata=user_metadata if user_metadata else None,
81-
extensions=extract_extensions_from_annotations(annotations),
81+
extensions=extract_extensions_from_mapping(annotations),
8282
image=image_spec,
8383
snapshotId=snapshot_id,
8484
entrypoint=entrypoint,

server/tests/test_docker_service.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,119 @@ def test_build_labels_stores_extensions_json():
13481348

13491349
assert labels[ACCESS_RENEW_EXTEND_SECONDS_METADATA_KEY] == "3600"
13501350

1351+
1352+
def test_build_labels_stores_opensandbox_extensions():
1353+
service = DockerSandboxService(config=_app_config())
1354+
request = CreateSandboxRequest(
1355+
image=ImageSpec(uri="python:3.11"),
1356+
resourceLimits=ResourceLimits(root={}),
1357+
env={},
1358+
entrypoint=["python"],
1359+
extensions={
1360+
"opensandbox.extensions.pool-ref": "my-pool",
1361+
"opensandbox.extensions.custom": "value",
1362+
"access.renew.extend.seconds": "1800",
1363+
},
1364+
)
1365+
1366+
labels, _ = service._build_labels_and_env("sandbox-ext2", request, None)
1367+
1368+
assert labels["opensandbox.io/extensions.pool-ref"] == "my-pool"
1369+
assert labels["opensandbox.io/extensions.custom"] == "value"
1370+
assert "opensandbox.io/extensions.access.renew.extend.seconds" not in labels
1371+
1372+
1373+
@patch("opensandbox_server.services.docker.docker_service.docker")
1374+
def test_container_to_sandbox_returns_extensions(mock_docker):
1375+
mock_client = MagicMock()
1376+
mock_client.containers.list.return_value = []
1377+
mock_docker.from_env.return_value = mock_client
1378+
1379+
service = DockerSandboxService(config=_app_config())
1380+
container = MagicMock()
1381+
container.attrs = {
1382+
"Config": {
1383+
"Labels": {
1384+
SANDBOX_ID_LABEL: "sandbox-ext",
1385+
"opensandbox.io/extensions.pool-ref": "my-pool",
1386+
"opensandbox.io/extensions.custom": "value",
1387+
"opensandbox.io/access-renew-extend-seconds": "1800",
1388+
},
1389+
"Cmd": ["python"],
1390+
},
1391+
"Created": "2025-01-01T00:00:00Z",
1392+
"State": {
1393+
"Status": "running",
1394+
"Running": True,
1395+
"FinishedAt": "0001-01-01T00:00:00Z",
1396+
"ExitCode": 0,
1397+
},
1398+
}
1399+
container.image = MagicMock(tags=["python:3.11"], short_id="sha-img")
1400+
1401+
sandbox = service._container_to_sandbox(container)
1402+
1403+
assert sandbox.extensions == {
1404+
"opensandbox.extensions.pool-ref": "my-pool",
1405+
"opensandbox.extensions.custom": "value",
1406+
}
1407+
assert sandbox.metadata is None
1408+
1409+
1410+
@pytest.mark.asyncio
1411+
@patch("opensandbox_server.services.docker.docker_service.docker")
1412+
async def test_create_sandbox_response_includes_extensions(mock_docker):
1413+
mock_client = MagicMock()
1414+
mock_client.containers.list.return_value = []
1415+
mock_docker.from_env.return_value = mock_client
1416+
1417+
service = DockerSandboxService(config=_app_config())
1418+
request = CreateSandboxRequest(
1419+
image=ImageSpec(uri="python:3.11"),
1420+
resourceLimits=ResourceLimits(root={}),
1421+
env={},
1422+
entrypoint=["python"],
1423+
extensions={"opensandbox.extensions.test-key": "test-value"},
1424+
)
1425+
1426+
with patch.object(service, "_create_and_start_container") as mock_create:
1427+
mock_container = MagicMock()
1428+
mock_container.image = MagicMock(tags=["python:3.11"])
1429+
mock_create.return_value = mock_container
1430+
response = await service.create_sandbox(request)
1431+
1432+
assert response.extensions == {"opensandbox.extensions.test-key": "test-value"}
1433+
1434+
1435+
@patch("opensandbox_server.services.docker.docker_service.docker")
1436+
def test_pending_sandbox_includes_extensions(mock_docker):
1437+
mock_client = MagicMock()
1438+
mock_client.containers.list.return_value = []
1439+
mock_docker.from_env.return_value = mock_client
1440+
1441+
service = DockerSandboxService(config=_app_config())
1442+
pending = PendingSandbox(
1443+
request=MagicMock(
1444+
metadata=None,
1445+
entrypoint=["python"],
1446+
image=ImageSpec(uri="python:3.11"),
1447+
platform=None,
1448+
snapshot_id=None,
1449+
extensions={
1450+
"opensandbox.extensions.pool-ref": "my-pool",
1451+
"access.renew.extend.seconds": "1800",
1452+
},
1453+
),
1454+
created_at=datetime.now(timezone.utc),
1455+
expires_at=datetime.now(timezone.utc),
1456+
status=SandboxStatus(state="Pending"),
1457+
)
1458+
1459+
sandbox = service._pending_to_sandbox("sandbox-pending-ext", pending)
1460+
1461+
assert sandbox.extensions == {"opensandbox.extensions.pool-ref": "my-pool"}
1462+
1463+
13511464
def test_build_labels_store_platform_constraints():
13521465
service = DockerSandboxService(config=_app_config())
13531466
request = CreateSandboxRequest(

server/tests/test_extensions.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
ACCESS_RENEW_EXTEND_SECONDS_METADATA_KEY,
2222
ACCESS_RENEW_EXTEND_SECONDS_MIN,
2323
apply_access_renew_extend_seconds_to_mapping,
24-
apply_extensions_to_annotations,
25-
extract_extensions_from_annotations,
24+
apply_extensions_to_mapping,
25+
extract_extensions_from_mapping,
2626
validate_extensions
2727
)
2828

@@ -105,7 +105,7 @@ class TestExtensionsToAnnotations:
105105
def test_single_extension_propagated(self):
106106
annotations: dict[str, str] = {}
107107
extensions = {"opensandbox.extensions.pool-ref": "my-pool"}
108-
apply_extensions_to_annotations(annotations, extensions)
108+
apply_extensions_to_mapping(annotations, extensions)
109109
assert annotations == {"opensandbox.io/extensions.pool-ref": "my-pool"}
110110

111111
def test_multiple_extensions_propagated(self):
@@ -114,7 +114,7 @@ def test_multiple_extensions_propagated(self):
114114
"opensandbox.extensions.pool-ref": "my-pool",
115115
"opensandbox.extensions.custom-key": "custom-value",
116116
}
117-
apply_extensions_to_annotations(annotations, extensions)
117+
apply_extensions_to_mapping(annotations, extensions)
118118
assert annotations == {
119119
"opensandbox.io/extensions.pool-ref": "my-pool",
120120
"opensandbox.io/extensions.custom-key": "custom-value",
@@ -126,7 +126,7 @@ def test_non_prefix_extension_not_propagated(self):
126126
"poolRef": "my-pool",
127127
"other.key": "value",
128128
}
129-
apply_extensions_to_annotations(annotations, extensions)
129+
apply_extensions_to_mapping(annotations, extensions)
130130
assert annotations == {}
131131

132132
def test_mixed_extensions_propagated_only_prefix_keys(self):
@@ -136,23 +136,23 @@ def test_mixed_extensions_propagated_only_prefix_keys(self):
136136
"poolRef": "ignored",
137137
"access.renew.extend.seconds": "1800",
138138
}
139-
apply_extensions_to_annotations(annotations, extensions)
139+
apply_extensions_to_mapping(annotations, extensions)
140140
assert annotations == {"opensandbox.io/extensions.pool-ref": "my-pool"}
141141

142142
def test_empty_extensions_noop(self):
143143
annotations: dict[str, str] = {"existing": "value"}
144-
apply_extensions_to_annotations(annotations, None)
144+
apply_extensions_to_mapping(annotations, None)
145145
assert annotations == {"existing": "value"}
146146

147147
def test_empty_extensions_dict_noop(self):
148148
annotations: dict[str, str] = {"existing": "value"}
149-
apply_extensions_to_annotations(annotations, {})
149+
apply_extensions_to_mapping(annotations, {})
150150
assert annotations == {"existing": "value"}
151151

152152
def test_preserves_existing_annotations(self):
153153
annotations: dict[str, str] = {"existing": "value"}
154154
extensions = {"opensandbox.extensions.new-key": "new-value"}
155-
apply_extensions_to_annotations(annotations, extensions)
155+
apply_extensions_to_mapping(annotations, extensions)
156156
assert annotations == {
157157
"existing": "value",
158158
"opensandbox.io/extensions.new-key": "new-value",
@@ -166,7 +166,7 @@ def test_restores_prefixed_annotations(self):
166166
"opensandbox.io/extensions.localized": "中文数据",
167167
}
168168

169-
assert extract_extensions_from_annotations(annotations) == {
169+
assert extract_extensions_from_mapping(annotations) == {
170170
"opensandbox.extensions.custom-key": "custom-value",
171171
"opensandbox.extensions.localized": "中文数据",
172172
}
@@ -177,8 +177,8 @@ def test_ignores_non_extension_annotations(self):
177177
"other": "value",
178178
}
179179

180-
assert extract_extensions_from_annotations(annotations) is None
180+
assert extract_extensions_from_mapping(annotations) is None
181181

182182
def test_empty_annotations_return_none(self):
183-
assert extract_extensions_from_annotations({}) is None
184-
assert extract_extensions_from_annotations(None) is None
183+
assert extract_extensions_from_mapping({}) is None
184+
assert extract_extensions_from_mapping(None) is None

tests/python/tests/test_sandbox_e2e.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,36 @@ async def test_01b_manual_cleanup(self):
357357

358358
logger.info("TEST 1 PASSED: Sandbox lifecycle and health test completed successfully")
359359

360+
@pytest.mark.timeout(120)
361+
@pytest.mark.order(1)
362+
async def test_01_extensions_round_trip(self):
363+
"""Verify extensions are returned in create response, get_info, and list."""
364+
cfg = create_connection_config()
365+
ext_sandbox = await Sandbox.create(
366+
image=SandboxImageSpec(get_sandbox_image()),
367+
resource=get_e2e_sandbox_resource(),
368+
connection_config=cfg,
369+
timeout=timedelta(minutes=2),
370+
ready_timeout=timedelta(seconds=30),
371+
metadata={"tag": "e2e-extensions"},
372+
extensions={
373+
"opensandbox.extensions.test-key": "test-value",
374+
"opensandbox.extensions.second": "second-value",
375+
},
376+
health_check_polling_interval=timedelta(milliseconds=500),
377+
)
378+
try:
379+
info = await ext_sandbox.get_info()
380+
assert info.extensions is not None, "extensions missing from get_info"
381+
assert info.extensions.get("opensandbox.extensions.test-key") == "test-value"
382+
assert info.extensions.get("opensandbox.extensions.second") == "second-value"
383+
logger.info("extensions round-trip OK: %s", info.extensions)
384+
finally:
385+
try:
386+
await ext_sandbox.kill()
387+
except Exception as e:
388+
logger.warning("extensions test teardown kill failed: %s", e)
389+
await ext_sandbox.close()
360390

361391
@pytest.mark.timeout(120)
362392
@pytest.mark.order(1)

0 commit comments

Comments
 (0)