From 401fe40c2e7045a7cb00d54743b0ff5bd4abc1ef Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Tue, 21 Apr 2026 17:49:07 -0400 Subject: [PATCH 1/2] add sandbox mounts --- leap0/__init__.py | 3 + leap0/_async/sandbox.py | 78 +++++++++++++++- leap0/_schemas/sandbox.py | 40 ++++++++- leap0/_sync/sandbox.py | 78 +++++++++++++++- leap0/models/sandbox.py | 160 +++++++++++++++++++++++++++++++++ tests/_async/test_sandboxes.py | 129 ++++++++++++++++++++++++++ tests/_sync/test_sandboxes.py | 114 ++++++++++++++++++++++- tests/models/test_sandbox.py | 46 +++++++++- 8 files changed, 637 insertions(+), 11 deletions(-) diff --git a/leap0/__init__.py b/leap0/__init__.py index 1fa700d..fcd9383 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -78,6 +78,7 @@ CreatePresignedURLParams, CreateSandboxParams, NetworkPolicyMode, + ObjectStorageMount, PresignedURL, SandboxListItem, SandboxListResponse, @@ -192,6 +193,7 @@ "LspJsonRpcResponse", "LspResponse", "NetworkPolicyMode", + "ObjectStorageMount", "ProcessClient", "ProcessResult", "PresignedURL", @@ -272,6 +274,7 @@ "CreateSandboxParams": (".models.sandbox", "CreateSandboxParams"), "CreatePresignedURLParams": (".models.sandbox", "CreatePresignedURLParams"), "NetworkPolicyMode": (".models.sandbox", "NetworkPolicyMode"), + "ObjectStorageMount": (".models.sandbox", "ObjectStorageMount"), "SandboxListItem": (".models.sandbox", "SandboxListItem"), "SandboxListResponse": (".models.sandbox", "SandboxListResponse"), "SandboxStatus": (".models.sandbox", "SandboxStatus"), diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index 757d7e6..9c6c0a4 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -16,14 +16,16 @@ from ..models.sandbox import ( CreatePresignedURLParams, CreateSandboxParams, + ObjectStorageMount, PresignedURL, Sandbox as SandboxData, SandboxListResponse, SandboxRef, SandboxStatus, + _validate_object_storage_mount_update, sandbox_id_of, ) -from .._schemas.sandbox import ListSandboxesResponseDict, NetworkPolicyDict, PresignedURLResponseDict, SandboxCreateResponseDict, SandboxStatusResponseDict +from .._schemas.sandbox import ListSandboxesResponseDict, NetworkPolicyDict, ObjectStorageMountDict, ObjectStorageMountRequestDict, ObjectStorageMountUpdateDict, PresignedURLResponseDict, SandboxCreateResponseDict, SandboxStatusResponseDict from .._utils.errors import intercept_errors from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http from ._transport import AsyncTransport @@ -183,6 +185,27 @@ async def get_workdir(self, http_timeout: float | None = None) -> str: """Fetch the configured working directory for the sandbox.""" return await self._client.sandboxes.get_workdir(self, http_timeout=http_timeout) + async def add_mount( + self, + mount: ObjectStorageMountRequestDict, + http_timeout: float | None = None, + ) -> ObjectStorageMount: + """Attach an object storage mount to this sandbox.""" + return await self._client.sandboxes.add_mount(self, mount, http_timeout=http_timeout) + + async def update_mount( + self, + mount_id: str, + mount: ObjectStorageMountUpdateDict, + http_timeout: float | None = None, + ) -> ObjectStorageMount: + """Update an existing object storage mount on this sandbox.""" + return await self._client.sandboxes.update_mount(self, mount_id, mount, http_timeout=http_timeout) + + async def delete_mount(self, mount_id: str, http_timeout: float | None = None) -> None: + """Delete an object storage mount from this sandbox.""" + await self._client.sandboxes.delete_mount(self, mount_id, http_timeout=http_timeout) + def _inject_otel_env(env_vars: dict[str, str] | None) -> dict[str, str] | None: endpoint = os.environ.get(OTEL_EXPORTER_OTLP_ENDPOINT_ENV) @@ -237,6 +260,7 @@ async def create( otel_export: bool = False, env_vars: dict[str, str] | None = None, network_policy: NetworkPolicyDict | None = None, + mounts: list[ObjectStorageMountRequestDict] | None = None, http_timeout: float | None = None, ) -> AsyncSandboxT | SandboxData | SandboxStatus: """Create a new sandbox from a template. @@ -252,6 +276,7 @@ async def create( also forwards ``OTEL_EXPORTER_OTLP_HEADERS`` when present. env_vars: Environment variables to set inside the sandbox. network_policy: Outbound network policy for the sandbox. + mounts: Object storage mounts to attach before boot. http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: @@ -266,6 +291,7 @@ async def create( otel_export=otel_export, env_vars=_inject_otel_env(env_vars) if otel_export else env_vars, network_policy=network_policy, + mounts=mounts, ) payload = params.to_payload() payload.pop("otel_export", None) @@ -453,6 +479,56 @@ async def delete_presigned_url(self, sandbox: SandboxRef, presigned_url_id: str, timeout=http_timeout, ) + @intercept_errors("Failed to add sandbox mount: ") + async def add_mount( + self, + sandbox: SandboxRef, + mount: ObjectStorageMountRequestDict, + http_timeout: float | None = None, + ) -> ObjectStorageMount: + normalized_mounts = CreateSandboxParams(mounts=[mount]).mounts + if normalized_mounts is None: + raise ValueError("mount is required") + data = cast(dict[str, object], await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/mounts", + json=normalized_mounts[0], + expected_status=201, + timeout=http_timeout, + )) + return ObjectStorageMount.from_dict(cast(ObjectStorageMountDict, data)) + + @intercept_errors("Failed to update sandbox mount: ") + async def update_mount( + self, + sandbox: SandboxRef, + mount_id: str, + mount: ObjectStorageMountUpdateDict, + http_timeout: float | None = None, + ) -> ObjectStorageMount: + mount_id_value = mount_id.strip() + if not mount_id_value: + raise ValueError("mount_id must be a non-empty string") + data = cast(dict[str, object], await self._transport.request_json( + "PATCH", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/mounts/{mount_id_value}", + json=_validate_object_storage_mount_update(mount), + timeout=http_timeout, + )) + return ObjectStorageMount.from_dict(cast(ObjectStorageMountDict, data)) + + @intercept_errors("Failed to delete sandbox mount: ") + async def delete_mount(self, sandbox: SandboxRef, mount_id: str, http_timeout: float | None = None) -> None: + mount_id_value = mount_id.strip() + if not mount_id_value: + raise ValueError("mount_id must be a non-empty string") + await self._transport.request( + "DELETE", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/mounts/{mount_id_value}", + expected_status=204, + timeout=http_timeout, + ) + def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: """Build an HTTPS URL for this sandbox. diff --git a/leap0/_schemas/sandbox.py b/leap0/_schemas/sandbox.py index bafd313..6ef772b 100644 --- a/leap0/_schemas/sandbox.py +++ b/leap0/_schemas/sandbox.py @@ -19,10 +19,41 @@ class NetworkPolicyDict(TypedDict, total=False): allow_cidrs: NotRequired[list[str]] transforms: NotRequired[list[TransformRuleDict]] -class SandboxCreateResponseDict(TypedDict): +class ObjectStorageMountRequestDict(TypedDict, total=False): + """Wire schema for object storage mount requests.""" + type: Required[str] + bucket: Required[str] + mount_path: Required[str] + endpoint: Required[str] + prefix: str + read_only: bool + access_key_id: str + secret_access_key: str + +class ObjectStorageMountUpdateDict(TypedDict, total=False): + """Wire schema for sandbox mount update requests.""" + bucket: str + mount_path: str + endpoint: str + prefix: str + read_only: bool + access_key_id: str + secret_access_key: str + +class ObjectStorageMountDict(TypedDict, total=False): + """Wire schema for object storage mounts returned by the API.""" + id: Required[str] + type: Required[str] + bucket: Required[str] + mount_path: Required[str] + prefix: str + read_only: bool + +class SandboxCreateResponseDict(TypedDict, total=False): """Wire schema for sandbox creation responses.""" - id: str + id: Required[str] template_id: str + mounts: list[ObjectStorageMountDict] vcpu: int memory: int disk: int @@ -32,10 +63,11 @@ class SandboxCreateResponseDict(TypedDict): created_at: str network_policy: NetworkPolicyDict | None -class SandboxStatusResponseDict(TypedDict): +class SandboxStatusResponseDict(TypedDict, total=False): """Wire schema for sandbox status responses.""" - id: str + id: Required[str] template_id: str + mounts: list[ObjectStorageMountDict] vcpu: int memory: int disk: int diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py index ceeb3a7..cb8ac19 100644 --- a/leap0/_sync/sandbox.py +++ b/leap0/_sync/sandbox.py @@ -15,14 +15,16 @@ from ..models.sandbox import ( CreatePresignedURLParams, CreateSandboxParams, + ObjectStorageMount, PresignedURL, Sandbox as SandboxData, SandboxListResponse, SandboxRef, SandboxStatus, + _validate_object_storage_mount_update, sandbox_id_of, ) -from .._schemas.sandbox import ListSandboxesResponseDict, NetworkPolicyDict, PresignedURLResponseDict, SandboxCreateResponseDict, SandboxStatusResponseDict +from .._schemas.sandbox import ListSandboxesResponseDict, NetworkPolicyDict, ObjectStorageMountDict, ObjectStorageMountRequestDict, ObjectStorageMountUpdateDict, PresignedURLResponseDict, SandboxCreateResponseDict, SandboxStatusResponseDict from .._utils.errors import intercept_errors from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http from ._transport import Transport @@ -180,6 +182,27 @@ def get_workdir(self, http_timeout: float | None = None) -> str: """Fetch the configured working directory for the sandbox.""" return self._client.sandboxes.get_workdir(self, http_timeout=http_timeout) + def add_mount( + self, + mount: ObjectStorageMountRequestDict, + http_timeout: float | None = None, + ) -> ObjectStorageMount: + """Attach an object storage mount to this sandbox.""" + return self._client.sandboxes.add_mount(self, mount, http_timeout=http_timeout) + + def update_mount( + self, + mount_id: str, + mount: ObjectStorageMountUpdateDict, + http_timeout: float | None = None, + ) -> ObjectStorageMount: + """Update an existing object storage mount on this sandbox.""" + return self._client.sandboxes.update_mount(self, mount_id, mount, http_timeout=http_timeout) + + def delete_mount(self, mount_id: str, http_timeout: float | None = None) -> None: + """Delete an object storage mount from this sandbox.""" + self._client.sandboxes.delete_mount(self, mount_id, http_timeout=http_timeout) + def _inject_otel_env(env_vars: dict[str, str] | None) -> dict[str, str] | None: endpoint = os.environ.get(OTEL_EXPORTER_OTLP_ENDPOINT_ENV) @@ -239,6 +262,7 @@ def create( telemetry: bool | None = None, env_vars: dict[str, str] | None = None, network_policy: NetworkPolicyDict | None = None, + mounts: list[ObjectStorageMountRequestDict] | None = None, http_timeout: float | None = None, ) -> SandboxT | SandboxData | SandboxStatus: """Create a new sandbox from a template. @@ -255,6 +279,7 @@ def create( telemetry: Deprecated alias for ``otel_export``. Use ``otel_export`` instead. env_vars: Environment variables to set inside the sandbox. network_policy: Outbound network policy for the sandbox. + mounts: Object storage mounts to attach before boot. http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: @@ -271,6 +296,7 @@ def create( otel_export=effective_otel_export, env_vars=_inject_otel_env(env_vars) if effective_otel_export else env_vars, network_policy=network_policy, + mounts=mounts, ) payload = params.to_payload() payload.pop("otel_export", None) @@ -452,6 +478,56 @@ def delete_presigned_url(self, sandbox: SandboxRef, presigned_url_id: str, http_ timeout=http_timeout, ) + @intercept_errors("Failed to add sandbox mount: ") + def add_mount( + self, + sandbox: SandboxRef, + mount: ObjectStorageMountRequestDict, + http_timeout: float | None = None, + ) -> ObjectStorageMount: + normalized_mounts = CreateSandboxParams(mounts=[mount]).mounts + if normalized_mounts is None: + raise ValueError("mount is required") + data = cast(dict[str, object], self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/mounts", + json=normalized_mounts[0], + expected_status=201, + timeout=http_timeout, + )) + return ObjectStorageMount.from_dict(cast(ObjectStorageMountDict, data)) + + @intercept_errors("Failed to update sandbox mount: ") + def update_mount( + self, + sandbox: SandboxRef, + mount_id: str, + mount: ObjectStorageMountUpdateDict, + http_timeout: float | None = None, + ) -> ObjectStorageMount: + mount_id_value = mount_id.strip() + if not mount_id_value: + raise ValueError("mount_id must be a non-empty string") + data = cast(dict[str, object], self._transport.request_json( + "PATCH", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/mounts/{mount_id_value}", + json=_validate_object_storage_mount_update(mount), + timeout=http_timeout, + )) + return ObjectStorageMount.from_dict(cast(ObjectStorageMountDict, data)) + + @intercept_errors("Failed to delete sandbox mount: ") + def delete_mount(self, sandbox: SandboxRef, mount_id: str, http_timeout: float | None = None) -> None: + mount_id_value = mount_id.strip() + if not mount_id_value: + raise ValueError("mount_id must be a non-empty string") + self._transport.request( + "DELETE", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/mounts/{mount_id_value}", + expected_status=204, + timeout=http_timeout, + ) + def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: """Build an HTTPS URL that routes directly to the sandbox. diff --git a/leap0/models/sandbox.py b/leap0/models/sandbox.py index 40a962c..1703749 100644 --- a/leap0/models/sandbox.py +++ b/leap0/models/sandbox.py @@ -15,6 +15,9 @@ CreatePresignedURLRequestDict, ListSandboxesResponseDict, NetworkPolicyDict, + ObjectStorageMountDict, + ObjectStorageMountRequestDict, + ObjectStorageMountUpdateDict, PresignedURLResponseDict, SandboxCreateResponseDict, SandboxListItemResponseDict, @@ -104,6 +107,157 @@ def _validate_network_policy(policy: NetworkPolicyDict | None) -> NetworkPolicyD return policy + +def _validate_object_storage_mounts( + mounts: list[ObjectStorageMountRequestDict] | None, +) -> list[ObjectStorageMountRequestDict] | None: + if mounts is None: + return None + if len(mounts) > 8: + raise ValueError("mounts must contain at most 8 entries") + + normalized: list[ObjectStorageMountRequestDict] = [] + seen_mount_paths: set[str] = set() + for index, mount in enumerate(mounts): + if not isinstance(mount, Mapping): + raise ValueError(f"mounts[{index}] must be a mapping, got: {mount!r}") + + mount_type = mount.get("type") + if mount_type != "object-storage": + raise ValueError(f"mounts[{index}].type must be 'object-storage'") + + bucket = mount.get("bucket") + if not isinstance(bucket, str) or not bucket.strip(): + raise ValueError(f"mounts[{index}].bucket must be a non-empty string") + + mount_path = mount.get("mount_path") + if not isinstance(mount_path, str) or not mount_path.startswith("/") or mount_path == "/": + raise ValueError(f"mounts[{index}].mount_path must be an absolute path") + if mount_path in seen_mount_paths: + raise ValueError(f"mounts[{index}].mount_path must be unique") + seen_mount_paths.add(mount_path) + + endpoint = mount.get("endpoint") + if not isinstance(endpoint, str) or not endpoint.strip(): + raise ValueError(f"mounts[{index}].endpoint must be a non-empty string") + + prefix = mount.get("prefix") + if prefix is not None: + if not isinstance(prefix, str): + raise ValueError(f"mounts[{index}].prefix must be a string") + if prefix and (prefix.startswith("/") or not prefix.endswith("/") or ".." in prefix): + raise ValueError( + f"mounts[{index}].prefix must be relative, must not contain '..', and must end with '/'") + + read_only = mount.get("read_only") + if read_only is not None and not isinstance(read_only, bool): + raise ValueError(f"mounts[{index}].read_only must be a boolean") + + access_key_id = mount.get("access_key_id") + if access_key_id is not None and not isinstance(access_key_id, str): + raise ValueError(f"mounts[{index}].access_key_id must be a string") + + secret_access_key = mount.get("secret_access_key") + if secret_access_key is not None and not isinstance(secret_access_key, str): + raise ValueError(f"mounts[{index}].secret_access_key must be a string") + + normalized.append(ObjectStorageMountRequestDict( + type="object-storage", + bucket=bucket.strip(), + mount_path=mount_path, + endpoint=endpoint.strip(), + **({"prefix": prefix} if isinstance(prefix, str) and prefix != "" else {}), + **({"read_only": read_only} if isinstance(read_only, bool) else {}), + **({"access_key_id": access_key_id} if isinstance(access_key_id, str) and access_key_id != "" else {}), + **({"secret_access_key": secret_access_key} if isinstance(secret_access_key, str) and secret_access_key != "" else {}), + )) + + return normalized + + +def _validate_object_storage_mount_update( + mount: ObjectStorageMountUpdateDict, +) -> ObjectStorageMountUpdateDict: + if not isinstance(mount, Mapping): + raise ValueError(f"mount update must be a mapping, got: {mount!r}") + + normalized: dict[str, str | bool] = {} + + if "bucket" in mount: + bucket = mount.get("bucket") + if not isinstance(bucket, str) or not bucket.strip(): + raise ValueError("bucket must be a non-empty string") + normalized["bucket"] = bucket.strip() + + if "mount_path" in mount: + mount_path = mount.get("mount_path") + if not isinstance(mount_path, str) or not mount_path.startswith("/") or mount_path == "/": + raise ValueError("mount_path must be an absolute path") + normalized["mount_path"] = mount_path + + if "endpoint" in mount: + endpoint = mount.get("endpoint") + if not isinstance(endpoint, str) or not endpoint.strip(): + raise ValueError("endpoint must be a non-empty string") + normalized["endpoint"] = endpoint.strip() + + if "prefix" in mount: + prefix = mount.get("prefix") + if not isinstance(prefix, str): + raise ValueError("prefix must be a string") + if prefix and (prefix.startswith("/") or not prefix.endswith("/") or ".." in prefix): + raise ValueError("prefix must be relative, must not contain '..', and must end with '/'") + normalized["prefix"] = prefix + + if "read_only" in mount: + read_only = mount.get("read_only") + if not isinstance(read_only, bool): + raise ValueError("read_only must be a boolean") + normalized["read_only"] = read_only + + if "access_key_id" in mount: + access_key_id = mount.get("access_key_id") + if not isinstance(access_key_id, str): + raise ValueError("access_key_id must be a string") + normalized["access_key_id"] = access_key_id + + if "secret_access_key" in mount: + secret_access_key = mount.get("secret_access_key") + if not isinstance(secret_access_key, str): + raise ValueError("secret_access_key must be a string") + normalized["secret_access_key"] = secret_access_key + + if not normalized: + raise ValueError("mount update must include at least one field") + + return ObjectStorageMountUpdateDict(**normalized) + + +@dataclass(slots=True) +class ObjectStorageMount: + """Non-secret object storage mount metadata returned by the API.""" + + id: str + type: str + bucket: str + mount_path: str + prefix: str | None = None + read_only: bool = False + + @classmethod + def from_dict(cls, data: ObjectStorageMountDict) -> ObjectStorageMount: + mount_id = data.get("id") + if not isinstance(mount_id, str) or not mount_id.strip(): + raise ValueError(f"ObjectStorageMount response missing required non-empty string 'id', got: {mount_id!r}") + return cls( + id=mount_id, + type=str(data.get("type", "")), + bucket=str(data.get("bucket", "")), + mount_path=str(data.get("mount_path", "")), + prefix=data.get("prefix") if isinstance(data.get("prefix"), str) else None, + read_only=bool(data.get("read_only", False)), + ) + class CreateSandboxParams(BaseModel): """Validated sandbox creation parameters.""" model_config = ConfigDict(extra="forbid") @@ -116,6 +270,7 @@ class CreateSandboxParams(BaseModel): otel_export: bool = False env_vars: dict[str, str] | None = None network_policy: NetworkPolicyDict | None = None + mounts: list[ObjectStorageMountRequestDict] | None = None @model_validator(mode="after") def _validate_values(self) -> CreateSandboxParams: @@ -131,6 +286,7 @@ def _validate_values(self) -> CreateSandboxParams: if not 1 <= self.timeout <= 28800: raise ValueError("timeout must be between 1 and 28800") self.network_policy = _validate_network_policy(self.network_policy) + self.mounts = _validate_object_storage_mounts(self.mounts) self.template_name = template_name return self @@ -179,6 +335,7 @@ class Sandbox(SandboxHandle): auto_pause: bool = False created_at: str = "" network_policy: NetworkPolicyDict | None = None + mounts: list[ObjectStorageMount] | None = None @classmethod def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: @@ -198,6 +355,7 @@ def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: auto_pause=bool(data.get("auto_pause", False)), created_at=data.get("created_at", ""), network_policy=data.get("network_policy"), + mounts=[ObjectStorageMount.from_dict(mount) for mount in data.get("mounts", [])], ) @dataclass(slots=True) @@ -212,6 +370,7 @@ class SandboxStatus(SandboxHandle): state: SandboxState | str auto_pause: bool created_at: str + mounts: list[ObjectStorageMount] | None = None @classmethod def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: @@ -223,6 +382,7 @@ def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: return cls( id=sandbox_id, template_id=data.get("template_id", ""), + mounts=[ObjectStorageMount.from_dict(mount) for mount in data.get("mounts", [])], vcpu=int(data.get("vcpu", 0)), memory=int(data.get("memory", 0)), disk=int(data.get("disk", 0)), diff --git a/tests/_async/test_sandboxes.py b/tests/_async/test_sandboxes.py index 26d9b89..119b95c 100644 --- a/tests/_async/test_sandboxes.py +++ b/tests/_async/test_sandboxes.py @@ -24,6 +24,43 @@ async def run() -> None: asyncio.run(run()) + def test_create_serializes_object_storage_mounts(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "id": "sbx-1", "template_id": "tpl-1", "vcpu": 2, "memory": 2048, + "disk": 10240, "timeout": 300, "state": "starting", "auto_pause": False, "created_at": "", + "mounts": [{"id": "mnt-1", "type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "prefix": "docs/", "read_only": True}], + } + result = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").create( + mounts=[{"type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "endpoint": "https://storage.example.com", "prefix": "docs/"}], + ) + assert async_mock_transport.request_json.call_args.kwargs["json"]["mounts"] == [{ + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "endpoint": "https://storage.example.com", + "prefix": "docs/", + }] + assert result.mounts is not None + assert result.mounts[0].bucket == "project-assets" + + asyncio.run(run()) + + def test_get_returns_mounts(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "id": "sbx-1", "template_id": "tpl-1", "vcpu": 2, "memory": 2048, + "disk": 10240, "timeout": 300, "state": "running", "auto_pause": False, "created_at": "", + "mounts": [{"id": "mnt-1", "type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "prefix": "docs/", "read_only": True}], + } + + result = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").get("sbx-1") + + assert result.mounts is not None + assert result.mounts[0].id == "mnt-1" + + asyncio.run(run()) + def test_factory_returns_async_sandbox(self, async_mock_transport): async def run() -> None: fake_client = SimpleNamespace( @@ -196,6 +233,63 @@ async def run() -> None: asyncio.run(run()) + def test_add_mount(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "id": "mnt-1", + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "prefix": "docs/", + "read_only": True, + } + + result = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").add_mount( + "sbx-1", + {"type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "endpoint": "https://storage.example.com", "prefix": "docs/"}, + ) + + args, kwargs = async_mock_transport.request_json.call_args + assert args == ("POST", "/v1/sandbox/sbx-1/mounts") + assert kwargs["expected_status"] == 201 + assert result.id == "mnt-1" + + asyncio.run(run()) + + def test_update_mount(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "id": "mnt-1", + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "prefix": "docs/", + "read_only": False, + } + + result = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").update_mount( + "sbx-1", + "mnt-1", + {"prefix": "docs/", "read_only": False}, + ) + + args, kwargs = async_mock_transport.request_json.call_args + assert args == ("PATCH", "/v1/sandbox/sbx-1/mounts/mnt-1") + assert kwargs["json"] == {"prefix": "docs/", "read_only": False} + assert result.read_only is False + + asyncio.run(run()) + + def test_delete_mount(self, async_mock_transport): + async def run() -> None: + await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").delete_mount("sbx-1", "mnt-1") + + args, kwargs = async_mock_transport.request.call_args + assert args == ("DELETE", "/v1/sandbox/sbx-1/mounts/mnt-1") + assert kwargs["expected_status"] == 204 + + asyncio.run(run()) + class TestAsyncSandbox: @@ -272,3 +366,38 @@ async def delete_presigned_url(sandbox: object, presigned_url_id: str, http_time await sandbox.delete_presigned_url("psu-1", http_timeout=3.5) asyncio.run(run()) + + def test_mount_helpers_delegate_to_sandboxes_client(self): + async def run() -> None: + sandboxes = SimpleNamespace() + fake_client = SimpleNamespace( + _filesystem=SimpleNamespace(), _git=SimpleNamespace(), _process=SimpleNamespace(), _pty=SimpleNamespace(), + _lsp=SimpleNamespace(), _ssh=SimpleNamespace(), _code_interpreter=SimpleNamespace(), _desktop=SimpleNamespace(), + sandboxes=sandboxes, + ) + + async def add_mount(sandbox: object, mount: dict[str, str], http_timeout: float | None = None): + assert mount["endpoint"] == "https://storage.example.com" + assert http_timeout == 1.5 + return None + + async def update_mount(sandbox: object, mount_id: str, mount: dict[str, str], http_timeout: float | None = None): + assert mount_id == "mnt-1" + assert mount == {"prefix": "docs/"} + assert http_timeout == 2.5 + return None + + async def delete_mount(sandbox: object, mount_id: str, http_timeout: float | None = None): + assert mount_id == "mnt-1" + assert http_timeout == 3.5 + + sandboxes.add_mount = add_mount + sandboxes.update_mount = update_mount + sandboxes.delete_mount = delete_mount + sandbox = AsyncSandbox(fake_client, Sandbox(id="sbx-1", state="running")) + + await sandbox.add_mount({"type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "endpoint": "https://storage.example.com"}, http_timeout=1.5) + await sandbox.update_mount("mnt-1", {"prefix": "docs/"}, http_timeout=2.5) + await sandbox.delete_mount("mnt-1", http_timeout=3.5) + + asyncio.run(run()) diff --git a/tests/_sync/test_sandboxes.py b/tests/_sync/test_sandboxes.py index ceed8d2..b9d39fe 100644 --- a/tests/_sync/test_sandboxes.py +++ b/tests/_sync/test_sandboxes.py @@ -21,13 +21,37 @@ def test_create(self, mock_transport): assert kwargs["json"]["template_name"] == "my-tpl" assert result.id == "sbx-1" + def test_create_serializes_object_storage_mounts(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "sbx-1", "template_id": "tpl-1", "vcpu": 2, "memory": 2048, + "disk": 10240, "timeout": 300, "state": "starting", "auto_pause": False, "created_at": "", + "mounts": [{"id": "mnt-1", "type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "prefix": "docs/", "read_only": True}], + } + + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").create( + mounts=[{"type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "endpoint": "https://storage.example.com", "prefix": "docs/"}], + ) + + assert mock_transport.request_json.call_args.kwargs["json"]["mounts"] == [{ + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "endpoint": "https://storage.example.com", + "prefix": "docs/", + }] + assert result.mounts is not None + assert result.mounts[0].bucket == "project-assets" + def test_get(self, mock_transport): mock_transport.request_json.return_value = { "id": "sbx-1", "template_id": "t", "vcpu": 1, "memory": 512, "disk": 10240, "timeout": 300, "state": "running", "auto_pause": False, "created_at": "", + "mounts": [{"id": "mnt-1", "type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "prefix": "docs/", "read_only": True}], } - SandboxesClient(mock_transport, sandbox_domain="s.dev").get("sbx-1") + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").get("sbx-1") assert mock_transport.request_json.call_args[0][1] == "/v1/sandbox/sbx-1/" + assert result.mounts is not None + assert result.mounts[0].id == "mnt-1" def test_list(self, mock_transport): mock_transport.request_json.return_value = { @@ -123,6 +147,69 @@ def test_delete_presigned_url(self, mock_transport): assert args == ("DELETE", "/v1/sandbox/sbx-1/presigned-url/psu-1") assert kwargs["expected_status"] == 204 + def test_add_mount(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "mnt-1", + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "prefix": "docs/", + "read_only": True, + } + + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").add_mount( + "sbx-1", + { + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "endpoint": "https://storage.example.com", + "prefix": "docs/", + }, + ) + + args, kwargs = mock_transport.request_json.call_args + assert args == ("POST", "/v1/sandbox/sbx-1/mounts") + assert kwargs["expected_status"] == 201 + assert kwargs["json"] == { + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "endpoint": "https://storage.example.com", + "prefix": "docs/", + } + assert result.id == "mnt-1" + + def test_update_mount(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "mnt-1", + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "prefix": "docs/", + "read_only": False, + } + + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").update_mount( + "sbx-1", + "mnt-1", + {"prefix": "docs/", "read_only": False}, + ) + + args, kwargs = mock_transport.request_json.call_args + assert args == ("PATCH", "/v1/sandbox/sbx-1/mounts/mnt-1") + assert kwargs["json"] == {"prefix": "docs/", "read_only": False} + assert result.read_only is False + + def test_delete_mount(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + + SandboxesClient(mock_transport, sandbox_domain="s.dev").delete_mount("sbx-1", "mnt-1") + + args, kwargs = mock_transport.request.call_args + assert args == ("DELETE", "/v1/sandbox/sbx-1/mounts/mnt-1") + assert kwargs["expected_status"] == 204 + def test_accepts_sandbox_object(self, mock_transport): mock_transport.request_json.return_value = { @@ -269,6 +356,31 @@ def test_refresh_updates_metadata(self): assert sandbox.state == "running" + def test_mount_helpers_delegate_to_sandboxes_client(self): + sandboxes = MagicMock() + sandboxes.add_mount.return_value = MagicMock(id="mnt-1") + sandboxes.update_mount.return_value = MagicMock(id="mnt-1") + client = SimpleNamespace( + _filesystem=MagicMock(), + _git=MagicMock(), + _process=MagicMock(), + _pty=MagicMock(), + _lsp=MagicMock(), + _ssh=MagicMock(), + _code_interpreter=MagicMock(), + _desktop=MagicMock(), + sandboxes=sandboxes, + ) + sandbox = RichSandbox(client, Sandbox(id="sbx-1", state="running")) + + sandbox.add_mount({"type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "endpoint": "https://storage.example.com"}, http_timeout=1.5) + sandbox.update_mount("mnt-1", {"prefix": "docs/"}, http_timeout=2.5) + sandbox.delete_mount("mnt-1", http_timeout=3.5) + + sandboxes.add_mount.assert_called_once_with(sandbox, {"type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "endpoint": "https://storage.example.com"}, http_timeout=1.5) + sandboxes.update_mount.assert_called_once_with(sandbox, "mnt-1", {"prefix": "docs/"}, http_timeout=2.5) + sandboxes.delete_mount.assert_called_once_with(sandbox, "mnt-1", http_timeout=3.5) + def test_pause_forwards_http_timeout(self): sandboxes = MagicMock() client = SimpleNamespace( diff --git a/tests/models/test_sandbox.py b/tests/models/test_sandbox.py index a446a1e..b45451e 100644 --- a/tests/models/test_sandbox.py +++ b/tests/models/test_sandbox.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -from leap0.models.sandbox import CreatePresignedURLParams, CreateSandboxParams, PresignedURL, Sandbox, SandboxStatus, _validate_network_policy, sandbox_id_of +from leap0.models.sandbox import CreatePresignedURLParams, CreateSandboxParams, ObjectStorageMount, PresignedURL, Sandbox, SandboxStatus, _validate_network_policy, _validate_object_storage_mount_update, sandbox_id_of class TestSandboxIdOf: @@ -29,12 +29,14 @@ class FakeSandbox: class TestSandbox: def test_full_dict(self): s = Sandbox.from_dict({"id": "sbx-1", "template_id": "tpl-1", "vcpu": 2, "memory": 2048, - "disk": 10240, "timeout": 300, "state": "running", "auto_pause": True, - "created_at": "2025-01-01", "network_policy": {"mode": "allow-all"}}) + "disk": 10240, "timeout": 300, "state": "running", "auto_pause": True, + "created_at": "2025-01-01", "network_policy": {"mode": "allow-all"}, + "mounts": [{"id": "mnt-1", "type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "prefix": "docs/", "read_only": True}]}) assert s.id == "sbx-1" assert s.vcpu == 2 assert s.state == "running" assert s.network_policy == {"mode": "allow-all"} + assert s.mounts == [ObjectStorageMount(id="mnt-1", type="object-storage", bucket="project-assets", mount_path="/data/assets", prefix="docs/", read_only=True)] def test_minimal_dict(self): s = Sandbox.from_dict({"id": "sbx-2"}) @@ -46,9 +48,11 @@ def test_minimal_dict(self): class TestSandboxStatus: def test_full_dict(self): s = SandboxStatus.from_dict({"id": "sbx-1", "template_id": "tpl-1", "vcpu": 4, "memory": 4096, - "disk": 10240, "timeout": 300, "state": "paused", "auto_pause": True, "created_at": "2025-01-01"}) + "disk": 10240, "timeout": 300, "state": "paused", "auto_pause": True, "created_at": "2025-01-01", + "mounts": [{"id": "mnt-1", "type": "object-storage", "bucket": "project-assets", "mount_path": "/data/assets", "prefix": "docs/", "read_only": True}]}) assert s.state == "paused" assert s.vcpu == 4 + assert s.mounts == [ObjectStorageMount(id="mnt-1", type="object-storage", bucket="project-assets", mount_path="/data/assets", prefix="docs/", read_only=True)] def test_empty_dict_raises(self): with pytest.raises(ValueError, match="missing required non-empty string 'id'"): @@ -56,6 +60,40 @@ def test_empty_dict_raises(self): class TestCreateSandboxParams: + def test_accepts_object_storage_mounts(self): + params = CreateSandboxParams(mounts=[{ + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "endpoint": "https://storage.example.com", + "prefix": "docs/", + }]) + + assert params.mounts == [{ + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "endpoint": "https://storage.example.com", + "prefix": "docs/", + }] + + def test_rejects_invalid_mounts(self): + with pytest.raises(ValueError, match=r"mounts\[0\]\.type"): + CreateSandboxParams(mounts=[{"type": "other", "bucket": "b", "mount_path": "/data", "endpoint": "https://storage.example.com"}]) + + with pytest.raises(ValueError, match=r"mounts\[0\]\.prefix"): + CreateSandboxParams(mounts=[{"type": "object-storage", "bucket": "b", "mount_path": "/data", "endpoint": "https://storage.example.com", "prefix": "/bad"}]) + + with pytest.raises(ValueError, match=r"mounts\[1\]\.mount_path must be unique"): + CreateSandboxParams(mounts=[ + {"type": "object-storage", "bucket": "a", "mount_path": "/data", "endpoint": "https://storage-a.example.com"}, + {"type": "object-storage", "bucket": "b", "mount_path": "/data", "endpoint": "https://storage-b.example.com"}, + ]) + + def test_rejects_empty_mount_update(self): + with pytest.raises(ValueError, match="at least one field"): + _validate_object_storage_mount_update({}) + def test_rejects_invalid_network_policy(self): with pytest.raises(ValueError, match=r"network_policy\.mode"): CreateSandboxParams(network_policy={"mode": "nope"}) From 0dd1dd807a28470ac51fb9459ca8b46eae7fb36a Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Tue, 21 Apr 2026 21:00:14 -0400 Subject: [PATCH 2/2] align sandbox mount validation and parsing --- leap0/models/sandbox.py | 34 +++++++++++++++++++++--------- tests/models/test_sandbox.py | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/leap0/models/sandbox.py b/leap0/models/sandbox.py index 1703749..c6faba1 100644 --- a/leap0/models/sandbox.py +++ b/leap0/models/sandbox.py @@ -6,6 +6,7 @@ import ipaddress import re from typing import TypeAlias +from urllib.parse import urlparse from pydantic import BaseModel, ConfigDict, model_validator @@ -108,6 +109,13 @@ def _validate_network_policy(policy: NetworkPolicyDict | None) -> NetworkPolicyD return policy +def _validate_url(value: str) -> str: + parsed = urlparse(value) + if not parsed.scheme or not parsed.netloc: + raise ValueError("endpoint must be a valid URL") + return value + + def _validate_object_storage_mounts( mounts: list[ObjectStorageMountRequestDict] | None, ) -> list[ObjectStorageMountRequestDict] | None: @@ -139,13 +147,17 @@ def _validate_object_storage_mounts( endpoint = mount.get("endpoint") if not isinstance(endpoint, str) or not endpoint.strip(): - raise ValueError(f"mounts[{index}].endpoint must be a non-empty string") + raise ValueError(f"mounts[{index}].endpoint must be a valid URL") + try: + endpoint = _validate_url(endpoint.strip()) + except ValueError as err: + raise ValueError(f"mounts[{index}].{err}") from err prefix = mount.get("prefix") if prefix is not None: if not isinstance(prefix, str): raise ValueError(f"mounts[{index}].prefix must be a string") - if prefix and (prefix.startswith("/") or not prefix.endswith("/") or ".." in prefix): + if prefix == "" or prefix.startswith("/") or not prefix.endswith("/") or ".." in prefix: raise ValueError( f"mounts[{index}].prefix must be relative, must not contain '..', and must end with '/'") @@ -165,11 +177,11 @@ def _validate_object_storage_mounts( type="object-storage", bucket=bucket.strip(), mount_path=mount_path, - endpoint=endpoint.strip(), + endpoint=endpoint, **({"prefix": prefix} if isinstance(prefix, str) and prefix != "" else {}), **({"read_only": read_only} if isinstance(read_only, bool) else {}), - **({"access_key_id": access_key_id} if isinstance(access_key_id, str) and access_key_id != "" else {}), - **({"secret_access_key": secret_access_key} if isinstance(secret_access_key, str) and secret_access_key != "" else {}), + **({"access_key_id": access_key_id} if isinstance(access_key_id, str) else {}), + **({"secret_access_key": secret_access_key} if isinstance(secret_access_key, str) else {}), )) return normalized @@ -198,14 +210,14 @@ def _validate_object_storage_mount_update( if "endpoint" in mount: endpoint = mount.get("endpoint") if not isinstance(endpoint, str) or not endpoint.strip(): - raise ValueError("endpoint must be a non-empty string") - normalized["endpoint"] = endpoint.strip() + raise ValueError("endpoint must be a valid URL") + normalized["endpoint"] = _validate_url(endpoint.strip()) if "prefix" in mount: prefix = mount.get("prefix") if not isinstance(prefix, str): raise ValueError("prefix must be a string") - if prefix and (prefix.startswith("/") or not prefix.endswith("/") or ".." in prefix): + if prefix == "" or prefix.startswith("/") or not prefix.endswith("/") or ".." in prefix: raise ValueError("prefix must be relative, must not contain '..', and must end with '/'") normalized["prefix"] = prefix @@ -344,6 +356,7 @@ def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: if not isinstance(sandbox_id, str) or not sandbox_id.strip(): raise ValueError(f"Sandbox response missing required non-empty string 'id', got: {sandbox_id!r}") state = _parse_sandbox_state(data.get("state")) + mounts = [ObjectStorageMount.from_dict(mount) for mount in data["mounts"]] if "mounts" in data else None return cls( id=sandbox_id, template_id=data.get("template_id", ""), @@ -355,7 +368,7 @@ def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: auto_pause=bool(data.get("auto_pause", False)), created_at=data.get("created_at", ""), network_policy=data.get("network_policy"), - mounts=[ObjectStorageMount.from_dict(mount) for mount in data.get("mounts", [])], + mounts=mounts, ) @dataclass(slots=True) @@ -379,10 +392,11 @@ def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: if not isinstance(sandbox_id, str) or not sandbox_id.strip(): raise ValueError(f"SandboxStatus response missing required non-empty string 'id', got: {sandbox_id!r}") state = _parse_sandbox_state(data.get("state")) + mounts = [ObjectStorageMount.from_dict(mount) for mount in data["mounts"]] if "mounts" in data else None return cls( id=sandbox_id, template_id=data.get("template_id", ""), - mounts=[ObjectStorageMount.from_dict(mount) for mount in data.get("mounts", [])], + mounts=mounts, vcpu=int(data.get("vcpu", 0)), memory=int(data.get("memory", 0)), disk=int(data.get("disk", 0)), diff --git a/tests/models/test_sandbox.py b/tests/models/test_sandbox.py index b45451e..5091daa 100644 --- a/tests/models/test_sandbox.py +++ b/tests/models/test_sandbox.py @@ -43,6 +43,7 @@ def test_minimal_dict(self): assert s.id == "sbx-2" assert s.state == "starting" assert s.network_policy is None + assert s.mounts is None class TestSandboxStatus: @@ -58,6 +59,12 @@ def test_empty_dict_raises(self): with pytest.raises(ValueError, match="missing required non-empty string 'id'"): SandboxStatus.from_dict({}) + def test_absent_mounts_preserved_as_none(self): + s = SandboxStatus.from_dict({"id": "sbx-2", "template_id": "tpl-1", "vcpu": 1, + "memory": 512, "disk": 10240, "timeout": 300, + "state": "running", "auto_pause": False, "created_at": "2025-01-01"}) + assert s.mounts is None + class TestCreateSandboxParams: def test_accepts_object_storage_mounts(self): @@ -77,10 +84,35 @@ def test_accepts_object_storage_mounts(self): "prefix": "docs/", }] + def test_preserves_empty_mount_credentials(self): + params = CreateSandboxParams(mounts=[{ + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "endpoint": "https://storage.example.com", + "access_key_id": "", + "secret_access_key": "", + }]) + + assert params.mounts == [{ + "type": "object-storage", + "bucket": "project-assets", + "mount_path": "/data/assets", + "endpoint": "https://storage.example.com", + "access_key_id": "", + "secret_access_key": "", + }] + def test_rejects_invalid_mounts(self): with pytest.raises(ValueError, match=r"mounts\[0\]\.type"): CreateSandboxParams(mounts=[{"type": "other", "bucket": "b", "mount_path": "/data", "endpoint": "https://storage.example.com"}]) + with pytest.raises(ValueError, match=r"mounts\[0\]\.endpoint must be a valid URL"): + CreateSandboxParams(mounts=[{"type": "object-storage", "bucket": "b", "mount_path": "/data", "endpoint": "not-a-url"}]) + + with pytest.raises(ValueError, match=r"mounts\[0\]\.prefix must be relative, must not contain '\.\.', and must end with '/'"): + CreateSandboxParams(mounts=[{"type": "object-storage", "bucket": "b", "mount_path": "/data", "endpoint": "https://storage.example.com", "prefix": ""}]) + with pytest.raises(ValueError, match=r"mounts\[0\]\.prefix"): CreateSandboxParams(mounts=[{"type": "object-storage", "bucket": "b", "mount_path": "/data", "endpoint": "https://storage.example.com", "prefix": "/bad"}]) @@ -94,6 +126,14 @@ def test_rejects_empty_mount_update(self): with pytest.raises(ValueError, match="at least one field"): _validate_object_storage_mount_update({}) + def test_rejects_invalid_mount_update_endpoint(self): + with pytest.raises(ValueError, match="endpoint must be a valid URL"): + _validate_object_storage_mount_update({"endpoint": "not-a-url"}) + + def test_rejects_empty_mount_update_prefix(self): + with pytest.raises(ValueError, match=r"prefix must be relative, must not contain '\.\.', and must end with '/'"): + _validate_object_storage_mount_update({"prefix": ""}) + def test_rejects_invalid_network_policy(self): with pytest.raises(ValueError, match=r"network_policy\.mode"): CreateSandboxParams(network_policy={"mode": "nope"})