diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts index 7ca72c470..531308c82 100644 --- a/sdks/sandbox/javascript/src/api/lifecycle.ts +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -468,8 +468,11 @@ export interface components { metadata?: { [key: string]: string; }; - /** @description Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled. */ - expiresAt?: string | null; + /** + * Format: date-time + * @description Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled. + */ + expiresAt?: string; /** * Format: date-time * @description Sandbox creation timestamp @@ -498,8 +501,11 @@ export interface components { * Always present in responses since entrypoint is required in creation requests. */ entrypoint: string[]; - /** @description Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled. */ - expiresAt?: string | null; + /** + * Format: date-time + * @description Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled. + */ + expiresAt?: string; /** * Format: date-time * @description Sandbox creation timestamp @@ -583,9 +589,9 @@ export interface components { /** * @description Sandbox timeout in seconds. The sandbox will automatically terminate after this duration. * The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`). - * Omit or set null to disable automatic expiration and require explicit cleanup. + * Omit this field or set it to null to disable automatic expiration and require explicit cleanup. * Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject - * null timeout when the underlying workload provider does not support non-expiring sandboxes. + * omitted or null timeout when the underlying workload provider does not support non-expiring sandboxes. */ timeout?: number | null; /** @@ -656,6 +662,9 @@ export interface components { * **Best Practices**: * - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions. * - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently. + * + * **Well-known keys**: + * - `access.renew.extend.seconds` (optional): Decimal integer string from **300** to **86400** (5 minutes to 24 hours inclusive). Opts the sandbox into OSEP-0009 renew-on-access and sets per-renewal extension seconds. Omit to disable. Invalid values are rejected at creation with HTTP 400 (validated on the lifecycle create endpoint via `validate_extensions` in server `src/extensions/validation.py`). */ extensions?: { [key: string]: string; diff --git a/sdks/sandbox/python/README.md b/sdks/sandbox/python/README.md index afa969480..138fd76ef 100644 --- a/sdks/sandbox/python/README.md +++ b/sdks/sandbox/python/README.md @@ -117,16 +117,15 @@ sandbox = await Sandbox.resume( # Get current status info = await sandbox.get_info() print(f"State: {info.status.state}") -print(f"Expires: {info.expires_at}") # None when manual cleanup mode is used +print(f"Expires: {info.expires_at}") # None when no automatic expiration is configured ``` -Create a non-expiring sandbox by passing `timeout=None`: +Create a non-expiring sandbox by omitting `timeout`: ```python manual = await Sandbox.create( "ubuntu", connection_config=config, - timeout=None, ) ``` diff --git a/sdks/sandbox/python/scripts/generate_api.py b/sdks/sandbox/python/scripts/generate_api.py index 98d17a58e..c18d79ce6 100644 --- a/sdks/sandbox/python/scripts/generate_api.py +++ b/sdks/sandbox/python/scripts/generate_api.py @@ -249,53 +249,6 @@ def add_license_headers(root: Path) -> None: ) -def patch_lifecycle_nullable_nested_models(root: Path) -> None: - """Patch generated lifecycle models that openapi-python-client does not null-handle.""" - replacements = { - root / "models" / "image_spec.py": [ - ( - " if isinstance(_auth, Unset):\n auth = UNSET\n", - " if isinstance(_auth, Unset) or _auth is None:\n auth = UNSET\n", - ) - ], - root / "models" / "create_sandbox_response.py": [ - ( - " if isinstance(_metadata, Unset):\n metadata = UNSET\n", - " if isinstance(_metadata, Unset) or _metadata is None:\n metadata = UNSET\n", - ) - ], - root / "models" / "sandbox.py": [ - ( - " if isinstance(_metadata, Unset):\n metadata = UNSET\n", - " if isinstance(_metadata, Unset) or _metadata is None:\n metadata = UNSET\n", - ) - ], - root / "models" / "sandbox_status.py": [ - ( - " if isinstance(_last_transition_at, Unset):\n last_transition_at = UNSET\n", - " if isinstance(_last_transition_at, Unset) or _last_transition_at is None:\n last_transition_at = UNSET\n", - ) - ], - } - - patched_files = 0 - for file_path, file_replacements in replacements.items(): - if not file_path.exists(): - continue - - content = file_path.read_text(encoding="utf-8") - updated = content - for old, new in file_replacements: - if old in updated: - updated = updated.replace(old, new, 1) - - if updated != content: - file_path.write_text(updated, encoding="utf-8") - patched_files += 1 - - if patched_files: - print(f"✅ Patched nullable lifecycle model handling in {patched_files} files") - def post_process_generated_code() -> None: """Post-process the generated code to ensure proper package structure.""" @@ -316,7 +269,6 @@ def post_process_generated_code() -> None: add_license_headers(Path("src/opensandbox/api/egress")) add_license_headers(Path("src/opensandbox/api/lifecycle")) add_license_headers(Path("src/opensandbox/api")) - patch_lifecycle_nullable_nested_models(Path("src/opensandbox/api/lifecycle")) def main() -> None: diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request.py index 23afe03a5..5b12fd5a5 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request.py @@ -72,9 +72,9 @@ class CreateSandboxRequest: timeout (int | None | Unset): Sandbox timeout in seconds. The sandbox will automatically terminate after this duration. The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`). - Omit or set null to disable automatic expiration and require explicit cleanup. + Omit this field or set it to null to disable automatic expiration and require explicit cleanup. Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject - null timeout when the underlying workload provider does not support non-expiring sandboxes. + omitted or null timeout when the underlying workload provider does not support non-expiring sandboxes. env (CreateSandboxRequestEnv | Unset): Environment variables to inject into the sandbox runtime. Example: {'API_KEY': 'secret-key', 'DEBUG': 'true', 'LOG_LEVEL': 'info'}. metadata (CreateSandboxRequestMetadata | Unset): Custom key-value metadata for management, filtering, and @@ -98,6 +98,12 @@ class CreateSandboxRequest: **Best Practices**: - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions. - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently. + + **Well-known keys**: + - `access.renew.extend.seconds` (optional): Decimal integer string from **300** to **86400** (5 minutes to 24 + hours inclusive). Opts the sandbox into OSEP-0009 renew-on-access and sets per-renewal extension seconds. Omit + to disable. Invalid values are rejected at creation with HTTP 400 (validated on the lifecycle create endpoint + via `validate_extensions` in server `src/extensions/validation.py`). """ image: ImageSpec diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request_extensions.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request_extensions.py index 02a823363..ed1663c53 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request_extensions.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request_extensions.py @@ -36,6 +36,12 @@ class CreateSandboxRequestExtensions: - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions. - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently. + **Well-known keys**: + - `access.renew.extend.seconds` (optional): Decimal integer string from **300** to **86400** (5 minutes to 24 hours + inclusive). Opts the sandbox into OSEP-0009 renew-on-access and sets per-renewal extension seconds. Omit to disable. + Invalid values are rejected at creation with HTTP 400 (validated on the lifecycle create endpoint via + `validate_extensions` in server `src/extensions/validation.py`). + """ additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_response.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_response.py index a254b4cd2..a68cf23a8 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_response.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_response.py @@ -44,8 +44,8 @@ class CreateSandboxResponse: created_at (datetime.datetime): Sandbox creation timestamp entrypoint (list[str]): Entry process specification from creation request metadata (CreateSandboxResponseMetadata | Unset): Custom metadata from creation request - expires_at (datetime.datetime | None | Unset): Timestamp when sandbox will auto-terminate. Null when manual - cleanup is enabled. + expires_at (datetime.datetime | Unset): Timestamp when sandbox will auto-terminate. Omitted when manual cleanup + is enabled. """ id: str @@ -53,7 +53,7 @@ class CreateSandboxResponse: created_at: datetime.datetime entrypoint: list[str] metadata: CreateSandboxResponseMetadata | Unset = UNSET - expires_at: datetime.datetime | None | Unset = UNSET + expires_at: datetime.datetime | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -69,13 +69,9 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.metadata, Unset): metadata = self.metadata.to_dict() - expires_at: None | str | Unset - if isinstance(self.expires_at, Unset): - expires_at = UNSET - elif isinstance(self.expires_at, datetime.datetime): + expires_at: str | Unset = UNSET + if not isinstance(self.expires_at, Unset): expires_at = self.expires_at.isoformat() - else: - expires_at = self.expires_at field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) @@ -110,27 +106,17 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: _metadata = d.pop("metadata", UNSET) metadata: CreateSandboxResponseMetadata | Unset - if isinstance(_metadata, Unset) or _metadata is None: + if isinstance(_metadata, Unset): metadata = UNSET else: metadata = CreateSandboxResponseMetadata.from_dict(_metadata) - def _parse_expires_at(data: object) -> datetime.datetime | None | Unset: - if data is None: - return data - if isinstance(data, Unset): - return data - try: - if not isinstance(data, str): - raise TypeError() - expires_at_type_0 = isoparse(data) - - return expires_at_type_0 - except (TypeError, ValueError, AttributeError, KeyError): - pass - return cast(datetime.datetime | None | Unset, data) - - expires_at = _parse_expires_at(d.pop("expiresAt", UNSET)) + _expires_at = d.pop("expiresAt", UNSET) + expires_at: datetime.datetime | Unset + if isinstance(_expires_at, Unset): + expires_at = UNSET + else: + expires_at = isoparse(_expires_at) create_sandbox_response = cls( id=id, diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/image_spec.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/image_spec.py index c574bfd5d..3b7cbfe22 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/image_spec.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/image_spec.py @@ -78,7 +78,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: _auth = d.pop("auth", UNSET) auth: ImageSpecAuth | Unset - if isinstance(_auth, Unset) or _auth is None: + if isinstance(_auth, Unset): auth = UNSET else: auth = ImageSpecAuth.from_dict(_auth) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py index f355a6725..8c5bf01aa 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py @@ -49,8 +49,8 @@ class Sandbox: Always present in responses since entrypoint is required in creation requests. created_at (datetime.datetime): Sandbox creation timestamp metadata (SandboxMetadata | Unset): Custom metadata from creation request - expires_at (datetime.datetime | None | Unset): Timestamp when sandbox will auto-terminate. Null when manual - cleanup is enabled. + expires_at (datetime.datetime | Unset): Timestamp when sandbox will auto-terminate. Omitted when manual cleanup + is enabled. """ id: str @@ -59,7 +59,7 @@ class Sandbox: entrypoint: list[str] created_at: datetime.datetime metadata: SandboxMetadata | Unset = UNSET - expires_at: datetime.datetime | None | Unset = UNSET + expires_at: datetime.datetime | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -77,13 +77,9 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.metadata, Unset): metadata = self.metadata.to_dict() - expires_at: None | str | Unset - if isinstance(self.expires_at, Unset): - expires_at = UNSET - elif isinstance(self.expires_at, datetime.datetime): + expires_at: str | Unset = UNSET + if not isinstance(self.expires_at, Unset): expires_at = self.expires_at.isoformat() - else: - expires_at = self.expires_at field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) @@ -122,27 +118,17 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: _metadata = d.pop("metadata", UNSET) metadata: SandboxMetadata | Unset - if isinstance(_metadata, Unset) or _metadata is None: + if isinstance(_metadata, Unset): metadata = UNSET else: metadata = SandboxMetadata.from_dict(_metadata) - def _parse_expires_at(data: object) -> datetime.datetime | None | Unset: - if data is None: - return data - if isinstance(data, Unset): - return data - try: - if not isinstance(data, str): - raise TypeError() - expires_at_type_0 = isoparse(data) - - return expires_at_type_0 - except (TypeError, ValueError, AttributeError, KeyError): - pass - return cast(datetime.datetime | None | Unset, data) - - expires_at = _parse_expires_at(d.pop("expiresAt", UNSET)) + _expires_at = d.pop("expiresAt", UNSET) + expires_at: datetime.datetime | Unset + if isinstance(_expires_at, Unset): + expires_at = UNSET + else: + expires_at = isoparse(_expires_at) sandbox = cls( id=id, diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox_status.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox_status.py index 5338e3cdd..f3fc6c659 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox_status.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox_status.py @@ -106,7 +106,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: _last_transition_at = d.pop("lastTransitionAt", UNSET) last_transition_at: datetime.datetime | Unset - if isinstance(_last_transition_at, Unset) or _last_transition_at is None: + if isinstance(_last_transition_at, Unset): last_transition_at = UNSET else: last_transition_at = isoparse(_last_transition_at) diff --git a/sdks/sandbox/python/tests/test_models_stability.py b/sdks/sandbox/python/tests/test_models_stability.py index 24d320410..4968dbe65 100644 --- a/sdks/sandbox/python/tests/test_models_stability.py +++ b/sdks/sandbox/python/tests/test_models_stability.py @@ -56,42 +56,38 @@ def test_sandbox_image_spec_rejects_blank_image() -> None: SandboxImageSpec(" ") -def test_api_image_spec_tolerates_null_auth() -> None: - spec = ApiImageSpec.from_dict({"uri": "python:3.11", "auth": None}) +def test_api_image_spec_tolerates_omitted_auth() -> None: + spec = ApiImageSpec.from_dict({"uri": "python:3.11"}) assert spec.uri == "python:3.11" assert spec.auth is UNSET -def test_api_create_sandbox_response_tolerates_null_metadata() -> None: +def test_api_create_sandbox_response_tolerates_omitted_optional_fields() -> None: response = ApiCreateSandboxResponse.from_dict( { "id": "sandbox-1", - "status": {"state": "Running", "lastTransitionAt": None}, + "status": {"state": "Running"}, "createdAt": "2025-01-01T00:00:00Z", "entrypoint": ["/bin/sh"], - "metadata": None, - "expiresAt": None, } ) assert response.metadata is UNSET - assert response.expires_at is None + assert response.expires_at is UNSET assert response.status.last_transition_at is UNSET -def test_api_sandbox_tolerates_null_metadata() -> None: +def test_api_sandbox_tolerates_omitted_optional_fields() -> None: sandbox = ApiSandbox.from_dict( { "id": "sandbox-1", - "image": {"uri": "python:3.11", "auth": None}, - "status": {"state": "Running", "lastTransitionAt": None}, + "image": {"uri": "python:3.11"}, + "status": {"state": "Running"}, "entrypoint": ["/bin/sh"], "createdAt": "2025-01-01T00:00:00Z", - "metadata": None, - "expiresAt": None, } ) assert sandbox.metadata is UNSET - assert sandbox.expires_at is None + assert sandbox.expires_at is UNSET assert sandbox.status.last_transition_at is UNSET diff --git a/server/README.md b/server/README.md index 13d3bb5b7..2637a2f89 100644 --- a/server/README.md +++ b/server/README.md @@ -155,19 +155,19 @@ For **experimental** lifecycle options (e.g. auto-renew on access), see [Experim - `timeout` requests must be at least 60 seconds. - The maximum allowed TTL is controlled by `server.max_sandbox_timeout_seconds`. -- Omit `timeout` or set it to `null` in the create request to use manual cleanup mode instead of automatic expiration. +- Omit `timeout` in the create request to use manual cleanup mode instead of automatic expiration. **Upgrade order for manual cleanup** - Existing TTL-only clients can continue to work without changes as long as they do not encounter manual-cleanup sandboxes. -- Manual cleanup changes the lifecycle response contract: `expiresAt` may be `null`, and other nullable lifecycle fields may also be serialized explicitly as `null`. +- Manual cleanup changes the lifecycle response contract: `expiresAt` may be omitted, and other optional lifecycle fields may also be omitted when unset. - In practice this can include fields such as `metadata`, `status.reason`, `status.message`, and `status.lastTransitionAt`, depending on the sandbox state and the server response model. -- Before creating any manual-cleanup sandbox, upgrade every SDK/client that may call `create`, `get`, or `list` on the lifecycle API. +- Before creating any manual-cleanup sandbox, ensure every SDK/client tolerates omitted optional lifecycle fields on `create`, `get`, and `list` responses. - Recommended rollout order: 1. Upgrade SDKs/clients 2. Upgrade the server - 3. Start creating sandboxes with `timeout` omitted or `null` -- Do not introduce manual-cleanup sandboxes into a shared environment while old SDKs are still actively reading lifecycle responses. + 3. Start creating sandboxes with `timeout` omitted +- Do not introduce manual-cleanup sandboxes into a shared environment while old SDKs are still actively assuming optional lifecycle fields are always present. **Security hardening (applies to all Docker modes)** ```toml diff --git a/server/src/api/lifecycle.py b/server/src/api/lifecycle.py index cd4e7ca4b..e2deb800c 100644 --- a/server/src/api/lifecycle.py +++ b/server/src/api/lifecycle.py @@ -54,6 +54,7 @@ @router.post( "/sandboxes", response_model=CreateSandboxResponse, + response_model_exclude_none=True, status_code=status.HTTP_202_ACCEPTED, responses={ 202: {"description": "Sandbox creation accepted for asynchronous provisioning"}, @@ -92,6 +93,7 @@ async def create_sandbox( @router.get( "/sandboxes", response_model=ListSandboxesResponse, + response_model_exclude_none=True, responses={ 200: {"description": "Paginated collection of sandboxes"}, 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, @@ -155,6 +157,7 @@ async def list_sandboxes( @router.get( "/sandboxes/{sandbox_id}", response_model=Sandbox, + response_model_exclude_none=True, responses={ 200: {"description": "Sandbox current state and metadata"}, 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, diff --git a/server/src/main.py b/server/src/main.py index be7b068d7..a873d5fe5 100644 --- a/server/src/main.py +++ b/server/src/main.py @@ -70,7 +70,6 @@ getattr(logging, app_config.server.log_level.upper(), logging.INFO) ) -from src.api.lifecycle import router # noqa: E402 from src.api.pool import router as pool_router # noqa: E402 from src.api.lifecycle import router, sandbox_service # noqa: E402 from src.api.proxy import router as proxy_router # noqa: E402 diff --git a/server/tests/k8s/test_pool_service.py b/server/tests/k8s/test_pool_service.py index 32f6e09df..366cee109 100644 --- a/server/tests/k8s/test_pool_service.py +++ b/server/tests/k8s/test_pool_service.py @@ -19,7 +19,7 @@ """ import pytest -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock from kubernetes.client import ApiException from src.api.schema import ( diff --git a/server/tests/test_pool_api.py b/server/tests/test_pool_api.py index 3917ee434..807fc78ee 100644 --- a/server/tests/test_pool_api.py +++ b/server/tests/test_pool_api.py @@ -19,7 +19,6 @@ so no real cluster connection is needed. """ -import pytest from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from fastapi import HTTPException, status as http_status diff --git a/server/tests/test_routes.py b/server/tests/test_routes.py index 73df44037..b06926c25 100644 --- a/server/tests/test_routes.py +++ b/server/tests/test_routes.py @@ -164,14 +164,14 @@ def test_get_sandbox_success( """ pass - def test_get_sandbox_preserves_nullable_expires_at( + def test_get_sandbox_omits_optional_none_fields( self, client: TestClient, auth_headers: dict, monkeypatch, ): """ - Ensure expiresAt is returned as null for manual-cleanup sandboxes. + Ensure optional fields with None values are omitted from responses. """ now = datetime.now(timezone.utc) sandbox = Sandbox( @@ -195,16 +195,15 @@ def get_sandbox(sandbox_id: str) -> Sandbox: assert response.status_code == 200 payload = response.json() - assert payload["metadata"] is None assert payload["id"] == "sandbox-123" assert payload["entrypoint"] == ["python"] - assert "expiresAt" in payload - assert payload["expiresAt"] is None + assert "metadata" not in payload + assert "expiresAt" not in payload assert "createdAt" in payload assert payload["status"]["state"] == "Running" - assert payload["status"]["reason"] is None - assert payload["status"]["message"] is None - assert payload["status"]["lastTransitionAt"] is None + assert "reason" not in payload["status"] + assert "message" not in payload["status"] + assert "lastTransitionAt" not in payload["status"] def test_get_sandbox_not_found( self, diff --git a/server/tests/test_routes_create_delete.py b/server/tests/test_routes_create_delete.py index 00a255bb2..5c52468c5 100644 --- a/server/tests/test_routes_create_delete.py +++ b/server/tests/test_routes_create_delete.py @@ -60,7 +60,7 @@ async def create_sandbox(request) -> CreateSandboxResponse: assert calls[0].image.uri == "python:3.11" -def test_create_sandbox_manual_cleanup_returns_null_expiration( +def test_create_sandbox_manual_cleanup_omits_none_fields( client: TestClient, auth_headers: dict, sample_sandbox_request: dict, @@ -91,11 +91,11 @@ async def create_sandbox(request) -> CreateSandboxResponse: assert response.status_code == 202 payload = response.json() - assert payload["expiresAt"] is None - assert payload["metadata"] is None - assert payload["status"]["reason"] is None - assert payload["status"]["message"] is None - assert payload["status"]["lastTransitionAt"] is None + assert "expiresAt" not in payload + assert "metadata" not in payload + assert "reason" not in payload["status"] + assert "message" not in payload["status"] + assert "lastTransitionAt" not in payload["status"] def test_create_sandbox_rejects_invalid_request( diff --git a/server/tests/test_routes_get_sandbox.py b/server/tests/test_routes_get_sandbox.py index 9ba86b249..7585bb8c8 100644 --- a/server/tests/test_routes_get_sandbox.py +++ b/server/tests/test_routes_get_sandbox.py @@ -80,6 +80,39 @@ def get_sandbox(sandbox_id: str) -> Sandbox: } +def test_get_sandbox_omits_none_fields( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + now = datetime.now(timezone.utc) + + class StubService: + @staticmethod + def get_sandbox(sandbox_id: str) -> Sandbox: + return Sandbox( + id=sandbox_id, + image=ImageSpec(uri="python:3.11"), + status=SandboxStatus(state="Running"), + metadata=None, + entrypoint=["python", "-V"], + expiresAt=None, + createdAt=now, + ) + + monkeypatch.setattr(lifecycle, "sandbox_service", StubService()) + + response = client.get("/v1/sandboxes/sbx-manual", headers=auth_headers) + + assert response.status_code == 200 + payload = response.json() + assert "expiresAt" not in payload + assert "metadata" not in payload + assert "reason" not in payload["status"] + assert "message" not in payload["status"] + assert "lastTransitionAt" not in payload["status"] + + def test_get_sandbox_requires_api_key(client: TestClient) -> None: response = client.get("/v1/sandboxes/sbx-001") diff --git a/server/tests/test_routes_list_sandboxes.py b/server/tests/test_routes_list_sandboxes.py index 046a4ea87..ed3757704 100644 --- a/server/tests/test_routes_list_sandboxes.py +++ b/server/tests/test_routes_list_sandboxes.py @@ -132,7 +132,7 @@ def list_sandboxes(request) -> ListSandboxesResponse: assert captured_requests[0].filter.metadata == {"team": "infra", "note": ""} -def test_list_sandboxes_preserves_only_nullable_expires_at( +def test_list_sandboxes_omits_none_fields( client: TestClient, auth_headers: dict, monkeypatch, @@ -169,11 +169,11 @@ def list_sandboxes(request) -> ListSandboxesResponse: assert response.status_code == 200 item = response.json()["items"][0] - assert item["expiresAt"] is None - assert item["metadata"] is None - assert item["status"]["reason"] is None - assert item["status"]["message"] is None - assert item["status"]["lastTransitionAt"] is None + assert "expiresAt" not in item + assert "metadata" not in item + assert "reason" not in item["status"] + assert "message" not in item["status"] + assert "lastTransitionAt" not in item["status"] def test_list_sandboxes_validates_page_bounds( diff --git a/specs/sandbox-lifecycle.yml b/specs/sandbox-lifecycle.yml index bbf4f63ae..7ef2e4c20 100644 --- a/specs/sandbox-lifecycle.yml +++ b/specs/sandbox-lifecycle.yml @@ -533,11 +533,9 @@ components: description: Custom metadata from creation request expiresAt: - oneOf: - - type: string - format: date-time - - type: 'null' - description: Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled. + type: string + format: date-time + description: Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled. createdAt: type: string @@ -589,11 +587,9 @@ components: Always present in responses since entrypoint is required in creation requests. expiresAt: - oneOf: - - type: string - format: date-time - - type: 'null' - description: Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled. + type: string + format: date-time + description: Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled. createdAt: type: string @@ -707,9 +703,9 @@ components: description: | Sandbox timeout in seconds. The sandbox will automatically terminate after this duration. The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`). - Omit or set null to disable automatic expiration and require explicit cleanup. + Omit this field or set it to null to disable automatic expiration and require explicit cleanup. Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject - null timeout when the underlying workload provider does not support non-expiring sandboxes. + omitted or null timeout when the underlying workload provider does not support non-expiring sandboxes. resourceLimits: $ref: '#/components/schemas/ResourceLimits'